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
@@ -0,0 +1 @@
{"specId": "ce34e647-8d91-4295-a3c0-7b001abccdee", "workflowType": "requirements-first", "specType": "feature"}
@@ -0,0 +1,69 @@
# Stonks Oracle Intelligence Architecture Review
## Recommendation
Add generic OpenAI-compatible support, but implement it as a protocol/capability layer rather than a third vendor-specific branch. Keep Ollama native support. Convert the existing vLLM path into an OpenAI-compatible endpoint profile.
For the RTX 4070 Ti SUPER cluster, do not replace the current 9B model with one smaller all-purpose model. Retain the 9B model as a focused adjudicator and split routine work into CPU-first specialist stages:
1. Deterministic parsing and symbol-registry resolution.
2. GLiNER2 Large for entities, event classes, relations, and evidence spans.
3. FinBERT for company-specific financial sentiment probabilities.
4. Retrieval-based novelty and duplicate detection.
5. Calibrated confidence from observed field correctness.
6. A stock-specific tabular model trained on realized abnormal returns for impact and horizon.
7. The existing 9B Qwen-class model for ambiguous, causal, multi-company, or implied reasoning.
This preserves the current reasoning ceiling, reduces average GPU inference, improves evidence fidelity, and adds stock-specific intelligence that a general language model cannot obtain from article text alone.
## Option Review
| Option | Best use | Weakness | Production role |
|---|---|---|---|
| Current Qwen3.5-class 9B monolith | Broad zero-shot semantics and hard reasoning | Expensive per document; stochastic; self-scores confidence/novelty/impact; weak calibration | Keep as adjudicator, not universal extractor |
| Qwen3.5 4B | Smaller generalist | Lower reasoning ceiling with same architectural weaknesses | Benchmark only; not preferred |
| NuExtract 1.5 3.8B | Literal schema filling | Limited implicit market reasoning; adds another generative runtime | Optional benchmark/fallback |
| NuExtract 1.5 Smol 1.7B | Compact long-form extraction | Still autoregressive and not a sentiment/impact model | Optional CPU/on-demand filing stage |
| NuExtract Tiny 0.5B | Very small extraction experiments | Accuracy ceiling too low for authoritative trading inputs without task tuning | Research/fine-tuning baseline |
| GLiNER2 Large 340M | CPU-first entities, classes, relations, spans | Needs calibration and task-specific tuning for best results | Primary fast-path specialist |
| FinBERT | Financial positive/negative/neutral probabilities | Not an extractor or reasoner | Per-company evidence sentiment |
| Hybrid specialist + 9B | Routine precision plus retained hard-case intelligence | More engineering and observability work | Recommended architecture |
| Hybrid + historical impact model | Text intelligence plus actual market-response learning | Requires leakage-safe dataset and monitoring | Best end-state |
## Highest-Priority Existing Problems
1. `services/extractor/vllm_client.py` ignores the supplied schema and requests only a generic JSON object.
2. The vLLM default extraction temperature is `0.7`.
3. Unknown provider values silently route to Ollama.
4. Documents are truncated to 8,000 characters.
5. The model is asked to invent authoritative novelty, confidence, impact, and horizon values.
6. Those self-scores directly affect aggregation weighting.
7. Provider attribution is hardcoded to Ollama.
8. The extractor processes one job at a time at the application layer.
9. Endpoint/model defaults conflict across code, migrations, Helm, and the vLLM deployment.
10. A tracked Helm override contains plaintext production-like credentials and requires immediate rotation.
## Expected Performance Shape
The following are design targets to validate, not promises:
- 60-80% of representative documents accepted through the CPU fast path after calibration.
- 2x or greater reduction in GPU-seconds per accepted document.
- Peak GPU memory near the current 9B deployment because no second generative model is permanently GPU-resident.
- p50 latency substantially lower for routine documents.
- p95 latency near the current model path for adjudicated documents.
- Better exact-field and evidence accuracy from deterministic/specialist stages.
- Same broad semantic ceiling because the 9B model remains available.
- Better impact/horizon calibration once the historical outcome model is approved.
## Immediate Next Decision
The first implementation milestone should not be GLiNER integration. It should be:
1. Rotate exposed credentials.
2. Establish the real runtime model/configuration.
3. Fix strict JSON Schema output and temperature on the current 9B endpoint.
4. Build the gold corpus and replay harness.
5. Then implement the gateway and specialist shadow path.
That order creates a fair baseline and prevents the project from attributing simple request fixes to the new architecture.
@@ -0,0 +1,792 @@
# Design Document
## Overview
Intelligence Pipeline v3 replaces a monolithic "article to final trading-oriented JSON" request with a staged evidence and prediction architecture. The existing 9B model remains available, but its role changes from universal extractor and self-scorer to **semantic adjudicator** for the minority of documents that need broad language understanding.
The design intentionally chooses the best long-term architecture rather than the minimum code change:
- Generic OpenAI-compatible support is implemented as a capability-aware gateway, not another provider branch.
- Explicit facts, entities, numbers, and sentiment are produced by CPU-first specialist components.
- Novelty comes from retrieval and similarity.
- Confidence comes from empirical calibration.
- Impact and horizon come from a stock-specific model trained against realized outcomes.
- The existing 9B vLLM model handles ambiguity, causality, implication, and conflicts.
- Every field retains source evidence and model lineage.
## Repository Review Findings
The following findings materially shaped this design:
| Finding | Repository location | Consequence |
|---|---|---|
| The vLLM client receives a JSON Schema but sends only `response_format: {"type": "json_object"}`. | `services/extractor/vllm_client.py:63-91` | The server is not constraining generation to the actual schema. |
| vLLM extraction defaults to temperature `0.7`. | `services/shared/config.py:64-71`, `services/shared/config.py:284-291` | Routine extraction is needlessly stochastic. |
| Unknown provider values silently fall back to Ollama. | `services/extractor/llm_factory.py:1-6`, `services/extractor/llm_factory.py:47-67` | Configuration mistakes can invoke the wrong endpoint without failing. |
| Long documents are truncated to the first 8,000 characters. | `services/extractor/prompts.py:102-105` | Filings, transcripts, and long articles can lose material facts. |
| The prompt asks one model for summary, entities, relevance, sentiment, impact, horizon, novelty, confidence, and evidence. | `services/extractor/prompts.py:107-126` | Extraction, reasoning, prediction, and self-evaluation are coupled. |
| The prompt supplies tracked tickers and invites inferred sector/theme exposure. | `services/extractor/prompts.py:85-98` | Explicit mentions and inferred exposure are mixed before evidence validation. |
| Persisted provider attribution is hardcoded to `ollama`, including failures. | `services/extractor/worker.py:166-184`, `services/extractor/worker.py:227-244` | Audit and model-performance attribution are incorrect for vLLM. |
| A single worker loop pops and processes one job at a time. | `services/extractor/main.py:438-468`, invocation near `services/extractor/main.py:628` | Application-level parallelism is constrained even if vLLM supports batching. |
| Runtime refresh mutates a client's private `_config`. | `services/extractor/main.py:496-531` | The protocol does not expose lifecycle or reconfiguration cleanly. |
| The thesis rewriter reimplements Ollama/vLLM branching. | `services/recommendation/thesis_llm.py:87-200` | Provider support is duplicated and will continue drifting. |
| Model defaults conflict across Python config, database migrations, Helm values, and the standalone vLLM deployment. | `services/shared/config.py`, `infra/migrations`, `infra/helm/stonks-oracle/values.yaml`, `infra/kube-vllm/deployment.yaml` | The repository cannot prove which model is canonical at runtime. |
| Model-produced novelty and confidence directly affect aggregation weight; model-produced impact is reused as sentiment strength and impact. | `services/aggregation/scoring.py:436-529`, `services/aggregation/worker.py:430-463` | Uncalibrated model self-scores can materially influence downstream signals. |
| A tracked Helm override contains plaintext production-like credentials. | `infra/helm/stonks-oracle/values-live-math.yaml` | Immediate rotation and history remediation are required before feature work ships. |
The existing test suite around the LLM clients is useful. The focused provider tests passed after installing the declared dependencies plus the missing property-test dependency, but they encode current behavior and do not test true schema-constrained vLLM output.
## Decision Summary
### 1. Add generic OpenAI-compatible support
Yes, but do not add an `OpenAIClient` beside `VLLMClient` and `OllamaClient`. Rename the concept:
- `OllamaNativeClient` for `/api/chat` and Ollama-specific controls.
- `OpenAICompatibleClient` for `/v1/chat/completions` and optionally `/v1/responses` after a separate compatibility gate.
- `SpecialistHttpClient` for typed non-generative endpoints.
`vllm` becomes a profile alias whose protocol is `openai_chat`. Hosted OpenAI, LM Studio, SGLang, LocalAI, or another compatible server can be represented by endpoint capabilities rather than new `if provider == ...` branches.
Use direct `httpx` requests in the generic layer. This keeps the wire payload explicit, permits provider-specific `extra_body`, simplifies redacted request auditing, and avoids binding every compatible server to one SDK's assumptions.
### 2. Retain the 9B model, but stop using it for every stage
The RTX 4070 Ti SUPER remains dedicated to one 9B-class vLLM deployment. This preserves the current semantic ceiling and current peak VRAM class. The 9B model is invoked only for ambiguous cases and receives a compressed evidence packet rather than the raw entire document and ticker universe.
### 3. Use a CPU-first fast path
Run the following on CPU nodes:
- GLiNER2 Large candidate for entities, event classes, relations, and schema-oriented extraction.
- FinBERT candidate for company-specific positive/negative/neutral probabilities.
- Deterministic parsers and the existing symbol registry for numeric facts and ticker identity.
- Compact embeddings plus fingerprints for novelty and deduplication.
- A calibrated tabular impact model for market direction, magnitude, and horizon.
NuExtract 1.5 Smol is retained as an evaluated optional stage for long-form or hierarchical extraction. It is not made a second always-resident GPU model because the intended deployment should preserve the 9B model's GPU footprint and because GLiNER2 plus deterministic parsing may already cover most literal extraction.
## Architecture
```mermaid
flowchart TD
A[Normalized document] --> B[Segmenter and offset map]
B --> C[Deterministic candidates\ncompany aliases, tickers, numbers, dates]
B --> D[GLiNER2 specialist\nentities, events, relations, facts]
C --> E[Symbol resolver]
D --> E
E --> F[Evidence linker and verifier]
F --> G[FinBERT per-company sentiment]
F --> H[Novelty and dedup retrieval]
G --> I[Confidence calibrator]
H --> I
I --> J{Fast-path acceptance?}
J -->|yes| K[Approved evidence graph]
J -->|no| L[9B Qwen adjudicator on vLLM]
L --> M[Post-adjudication verifier]
M --> K
K --> N[Stock-specific impact model]
N --> O[v3 intelligence records]
O --> P[Compatibility adapter]
P --> Q[Current aggregation and recommendation consumers]
```
### Why this can be more intelligent without a larger footprint
A monolithic 9B model is broadly intelligent but is not necessarily the best estimator for every subproblem. The v3 design keeps that model for tasks requiring broad semantics while giving narrower jobs to components whose output can be calibrated and verified. The impact model adds information the language model does not have: observed historical market response. The result is not merely a smaller extractor; it is a system that combines textual reasoning with market-specific learned behavior.
## Component Design
### A. Inference Gateway
#### Package layout
```text
services/shared/inference/
├── protocol.py
├── models.py
├── registry.py
├── router.py
├── capabilities.py
├── errors.py
├── redaction.py
└── clients/
├── ollama_native.py
├── openai_compatible.py
└── specialist_http.py
```
#### Core types
```python
@dataclass(frozen=True)
class ProviderCapabilities:
chat_completions: bool
responses_api: bool
json_schema: bool
json_object: bool
seed: bool
usage: bool
max_completion_tokens: bool
reasoning_toggle: bool
model_listing: bool
@dataclass(frozen=True)
class InferenceTarget:
endpoint_id: UUID
deployment_id: UUID
protocol: Literal["ollama_native", "openai_chat", "specialist_http"]
base_url: str
model: str
capabilities: ProviderCapabilities
auth_secret_ref: str | None
extra_headers: Mapping[str, str]
extra_body: Mapping[str, Any]
@dataclass
class StructuredGenerationRequest:
messages: list[ChatMessage]
json_schema: dict[str, Any] | None
max_output_tokens: int
temperature: float = 0.0
seed: int | None = 0
timeout_seconds: float = 120.0
trace_id: str = ""
@dataclass
class InferenceResult:
content: str
parsed: dict[str, Any] | None
target: InferenceTarget
structured_mode: Literal["json_schema", "json_object", "prompt_only", "none"]
latency_ms: int
input_tokens: int | None
output_tokens: int | None
request_id: str | None
repaired: bool
retries: int
```
#### OpenAI-compatible structured output
The client chooses the strongest declared mode:
1. `json_schema`: send the actual schema and strict mode.
2. `json_object`: allow only if the deployment profile explicitly permits it.
3. `prompt_only`: allow only for experiments or legacy fallback.
For current vLLM versions, the gateway should support both standard `response_format` JSON Schema and a configurable vLLM `structured_outputs` extra body because deployed versions may differ. The endpoint profile records which wire form passed its capability probe.
Example standard payload:
```json
{
"model": "AxionML/Qwen3.5-9B-NVFP4",
"messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}],
"temperature": 0,
"max_tokens": 1536,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "adjudication_response",
"strict": true,
"schema": {}
}
}
}
```
The gateway validates the parsed response again locally. Wire constraints reduce malformed output; they do not replace semantic validation.
### B. Endpoint Registry
#### New tables
```sql
CREATE TABLE inference_endpoints (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
protocol TEXT NOT NULL CHECK (protocol IN ('ollama_native','openai_chat','specialist_http')),
base_url TEXT NOT NULL,
auth_secret_ref TEXT,
auth_scheme TEXT NOT NULL DEFAULT 'bearer',
default_headers JSONB NOT NULL DEFAULT '{}',
health_path TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE model_deployments (
id UUID PRIMARY KEY,
endpoint_id UUID NOT NULL REFERENCES inference_endpoints(id),
served_model_name TEXT NOT NULL,
display_name TEXT NOT NULL,
capabilities JSONB NOT NULL,
context_window INTEGER,
max_output_tokens INTEGER,
quantization TEXT,
runtime_metadata JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(endpoint_id, served_model_name)
);
CREATE TABLE agent_stage_bindings (
id UUID PRIMARY KEY,
agent_id UUID NOT NULL REFERENCES ai_agents(id),
stage TEXT NOT NULL,
model_deployment_id UUID REFERENCES model_deployments(id),
route_order INTEGER NOT NULL DEFAULT 0,
routing_config JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(agent_id, stage, route_order)
);
```
Authentication values are not stored in these tables. `auth_secret_ref` identifies a mounted secret or environment key understood by the deployment.
### C. Document Segmenter
The segmenter replaces the 8,000-character prefix truncation.
#### Output
```python
class DocumentChunk(BaseModel):
chunk_id: str
document_id: UUID
document_type: str
section_path: list[str]
speaker: str | None
start_char: int
end_char: int
text: str
overlap_left: int
overlap_right: int
boilerplate_score: float
```
Suggested initial limits:
| Document type | Target chunk size | Overlap | Notes |
|---|---:|---:|---|
| News / press release | 700-1,000 tokens | 100 tokens | Preserve paragraph boundaries. |
| Filing | 900-1,300 tokens | 150 tokens | Preserve headings and item sections. |
| Transcript | 700-1,000 tokens | 100 tokens | Preserve speaker turns. |
| Macro event | 500-800 tokens | 80 tokens | Favor compact event context. |
The final values are benchmark parameters, not hard-coded assumptions.
### D. Candidate Extraction and Symbol Resolution
Deterministic parsers generate high-precision candidates before specialist inference:
- Ticker tokens and exchange-qualified symbols.
- Currency and number expressions, including `million`, `billion`, ranges, percentages, basis points, and per-share amounts.
- Calendar and fiscal periods.
- Comparison cues such as `up`, `down`, `beat`, `miss`, `raised`, `cut`, `above`, and `below`.
- Company aliases from the symbol registry.
GLiNER2 receives focused schemas and returns spans for entities, event classes, relations, and structured facts. The resolver merges deterministic and specialist candidates using source offsets, aliases, and local context.
Explicit mentions and inferred exposures are different edge types:
```text
Document --explicitly_mentions--> Company
Event --directly_affects--> Company
Event --inferred_exposure--> Company
Company --competes_with--> Company
Company --supplies--> Company
```
Only explicit and verified direct effects enter the primary company extraction. Inferred exposure continues through the existing interpolation/propagation architecture with separate confidence and provenance.
### E. Sentiment Stage
FinBERT is run on company-linked evidence groups rather than the entire article. Each record contains:
```python
class CompanySentiment(BaseModel):
company_id: UUID
evidence_ids: list[UUID]
positive_probability: float
negative_probability: float
neutral_probability: float
calibration_version: str
model_deployment_id: UUID
```
Mixed sentiment is computed from multiple evidence groups and disagreement. It is not an unconstrained fourth softmax label.
### F. Novelty Stage
Novelty becomes retrieval-based:
1. Compute an exact/near-duplicate fingerprint of normalized content.
2. Embed document chunks and canonical company-event representations.
3. Search a recent window in the vector index.
4. Calculate document novelty and event novelty from nearest-neighbor similarity, duplicate count, source timing, and event identity.
5. Store nearest matches for explainability.
A compact embedding model will be selected in the evaluation harness. The implementation must keep the embedding backend replaceable and must not entangle novelty scoring with the generative endpoint.
### G. Confidence and Routing
#### Confidence features
- Entity span score.
- Alias-resolution margin between first and second candidate.
- Numeric parser validity.
- Evidence coverage.
- Relation score.
- Sentiment calibration confidence.
- Cross-stage agreement.
- Duplicate/novelty certainty.
- Document completeness.
- Document type and known hard-case patterns.
A calibration artifact maps these features to field-level correctness probabilities. Routing uses both calibrated confidence and hard rules.
#### Example adjudication triggers
```text
UNRESOLVED_ALIAS
MULTIPLE_PRIMARY_COMPANIES
CONTRADICTORY_NUMERIC_FACTS
CONFLICTING_SENTIMENT
IMPLIED_CAUSAL_IMPACT
GUIDANCE_VS_CONSENSUS_REQUIRES_REASONING
MATERIAL_FIELD_MISSING
EVIDENCE_COVERAGE_BELOW_THRESHOLD
CALIBRATED_CONFIDENCE_BELOW_THRESHOLD
LONG_DOCUMENT_CROSS_CHUNK_RELATION
```
The desired initial target is 60-80 percent Fast_Path coverage after calibration. This is an evaluation target, not an assumed result.
### H. Adjudication Packet
The 9B model receives only the information necessary to resolve a specific ambiguity:
```python
class AdjudicationPacket(BaseModel):
document_id: UUID
document_type: str
question_codes: list[str]
candidate_companies: list[CompanyCandidate]
candidate_events: list[EventCandidate]
candidate_facts: list[FactCandidate]
candidate_sentiments: list[CompanySentiment]
evidence_spans: list[EvidenceSpan]
relevant_chunks: list[DocumentChunk]
required_decisions: list[str]
```
The adjudicator output does not contain final novelty, confidence, impact, or horizon. It resolves candidate identity, relationship, event interpretation, and supported qualitative direction. All output is evidence-linked and revalidated.
### I. Impact and Horizon Model
This is the highest-impact stock-specific change.
#### Problem decomposition
- **Text sentiment**: What tone or directional implication is supported by the document?
- **Event identity**: What happened?
- **Market impact**: How has this kind of event historically affected this kind of security in this market regime?
- **Horizon**: Over what time window did the response usually manifest or decay?
The current model conflates these. v3 separates them.
#### Features
- Event class probability vector.
- Company-specific sentiment probability vector.
- Numeric magnitude and normalized surprise when consensus or prior value exists.
- Source credibility and historical source accuracy.
- Novelty and duplicate count.
- Evidence coverage and extraction uncertainty.
- Document type.
- Company sector, industry, market-cap bucket, liquidity, and beta.
- Pre-event volatility, volume regime, and broad market regime.
- Whether the event is direct, second-order, confirmed, quoted, or speculative.
#### Labels
Generate leakage-safe targets at defined event timestamps:
- Signed abnormal return relative to an approved benchmark.
- Absolute abnormal move.
- Abnormal volume.
- Direction labels for intraday, 1d, 7d, 30d, and 90d windows.
- Time to peak response and decay where data quality supports it.
#### Model family
Start with a deterministic event-weight baseline plus a CPU tabular learner such as gradient-boosted trees. Calibrate class probabilities out-of-time. The model artifact and feature pipeline are versioned independently.
The compatibility adapter may initially map expected signed magnitude to current `impact_score` and the most probable horizon to current `impact_horizon`, but richer distributions remain available to new consumers.
### J. V3 Storage Schema
Suggested logical records:
```python
class EvidenceSpan(BaseModel):
id: UUID
document_id: UUID
chunk_id: str
start_char: int
end_char: int
text: str
checksum: str
class ExtractedEntity(BaseModel):
id: UUID
entity_type: str
literal_text: str
canonical_id: UUID | None
evidence_id: UUID
confidence: float
derivation: str
class ExtractedFact(BaseModel):
id: UUID
fact_type: str
subject_entity_id: UUID | None
predicate: str
literal_value: str
normalized_value: dict | None
period: dict | None
evidence_ids: list[UUID]
confidence: float
derivation: str
class CompanySignalCandidate(BaseModel):
company_id: UUID
relevance_probability: float
event_probabilities: dict[str, float]
sentiment_probabilities: dict[str, float]
direction_probabilities: dict[str, float]
horizon_probabilities: dict[str, float]
expected_magnitude: float | None
evidence_ids: list[UUID]
routing_reasons: list[str]
adjudicated: bool
class StageLineage(BaseModel):
stage: str
endpoint_id: UUID | None
deployment_id: UUID | None
model_version: str | None
schema_version: str
calibration_version: str | None
started_at: datetime
duration_ms: int
status: str
```
### K. Compatibility Adapter
The adapter creates current records without discarding v3 provenance:
| Current field | V3 source |
|---|---|
| `summary` | Deterministic template or optional 9B narrative generated from approved facts. |
| `macro_themes` | Approved event/theme classes. |
| `novelty_score` | Retrieval-derived event/document novelty. |
| `confidence` | Calibrated record correctness probability. |
| `ticker` | Canonical symbol registry resolution. |
| `relevance` | Calibrated direct-relevance probability. |
| `sentiment` | Company-specific calibrated distribution mapped to legacy enum. |
| `impact_score` | Approved impact-model magnitude mapped to legacy range. |
| `impact_horizon` | Most probable approved horizon. |
| `catalyst_type` | Versioned event taxonomy mapping. |
| `evidence_spans` | Exact source spans. |
The adapter marks `model_provider = 'hybrid'` and stores complete stage lineage separately. No provider identity is hardcoded.
## Deployment Design for RTX 4070 Ti SUPER Cluster
### GPU deployment
One vLLM pod remains on the RTX 4070 Ti SUPER:
```yaml
resources:
limits:
nvidia.com/gpu: 1
nodeSelector:
accelerator: rtx-4070-ti-super
args:
- --model
- AxionML/Qwen3.5-9B-NVFP4
- --served-model-name
- stonks-adjudicator-9b
- --max-model-len
- "8192"
- --max-num-seqs
- "8"
- --gpu-memory-utilization
- "0.80"
- --structured-outputs-config.backend
- auto
```
Exact flags must match the pinned vLLM version. The deployment test must verify strict schema output before promotion.
### CPU specialist deployment
```yaml
replicas: 2
resources:
requests:
cpu: "2"
memory: 4Gi
limits:
cpu: "6"
memory: 10Gi
```
Initial pod contents:
- GLiNER2 Large.
- FinBERT.
- Tokenizers and deterministic parsers.
- Optional embedding model.
NuExtract 1.5 Smol should run as a separate benchmark or on-demand CPU deployment so its value can be measured independently.
### Queue topology
```text
extraction.incoming
-> intelligence.router
-> extraction.fast
-> extraction.adjudication
-> extraction.persist
-> extraction.review
```
The router owns document state transitions. Workers use leases and idempotency keys so a retry cannot create duplicate intelligence records.
### Concurrency
- Fast path: configurable worker pool, initially 4-8 concurrent documents per pod.
- Specialist API: micro-batching bounded by maximum wait time.
- Adjudicator: application semaphore aligned with vLLM `max-num-seqs` and measured KV-cache behavior.
- Persistence: independent bounded pool.
## OpenAI-Compatible Support Details
### Profiles
| Profile | Protocol | Typical use |
|---|---|---|
| `ollama` | `ollama_native` | Existing Ollama endpoint. |
| `vllm` | `openai_chat` | Backward-compatible alias using vLLM capability profile. |
| `openai` | `openai_chat` | Hosted OpenAI endpoint with secret reference and egress policy. |
| `openai_compatible` | `openai_chat` | Any explicitly configured compatible server. |
| `specialist` | `specialist_http` | Typed GLiNER/FinBERT service. |
### Capability probes
A deployment activation test performs:
1. Health request.
2. Model listing if supported.
3. Minimal chat request.
4. Strict JSON Schema request.
5. Usage metadata check.
6. Seed/determinism check if declared.
7. Maximum output field compatibility check.
Probe results are stored with timestamp and software version. A failed capability cannot be enabled merely by selecting it in the UI.
### Egress and data policy
Endpoint profiles include data-handling classification:
```text
local_private
cluster_private
approved_external
forbidden_for_sensitive_docs
```
External endpoints are disabled by default. Routing to an approved external endpoint requires both an active binding and a document policy permitting egress.
## Evaluation Strategy
### Gold corpus
Build a minimum initial corpus of 1,000 human-reviewed documents, stratified across:
- News, filings, transcripts, and press releases.
- Single-company and multi-company stories.
- Earnings beats/misses and guidance changes.
- M&A, legal, regulatory, product, supply-chain, rating, management, and macro events.
- Explicit facts versus implied consequences.
- Short and long documents.
- Duplicate and recycled stories.
### Compared systems
1. Current production path with current model and current prompt.
2. Current 9B model with corrected temperature and strict JSON Schema.
3. GLiNER2 + deterministic extraction.
4. GLiNER2 + deterministic extraction + FinBERT.
5. Optional NuExtract 1.5 Smol extraction path.
6. Full v3 fast path.
7. Full v3 with 9B adjudication.
This separation prevents architecture gains from being confused with a simple fix to the current vLLM request.
### Metrics
- Company/ticker precision, recall, F1.
- Event macro-F1 and per-class F1.
- Numeric exact match with tolerance-aware normalization.
- Relation F1.
- Evidence support and offset validity.
- Company-specific sentiment macro-F1.
- Confidence ECE and Brier score.
- Unsupported-claim rate.
- JSON/schema failure rate.
- Fast-path coverage.
- Adjudication reason distribution.
- p50/p95 latency and documents per minute.
- CPU-seconds, GPU-seconds, tokens, and peak GPU memory per document.
- Impact-model direction accuracy, calibration, rank correlation, and out-of-time error by horizon.
### Promotion sequence
1. Correct current vLLM schema constraints and temperature; establish baseline.
2. Run v3 offline replay.
3. Run v3 shadow mode.
4. Enable v3 for audit-only UI.
5. Canary compatibility outputs for a small percentage of non-trading downstream traffic.
6. Canary signal influence with automatic rollback.
7. Promote by document type and confidence tier.
8. Retire v2 only after a separately reviewed milestone.
## Security Design
### Immediate blocker
The repository contains plaintext production-like credentials in a tracked Helm values file. Treat them as compromised:
1. Rotate the database, object-store, Redis, broker, and market-data credentials.
2. Disable or replace old keys.
3. Remove secret values from tracked files.
4. Purge historical values using an approved Git history rewrite process.
5. Migrate to an external secret manager.
6. Add secret scanning to local hooks and CI.
7. Audit access logs for the affected credentials.
No values are reproduced in this specification.
### Inference security
- Secrets are resolved only at runtime.
- Request logging records hashes and metadata, not authorization values.
- Raw source text is not logged at INFO level.
- External endpoint use is policy-gated.
- Stored raw prompts and responses use restricted object-store buckets and retention policies.
- Provider errors are normalized to avoid echoing secret-bearing response headers.
## Testing Strategy
### Unit tests
- Endpoint capability selection.
- Strict schema payload construction.
- Header and error redaction.
- Segment offset round trips.
- Numeric normalization.
- Alias resolution and ambiguity margins.
- Evidence linkage.
- Compatibility mappings.
- Confidence feature construction.
- Routing rules.
### Property-based tests
- Every Evidence_Span round-trips to identical source text.
- Normalization never changes the literal stored value.
- Unknown providers always fail closed.
- Credentials never appear in serialized errors or logs.
- Compatibility mappings remain bounded in legacy field ranges.
- Reprocessing the same document and model versions is idempotent.
- Route decisions are deterministic for identical calibrated inputs.
### Contract tests
- Ollama native endpoint.
- vLLM OpenAI-compatible endpoint.
- Mock hosted OpenAI-compatible endpoint.
- Specialist service schemas.
- Capability probe behavior across supported structured-output modes.
### Integration tests
- Full document through fast path.
- Full document through adjudication path.
- Adjudicator outage with safe fast-path handling.
- Long filing crossing multiple chunks.
- Multi-company article with opposing sentiment.
- Duplicate story and novelty calculation.
- Rollback from v3 to v2.
### Load tests
- CPU specialist batching.
- Queue backpressure.
- vLLM concurrent adjudication.
- Peak 4070 Ti SUPER memory.
- End-to-end throughput at representative article arrival rates.
## Migration Plan
### Phase 0: Security and baseline
Rotate secrets, add scanning, pin current runtime configuration, and benchmark the existing path.
### Phase 1: Provider foundation
Add the Inference Gateway and registry. Convert current extractor, classifier, and thesis rewriter. Keep behavior otherwise equivalent.
### Phase 2: Correct current generative extraction
Use strict schema-constrained vLLM output, temperature zero, accurate provider lineage, and bounded output sizes. This produces a fair current baseline.
### Phase 3: V3 data and specialist shadow path
Add segmentation, v3 storage, deterministic extraction, GLiNER2, FinBERT, novelty, confidence, and audit UI. Persist shadow outputs only.
### Phase 4: Adjudication and impact model
Add confidence routing, focused 9B adjudication, historical outcome features, deterministic impact baseline, and trained impact model.
### Phase 5: Canary and promotion
Enable compatibility outputs by percentage and document type, then gradually allow v3 signals into aggregation.
### Phase 6: Fine-tuning and cleanup
Fine-tune specialist models from reviewed cases, increase fast-path coverage, then remove deprecated v2 code in a separate change.
## Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Specialist model misses implicit meaning. | Retain 9B adjudication with calibrated routing. |
| Complexity creates more failure modes. | Typed stage contracts, idempotent queues, stage-level metrics, and safe fallback. |
| Fast-path confidence is overestimated. | Held-out calibration, conservative thresholds, and shadow review. |
| Impact model learns leakage or regime artifacts. | Event-time feature snapshots, out-of-time validation, per-regime monitoring, and immutable predictions. |
| OpenAI-compatible servers differ subtly. | Capability probes and profile-specific wire settings, not optimistic assumptions. |
| A second model increases memory. | Keep specialists on CPU and make NuExtract optional/on-demand. |
| Existing downstream code assumes one model output. | Compatibility adapter and additive migrations. |
| Reviewer labels become inconsistent. | Annotation guide, double review for hard cases, and inter-annotator agreement tracking. |
@@ -0,0 +1,314 @@
# Requirements Document
## Introduction
Stonks Oracle currently asks a general-purpose generative model to perform entity discovery, ticker attribution, fact extraction, event classification, sentiment analysis, novelty estimation, confidence estimation, impact scoring, horizon selection, evidence quoting, and summarization in one response. That design is convenient, but it couples factual extraction to generative sampling and allows uncalibrated model self-assessments to influence signal weighting.
This specification introduces **Intelligence Pipeline v3**, a multi-stage, evidence-grounded inference system that preserves the current 9B model's reasoning ability for genuinely ambiguous documents while moving routine extraction, sentiment, novelty, confidence, and impact estimation into specialized and calibratable components. The target deployment retains the existing RTX 4070 Ti SUPER vLLM footprint and uses CPU-first specialist services for the fast path.
The specification also replaces provider-specific branching with a capability-aware inference gateway supporting Ollama native endpoints and generic OpenAI-compatible endpoints, including vLLM and hosted OpenAI-compatible services.
## Goals
1. Match or exceed the current 9B pipeline's field-level accuracy and reasoning ceiling.
2. Reduce average GPU work per document without increasing peak GPU memory materially.
3. Make every extracted fact traceable to evidence in the source document.
4. Replace model-generated confidence, novelty, impact, and horizon values with calibrated or deterministic values.
5. Support generic OpenAI-compatible inference without adding another duplicated provider branch.
6. Establish a measurable benchmark, shadow rollout, and promotion process.
7. Preserve downstream compatibility while the v2 schema and database consumers are migrated.
## Non-Goals
1. Replacing the existing recommendation, risk, or trading engines in one release.
2. Removing the current 9B model before the v3 pipeline passes promotion gates.
3. Treating backtest profit alone as proof of extraction correctness.
4. Sending credentials or proprietary documents to external providers by default.
5. Requiring a second GPU-resident generative model.
## Glossary
- **Inference_Gateway**: Shared client and routing layer that invokes Ollama-native, OpenAI-compatible, and specialist inference endpoints through one typed interface.
- **Endpoint_Profile**: Persisted endpoint configuration containing protocol, URL, authentication reference, capabilities, and health settings.
- **Model_Deployment**: A model served by an Endpoint_Profile with declared capabilities and limits.
- **Pipeline_Stage**: One step in Intelligence Pipeline v3, such as segmentation, extraction, sentiment, verification, novelty, adjudication, or impact prediction.
- **Fast_Path**: CPU-first processing that completes without invoking the 9B generative model.
- **Adjudication_Path**: Processing that invokes the 9B model because evidence is ambiguous, contradictory, incomplete, or semantically complex.
- **Evidence_Span**: Exact source text plus stable character offsets and a chunk identifier.
- **Candidate**: A proposed entity, fact, event, sentiment, or relation before validation and calibration.
- **Calibrated_Confidence**: Probability-like confidence derived from validation data, not a number supplied by a generative model.
- **Impact_Model**: A lightweight supervised model that estimates signed market impact and horizon from extracted features and historical outcomes.
- **Compatibility_Adapter**: Mapper from v3 records to the current v2 `document_intelligence` and `document_impact_records` structures.
- **Gold_Corpus**: Human-reviewed documents and field-level labels used for acceptance testing.
- **Shadow_Mode**: Running v3 alongside the current pipeline without allowing v3 outputs to affect production decisions.
## Requirements
### Requirement 1: Secure Baseline and Credential Remediation
**User Story:** As an operator, I want repository and deployment credentials handled through secret stores, so that model-pipeline improvements do not ship on top of exposed credentials.
#### Acceptance Criteria
1. THE Team SHALL rotate every credential currently stored as plaintext in tracked repository files before deploying Intelligence Pipeline v3.
2. THE Repository SHALL remove plaintext database, object-store, Redis, broker, and market-data credentials from tracked Helm values and Git history.
3. THE Deployment SHALL reference credentials through Kubernetes Secrets populated by External Secrets, SOPS, Sealed Secrets, or an equivalent approved mechanism.
4. THE CI_Pipeline SHALL run secret scanning on pull requests and protected branches.
5. IF secret scanning detects a high-confidence credential, THEN THE CI_Pipeline SHALL fail before packaging or deployment.
6. THE Documentation SHALL record the rotation date and affected secret names without recording secret values.
### Requirement 2: Capability-Aware Generic Inference Gateway
**User Story:** As a developer, I want one inference abstraction that supports Ollama and generic OpenAI-compatible services, so that endpoints can be changed without duplicating business logic.
#### Acceptance Criteria
1. THE Inference_Gateway SHALL support the protocols `ollama_native`, `openai_chat`, and `specialist_http`.
2. THE Inference_Gateway SHALL treat `vllm` as a backward-compatible profile alias for `openai_chat`, not as a separate client implementation.
3. WHEN an `openai_chat` request requires structured output and the endpoint declares `json_schema` support, THE Inference_Gateway SHALL send the complete supplied JSON Schema in strict structured-output mode.
4. WHEN an endpoint supports only JSON-object mode, THE Inference_Gateway SHALL use JSON-object mode only when that fallback is explicitly enabled for the Model_Deployment.
5. WHEN neither schema nor JSON-object constraints are supported, THE Inference_Gateway SHALL use prompt-only JSON generation only when explicitly enabled and SHALL mark the response as unconstrained.
6. IF a provider or protocol value is unknown, THEN THE Inference_Gateway SHALL fail closed with a configuration error and SHALL NOT silently route to Ollama.
7. THE Inference_Gateway SHALL support configurable base URL, request path, API-key secret reference, authorization scheme, additional headers, timeouts, retries, concurrency limit, and provider-specific extra request fields.
8. THE Inference_Gateway SHALL redact authentication values and configured sensitive headers from logs, traces, and stored request snapshots.
9. THE Inference_Gateway SHALL expose typed response metadata including endpoint ID, deployment ID, model name, protocol, request ID, latency, token usage, structured-output mode, retry count, and error category.
10. THE Inference_Gateway SHALL provide health and capability probes and cache their results with a bounded TTL.
11. WHEN endpoint capabilities are changed or a probe fails, THE Router SHALL invalidate the cached capability record before the next invocation.
12. THE Existing thesis rewriter, event classifier, and document extractor SHALL use the same Inference_Gateway rather than implementing separate Ollama/vLLM branches.
### Requirement 3: Canonical Endpoint and Model Registry
**User Story:** As an operator, I want the database and UI to identify exactly which endpoint and model serve each stage, so that environment, migration, Helm, and runtime defaults cannot drift silently.
#### Acceptance Criteria
1. THE Database SHALL store `inference_endpoints`, `model_deployments`, and `agent_stage_bindings` as canonical runtime records.
2. EACH Inference_Endpoint SHALL include name, protocol, base URL, authentication secret reference, health path, default headers, enabled state, and timestamps.
3. EACH Model_Deployment SHALL include endpoint ID, served model name, display name, capabilities, context limit, output limit, quantization, structured-output modes, and enabled state.
4. EACH Agent_Stage_Binding SHALL map an agent and pipeline stage to one or more ordered Model_Deployments plus routing configuration.
5. WHEN runtime configuration is resolved, THE Service SHALL record the exact endpoint, deployment, and binding revision used.
6. THE API SHALL validate endpoint URLs, protocol values, capability declarations, and model names before activation.
7. THE UI SHALL use controlled protocol and endpoint selections rather than an unrestricted provider text field.
8. THE Migration SHALL translate existing `ollama` and `vllm` agent settings into Endpoint_Profile and Model_Deployment records without breaking active agents.
9. THE Application SHALL have one documented fallback configuration source; conflicting model defaults in code, migrations, and Helm SHALL be removed.
### Requirement 4: Document Segmentation and Source Preservation
**User Story:** As an analyst, I want long articles, filings, and transcripts processed without destructive truncation, so that material facts near the end of a document are not lost.
#### Acceptance Criteria
1. THE Pipeline SHALL preserve the full normalized source document and SHALL NOT truncate it to a fixed character prefix for extraction.
2. THE Segmenter SHALL create sentence-aware chunks with stable chunk IDs, source character offsets, and configurable overlap.
3. THE Segmenter SHALL use document-type-specific chunk sizes for articles, filings, transcripts, and press releases.
4. THE Segmenter SHALL preserve headings, speaker labels, table-derived text markers, and section boundaries when present.
5. WHEN duplicate or boilerplate sections are detected, THE Segmenter SHALL mark them without deleting the only occurrence of a fact.
6. THE Pipeline SHALL retain a mapping from every downstream Evidence_Span to the original document offsets.
7. IF a document cannot be decoded or segmented, THEN THE Pipeline SHALL mark the document as a typed preprocessing failure and SHALL NOT fabricate an empty extraction.
### Requirement 5: Deterministic Candidate Generation and Ticker Resolution
**User Story:** As a signal consumer, I want explicit companies and numeric facts resolved deterministically where possible, so that a language model is not asked to invent identifiers or parse trivial values.
#### Acceptance Criteria
1. THE Candidate_Generator SHALL detect explicit ticker symbols, company names, aliases, executives, products, currencies, percentages, dates, ranges, EPS values, revenue values, guidance values, and common financial ratios.
2. THE Symbol_Resolver SHALL use the existing company and symbol registry as the source of truth for ticker identity.
3. THE Pipeline SHALL distinguish explicit company mentions from inferred exposure relationships.
4. THE Pipeline SHALL NOT pass the entire tracked-ticker universe to a generative prompt.
5. WHEN multiple companies match an alias, THE Symbol_Resolver SHALL return ranked candidates and SHALL require contextual disambiguation or adjudication.
6. WHEN a ticker is not present in the symbol registry, THE Pipeline SHALL preserve the literal mention as unresolved rather than inventing a registered ticker.
7. THE Numeric_Normalizer SHALL retain both literal source text and normalized values, currencies, units, periods, and ranges.
8. THE Pipeline SHALL reject normalized numeric facts whose value cannot be traced to an Evidence_Span.
### Requirement 6: Specialist Extraction Service
**User Story:** As an operator, I want routine entity, event, relation, and fact extraction to run on CPU-first specialist models, so that GPU capacity is reserved for difficult reasoning.
#### Acceptance Criteria
1. THE Specialist_Service SHALL expose batched APIs for entity extraction, schema extraction, relation extraction, and text classification.
2. THE Initial specialist extractor SHALL support company, person, product, event, financial metric, date, percentage, currency, and relationship schemas.
3. THE Specialist_Service SHALL return character spans and per-candidate scores for every extracted item.
4. THE Specialist_Service SHALL run without requiring the RTX 4070 Ti SUPER.
5. THE Initial deployment SHALL evaluate GLiNER2 Large as the primary unified extraction and classification model.
6. THE Benchmark SHALL evaluate NuExtract 1.5 Smol as an optional long-form or hierarchical fact-extraction stage, but it SHALL NOT become an always-resident GPU model without passing incremental-value and resource gates.
7. THE Specialist_Service SHALL support model version pinning, warm-up, health checks, bounded batching, and graceful degradation.
8. WHEN specialist inference fails, THE Router SHALL either retry according to policy or route to adjudication; it SHALL record the failure and SHALL NOT silently substitute default facts.
9. THE Specialist_Service SHALL expose model and schema versions in every response.
### Requirement 7: Company-Specific Financial Sentiment
**User Story:** As an analyst, I want sentiment tied to each company and supporting evidence, so that a positive statement about one firm is not applied to every company in the article.
#### Acceptance Criteria
1. THE Sentiment_Stage SHALL score evidence sentences or evidence groups associated with each resolved company.
2. THE Initial sentiment classifier SHALL evaluate FinBERT as the baseline financial-domain model.
3. THE Sentiment_Stage SHALL return positive, negative, and neutral probabilities rather than only a discrete label.
4. THE Pipeline SHALL derive mixed sentiment from conflicting supported evidence, not from an unconstrained model label.
5. WHEN an article mentions competitors with opposing effects, THE Pipeline SHALL produce separate company-specific sentiment records.
6. THE Sentiment_Stage SHALL preserve the evidence IDs used for each probability distribution.
7. THE Production model SHALL be calibrated on the Gold_Corpus before its probabilities are treated as confidence values.
### Requirement 8: Evidence Verification and Grounding
**User Story:** As an auditor, I want every material claim verified against source evidence, so that generated summaries and signals cannot rely on unsupported assertions.
#### Acceptance Criteria
1. EVERY material company fact, event, amount, direction, and relationship SHALL reference one or more Evidence_Spans.
2. THE Verifier SHALL check span validity, source offsets, entity association, and schema compatibility.
3. THE Benchmark SHALL evaluate a compact entailment verifier for claims that require semantic validation beyond exact matching.
4. IF a candidate conflicts with its evidence, THEN THE Pipeline SHALL reject it or route the conflict to adjudication.
5. THE Pipeline SHALL calculate evidence coverage as the proportion of required fields supported by valid spans.
6. THE Pipeline SHALL store rejected candidates and rejection reasons for audit and active learning.
7. JSON repair SHALL NOT transform an unsupported or truncated generative answer into a valid production extraction without marking it as repaired and revalidating every material field.
### Requirement 9: Deterministic Novelty and Duplicate Detection
**User Story:** As a signal consumer, I want novelty based on comparison with recent information, so that a model's subjective novelty guess does not amplify repeated news.
#### Acceptance Criteria
1. THE Novelty_Stage SHALL compare each document and material event against a configurable recent-history window.
2. THE Novelty_Stage SHALL combine exact/near-duplicate fingerprints with compact semantic embeddings.
3. THE Pipeline SHALL calculate novelty separately for document-level content and company-event content.
4. THE Novelty_Stage SHALL return nearest matching document or event IDs plus similarity scores.
5. THE Pipeline SHALL derive `novelty_score` from the similarity distribution and duplicate count using a versioned deterministic formula or calibrated model.
6. A generative model SHALL NOT provide the authoritative novelty value used by aggregation.
7. WHEN novelty cannot be calculated because history is unavailable, THE Pipeline SHALL use a conservative versioned default and mark the reason.
### Requirement 10: Calibrated Extraction Confidence
**User Story:** As a downstream scorer, I want confidence to reflect observed correctness, so that the system does not trust a model merely because it reports confidence in itself.
#### Acceptance Criteria
1. THE Pipeline SHALL calculate field-level and record-level confidence from specialist scores, symbol resolution, evidence validation, schema completeness, model agreement, and historical calibration.
2. A generative model's self-reported confidence SHALL NOT be used as authoritative extraction confidence.
3. THE Calibration_Process SHALL evaluate isotonic, Platt, or equivalent calibration methods on held-out Gold_Corpus data.
4. THE Pipeline SHALL report Expected Calibration Error and Brier score for probability-bearing stages.
5. THE Router SHALL use calibrated uncertainty and explicit conflict rules to choose Fast_Path or Adjudication_Path.
6. THE Pipeline SHALL retain stage-level confidence components for explainability.
7. WHEN calibration data is insufficient for a class, THE Pipeline SHALL use conservative thresholds and mark the class as under-calibrated.
### Requirement 11: 9B Generative Adjudicator
**User Story:** As an analyst, I want the current reasoning capability retained for hard documents, so that specialization does not reduce intelligence on nuanced cases.
#### Acceptance Criteria
1. THE Adjudicator SHALL initially use the existing 9B-class model served by vLLM on the RTX 4070 Ti SUPER.
2. THE Adjudicator SHALL receive selected source chunks, Evidence_Spans, candidate facts, candidate probabilities, conflicts, and a precise adjudication question rather than the entire tracked ticker list.
3. THE Adjudicator SHALL use strict JSON Schema constrained output when supported by the endpoint.
4. THE Adjudicator SHALL use deterministic generation settings appropriate for extraction, including a production default temperature of zero unless a benchmark proves a different value superior.
5. THE Adjudicator SHALL NOT be asked to provide authoritative novelty, confidence, or impact values.
6. THE Router SHALL invoke adjudication for unresolved entity aliases, contradictory evidence, multi-company causal relationships, implied consequences, complex guidance, materially incomplete fast-path results, or low calibrated confidence.
7. THE Adjudicator SHALL return field-level decisions, evidence references, and decision reasons.
8. IF adjudication output references evidence not supplied to it, THEN THE Verifier SHALL reject the unsupported field.
9. THE Adjudicator SHALL remain optional for thesis prose; deterministic signal records SHALL not depend on prose generation succeeding.
10. THE Peak GPU memory budget SHALL not exceed the measured current 9B deployment baseline by more than 5 percent unless explicitly approved.
### Requirement 12: Stock-Specific Impact and Horizon Model
**User Story:** As a trader, I want impact and horizon estimated from historical market behavior rather than language-model intuition, so that signals are tied to observed outcomes.
#### Acceptance Criteria
1. THE Pipeline SHALL separate textual sentiment from expected market impact.
2. THE Impact_Model SHALL consume versioned features including event type probabilities, sentiment probabilities, magnitude, surprise where available, source history, novelty, company attributes, market regime, pre-event volatility, and evidence quality.
3. THE Training_Pipeline SHALL create leakage-safe labels from abnormal returns and volume responses over configured horizons.
4. THE Initial model family SHALL be a CPU-efficient calibrated tabular model and SHALL include a transparent deterministic baseline.
5. THE Impact_Model SHALL output signed direction probabilities, expected magnitude, horizon probabilities, and model uncertainty.
6. THE Production model SHALL be evaluated out-of-time and by event type, sector, market-cap bucket, and source.
7. THE Pipeline SHALL preserve existing downstream fields through a Compatibility_Adapter while storing richer probability distributions in v3 tables.
8. IF no trained Impact_Model is approved, THEN THE Pipeline SHALL use the deterministic baseline and SHALL NOT fall back to a generative model's impact score.
9. THE Outcome_Evaluator SHALL feed realized outcomes back into model monitoring and retraining datasets without mutating historical predictions.
10. THE Pipeline SHALL version feature definitions, training data ranges, model artifacts, thresholds, and calibration artifacts.
### Requirement 13: Versioned Intelligence Schema and Provenance
**User Story:** As a developer, I want a richer schema with field-level provenance, so that downstream consumers can distinguish facts, probabilities, decisions, and generated prose.
#### Acceptance Criteria
1. THE Database SHALL store v3 entities, facts, evidence spans, company signal candidates, stage runs, adjudication decisions, and model lineage in normalized or well-defined JSONB-backed tables.
2. EVERY v3 field SHALL identify whether it is deterministic, specialist-derived, adjudicated, calibrated, or compatibility-derived.
3. EVERY stage run SHALL record input references, output references, model versions, endpoint identity, duration, error state, and trace ID.
4. THE Compatibility_Adapter SHALL map approved v3 outputs to existing v2 persistence records during migration.
5. THE Compatibility_Adapter SHALL identify its own version and SHALL not overwrite original v3 probabilities.
6. THE persisted `model_provider` and model lineage SHALL reflect the actual route used and SHALL not be hardcoded to Ollama.
7. THE Pipeline SHALL retain raw model output only in approved object storage with configured retention and access controls.
### Requirement 14: Parallelism, Queues, and Resource Isolation
**User Story:** As an operator, I want parallel throughput without saturating the GPU or blocking unrelated stages, so that the cluster remains responsive.
#### Acceptance Criteria
1. THE Extractor SHALL support multiple in-flight documents using bounded asynchronous workers rather than a single unbounded sequential loop.
2. THE Fast_Path and Adjudication_Path SHALL have separate queue or concurrency controls.
3. THE Specialist_Service SHALL support dynamic batching within configured latency limits.
4. THE Adjudicator SHALL enforce a GPU-safe concurrency semaphore coordinated with vLLM limits.
5. THE Router SHALL apply backpressure when either path exceeds its queue-depth or latency thresholds.
6. THE Deployment SHALL assign specialist workloads to CPU nodes and the 9B vLLM workload to the RTX 4070 Ti SUPER node by default.
7. THE System SHALL expose queue depth, service time, batch size, GPU memory, GPU utilization, fast-path rate, and adjudication rate.
8. WHEN the adjudicator is unavailable, THE Pipeline SHALL continue only for documents meeting a conservative fast-path acceptance threshold; all others SHALL remain queued or fail safely.
### Requirement 15: Observability, Audit, and Explainability
**User Story:** As an operator and analyst, I want to understand why a document produced a signal and which component made each decision.
#### Acceptance Criteria
1. THE Pipeline SHALL emit one distributed trace covering preprocessing, specialist stages, routing, adjudication, impact prediction, and persistence.
2. THE Metrics SHALL include field validity, evidence coverage, entity resolution rate, sentiment agreement, calibration metrics, fast-path coverage, adjudication causes, schema failure rate, latency percentiles, token usage, and GPU-seconds per document.
3. THE Audit API SHALL return model lineage and evidence for a document, company, and generated signal.
4. THE UI SHALL distinguish observed facts, inferred exposure, sentiment, predicted impact, and generated narrative.
5. THE Pipeline SHALL store routing reasons as structured codes rather than log-only text.
6. THE Pipeline SHALL allow a reviewer to mark a field correct, incorrect, unsupported, or ambiguous and add a corrected value.
7. Reviewer corrections SHALL be immutable audit events and SHALL feed the active-learning dataset only through an approved export process.
### Requirement 16: Benchmark, Shadow Mode, and Promotion Gates
**User Story:** As an owner, I want the new architecture proven against the current system before it affects trades, so that complexity is justified by measured improvement.
#### Acceptance Criteria
1. THE Team SHALL create a versioned Gold_Corpus covering articles, filings, press releases, transcripts, macro news, multi-company stories, contradictory reports, and long documents.
2. THE Evaluation_Harness SHALL run the current pipeline and every proposed v3 configuration on identical inputs.
3. THE Evaluation SHALL report field precision, recall, F1, exact-match accuracy, evidence support rate, ticker-resolution accuracy, event macro-F1, sentiment macro-F1, calibration, latency, throughput, CPU use, GPU use, and cost.
4. THE Evaluation SHALL report results by document type, event class, source, sector, and difficulty bucket.
5. THE Initial promotion gate SHALL require no statistically meaningful regression in any safety-critical field and measurable improvement in at least one of evidence support, calibration, schema validity, or resource efficiency.
6. THE Initial production target SHALL achieve at least 60 percent Fast_Path coverage on the representative corpus while meeting accuracy gates.
7. THE GPU-seconds per accepted document SHALL improve by at least 2x relative to the current 9B-every-document baseline before full promotion.
8. THE v3 pipeline SHALL run in Shadow_Mode for a configurable period and minimum document count before it may influence aggregation.
9. THE Promotion process SHALL support canary percentages, automatic rollback thresholds, and one-click reversion to the current pipeline.
10. Backtest or paper-trading performance SHALL be reported separately from extraction correctness and SHALL not override failed correctness gates.
### Requirement 17: Active Learning and Specialist Fine-Tuning
**User Story:** As a model owner, I want difficult and corrected examples to improve the specialist path over time, so that fewer documents require the 9B adjudicator.
#### Acceptance Criteria
1. THE Active_Learning_Exporter SHALL select low-confidence, conflicting, adjudicated, and reviewer-corrected examples without exporting secrets or unauthorized content.
2. THE Export format SHALL retain source text, spans, schema labels, relations, adjudicator decisions, reviewer corrections, and provenance.
3. THE Training_Pipeline SHALL support fine-tuning the selected specialist extractor on the Stonks Oracle schema.
4. EACH trained artifact SHALL be evaluated against a frozen holdout and the current production artifact.
5. A specialist model SHALL not be promoted solely because it reduces adjudication rate; it SHALL also pass field-level correctness and calibration gates.
6. THE Registry SHALL retain model cards containing training range, dataset version, intended use, limitations, and evaluation results.
### Requirement 18: Backward-Compatible Rollout
**User Story:** As a maintainer, I want to ship the new pipeline incrementally, so that existing APIs and downstream services continue operating during migration.
#### Acceptance Criteria
1. THE Current v2 extractor SHALL remain available behind a feature flag until v3 completes shadow and canary promotion.
2. THE Compatibility_Adapter SHALL produce the fields required by aggregation, recommendation, validation, reporting, and API consumers.
3. THE Database migration SHALL be additive before any destructive column or table change.
4. THE Deployment SHALL permit per-agent, per-document-type, and percentage-based routing between v2 and v3.
5. WHEN rollback is triggered, THE System SHALL route new work to v2 without deleting v3 audit data.
6. THE Team SHALL remove deprecated provider branches, v2 prompt logic, and compatibility mappings only in a separately approved cleanup milestone.
@@ -0,0 +1,427 @@
# Implementation Plan: Intelligence Pipeline v3
## Overview
Replace the monolithic 9B model extraction pipeline with a staged, evidence-grounded multi-component architecture. CPU-first specialist services handle routine extraction, sentiment, novelty, and calibration while the existing 9B vLLM model is preserved for semantic adjudication of ambiguous cases. A capability-aware inference gateway replaces duplicated provider branches, a stock-specific impact model replaces generative self-scores, and a full shadow/canary promotion process ensures measured improvement before production influence.
## Tasks
- [x] 1. Rotate exposed credentials
- [x] 1.1 Identify every live or reusable credential in `infra/helm/stonks-oracle/values-live-math.yaml` and any other tracked files
- [x] 1.2 Rotate database, MinIO/object-store, Redis, broker, and market-data credentials
- [x] 1.3 Disable the replaced keys and review relevant access logs
- [x] 1.4 Remove plaintext values from the working tree without copying them into issues, PRs, logs, or spec comments
- [x] 1.5 Purge the values from Git history using an approved coordinated history rewrite
- [x] 1.6 Verify that old credentials no longer authenticate
- _Requirements: 1.1, 1.2, 1.6_
- [x] 2. Add managed secret delivery
- [x] 2.1 Select External Secrets, SOPS, Sealed Secrets, or the cluster-standard mechanism
- [x] 2.2 Replace Helm secret values with secret references
- [x] 2.3 Document bootstrap and rotation procedures
- [x] 2.4 Add a deployment test proving pods receive required keys without values appearing in rendered manifests
- _Requirements: 1.3, 1.6_
- [x] 3. Add repository secret scanning
- [x] 3.1 Add a secret scanner to pre-commit or Kiro hooks
- [x] 3.2 Add the scanner to pull-request and protected-branch CI
- [x] 3.3 Add tests/fixtures that prove real-looking secrets fail and explicit safe fixtures pass
- _Requirements: 1.4, 1.5_
- [x] 4. Establish the current runtime source of truth
- [x] 4.1 Inventory active cluster deployments, agent database records, Helm releases, and environment variables
- [x] 4.2 Record the actual model, quantization, vLLM version, max model length, max sequences, GPU utilization limit, and current provider for every agent
- [x] 4.3 Resolve the conflicting Qwen/NuExtract defaults in code and infrastructure for the baseline branch
- [x] 4.4 Produce `docs/intelligence-pipeline-v3/current-runtime-baseline.md` without credentials
- _Requirements: 3.9_
- [x] 5. Build a baseline replay command
- [x] 5.1 Add a CLI that replays a fixed document set through the current pipeline without writing trading outputs
- [x] 5.2 Capture structured output, schema validity, retries, duration, token usage, GPU metrics, provider/model lineage, and current downstream mappings
- [x] 5.3 Pin all baseline configuration and random seeds that the provider supports
- [x] 5.4 Store baseline reports under a versioned artifact path
- _Requirements: 16.2, 16.3_
- [x] 6. Define the v3 annotation schema
- [x] 6.1 Define labels for entities, canonical companies, events, relations, numeric facts, periods, sentiment, evidence spans, direct effects, inferred exposure, and ambiguity
- [x] 6.2 Define evidence and adjudication guidelines with positive and negative examples
- [x] 6.3 Define which fields are safety-critical for promotion gates
- [x] 6.4 Add schema validators and sample annotations
- _Requirements: 16.1, 16.4_
- [x] 7. Create the first Gold Corpus
- [x] 7.1 Sample at least 1,000 documents stratified by type, event class, length, source, company count, and difficulty
- [x] 7.2 Include duplicate stories, long filings, transcripts, contradictory reports, macro events, and opposing multi-company effects
- [x] 7.3 Double-review a hard-case subset and calculate inter-annotator agreement
- [x] 7.4 Freeze a holdout split that cannot be used for prompt or model tuning
- _Requirements: 16.1_
- [x] 8. Implement evaluation metrics
- [x] 8.1 Implement entity/ticker precision, recall, F1, and ambiguity accuracy
- [x] 8.2 Implement event and relation macro/micro F1
- [x] 8.3 Implement numeric exact/tolerance-aware matching
- [x] 8.4 Implement evidence offset validity and support rate
- [x] 8.5 Implement sentiment macro-F1 and probability calibration metrics
- [x] 8.6 Implement latency, throughput, token, CPU, GPU, and memory metrics
- [x] 8.7 Generate per-document-type and per-difficulty reports
- _Requirements: 16.3, 16.4_
- [x] 9. Benchmark corrected current-model extraction
- [x] 9.1 Run the current request unchanged
- [x] 9.2 Run the same 9B model with temperature zero
- [x] 9.3 Run the same 9B model with strict JSON Schema output and temperature zero
- [x] 9.4 Quantify how much of the apparent architecture gain comes from fixing the current request alone
- _Requirements: 16.2, 16.3, 16.5_
- [x] 10. Add shared inference domain models
- [x] 10.1 Create `services/shared/inference/models.py` with capabilities, target, request, result, usage, and lineage types
- [x] 10.2 Create normalized error categories for timeout, authentication, rate limit, server, invalid response, schema, capability, and policy failures
- [x] 10.3 Add serialization tests proving credentials and sensitive headers are excluded
- _Requirements: 2.1, 2.8, 2.9_
- [x] 11. Implement `OpenAICompatibleClient`
- [x] 11.1 Implement `/v1/chat/completions` using `httpx.AsyncClient`
- [x] 11.2 Implement Bearer and configurable authentication headers via runtime secret resolution
- [x] 11.3 Implement standard `response_format.json_schema` payloads
- [x] 11.4 Implement configurable vLLM `structured_outputs` extra-body payloads
- [x] 11.5 Implement explicit JSON-object and prompt-only fallback policies
- [x] 11.6 Capture request ID, usage, finish reason, structured mode, retries, and provider error category
- [x] 11.7 Revalidate parsed JSON locally against the supplied schema
- [x] 11.8 Add contract tests against a mocked compatible server and the cluster vLLM deployment
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.9_
- [x] 12. Refactor Ollama support into `OllamaNativeClient`
- [x] 12.1 Move current Ollama request logic behind the shared request/result types
- [x] 12.2 Honor configured max output tokens and context settings consistently
- [x] 12.3 Preserve native schema formatting when supported and explicitly report prompt-only mode otherwise
- [x] 12.4 Retain stall/loop detection as Ollama-specific policy without leaking it into the generic protocol
- _Requirements: 2.1, 2.6_
- [x] 13. Implement capability probing
- [x] 13.1 Probe health and model listing
- [x] 13.2 Probe strict JSON Schema with a minimal schema
- [x] 13.3 Probe usage metadata, seed behavior, and output-token field compatibility
- [x] 13.4 Store probe results and software/version metadata with TTL
- [x] 13.5 Refuse activation when declared required capabilities fail
- _Requirements: 2.10, 2.11_
- [x] 14. Replace provider fallback behavior
- [x] 14.1 Replace `VLLMClient` with an alias/profile using `OpenAICompatibleClient`
- [x] 14.2 Make unknown providers a typed configuration error
- [x] 14.3 Add migration warnings for `vllm` provider records
- [x] 14.4 Add property tests proving unknown providers never invoke Ollama
- _Requirements: 2.2, 2.6_
- [x] 15. Migrate all LLM consumers
- [x] 15.1 Migrate document extraction
- [x] 15.2 Migrate global event classification
- [x] 15.3 Migrate thesis rewriting and remove duplicate provider branching
- [x] 15.4 Replace direct/private `_config` mutation with an explicit target refresh or client-pool lifecycle
- [x] 15.5 Fix persistence so actual endpoint, model, and route lineage are recorded
- _Requirements: 2.12, 13.6_
- [x] 16. Add registry migrations
- [x] 16.1 Create `inference_endpoints`
- [x] 16.2 Create `model_deployments`
- [x] 16.3 Create `agent_stage_bindings`
- [x] 16.4 Add revision, audit, uniqueness, and enabled-state constraints
- [x] 16.5 Add additive lineage columns/tables for existing performance logs
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 17. Implement registry resolver
- [x] 17.1 Resolve active stage bindings with TTL caching
- [x] 17.2 Invalidate cache on revisions and failed probes
- [x] 17.3 Resolve authentication only at invocation time
- [x] 17.4 Add deterministic resolution and fail-closed tests
- _Requirements: 3.5, 3.9_
- [x] 18. Migrate existing provider records
- [x] 18.1 Create the current Ollama endpoint profile if in use
- [x] 18.2 Create the current vLLM OpenAI-compatible endpoint profile
- [x] 18.3 Create model deployments matching actual runtime state
- [x] 18.4 Convert agent and variant provider/model fields to stage bindings while retaining compatibility reads
- [x] 18.5 Remove conflicting runtime model defaults after migration verification
- _Requirements: 3.8, 3.9_
- [x] 19. Add endpoint API and UI
- [x] 19.1 Add CRUD endpoints that never return secret values
- [x] 19.2 Add probe, enable, disable, and test-structured-output actions
- [x] 19.3 Replace free-text provider inputs with protocol, endpoint, and model-deployment selectors
- [x] 19.4 Display last probe, capabilities, model limits, and active stage bindings
- [x] 19.5 Require confirmation for external endpoint egress enablement
- _Requirements: 3.6, 3.7_
- [x] 20. Add v3 persistence tables
- [x] 20.1 Add pipeline runs and stage runs
- [x] 20.2 Add document chunks and evidence spans
- [x] 20.3 Add extracted entities, facts, relations, and rejected candidates
- [x] 20.4 Add company signal candidates and probability distributions
- [x] 20.5 Add adjudication decisions, routing reasons, calibration references, and model lineage
- [x] 20.6 Add idempotency and immutable-revision constraints
- _Requirements: 13.1, 13.2, 13.3_
- [x] 21. Implement sentence-aware segmenter
- [x] 21.1 Preserve source offsets and checksums
- [x] 21.2 Add document-type-specific chunk strategies
- [x] 21.3 Preserve filing sections and transcript speakers
- [x] 21.4 Mark boilerplate and duplicate chunks
- [x] 21.5 Remove the 8,000-character truncation from v3
- [x] 21.6 Add property tests proving every chunk/evidence span maps exactly to source text
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 22. Implement compatibility adapter skeleton
- [x] 22.1 Map approved v3 records to current intelligence and impact data classes
- [x] 22.2 Persist `hybrid` lineage plus stage details
- [x] 22.3 Add golden mapping tests for every legacy enum and field range
- [x] 22.4 Keep adapter output disabled outside replay/shadow mode
- _Requirements: 13.4, 13.5, 18.2_
- [x] 23. Implement deterministic financial parsing
- [x] 23.1 Parse tickers, currencies, money, percentages, basis points, ranges, EPS, revenue, dates, and fiscal periods
- [x] 23.2 Store literal and normalized representations
- [x] 23.3 Link each candidate to exact offsets
- [x] 23.4 Add broad property tests for numeric formatting and unit conversions
- _Requirements: 5.1, 5.7, 5.8_
- [x] 24. Integrate symbol registry resolution
- [x] 24.1 Build canonical alias indexes from existing companies and symbol registry data
- [x] 24.2 Return ranked candidates and ambiguity margins
- [x] 24.3 Separate explicit mentions from inferred exposures
- [x] 24.4 Preserve unresolved literal entities without invented tickers
- [x] 24.5 Add tests for aliases shared by multiple companies
- _Requirements: 5.2, 5.3, 5.4, 5.5, 5.6_
- [x] 25. Create specialist inference service
- [x] 25.1 Add typed batch endpoints for entities, classification, relations, and structured extraction
- [x] 25.2 Integrate pinned GLiNER2 Large as the initial candidate
- [x] 25.3 Return spans, scores, model version, and schema version
- [x] 25.4 Add bounded dynamic batching and warm-up
- [x] 25.5 Add Kubernetes CPU deployment, health probes, and metrics
- [x] 25.6 Add contract and load tests
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.7, 6.9_
- [x] 26. Integrate company-specific sentiment
- [x] 26.1 Build company-linked evidence groups
- [x] 26.2 Integrate pinned FinBERT baseline
- [x] 26.3 Store full probability distributions and evidence IDs
- [x] 26.4 Implement mixed sentiment from evidence-group disagreement
- [x] 26.5 Benchmark and calibrate on the Gold_Corpus
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_
- [x] 27. Benchmark NuExtract 1.5 Smol
- [x] 27.1 Add an isolated adapter and CPU/on-demand deployment
- [x] 27.2 Test hierarchical extraction on long filings and transcripts
- [x] 27.3 Measure incremental correctness over GLiNER2 plus deterministic parsing
- [x] 27.4 Measure CPU latency and memory
- [x] 27.5 Promote it only for document classes where incremental value passes a predefined gate
- _Requirements: 6.6_
- [x] 28. Add evidence verification
- [x] 28.1 Validate offsets, source text, entity association, and numeric consistency
- [x] 28.2 Add rejected-candidate storage and reason codes
- [x] 28.3 Benchmark a compact entailment verifier on claims exact matching cannot validate
- [x] 28.4 Add unsupported-claim and evidence-coverage metrics
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_
- [x] 29. Implement retrieval-based novelty
- [x] 29.1 Add exact and near-duplicate fingerprints
- [x] 29.2 Add replaceable compact embedding backend
- [x] 29.3 Index document and canonical company-event embeddings
- [x] 29.4 Return nearest matches and similarity scores
- [x] 29.5 Implement and version the novelty formula
- [x] 29.6 Compare novelty values against human duplicate/novelty labels
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
- [x] 30. Build confidence feature pipeline
- [x] 30.1 Compute field-level features from extraction, resolution, evidence, sentiment, and agreement
- [x] 30.2 Train and compare calibration methods on training folds
- [x] 30.3 Evaluate ECE and Brier score on held-out data
- [x] 30.4 Version and load calibration artifacts
- [x] 30.5 Define conservative defaults for underrepresented classes
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.6, 10.7_
- [x] 31. Implement deterministic routing engine
- [x] 31.1 Define routing reason enums
- [x] 31.2 Implement hard ambiguity/conflict rules
- [x] 31.3 Implement calibrated fast-path thresholds by document and event type
- [x] 31.4 Store every route decision and feature snapshot
- [x] 31.5 Add property tests for determinism and threshold boundaries
- _Requirements: 10.5, 11.6_
- [x] 32. Define adjudication schemas
- [x] 32.1 Define candidate, conflict, question, evidence, and decision models
- [x] 32.2 Exclude authoritative confidence, novelty, impact, and horizon from the model output
- [x] 32.3 Require evidence IDs for every material decision
- _Requirements: 11.2, 11.5, 11.7_
- [x] 33. Build focused adjudication prompts
- [x] 33.1 Build packets from only relevant chunks and candidates
- [x] 33.2 Use strict JSON Schema and temperature zero
- [x] 33.3 Set a bounded output budget appropriate to decisions rather than long summaries
- [x] 33.4 Add prompt/version metadata and exact provider lineage
- _Requirements: 11.2, 11.3, 11.4_
- [x] 34. Deploy and validate the 9B adjudicator
- [x] 34.1 Pin the approved 9B model and vLLM version
- [x] 34.2 Verify strict structured output with the deployment's actual vLLM version
- [x] 34.3 Measure peak VRAM against the current baseline and enforce the +5 percent gate
- [x] 34.4 Load-test concurrency and select a safe application semaphore
- [x] 34.5 Add availability and queue-depth alerts
- _Requirements: 11.1, 11.10_
- [x] 35. Add post-adjudication verification
- [x] 35.1 Verify every referenced evidence ID was included in the packet
- [x] 35.2 Reject unsupported or schema-incompatible decisions
- [x] 35.3 Preserve both pre-adjudication candidates and final decisions
- [x] 35.4 Route repeated failures to review rather than accepting repaired defaults
- _Requirements: 11.8, 8.7_
- [x] 36. Define event-time feature snapshots
- [x] 36.1 Define feature names, types, timing rules, and missing-value policy
- [x] 36.2 Include event, sentiment, magnitude, surprise, source, novelty, evidence, company, volatility, volume, and regime features
- [x] 36.3 Persist immutable feature snapshots at prediction time
- [x] 36.4 Add leakage tests preventing post-event data from entering features
- _Requirements: 12.2, 12.10_
- [x] 37. Build outcome labels
- [x] 37.1 Define approved market benchmarks and abnormal-return calculations
- [x] 37.2 Generate signed and absolute response labels for intraday, 1d, 7d, 30d, and 90d horizons
- [x] 37.3 Generate abnormal-volume and time-to-peak labels where data quality permits
- [x] 37.4 Version label-generation code and market-data snapshots
- _Requirements: 12.3_
- [x] 38. Implement deterministic impact baseline
- [x] 38.1 Map event classes, sentiment, magnitude, evidence, novelty, and source credibility to conservative outputs
- [x] 38.2 Unit-test every event type and boundary
- [x] 38.3 Use this baseline whenever no approved trained model exists
- _Requirements: 12.4, 12.8_
- [x] 39. Train calibrated tabular impact models
- [x] 39.1 Train CPU-efficient gradient-boosted candidates for direction, magnitude, and horizon
- [x] 39.2 Use walk-forward/out-of-time splits
- [x] 39.3 Calibrate probabilities on a separate calibration fold
- [x] 39.4 Report metrics by event, sector, market cap, source, and regime
- [x] 39.5 Register artifacts, feature versions, training ranges, and model cards
- _Requirements: 12.4, 12.5, 12.6, 12.10_
- [x] 40. Integrate impact outputs
- [x] 40.1 Store full direction, magnitude, horizon, and uncertainty outputs
- [x] 40.2 Map approved outputs to legacy `impact_score` and `impact_horizon` through the compatibility adapter
- [x] 40.3 Remove generative impact/novelty/confidence from aggregation inputs in v3 mode
- [x] 40.4 Add comparison dashboards against realized outcomes
- _Requirements: 12.1, 12.7, 12.9_
- [x] 41. Implement v3 pipeline orchestrator
- [x] 41.1 Create explicit stage state transitions and idempotency keys
- [x] 41.2 Add fast-path, adjudication, persistence, and review queues
- [x] 41.3 Implement leases, retry policies, dead-letter handling, and resumable stages
- [x] 41.4 Keep v2 and v3 routing behind independent feature flags
- _Requirements: 14.1, 14.2, 14.5, 14.8_
- [x] 42. Add bounded application parallelism
- [x] 42.1 Replace the single sequential extraction loop for v3 with configurable async workers
- [x] 42.2 Add specialist micro-batching
- [x] 42.3 Add adjudicator semaphore and queue backpressure
- [x] 42.4 Add load shedding rules that never drop safety-critical documents silently
- _Requirements: 14.1, 14.3, 14.4, 14.5_
- [x] 43. Add traces and metrics
- [x] 43.1 Trace every stage under one document trace ID
- [x] 43.2 Add stage latency, errors, batch size, queue depth, and route metrics
- [x] 43.3 Add field accuracy, evidence coverage, calibration, fast-path rate, and adjudication reason dashboards
- [x] 43.4 Add GPU memory, utilization, and GPU-seconds per document
- [x] 43.5 Add alerts for schema failures, unsupported claims, calibration drift, queue saturation, and provider probe failures
- _Requirements: 14.7, 15.1, 15.2, 15.5_
- [x] 44. Add audit/review API and UI
- [x] 44.1 Display source evidence and offsets for each fact
- [x] 44.2 Display specialist probabilities, routing reasons, adjudicator decisions, and impact-model outputs separately
- [x] 44.3 Allow immutable reviewer correction events
- [x] 44.4 Add filters for low confidence, unsupported claims, and adjudicated documents
- _Requirements: 15.3, 15.4, 15.6, 15.7_
- [x] 45. Run offline replay
- [x] 45.1 Compare every required system configuration on the Gold_Corpus
- [x] 45.2 Publish field-level, calibration, resource, and difficulty-bucket reports
- [x] 45.3 Confirm corrected current-9B baseline versus full v3 incremental gain
- [x] 45.4 Reject or retune any stage failing safety-critical gates
- _Requirements: 16.2, 16.3, 16.4, 16.5_
- [x] 46. Enable production shadow mode
- [x] 46.1 Run v3 for live documents without affecting aggregation or trading
- [x] 46.2 Compare v2/v3 disagreements and sample reviews by risk
- [x] 46.3 Measure fast-path coverage, GPU reduction, and operational stability
- [x] 46.4 Require the configured minimum shadow duration and document count
- _Requirements: 16.6, 16.7, 16.8_
- [x] 47. Canary compatibility outputs
- [x] 47.1 Enable v3 adapter outputs for non-trading consumers first
- [x] 47.2 Add percentage- and document-type-based routing
- [x] 47.3 Configure automatic rollback on correctness, latency, queue, or availability thresholds
- [x] 47.4 Verify rollback leaves v3 audit records intact
- _Requirements: 16.9, 18.4, 18.5_
- [x] 48. Canary signal influence
- [x] 48.1 Enable v3 signals in paper trading at a small percentage
- [x] 48.2 Report extraction correctness separately from trading outcomes
- [x] 48.3 Review material recommendation divergences
- [x] 48.4 Promote only after explicit owner approval and all gates pass
- _Requirements: 16.9, 16.10_
- [x] 49. Build active-learning export
- [x] 49.1 Select low-confidence, conflicting, adjudicated, and corrected cases
- [x] 49.2 Remove or policy-filter sensitive content
- [x] 49.3 Export source spans, labels, relations, decisions, and provenance in a versioned format
- _Requirements: 17.1, 17.2_
- [x] 50. Fine-tune specialist extractor
- [x] 50.1 Train GLiNER2 candidate artifacts on the Stonks Oracle schema
- [x] 50.2 Evaluate against frozen holdout and production artifact
- [x] 50.3 Calibrate new scores and update routing thresholds
- [x] 50.4 Promote only when correctness gates pass, not merely when adjudication rate falls
- _Requirements: 17.3, 17.4, 17.5, 17.6_
- [x] 51. Deprecate legacy paths
- [x] 51.1 Remove duplicated `VLLMClient`/provider branching after all consumers use the gateway
- [x] 51.2 Remove v2 8,000-character truncation and monolithic extraction prompt after v2 retirement
- [x] 51.3 Remove obsolete environment/model defaults and provider free-text fields
- [x] 51.4 Remove compatibility adapter only after every downstream consumer reads v3 natively
- [x] 51.5 Archive final migration and benchmark reports
- _Requirements: 18.6_
- [x] 52. Checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Task Dependency Graph
```json
{
"waves": [
{ "id": 0, "tasks": ["1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "2.1", "2.2", "2.3", "2.4", "3.1", "3.2", "3.3", "4.1", "4.2", "4.3", "4.4", "5.1", "5.2", "5.3", "5.4"] },
{ "id": 1, "tasks": ["6.1", "6.2", "6.3", "6.4", "7.1", "7.2", "7.3", "7.4", "8.1", "8.2", "8.3", "8.4", "8.5", "8.6", "8.7", "9.1", "9.2", "9.3", "9.4"] },
{ "id": 2, "tasks": ["10.1", "10.2", "10.3", "11.1", "11.2", "11.3", "11.4", "11.5", "11.6", "11.7", "11.8", "12.1", "12.2", "12.3", "12.4"] },
{ "id": 3, "tasks": ["13.1", "13.2", "13.3", "13.4", "13.5", "14.1", "14.2", "14.3", "14.4", "15.1", "15.2", "15.3", "15.4", "15.5", "16.1", "16.2", "16.3", "16.4", "16.5"] },
{ "id": 4, "tasks": ["17.1", "17.2", "17.3", "17.4", "18.1", "18.2", "18.3", "18.4", "18.5", "19.1", "19.2", "19.3", "19.4", "19.5", "20.1", "20.2", "20.3", "20.4", "20.5", "20.6", "21.1", "21.2", "21.3", "21.4", "21.5", "21.6", "22.1", "22.2", "22.3", "22.4"] },
{ "id": 5, "tasks": ["23.1", "23.2", "23.3", "23.4", "24.1", "24.2", "24.3", "24.4", "24.5", "25.1", "25.2", "25.3", "25.4", "25.5", "25.6", "26.1", "26.2", "26.3", "26.4", "26.5", "27.1", "27.2", "27.3", "27.4", "27.5", "28.1", "28.2", "28.3", "28.4"] },
{ "id": 6, "tasks": ["29.1", "29.2", "29.3", "29.4", "29.5", "29.6", "30.1", "30.2", "30.3", "30.4", "30.5", "31.1", "31.2", "31.3", "31.4", "31.5"] },
{ "id": 7, "tasks": ["32.1", "32.2", "32.3", "33.1", "33.2", "33.3", "33.4", "34.1", "34.2", "34.3", "34.4", "34.5", "35.1", "35.2", "35.3", "35.4"] },
{ "id": 8, "tasks": ["36.1", "36.2", "36.3", "36.4", "37.1", "37.2", "37.3", "37.4", "38.1", "38.2", "38.3", "39.1", "39.2", "39.3", "39.4", "39.5", "40.1", "40.2", "40.3", "40.4"] },
{ "id": 9, "tasks": ["41.1", "41.2", "41.3", "41.4", "42.1", "42.2", "42.3", "42.4", "43.1", "43.2", "43.3", "43.4", "43.5", "44.1", "44.2", "44.3", "44.4"] },
{ "id": 10, "tasks": ["45.1", "45.2", "45.3", "45.4", "46.1", "46.2", "46.3", "46.4", "47.1", "47.2", "47.3", "47.4", "48.1", "48.2", "48.3", "48.4"] },
{ "id": 11, "tasks": ["49.1", "49.2", "49.3", "50.1", "50.2", "50.3", "50.4", "51.1", "51.2", "51.3", "51.4", "51.5"] }
]
}
```
## Notes
- This plan is intentionally staged so the current system remains available until the replacement is measured and promoted
- Task 1 (credential rotation) is a **BLOCKER** — must complete before any feature deployment
- Tasks within the same wave may run in parallel; tasks in later waves depend on earlier waves completing
- Each phase ends with an explicit evidence artifact: test output, benchmark report, migration result, or deployment probe
- The current v2 extractor remains available behind a feature flag until v3 completes shadow and canary promotion
- Peak GPU memory must not exceed the measured current 9B deployment baseline by more than 5 percent
- The definition of done requires: credentials rotated, all consumers on shared gateway, evidence-linked outputs, calibrated scores replacing generative self-scores, 9B preserved for adjudication, and shadow/canary gates passed
- Rollback to the current production path must be exercised successfully before full promotion
- Property tests validate deterministic behaviors (unknown providers fail closed, chunk offsets map to source, routing thresholds are deterministic)
- Deprecated legacy paths (task 51) require separate approval and must not proceed until all downstream consumers read v3 natively
@@ -0,0 +1 @@
{"specId": "f5d99301-94ef-4dc2-8ba4-ccefeee7ecba", "workflowType": "requirements-first", "specType": "bugfix"}
+73
View File
@@ -0,0 +1,73 @@
# Bugfix Requirements Document
## Introduction
Multiple operational bugs discovered in the stonks-beta namespace prevent the validation/calibration feedback loop from functioning and degrade ingestion throughput. The core issue is that the outcome evaluation → metrics computation → quality gate pipeline is completely disconnected from the production scheduler, making the platform unable to self-calibrate or validate predictions. Additionally, Polygon API rate limiting causes ~40% request failures per cycle, a broken config query prevents the v3 engine from being toggled, several periodic snapshot tasks are missing from the scheduler, the lake-publisher deployment is idle/redundant, and order rejection reasons are lost.
## Bug Analysis
### Current Behavior (Defect)
1.1 WHEN the scheduler enqueues ingestion jobs for all 50 tickers' news_api and market_api sources simultaneously THEN the system exhausts the Polygon free-tier rate limit (5 req/min) resulting in ~40% of sources receiving HTTP 429 Too Many Requests every cycle
1.2 WHEN the aggregation worker reads the v3_engine_enabled flag via `_V3_ENGINE_FLAG_QUERY` THEN the system queries non-existent columns `key` and `value` on the `risk_configs` table (actual schema: `name` varchar, `config` JSONB) causing a PostgreSQL error every aggregation cycle
1.3 WHEN a scheduler cycle completes THEN the system never calls `evaluate_matured_predictions()` because it is not wired into the scheduler's main loop — only imported in `backtest_replay.py`
1.4 WHEN a scheduler cycle completes THEN the system never calls `compute_and_store_metric_snapshots()` because it is not wired into the scheduler's main loop — only called from backtest replay
1.5 WHEN the model quality gate evaluates trading eligibility THEN the system always fails with "no model metric snapshot available — defaulting to paper-only" because `model_metric_snapshots` table is permanently empty (consequence of bug 1.4)
1.6 WHEN the trading engine runs daily THEN the system never captures portfolio state snapshots to the `portfolio_snapshots` table because no periodic scheduler task invokes this capture
1.7 WHEN the trading engine runs daily THEN the system never captures risk state snapshots to the `daily_risk_snapshots` table because no periodic scheduler task invokes this capture
1.8 WHEN a prediction snapshot is created while Polygon rate-limiting has prevented the market data fetch THEN the system stores NULL in `price_at_prediction` (affecting 21% of snapshots), degrading downstream outcome evaluation accuracy
1.9 WHEN the standalone `lake-publisher` deployment polls `stonks:beta:queue:lake_publish` THEN the queue is always empty (0 items) because all lake publishing happens inline in broker-adapter and recommendation services — the deployment consumes zero work and wastes resources
1.10 WHEN Alpaca returns HTTP 401 for an order submission THEN the system sets order status to "rejected" but leaves the `rejection_reason` column NULL, capturing the error message only in the `decision_trace` JSONB field
### Expected Behavior (Correct)
2.1 WHEN the scheduler enqueues ingestion jobs for Polygon-backed sources (news_api, market_api) THEN the system SHALL pace/stagger requests across the polling interval to stay within the Polygon rate limit, achieving near-zero 429 responses per cycle
2.2 WHEN the aggregation worker reads the v3_engine_enabled flag THEN the system SHALL query `SELECT config FROM risk_configs WHERE name = 'v3_engine_enabled'` and parse the JSONB value to determine the boolean toggle state
2.3 WHEN a scheduler cycle completes and sufficient time has elapsed since the last evaluation THEN the system SHALL call `evaluate_matured_predictions()` to evaluate prediction snapshots whose horizon has elapsed, populating the `prediction_outcomes` table
2.4 WHEN a scheduler cycle completes and sufficient time has elapsed since the last computation THEN the system SHALL call `compute_and_store_metric_snapshots()` to compute aggregate model metrics across all lookback/horizon combinations, populating `model_metric_snapshots`
2.5 WHEN the model quality gate evaluates trading eligibility THEN the system SHALL have recent metric snapshots available and evaluate thresholds against actual model performance data
2.6 WHEN market hours close (or on a daily schedule) THEN the system SHALL capture and persist the current portfolio state to `portfolio_snapshots` including value, returns, positions, and risk metrics
2.7 WHEN market hours close (or on a daily schedule) THEN the system SHALL capture and persist the current risk state to `daily_risk_snapshots` including portfolio value, daily P&L, trade count, and sector positions
2.8 WHEN a prediction snapshot is created and market price is unavailable due to rate limiting THEN the system SHALL retry the price fetch or defer the snapshot until price data is available, reducing NULL `price_at_prediction` occurrences to near zero
2.9 WHEN the lake-publisher deployment architecture is reviewed THEN the system SHALL either route lake publish jobs through the Redis queue to the standalone deployment, or remove the redundant deployment — eliminating the idle pod
2.10 WHEN Alpaca returns an HTTP error (401, 403, or any rejection) for an order submission THEN the system SHALL populate the `rejection_reason` column with the HTTP error message/status in addition to recording it in `decision_trace`
### Unchanged Behavior (Regression Prevention)
3.1 WHEN sources with valid rate-limit headroom are enqueued THEN the system SHALL CONTINUE TO enqueue and process them without artificial delay
3.2 WHEN risk_configs is queried for other configuration keys (e.g., `model_quality_gate_config`, `macro_enabled`) THEN the system SHALL CONTINUE TO read them correctly using the existing `name`/`config` column pattern
3.3 WHEN the backtest replay module calls `evaluate_matured_predictions()` and `compute_and_store_metric_snapshots()` THEN the system SHALL CONTINUE TO execute them as part of backtest validation
3.4 WHEN prediction snapshots are created with available market prices THEN the system SHALL CONTINUE TO store the correct `price_at_prediction` value immediately
3.5 WHEN the existing inline lake publishing in broker-adapter and recommendation services writes facts THEN the system SHALL CONTINUE TO produce correct Parquet partitions in MinIO
3.6 WHEN orders succeed (HTTP 200 from Alpaca) THEN the system SHALL CONTINUE TO process them normally without modifying the `rejection_reason` column
3.7 WHEN the scheduler runs ingestion, extraction, aggregation, recommendation, and trading tasks THEN the system SHALL CONTINUE TO execute them on the existing cadence without disruption
3.8 WHEN the trading engine makes decisions and submits orders THEN the system SHALL CONTINUE TO record full decision context in `decision_trace` JSONB as before
3.9 WHEN the model quality gate passes (once metrics are populated) THEN the system SHALL CONTINUE TO allow promotion to live trading mode per existing threshold logic
3.10 WHEN the reporting collector fetches portfolio_snapshots and daily_risk_snapshots for report generation THEN the system SHALL CONTINUE TO query and render them using the existing schema
+396
View File
@@ -0,0 +1,396 @@
# Technical Design: ops-pipeline-fixes
## Overview
This design addresses 10 operational bugs that prevent the validation/calibration feedback loop from functioning and degrade ingestion throughput in the `stonks-beta` namespace. The fixes span the scheduler (rate limiting + periodic tasks), aggregation worker (config query), broker service (rejection reason), prediction snapshot (price fallback), and Helm chart (dead pod removal). All changes are localized with graceful fallbacks and no schema migrations required.
## Bug Details
Multiple operational bugs in the `stonks-beta` namespace prevent the validation/calibration feedback loop from functioning and degrade ingestion throughput. The core pipeline (ingestion → extraction → aggregation → recommendation → trading) flows end-to-end, but:
- The outcome evaluation → metrics computation → quality gate feedback loop is completely disconnected
- Polygon API rate limiting causes ~40% ingestion failures per cycle
- A broken config query prevents the v3 engine toggle from working
- Portfolio/risk snapshots are never captured
- Order rejection reasons are lost
```mermaid
graph TD
subgraph "Scheduler (services/scheduler/app.py)"
A[schedule_cycle] -->|paced enqueue| B[Ingestion Queue]
C[validation_cycle] -->|hourly| D[evaluate_matured_predictions]
C -->|after outcomes| E[compute_and_store_metric_snapshots]
F[snapshot_cycle] -->|daily 16:30 ET| G[capture_portfolio_snapshot]
F -->|daily 16:30 ET| H[capture_risk_snapshot]
end
subgraph "Aggregation (services/aggregation/worker.py)"
I[_read_v3_flag] -->|fixed query| J[risk_configs.config JSONB]
end
subgraph "Broker (services/adapters/broker_service.py)"
K[persist_order] -->|rejected status| L[orders.rejection_reason]
end
D --> O[prediction_outcomes]
E --> P[model_metric_snapshots]
P --> Q[Quality Gate]
```
## Expected Behavior
2.1 The scheduler SHALL pace Polygon API requests within the free-tier limit (~5 req/min), achieving near-zero 429 responses per cycle.
2.2 The aggregation worker SHALL read `v3_engine_enabled` from the `risk_configs` JSONB `config` column (not non-existent `key`/`value` columns).
2.3 The scheduler SHALL call `evaluate_matured_predictions()` hourly to populate `prediction_outcomes`.
2.4 The scheduler SHALL call `compute_and_store_metric_snapshots()` after outcome evaluation to populate `model_metric_snapshots`.
2.5 The quality gate SHALL have recent metric data available once the validation cycle runs.
2.6 The scheduler SHALL capture daily portfolio snapshots to `portfolio_snapshots` after market close.
2.7 The scheduler SHALL capture daily risk snapshots to `daily_risk_snapshots` after market close.
2.8 Prediction snapshots SHALL fall back to positions table prices when market_snapshots data is unavailable.
2.9 The lake-publisher deployment SHALL be scaled to 0 (idle pod, wasted resources).
2.10 The broker service SHALL populate `rejection_reason` on orders when broker or risk engine rejects.
## Hypothesized Root Cause
### Bug 1.1 — Polygon Rate Limiting
`POLYGON_GLOBAL_RATE_LIMIT = 45` in `services/scheduler/app.py` is set for a paid Polygon plan but the deployed instance uses the free tier (5 req/min). All 50+ sources are attempted per cycle, exhausting the limit instantly.
### Bug 1.2 — v3_engine_enabled Config Read
`_V3_ENGINE_FLAG_QUERY` in `services/aggregation/worker.py` reads `SELECT value FROM risk_configs WHERE key = 'v3_engine_enabled'`. The actual table has columns `name` (varchar) and `config` (JSONB) — no `key` or `value` column exists.
### Bug 1.3 & 1.4 — Outcome Evaluator & Metrics Never Scheduled
`evaluate_matured_predictions()` and `compute_and_store_metric_snapshots()` exist in `services/validation/` but are only imported in `services/trading/backtest_replay.py`. The scheduler main loop in `services/scheduler/app.py` has no call to either function.
### Bug 1.5 — Quality Gate Permanently Failing
`services/trading/model_quality_gate.py` queries `model_metric_snapshots` which is always empty (consequence of 1.4). Returns "no model metric snapshot available — defaulting to paper-only" every time.
### Bug 1.6 & 1.7 — Portfolio/Risk Snapshots
The trading engine has `_persist_daily_snapshot()` but it only executes when the engine's main loop is actively processing trades. The trading-engine pod shows only health checks — its main loop isn't cycling because there are no active trade triggers flowing through it. No fallback capture exists in the scheduler.
### Bug 1.8 — Market Price Gaps
`services/validation/prediction_snapshot.py` queries `market_snapshots` for price at prediction time. When Polygon rate limiting prevents market data fetches, no snapshot exists and `price_at_prediction` is NULL. 21% of snapshots affected.
### Bug 1.9 — Lake Publisher Idle
The standalone `lake-publisher` deployment polls `stonks:beta:queue:lake_publish` but all services (broker-adapter, recommendation) import `services.lake_publisher.worker` directly and publish inline — never pushing to the Redis queue.
### Bug 1.10 — Order rejection_reason NULL
`_INSERT_ORDER` SQL in `services/adapters/broker_service.py` doesn't include `rejection_reason` or `rejected_at` columns. The error is stored in `decision_trace` JSONB but the dedicated column stays NULL. The reconciliation path (`_reconcile_open_orders`) does set these columns, but initial persist does not.
## Fix Implementation
### Fix 1: Polygon Rate Limit Constant (Bug 1.1)
**File:** `services/scheduler/app.py`
Replace the hardcoded constant with an env-configurable value defaulting to 5:
```python
# Before:
POLYGON_GLOBAL_RATE_LIMIT: int = 45
# After:
POLYGON_GLOBAL_RATE_LIMIT: int = int(os.getenv("POLYGON_GLOBAL_RATE_LIMIT", "5"))
```
The existing `check_rate_limit()` function already implements per-minute windowed counting and skips sources once the limit is hit. By reducing the constant to match the free-tier limit, the system will naturally pace — enqueuing ~5 Polygon sources per minute across scheduler ticks (15s interval = 4 ticks/min). Skipped sources are retried next cycle.
**Validates:** Bugfix 2.1; Regression 3.1, 3.7
---
### Fix 2: v3_engine_enabled Config Query (Bug 1.2)
**File:** `services/aggregation/worker.py`
Replace the broken query and function:
```python
# Before:
_V3_ENGINE_FLAG_QUERY = """
SELECT value FROM risk_configs WHERE key = 'v3_engine_enabled'
"""
# After:
_V3_ENGINE_FLAG_QUERY = """
SELECT config->>'v3_engine_enabled' AS enabled
FROM risk_configs
WHERE name = 'default' AND active = TRUE
LIMIT 1
"""
async def _read_v3_flag(pool: asyncpg.Pool) -> bool:
"""Read v3_engine_enabled from risk_configs JSONB. Default False on error."""
try:
row = await pool.fetchrow(_V3_ENGINE_FLAG_QUERY)
if row and row["enabled"]:
return row["enabled"].lower() in ("true", "1", "yes")
return False
except Exception as e:
logger.warning("Failed to read v3_engine_enabled flag: %s", e)
return False
```
Reads from the `default` active risk_config's JSONB `config` field. Falls back to False (unchanged fail-safe).
**Validates:** Bugfix 2.2; Regression 3.2
---
### Fix 3: Validation Cycle in Scheduler (Bugs 1.3, 1.4, 1.5)
**File:** `services/scheduler/app.py`
Add a new periodic task (every ~240 ticks = ~60 minutes):
```python
# New constant:
VALIDATION_CYCLE_INTERVAL = int(os.getenv("VALIDATION_CYCLE_INTERVAL", "240"))
# New counter in main():
validation_counter = 0
# In main loop after existing periodic tasks:
validation_counter += 1
if validation_counter >= VALIDATION_CYCLE_INTERVAL:
validation_counter = 0
await run_validation_cycle(pool)
```
New function:
```python
async def run_validation_cycle(pool: asyncpg.Pool) -> None:
"""Run outcome evaluation and metric computation (hourly).
Requirements: 2.3, 2.4, 2.5
"""
from services.validation.outcome_evaluator import evaluate_matured_predictions
from services.validation.metrics import compute_and_store_metric_snapshots
try:
outcomes = await evaluate_matured_predictions(pool)
logger.info("Validation: evaluated %d prediction outcomes", outcomes)
except Exception:
logger.exception("Validation: outcome evaluation failed")
return # Skip metrics if outcomes failed
try:
snapshots = await compute_and_store_metric_snapshots(pool)
logger.info("Validation: computed %d metric snapshots", len(snapshots))
except Exception:
logger.exception("Validation: metric computation failed")
```
**Validates:** Bugfix 2.3, 2.4, 2.5; Regression 3.3
---
### Fix 4: Daily Portfolio & Risk Snapshots (Bugs 1.6, 1.7)
**File:** `services/scheduler/app.py`
Add a daily snapshot task that runs every ~60 minutes but only captures once per day after 16:30 ET:
```python
SNAPSHOT_CYCLE_INTERVAL = int(os.getenv("SNAPSHOT_CYCLE_INTERVAL", "240"))
snapshot_counter = 0
# In main loop:
snapshot_counter += 1
if snapshot_counter >= SNAPSHOT_CYCLE_INTERVAL:
snapshot_counter = 0
await maybe_capture_daily_snapshots(pool)
```
New function:
```python
async def maybe_capture_daily_snapshots(pool: asyncpg.Pool) -> None:
"""Capture portfolio and risk snapshots once daily after market close.
Requirements: 2.6, 2.7
"""
et_now = datetime.now(ZoneInfo("America/New_York"))
# Only after 4:30 PM ET
if et_now.hour < 16 or (et_now.hour == 16 and et_now.minute < 30):
return
today = et_now.date()
# Already captured today?
existing = await pool.fetchval(
"SELECT 1 FROM portfolio_snapshots WHERE snapshot_date = $1 LIMIT 1",
today,
)
if existing:
return
# Portfolio snapshot from positions + account data
try:
positions = await pool.fetch("SELECT * FROM positions WHERE quantity > 0")
portfolio_value = sum(
float(r["current_price"] or 0) * float(r["quantity"])
for r in positions
)
unrealized_pnl = sum(float(r["unrealized_pnl"] or 0) for r in positions)
await pool.execute(
"""INSERT INTO portfolio_snapshots
(snapshot_date, portfolio_value, unrealized_pnl, positions)
VALUES ($1, $2, $3, $4::jsonb)""",
today, portfolio_value, unrealized_pnl,
json.dumps([dict(r) for r in positions], default=str),
)
logger.info("Captured portfolio snapshot: value=%.2f", portfolio_value)
except Exception:
logger.exception("Failed to capture portfolio snapshot")
# Risk snapshot from daily activity
try:
daily_orders = await pool.fetchval(
"SELECT count(*) FROM orders WHERE created_at::date = $1", today
)
daily_pnl = sum(float(r["unrealized_pnl"] or 0) for r in positions) if positions else 0.0
await pool.execute(
"""INSERT INTO daily_risk_snapshots
(account_id, snapshot_date, portfolio_value, daily_pnl, daily_trade_count)
VALUES ((SELECT id FROM broker_accounts LIMIT 1), $1, $2, $3, $4)
ON CONFLICT DO NOTHING""",
today, portfolio_value, daily_pnl, daily_orders or 0,
)
logger.info("Captured risk snapshot: pnl=%.2f trades=%d", daily_pnl, daily_orders or 0)
except Exception:
logger.exception("Failed to capture risk snapshot")
```
**Validates:** Bugfix 2.6, 2.7; Regression 3.10
---
### Fix 5: Prediction Price Fallback (Bug 1.8)
**File:** `services/validation/prediction_snapshot.py`
After the primary `market_snapshots` price lookup returns NULL, add a fallback:
```python
# After market_snapshots lookup:
if price_at_prediction is None:
pos_row = await conn.fetchrow(
"SELECT current_price FROM positions "
"WHERE ticker = $1 AND current_price IS NOT NULL LIMIT 1",
ticker,
)
if pos_row:
price_at_prediction = float(pos_row["current_price"])
```
Only covers tickers with open positions (currently 10). Acceptable tradeoff — most active tickers are the ones we hold.
**Validates:** Bugfix 2.8; Regression 3.4
---
### Fix 6: Lake Publisher Scale-Down (Bug 1.9)
**File:** `infra/helm/stonks-oracle/values.yaml`
```yaml
# Change:
replicas: 0
```
Keeps the deployment definition intact for future use but schedules no pods.
**Validates:** Bugfix 2.9; Regression 3.5
---
### Fix 7: Order rejection_reason Population (Bug 1.10)
**File:** `services/adapters/broker_service.py`
Extend `_INSERT_ORDER` to include `rejection_reason` and `rejected_at`:
```python
_INSERT_ORDER = """
INSERT INTO orders (
id, recommendation_id, broker_account_id, ticker, side, order_type,
quantity, limit_price, stop_price, status, idempotency_key,
broker_order_id, decision_trace, submitted_at, filled_at,
fill_price, fill_quantity, rejection_reason, rejected_at
) VALUES (
$1::uuid, $2, $3::uuid, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13::jsonb, $14, $15,
$16, $17, $18, $19
)
ON CONFLICT (idempotency_key) DO UPDATE SET
status = EXCLUDED.status,
broker_order_id = EXCLUDED.broker_order_id,
filled_at = EXCLUDED.filled_at,
fill_price = EXCLUDED.fill_price,
fill_quantity = EXCLUDED.fill_quantity,
rejection_reason = COALESCE(EXCLUDED.rejection_reason, orders.rejection_reason),
rejected_at = COALESCE(EXCLUDED.rejected_at, orders.rejected_at),
updated_at = NOW()
"""
```
Update `persist_order()` to pass the new parameters:
```python
rejection_reason = resp.error if resp.status == OrderStatus.REJECTED else None
rejected_at = now if resp.status == OrderStatus.REJECTED else None
# Add as params $18, $19
```
**Validates:** Bugfix 2.10; Regression 3.6, 3.8
---
## Correctness Properties
Property 1: Rate limit compliance — After fix, the rolling 1-minute window for Polygon requests SHALL NOT exceed the configured limit (default 5). Existing `check_rate_limit()` windowed counter enforces this; we only change the threshold constant.
Property 2: Validation cycle completeness — `prediction_outcomes` row count SHALL grow monotonically after the first validation cycle runs. Each run finds matured snapshots not yet evaluated and persists outcomes.
Property 3: Metric snapshot freshness — `model_metric_snapshots` SHALL contain rows with `generated_at` within the last 2 hours after 2+ validation cycles. The quality gate can then evaluate against real data.
Property 4: Config read correctness — `_read_v3_flag()` SHALL return True when `risk_configs.config->>'v3_engine_enabled'` is `'true'` and False for all other values including NULL or missing key.
Property 5: Snapshot idempotency — `portfolio_snapshots` SHALL contain at most 1 row per `snapshot_date`. The `maybe_capture_daily_snapshots` function checks for existing rows before insert.
Property 6: Rejection reason preservation — Every order with `status = 'rejected'` persisted via `persist_order()` SHALL have a non-NULL `rejection_reason` extracted from the error response.
## Testing Strategy
- **Unit tests:** Update `test_scheduler.py` with a test verifying `run_validation_cycle` is called after the counter threshold. Test `_read_v3_flag` with mocked JSONB config returning various values.
- **Integration tests:** Verify `persist_order` with rejected status populates `rejection_reason` column.
- **Manual verification post-deploy:**
- `kubectl logs deployment/scheduler -n stonks-beta --tail=100 | grep Validation` shows outcome counts
- `SELECT count(*) FROM prediction_outcomes` starts growing within 1 hour
- `SELECT count(*) FROM model_metric_snapshots` populates after outcomes exist
- Scheduler logs show significantly fewer "Rate limit hit" warnings
- Aggregation logs no longer show "column value does not exist" error
- After market close: `SELECT * FROM portfolio_snapshots WHERE snapshot_date = CURRENT_DATE` returns 1 row
## Glossary
| Term | Definition |
|------|-----------|
| Validation cycle | Hourly scheduler task: evaluate_matured_predictions → compute_and_store_metric_snapshots |
| Quality gate | Threshold check on model_metric_snapshots that determines if trading can be promoted from paper to live |
| Prediction snapshot | Frozen state of a recommendation at generation time (prices, evidence, scores) |
| Outcome evaluation | Matching a matured prediction snapshot against realized market returns |
| Polygon free tier | API plan with ~5 requests/minute rate limit |
+69
View File
@@ -0,0 +1,69 @@
# Implementation Plan: ops-pipeline-fixes
## Overview
Fix 10 operational bugs preventing the validation/calibration feedback loop from functioning and degrading ingestion throughput. Changes span scheduler (rate limiting + periodic tasks), aggregation worker (config query), broker service (rejection reason), prediction snapshot (price fallback), and Helm chart (dead pod removal).
## Tasks
- [x] 1. Fix Polygon global rate limit — In `services/scheduler/app.py`, replace `POLYGON_GLOBAL_RATE_LIMIT: int = 45` with `POLYGON_GLOBAL_RATE_LIMIT: int = int(os.getenv("POLYGON_GLOBAL_RATE_LIMIT", "5"))` to make it env-configurable and default to the free-tier limit
- **Validates: Bugfix 2.1; Regression 3.1, 3.7**
- [x] 2. Fix v3_engine_enabled query — In `services/aggregation/worker.py`, replace `_V3_ENGINE_FLAG_QUERY` from `SELECT value FROM risk_configs WHERE key = 'v3_engine_enabled'` to `SELECT config->>'v3_engine_enabled' AS enabled FROM risk_configs WHERE name = 'default' AND active = TRUE LIMIT 1`, and rewrite `_read_v3_flag()` to parse the returned string (checking for "true"/"1"/"yes"), returning False for NULL/missing/error
- **Validates: Bugfix 2.2; Regression 3.2**
- [x] 3. Add validation cycle constant and counter — In `services/scheduler/app.py`, add `VALIDATION_CYCLE_INTERVAL = int(os.getenv("VALIDATION_CYCLE_INTERVAL", "240"))` constant and `validation_counter = 0` initialization in `main()`
- **Validates: Bugfix 2.3, 2.4**
- [x] 4. Implement run_validation_cycle function — In `services/scheduler/app.py`, implement `run_validation_cycle(pool)` that calls `evaluate_matured_predictions(pool)` followed by `compute_and_store_metric_snapshots(pool)`, with try/except logging for each and skipping metrics if outcomes fail
- **Validates: Bugfix 2.3, 2.4, 2.5; Regression 3.3**
- [x] 5. Wire validation cycle into main loop — In `services/scheduler/app.py` main loop, add the counter increment and conditional call to `run_validation_cycle(pool)` after the existing `report_schedule_counter` block
- **Validates: Bugfix 2.3, 2.4, 2.5**
- [x] 6. Add snapshot cycle constant and counter — In `services/scheduler/app.py`, add `SNAPSHOT_CYCLE_INTERVAL = int(os.getenv("SNAPSHOT_CYCLE_INTERVAL", "240"))` constant and `snapshot_counter = 0` initialization in `main()`
- **Validates: Bugfix 2.6, 2.7**
- [x] 7. Implement maybe_capture_daily_snapshots function — In `services/scheduler/app.py`, implement `maybe_capture_daily_snapshots(pool)` that checks time (after 16:30 ET), checks idempotency (no existing row for today), queries positions table for portfolio value/unrealized PnL, and inserts into `portfolio_snapshots` and `daily_risk_snapshots`
- **Validates: Bugfix 2.6, 2.7; Regression 3.10**
- [x] 8. Wire snapshot cycle into main loop — In `services/scheduler/app.py` main loop, add the counter increment and conditional call to `maybe_capture_daily_snapshots(pool)` after the validation counter block
- **Validates: Bugfix 2.6, 2.7**
- [x] 9. Add prediction price fallback — In `services/validation/prediction_snapshot.py`, after the primary market_snapshots price lookup returns NULL for `price_at_prediction`, add a fallback query to positions table: `SELECT current_price FROM positions WHERE ticker = $1 AND current_price IS NOT NULL LIMIT 1`
- **Validates: Bugfix 2.8; Regression 3.4**
- [x] 10. Scale down lake-publisher — In `infra/helm/stonks-oracle/values.yaml`, change the lake-publisher `replicas` from `1` to `0`
- **Validates: Bugfix 2.9; Regression 3.5**
- [x] 11. Extend _INSERT_ORDER SQL — In `services/adapters/broker_service.py`, extend `_INSERT_ORDER` SQL to include `rejection_reason` and `rejected_at` as parameters $18 and $19, with COALESCE in the ON CONFLICT UPDATE clause to preserve existing values
- **Validates: Bugfix 2.10; Regression 3.6, 3.8**
- [x] 12. Update persist_order parameters — In `services/adapters/broker_service.py`, update `persist_order()` to compute `rejection_reason = resp.error if resp.status == OrderStatus.REJECTED else None` and `rejected_at = now if resp.status == OrderStatus.REJECTED else None`, passing them as the final two parameters in the execute call
- **Validates: Bugfix 2.10; Regression 3.6, 3.8**
- [x] 13. Lint and test — Run `.venv/bin/ruff check services/` and `.venv/bin/python -m pytest tests/ -x --tb=short -q` to verify no regressions
- **Validates: Regression 3.13.10**
## Task Dependency Graph
```json
{
"waves": [
{"tasks": [1, 2, 9, 10]},
{"tasks": [3, 6, 11]},
{"tasks": [4, 7, 12]},
{"tasks": [5, 8]},
{"tasks": [13]}
]
}
```
Tasks 1, 2, 9, 10 are fully independent. Tasks 3/6/11 set up constants needed by 4/7/12. Tasks 5/8 wire into the main loop after their functions exist. Task 13 validates everything last.
## Notes
- No database migrations required — all tables already exist with correct columns
- All scheduler changes use the existing counter-based periodic task pattern already established for cleanup, aggregation, and report tasks
- Lazy imports in `run_validation_cycle` avoid circular imports and keep scheduler startup fast
- The `maybe_capture_daily_snapshots` idempotency check prevents duplicate rows on scheduler restart
@@ -0,0 +1,363 @@
# V3 Annotation Guidelines
**Schema version:** 1.0.0
**Last updated:** 2025-01-15
## Purpose
These guidelines define how human annotators and automated systems label documents in the Intelligence Pipeline v3 Gold Corpus. Every annotation must be evidence-grounded — no label is valid without a supporting evidence span traceable to the source text.
## Core Principles
1. **Evidence first.** If you cannot point to exact text that supports a label, do not apply the label.
2. **Explicit over inferred.** Mark only what the document explicitly states in primary annotations. Inferred exposure uses a separate, lower-confidence channel.
3. **Precision over recall.** A missed entity is preferable to a fabricated one. The pipeline uses multiple stages — later stages catch omissions.
4. **Reproducibility.** Two annotators given the same document should produce substantially the same labels. Ambiguous cases are marked, not resolved by guess.
---
## Evidence Spans
### Definition
An evidence span is the exact substring of the source document that supports an annotation. It uses zero-based character offsets into the original (pre-chunking) document text.
### Rules
- Every entity, event, relation, numeric fact, and sentiment annotation MUST reference at least one evidence span.
- Spans should be minimal but complete — include enough context for the label to be verifiable without the full document.
- Overlapping spans are permitted (e.g., the same sentence supports both an entity and an event).
- The `text` field MUST exactly match `source_text[start_char:end_char]`.
### Positive example
```
Source: "Apple Inc. reported quarterly earnings of $1.52 per share"
Span: start_char=0, end_char=10, text="Apple Inc."
```
### Negative example
```
Source: "Apple Inc. reported quarterly earnings of $1.52 per share"
Span: start_char=0, end_char=5, text="Apple"
```
❌ Truncating "Apple Inc." to "Apple" loses the corporate suffix needed to distinguish from Apple Records or the fruit.
---
## Entity Annotation
### Entity Types
| Type | When to use | Example |
|------|-------------|---------|
| `company` | Legal entity, publicly traded firm, government agency | "Apple Inc.", "The Federal Reserve" |
| `person` | Named individual | "Tim Cook", "Jerome Powell" |
| `product` | Named product or service | "iPhone 16", "Azure OpenAI Service" |
| `event` | Named event instance | "Q1 2025 earnings call" |
| `financial_metric` | Named metric class | "EPS", "revenue", "free cash flow" |
| `date` | Temporal expression | "Q1 2025", "January 15, 2025" |
| `percentage` | Percentage value | "4%", "25 basis points" |
| `currency` | Monetary value | "$1.52", "$10 billion" |
| `relationship` | Explicit relationship mention | "subsidiary", "joint venture partner" |
### Canonical Resolution
- If an entity maps to a company in the symbol registry, set `canonical_id` and `canonical_name` (ticker).
- If an entity is ambiguous (e.g., "Apple" could be AAPL or a fruit company), mark an ambiguity marker and set confidence below 1.0.
- Do NOT invent canonical IDs. If not in the registry, leave `canonical_id` as null.
### Positive example
```json
{
"entity_type": "company",
"literal_text": "Alphabet",
"canonical_id": "googl-uuid",
"canonical_name": "GOOGL",
"confidence": 0.97
}
```
### Negative example
```json
{
"entity_type": "company",
"literal_text": "the company",
"canonical_id": "aapl-uuid",
"canonical_name": "AAPL",
"confidence": 0.90
}
```
❌ "the company" is a pronoun reference, not an entity mention. Resolve coreference but annotate the actual named mention, not the pronoun.
---
## Event Classification
### Event Classes
| Class | Definition | Distinguishing criteria |
|-------|-----------|------------------------|
| `earnings_beat` | Reported EPS or revenue exceeds consensus | Explicit comparison to estimates |
| `earnings_miss` | Reported EPS or revenue below consensus | Explicit comparison to estimates |
| `guidance_raise` | Forward guidance raised vs prior or consensus | Future-looking, not historical result |
| `guidance_cut` | Forward guidance lowered | Future-looking, not historical result |
| `ma_announcement` | Merger, acquisition, investment, or divestiture | Transaction between entities |
| `legal_regulatory` | Lawsuit, fine, regulatory action, or settlement | Legal or regulatory body involved |
| `product_launch` | New product, service, or major feature announced | Not routine updates |
| `supply_chain` | Disruption, partnership, or change in supply relationships | Affects production/delivery |
| `rating_change` | Analyst upgrade, downgrade, or target change | From research analyst/firm |
| `management_change` | CEO/CFO/board appointment, resignation, or removal | C-suite or board level |
| `macro_event` | Interest rates, policy, trade, geopolitical | Not specific to one company |
| `dividend_change` | Dividend increase, decrease, or special dividend | Shareholder distribution |
| `buyback` | Share repurchase program announcement or completion | Capital return via buyback |
### Adjudication triggers for events
Route to the 9B adjudicator when:
- The same facts could be classified as multiple event types (e.g., guidance_raise during an earnings call could be either earnings_beat or guidance_raise — label the most specific applicable class).
- The event is implied but not explicitly stated.
- The primary company is unclear.
### Positive example
```
Source: "Apple beat earnings expectations with EPS of $1.52 vs $1.43 expected"
Event class: earnings_beat
Confidence: 0.98
```
### Negative example
```
Source: "Apple reported EPS of $1.52"
Event class: earnings_beat
```
❌ Without a comparison to consensus/estimates, this is a numeric fact report, not an earnings beat. The document must provide evidence of beating expectations.
---
## Relations
### Relation Types
| Type | Subject | Object | When to use |
|------|---------|--------|-------------|
| `directly_affects` | Event | Company | Event explicitly names or discusses the company |
| `inferred_exposure` | Event | Company | Exposure inferred from sector, supply chain, or competition |
| `competes_with` | Company | Company | Competitive relationship stated or clearly implied |
| `supplies` | Company | Company | Supply chain relationship stated |
### Critical distinction: directly_affects vs inferred_exposure
- `directly_affects`: The document **explicitly states** the company is impacted. Evidence span exists.
- `inferred_exposure`: The impact is **reasoned** from relationships, not stated. May have weak or no direct evidence span.
Only `directly_affects` enters primary company extraction. `inferred_exposure` flows through the separate interpolation/propagation architecture with distinct confidence and provenance.
### Positive example (directly_affects)
```
Source: "Microsoft announced a $10 billion investment in OpenAI"
Relation: directly_affects(event=ma_announcement, company=Microsoft)
Evidence: "Microsoft announced"
```
### Negative example (incorrectly using directly_affects)
```
Source: "Microsoft announced a $10 billion investment in OpenAI"
Relation: directly_affects(event=ma_announcement, company=Google)
```
❌ Google is not mentioned in the event sentence. This should be `inferred_exposure` based on competitive relationship, with appropriate lower confidence.
---
## Numeric Facts
### Annotation rules
1. Always store both `literal_value` (exact text) and `normalized_value` (parsed number).
2. Include `unit` (USD, %, bps, shares, etc.).
3. Link to the subject entity when determinable.
4. Use `predicate` to capture the semantic role: reported, expected, raised_to, cut_to, beat_by, missed_by.
5. Include `period` when the fact references a specific time frame.
### Normalization conventions
| Literal | Normalized | Unit |
|---------|-----------|------|
| "$1.52" | 1.52 | USD |
| "$94.9 billion" | 94900000000 | USD |
| "25 basis points" | 0.25 | percentage_points |
| "4%" | 4.0 | % |
| "$0.26 per share" | 0.26 | USD |
### Positive example
```json
{
"fact_type": "eps",
"predicate": "reported",
"literal_value": "$1.52 per share",
"normalized_value": 1.52,
"unit": "USD",
"period": {"period_type": "fiscal_quarter", "fiscal_year": 2025, "fiscal_quarter": 1}
}
```
### Negative example
```json
{
"fact_type": "eps",
"predicate": "reported",
"literal_value": "$1.52 per share",
"normalized_value": 152,
"unit": "cents"
}
```
❌ While $1.52 = 152 cents, always normalize to the unit stated in the source. Conversion to a different unit introduces potential confusion.
---
## Sentiment
### Rules
1. Sentiment is **company-specific**, not document-level. A single article can have positive sentiment for one company and negative for another.
2. Annotate probability distributions (positive, negative, neutral) that sum to 1.0.
3. `mixed` label is used when evidence groups disagree — it is computed from evidence-group-level disagreement, NOT an unconstrained fourth class.
4. The label should reflect the dominant probability.
### When to label "mixed"
Label `mixed` when:
- Different paragraphs contain opposing sentiment for the same company
- The same fact has both positive and negative implications (e.g., restructuring = cost cuts but also layoffs)
- Analyst opinions explicitly disagree within the document
Do NOT label `mixed` when:
- Sentiment is merely uncertain or mild — that's `neutral` with lower confidence
- The document discusses multiple companies with different sentiments — annotate separately per company
### Positive example
```json
{
"label": "mixed",
"positive_probability": 0.40,
"negative_probability": 0.45,
"neutral_probability": 0.15,
"evidence_ids": ["ev-pressure", "ev-validation"]
}
```
(Article says AI investment pressures cloud revenue but validates the broader thesis)
### Negative example
```json
{
"label": "mixed",
"positive_probability": 0.85,
"negative_probability": 0.05,
"neutral_probability": 0.10
}
```
❌ When positive_probability dominates at 0.85, the label should be `positive`, not `mixed`. Mixed requires genuine disagreement in evidence.
---
## Direct Effects vs Inferred Exposure
### Direct Effects
A direct effect means the document **explicitly states or clearly demonstrates** that an event impacts a specific company.
**Criteria:**
- The company is named in the same sentence or paragraph as the event
- The causal link is stated, not inferred
- Evidence span directly connects event to company
### Inferred Exposure
Inferred exposure captures **reasoned but unstated** impacts on companies.
**Criteria:**
- The company is NOT explicitly linked to the event in the source text
- The connection comes from known relationships (competitor, supplier, sector peer)
- Confidence should be lower than direct effects (typically 0.50.8)
- Requires `reasoning` field explaining the inference chain
### Adjudication routing
When it's unclear whether an effect is direct or inferred, mark an ambiguity marker with type `implied_causal_impact` and route to the 9B adjudicator.
---
## Ambiguity Markers
### When to flag
Flag ambiguity when:
- An alias resolves to multiple candidate companies (`unresolved_alias`)
- Multiple companies could be the primary subject (`multiple_primary_companies`)
- Numeric facts within the same document contradict each other (`contradictory_numeric_facts`)
- Sentiment evidence points in opposing directions for the same company (`conflicting_sentiment`)
- Impact is implied through causal chain, not stated (`implied_causal_impact`)
- Guidance must be compared to consensus to determine direction (`guidance_vs_consensus_requires_reasoning`)
- A required field cannot be determined from available evidence (`material_field_missing`)
- Evidence covers less than the minimum threshold for confident extraction (`evidence_coverage_below_threshold`)
- Calibrated confidence falls below the routing threshold (`calibrated_confidence_below_threshold`)
- A relation spans multiple document chunks (`long_document_cross_chunk_relation`)
### Severity levels
- **low**: The annotation is likely correct but has reduced certainty. Fast path may proceed with a confidence penalty.
- **medium**: The annotation requires review. Routes to adjudication by default.
- **high**: The annotation cannot be reliably made without semantic reasoning. Always routes to adjudication.
---
## Safety-Critical Fields
The following fields are **safety-critical** for promotion gates. Errors in these fields can directly cause incorrect trading decisions:
| Field | Why it's critical | Minimum promotion gate |
|-------|-------------------|----------------------|
| Company identity (ticker) | Wrong ticker = trade on wrong security | Precision ≥ 0.95, Recall ≥ 0.90 |
| Event class | Misclassifying beat/miss inverts signal direction | Macro-F1 ≥ 0.85 |
| Sentiment direction | Wrong sentiment → wrong position direction | Direction accuracy ≥ 0.90 |
| Numeric fact values | Wrong magnitude affects impact estimation | Tolerance match ≥ 0.92 |
| Direct effect attribution | Wrong company attribution creates false signals | Precision ≥ 0.93 |
| Evidence support | Unsupported claims are unverifiable | Support rate ≥ 0.95 |
| Confidence calibration | Overconfidence bypasses review | ECE ≤ 0.05 |
Annotators must pay special attention to these fields. During review, any error in a safety-critical field requires correction before the annotation can receive "gold" status.
---
## Annotation Workflow
1. **First pass:** Identify all entities and evidence spans
2. **Second pass:** Classify events and link to companies
3. **Third pass:** Extract numeric facts with periods
4. **Fourth pass:** Assess per-company sentiment
5. **Fifth pass:** Identify relations, direct effects, and inferred exposures
6. **Sixth pass:** Flag ambiguities and set confidence levels
7. **Review:** Senior annotator validates safety-critical fields
### Inter-annotator agreement
Hard cases (flagged with ambiguity markers) receive double annotation. Inter-annotator agreement is measured per field type using Cohen's kappa. Target: κ ≥ 0.80 for entity and event labels, κ ≥ 0.70 for relations and sentiment.
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2025-01-15 | Initial schema and guidelines |
+84
View File
@@ -0,0 +1,84 @@
# Session Context — July 11, 2026
## Current State
### Active Namespace: `stonks-beta`
- This is the ONLY namespace that should be running
- `stonks-oracle` namespace has been scaled to 0 replicas (all deployments)
- Dashboard: `https://stonks-beta.celestium.life`
- API: `https://stonks-api-beta.celestium.life`
### What Was Done This Session
#### 1. Pipeline Health Fixes (spec: `.kiro/specs/pipeline-health-fixes/`)
All implemented and deployed:
- **Stuck Parsed Docs**: `STALE_PARSED_THRESHOLD_MINUTES` 240→30, `LIMIT` 100→500, `_ENQUEUED_TTL` 14400→3600 (`services/scheduler/app.py`)
- **Price Fallback**: Added 24h market_snapshots time-window fallback in `create_prediction_snapshot()` (`services/validation/prediction_snapshot.py`)
- **Sentiment Normalization**: Added `normalize_impact_scores()` z-score function (`services/aggregation/scoring.py`) + integrated into `aggregate_company_window()` (`services/aggregation/worker.py`)
- **Signal Engine**: Replicas set to 0 in all Helm values files
- **Quality Gate**: `max_snapshot_age_hours` 24→48 (`services/trading/model_quality_gate.py`)
- **Backfill script**: `scripts/backfill_snapshot_prices.py` (one-time, not yet run on beta)
#### 2. Extractor Null-Field Fix
- `services/extractor/schemas.py`: `_normalize_extraction_data()` now handles `None` values (not just missing keys) and filters out company entries with empty ticker
- Test updated: `tests/test_extractor_schemas.py::test_validate_semantic_missing_ticker_is_error`
#### 3. Macro Doc Status Fix
- `services/extractor/main.py`: `_process_macro_classification()` now updates document status to 'extracted' on success, 'extraction_failed' on error
- Beta DB: manually fixed 1849 stuck macro docs (UPDATE status='extracted' WHERE id IN global_events)
#### 4. Dashboard Fix
- `frontend/src/pages/OpsPipeline.tsx`: Document Stages now uses time-filtered `/health` data (consistent with other sections), all-time from SSE stream shown as subtitle, time range labels added to all sections
#### 5. CI/CD DNS Fix
- `.woodpecker/*.yml`: All 5 pipeline files now use `clone.git.settings.remote: http://10.43.73.77:3000/admin/stonks-oracle.git` (Gitea ClusterIP directly, bypasses DNS)
- CoreDNS: scaled to 4 replicas, `forward . 192.168.42.1`, `dnsPolicy: None` with `nameservers: [192.168.42.1]`
- Woodpecker: `WOODPECKER_BACKEND_K8S_DNS_CONFIG` has `nameservers:[10.43.0.10]` + searches including `git-server.svc.cluster.local`
### Known Issues / TODO
1. **`stonks-oracle` namespace**: Scaled to 0 but still exists with stale data (42K extraction queue in Redis DB 0). Could be cleaned up or deleted entirely.
2. **Thesis Rewriter agent**: Was hammering vLLM from stonks-oracle namespace (5600+ calls/24h). Now stopped since namespace is scaled down. If it was also running in beta, check if recommendation service is calling vLLM for thesis rewrites excessively.
3. **`AxionML/Qwen3.5-9B-NVFP4` requests**: Something external is hitting vLLM with a model that doesn't exist (404s). Not from our pipeline — likely Open WebUI or another tool on the network configured with wrong model name. Source IP: goes through `vllm-metrics` nginx proxy (`10.42.1.155`).
4. **GitHub mirror**: `finalize.yml` mirror-github step fails (SSH key or DNS). Has `failure: ignore` so non-blocking. Needs `github_ssh_key` secret configured in Woodpecker.
5. **OpsPipeline dashboard**: Numbers now show time-filtered data. The "Document Stages" section shows counts from the selected time window (default 24h), with all-time totals as subtle subtitles. Currently beta shows: extracted=5417, low_quality=1659, parsed=15.
6. **Aggregation not generating trends on weekends**: Expected — market hours check prevents weekend trend generation. Will resume Monday.
7. **15 docs still in `parsed` status**: These are likely fresh ingests waiting for the next extraction cycle. Not stuck.
### Agent Performance (beta, last 24h as of session end)
- Document Intelligence Extractor: 33 calls, 94% success, avg 11.4s, conf 0.794
- Global Event Classifier: 81 calls, 99% success, avg 4.2s, conf 0.745
- Thesis Rewriter: 5603 calls, 100% success, avg 2.5s (from stonks-oracle before shutdown)
- Report Summarizer: 6 calls, 100% success, avg 6.9s
### Infrastructure
- k3s cluster: 4 NixOS nodes (gremlin-1 through gremlin-4)
- vLLM: `vllm-service` namespace, model `numind/NuExtract3`, 4070 Ti Super 16GB
- CoreDNS: 4 replicas, `forward . 192.168.42.1`
- Redis: DB 0 = stonks-oracle (stale), DB 1 = stonks-beta (active)
- PostgreSQL: shared instance, both namespaces use same DB server (different databases? or same? — needs verification)
- Gitea: `git-server` namespace, ClusterIP 10.43.73.77:3000, NodePort 30300
- Woodpecker: `woodpecker` namespace, kubernetes backend, 2 agents
### Key Files Modified
```
services/scheduler/app.py — recovery thresholds + batch limit
services/validation/prediction_snapshot.py — 24h price fallback
services/aggregation/scoring.py — normalize_impact_scores()
services/aggregation/worker.py — normalization integration
services/trading/model_quality_gate.py — 48h threshold
services/extractor/schemas.py — null field handling
services/extractor/main.py — macro doc status update
frontend/src/pages/OpsPipeline.tsx — dashboard fix
scripts/backfill_snapshot_prices.py — new script
tests/test_pbt_pipeline_health_*.py — PBT tests
tests/test_extractor_schemas.py — updated test
infra/helm/stonks-oracle/values*.yaml — signal-engine replicas
.woodpecker/*.yml — ClusterIP clone fix
```
+144
View File
@@ -0,0 +1,144 @@
# Stonks Oracle — What It Is and What It Does
## The One-Liner
Stonks Oracle is an autonomous market intelligence system that reads the news so you don't have to, forms a view on 50 publicly traded companies, and paper-trades that view — then grades its own homework.
---
## The Problem It Solves
Markets are noisy. Every day, hundreds of news articles, SEC filings, earnings transcripts, and geopolitical headlines hit the wire. A human analyst covering even a dozen names struggles to weigh all of it in real time. Most retail and even some institutional desks end up reacting to headlines rather than synthesizing the full picture.
Stonks Oracle replaces that manual synthesis with an always-on pipeline:
1. **It reads everything.** News articles, 10-K/10-Q filings, earnings calls, press releases, and macro/geopolitical headlines — ingested automatically on a schedule.
2. **It extracts structured intelligence.** A local AI model reads each document and pulls out: which companies are mentioned, the sentiment (bullish / bearish / neutral), the catalyst type (earnings, product launch, regulatory action, M&A, etc.), impact horizon (same-day through 90 days), key facts, and material risks.
3. **It forms a view.** Those individual extractions are aggregated into rolling trend summaries per company, refreshed continuously. The system flags contradictions (e.g., one filing is bullish but a news article is bearish) and tracks confidence based on evidence depth.
4. **It decides whether to trade.** When confidence is high enough, contradiction is low, and evidence is fresh, it issues a buy or sell recommendation — with a full written thesis explaining why.
5. **It executes paper trades.** An autonomous trading engine places orders through Alpaca's paper-trading system. Position sizing, stop-losses, take-profits, sector concentration limits, and circuit breakers are all built in.
6. **It measures itself.** Every prediction is frozen at the moment it's made, then checked against actual price movements days and weeks later. The system tracks its own win rate, calibration, and whether it's beating SPY.
---
## The Universe
50 companies across 10 sectors:
| Sector | Examples |
|--------|----------|
| Technology | AAPL, MSFT, NVDA, GOOGL, META |
| Consumer Cyclical | AMZN, TSLA, NKE, SBUX |
| Financial Services | JPM, GS, V, MA |
| Healthcare | JNJ, UNH, PFE, LLY |
| Energy | XOM, CVX, COP |
| Communication Services | NFLX, DIS, T |
| Industrials | CAT, BA, UPS |
| Consumer Defensive | PG, KO, WMT |
| Real Estate | AMT, PLD |
| Utilities | NEE, DUK |
46 competitor relationships are defined (direct rivals, same-sector peers, overlapping products, supply chain adjacencies) so the system can propagate signals — e.g., if a semiconductor shortage hits one chipmaker, the system assesses exposure for its competitors and supply chain partners.
---
## The Three Signal Layers
Think of these as three analysts sitting at the same desk, each watching a different feed:
### Layer 1 — Company-Specific Intelligence
The bread and butter. Every news article and filing about a specific company gets scored for sentiment, impact magnitude, and time horizon. These signals are weighted by recency (yesterday's earnings matter more than last month's), source credibility, and novelty (the fifth article repeating the same news adds less information than the first).
Trend summaries roll up across five windows: intraday, 1 day, 7 days, 30 days, and 90 days — giving both a "what's happening right now" and a "what's the longer arc" view.
### Layer 2 — Macro & Geopolitical
Global events (trade wars, rate decisions, geopolitical crises, commodity shocks) are classified by impact type and severity. Each company has an exposure profile — geographic revenue mix, supply chain regions, commodity dependencies — that maps macro events down to company-level impact scores.
A tariff announcement on Chinese imports doesn't affect all 50 companies equally. Apple with its Chinese manufacturing exposure gets a higher impact score than Procter & Gamble with largely domestic supply chains.
### Layer 3 — Competitive & Historical Patterns
The system mines its own history: when this type of catalyst (say, an earnings beat) happened to this company in the past, what happened to the stock? What happened to its competitors? If NVIDIA reports a blowout quarter, does AMD tend to sell off or rally in sympathy?
This layer also tracks major corporate actions (M&A, restructurings, leadership changes) and propagates their implications across the competitive web.
**Safety rule:** The system never trades on macro or competitive signals alone. If there's no company-specific evidence supporting the thesis, the recommendation is downgraded to informational only.
---
## How a Trade Happens
Here's the chain from "news article published" to "paper order placed":
1. **Ingestion** — The article is fetched, deduplicated, and stored.
2. **Parsing** — Raw HTML is cleaned, boilerplate is stripped, quality is scored.
3. **Extraction** — The AI model reads the cleaned text and produces structured JSON: tickers mentioned, sentiment, catalysts, key facts, risks.
4. **Aggregation** — The new extraction is merged into rolling trend summaries for each mentioned company. Confidence, contradiction, and evidence depth are recalculated.
5. **Recommendation** — If the trend passes quality filters (enough evidence, high enough confidence, low enough contradiction, not stale), a BUY or SELL recommendation is generated with a written thesis.
6. **Risk checks** — The trading engine asks: Is the circuit breaker tripped? Is the market open? Do I already have too many positions? Is this sector already overweight? Are earnings in the next 48 hours?
7. **Position sizing** — Dollar amount is computed from confidence, portfolio heat, and the current risk tier (conservative / moderate / aggressive — auto-adjusted based on trailing performance).
8. **Execution** — The order goes to Alpaca's paper-trading API. Stop-loss and take-profit levels are set automatically based on the stock's recent volatility.
9. **Monitoring** — Open positions are tracked with trailing stops. If a position declines past its stop, it's closed. If it hits the take-profit target, it's closed.
10. **Scoring** — Days later, the prediction is evaluated against the actual price move. Did the call go the right way? Did the confidence track reality?
---
## Risk Management (Built In, Not Bolted On)
- **Circuit breakers** — If daily losses exceed a threshold or a single position loses too much, all trading halts automatically.
- **Position caps** — No single position can consume more than a set percentage of the portfolio.
- **Sector concentration limits** — The system won't pile into one sector even if all signals are bullish.
- **Correlation awareness** — New positions are rejected if they'd push portfolio correlation too high.
- **Earnings blackout** — Position sizes are reduced or skipped entirely within 48 hours of an earnings announcement.
- **Reserve pool** — Profits are partially siphoned into an emergency liquidity reserve.
- **Risk tier auto-adjustment** — The system evaluates its own Sharpe ratio, drawdown, and win rate daily and shifts between conservative, moderate, and aggressive modes.
---
## Self-Grading: The Validation Loop
Most trading systems tell you their view. Few systematically check whether that view was right.
Stonks Oracle captures every prediction as an immutable snapshot — the thesis, the confidence, the price at the time, the evidence cited. Then it waits. After the prediction's time horizon elapses (1 day, 7 days, 30 days), it compares the predicted direction against the actual price movement and computes:
- **Win rate** — What fraction of directional calls were correct?
- **Calibration** — When the system says "70% confident bullish," does the stock actually go up ~70% of the time? (If it only goes up 50% of the time, the system is overconfident.)
- **Information coefficient** — Does the system's score have any linear correlation with actual returns?
- **Excess return vs. SPY** — Is it adding alpha, or would you be better off in an index fund?
- **Source attribution** — Which news sources and signal types actually contribute to correct predictions? Which are noise?
If model quality drops below defined thresholds, a safety gate prevents the system from upgrading recommendations from "informational" to "paper eligible" — it forces itself to the sidelines until accuracy recovers.
---
## The Dashboard
A web-based interface lets you see everything the system sees:
- **Home** — Portfolio value, daily P&L, risk tier, active alerts.
- **Companies** — The tracked universe with current trend summaries and signal strength.
- **Documents** — Every ingested article and filing, with the AI's structured extraction visible.
- **Trends** — Per-company trend charts across all time windows, with evidence chains you can click through.
- **Recommendations** — Active and historical recommendations with full theses and risk classifications.
- **Trading** — The engine's status: open positions, reserve pool, circuit breaker state, portfolio heat map.
- **Orders & Positions** — Full trade blotter with execution details.
- **Macro Events** — Global event timeline showing what the system is tracking at the geopolitical level.
- **Reports** — AI-generated daily and weekly performance summaries.
- **Model Performance** — Calibration curves, win rate trends, source reliability scores.
- **SQL Explorer** — Ad-hoc queries against the full analytical data warehouse, with a chart builder.
---
## What It Is Not
- **Not a live trading system (yet).** All trades are paper trades through Alpaca's sandbox. The architecture supports live execution, but safety gates and validation must demonstrate consistent edge before real money is at risk.
- **Not a black box.** Every recommendation includes a full thesis, every trade has a decision trace, every prediction links back to the specific evidence that drove it.
- **Not a prediction guarantee.** Markets are hard. The system's value is in disciplined synthesis, consistent process, and honest self-measurement — not in claiming to always be right.
---
## Where It's Headed
Active development is upgrading the signal math from rule-based heuristics to probabilistic Bayesian inference — running both approaches in parallel, comparing their verdicts, and using the disagreements as training signals for continuous improvement. The goal is a system that not only reads the market but learns from its own track record which types of evidence, in which market regimes, actually predict future price moves.
@@ -0,0 +1,108 @@
{{- if .Values.specialist }}
{{- if .Values.specialist.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: specialist
namespace: {{ .Release.Namespace }}
labels:
app: specialist
{{- include "stonks.labels" . | nindent 4 }}
stonks-oracle/tier: processing
spec:
replicas: {{ .Values.specialist.replicas | default 2 }}
selector:
matchLabels:
app: specialist
template:
metadata:
labels:
app: specialist
stonks-oracle/tier: processing
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- include "stonks.podSecurityContext" . | nindent 8 }}
containers:
- name: specialist
image: {{ .Values.image.registry }}/specialist:{{ .Values.image.tag }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["sh", "-c", "uvicorn services.specialist.app:app --host 0.0.0.0 --port 8000"]
ports:
- containerPort: 8000
env:
- name: SPECIALIST_MODEL
value: {{ .Values.specialist.model | default "urchade/gliner_large-v2.1" | quote }}
- name: SPECIALIST_MAX_BATCH_SIZE
value: {{ .Values.specialist.maxBatchSize | default "32" | quote }}
- name: SPECIALIST_MAX_WAIT_MS
value: {{ .Values.specialist.maxWaitMs | default "50.0" | quote }}
- name: SPECIALIST_MAX_QUEUE_SIZE
value: {{ .Values.specialist.maxQueueSize | default "256" | quote }}
- name: SPECIALIST_TEST_MODE
value: {{ .Values.specialist.testMode | default "0" | quote }}
securityContext:
{{- include "stonks.containerSecurityContext" . | nindent 12 }}
envFrom:
- configMapRef:
name: stonks-config
{{- range .Values.specialist.secrets }}
- secretRef:
name: {{ . }}
{{- end }}
resources:
requests:
cpu: {{ .Values.specialist.resources.requests.cpu | default "2" | quote }}
memory: {{ .Values.specialist.resources.requests.memory | default "4Gi" }}
limits:
cpu: {{ .Values.specialist.resources.limits.cpu | default "6" | quote }}
memory: {{ .Values.specialist.resources.limits.memory | default "10Gi" }}
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
volumeMounts:
- name: tmp
mountPath: /tmp
- name: model-cache
mountPath: /root/.cache
volumes:
- name: tmp
emptyDir:
sizeLimit: 10Mi
- name: model-cache
emptyDir:
sizeLimit: 5Gi
---
apiVersion: v1
kind: Service
metadata:
name: specialist
namespace: {{ .Release.Namespace }}
labels:
app: specialist
{{- include "stonks.labels" . | nindent 4 }}
spec:
selector:
app: specialist
ports:
- port: 8000
targetPort: 8000
protocol: TCP
{{- end }}
{{- end }}
+18
View File
@@ -289,6 +289,24 @@ superset:
requests: { cpu: 200m, memory: 512Mi } requests: { cpu: 200m, memory: 512Mi }
limits: { cpu: "1", memory: 2Gi } limits: { cpu: "1", memory: 2Gi }
## Specialist inference service (CPU-first NER/classification)
specialist:
enabled: true
replicas: 2
model: "urchade/gliner_large-v2.1"
maxBatchSize: "32"
maxWaitMs: "50.0"
maxQueueSize: "256"
testMode: "0"
secrets: [stonks-core-secrets]
resources:
requests:
cpu: "2"
memory: 4Gi
limits:
cpu: "6"
memory: 10Gi
## Network policies ## Network policies
networkPolicies: networkPolicies:
enabled: true enabled: true
+122
View File
@@ -0,0 +1,122 @@
-- Migration 040: Inference Registry
-- Creates tables for the capability-aware inference gateway:
-- inference_endpoints, model_deployments, agent_stage_bindings
-- Adds lineage columns to agent_performance_log for v3 provenance tracking.
-- ─── Helper: auto-update updated_at on row modification ───────────────────────
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ─── inference_endpoints ──────────────────────────────────────────────────────
-- Stores registered inference service endpoints (Ollama, vLLM, OpenAI-compat, specialist).
CREATE TABLE IF NOT EXISTS inference_endpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
protocol TEXT NOT NULL CHECK (protocol IN ('ollama_native', 'openai_chat', 'specialist_http')),
base_url TEXT NOT NULL,
auth_secret_ref TEXT,
auth_scheme TEXT NOT NULL DEFAULT 'bearer',
default_headers JSONB NOT NULL DEFAULT '{}',
health_path TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_inference_endpoints_protocol
ON inference_endpoints(protocol);
-- Auto-update updated_at on inference_endpoints changes
DROP TRIGGER IF EXISTS trg_inference_endpoints_updated_at ON inference_endpoints;
CREATE TRIGGER trg_inference_endpoints_updated_at
BEFORE UPDATE ON inference_endpoints
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ─── model_deployments ────────────────────────────────────────────────────────
-- A model served by an endpoint, with declared capabilities and limits.
CREATE TABLE IF NOT EXISTS model_deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint_id UUID NOT NULL REFERENCES inference_endpoints(id) ON DELETE CASCADE,
served_model_name TEXT NOT NULL,
display_name TEXT NOT NULL,
capabilities JSONB NOT NULL,
context_window INTEGER,
max_output_tokens INTEGER,
quantization TEXT,
runtime_metadata JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(endpoint_id, served_model_name)
);
CREATE INDEX IF NOT EXISTS idx_model_deployments_endpoint
ON model_deployments(endpoint_id);
-- Auto-update updated_at on model_deployments changes
DROP TRIGGER IF EXISTS trg_model_deployments_updated_at ON model_deployments;
CREATE TRIGGER trg_model_deployments_updated_at
BEFORE UPDATE ON model_deployments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ─── agent_stage_bindings ─────────────────────────────────────────────────────
-- Maps an agent + pipeline stage to one or more ordered model deployments.
CREATE TABLE IF NOT EXISTS agent_stage_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES ai_agents(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
model_deployment_id UUID REFERENCES model_deployments(id) ON DELETE SET NULL,
route_order INTEGER NOT NULL DEFAULT 0,
routing_config JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(agent_id, stage, route_order)
);
CREATE INDEX IF NOT EXISTS idx_agent_stage_bindings_agent
ON agent_stage_bindings(agent_id);
CREATE INDEX IF NOT EXISTS idx_agent_stage_bindings_deployment
ON agent_stage_bindings(model_deployment_id);
-- Auto-update updated_at on agent_stage_bindings changes
DROP TRIGGER IF EXISTS trg_agent_stage_bindings_updated_at ON agent_stage_bindings;
CREATE TRIGGER trg_agent_stage_bindings_updated_at
BEFORE UPDATE ON agent_stage_bindings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ─── Additive lineage columns on agent_performance_log ────────────────────────
-- Tracks which endpoint/deployment/binding was used for each logged invocation.
-- NOTE: Revision increment logic is handled at the application layer:
-- each UPDATE to inference_endpoints, model_deployments, or agent_stage_bindings
-- should increment the revision column (enforced by service code, not DB trigger,
-- to allow flexible conflict resolution).
ALTER TABLE agent_performance_log
ADD COLUMN IF NOT EXISTS endpoint_id UUID REFERENCES inference_endpoints(id) ON DELETE SET NULL;
ALTER TABLE agent_performance_log
ADD COLUMN IF NOT EXISTS deployment_id UUID REFERENCES model_deployments(id) ON DELETE SET NULL;
ALTER TABLE agent_performance_log
ADD COLUMN IF NOT EXISTS binding_revision INTEGER;
ALTER TABLE agent_performance_log
ADD COLUMN IF NOT EXISTS structured_mode TEXT;
CREATE INDEX IF NOT EXISTS idx_agent_perf_endpoint
ON agent_performance_log(endpoint_id)
WHERE endpoint_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agent_perf_deployment
ON agent_performance_log(deployment_id)
WHERE deployment_id IS NOT NULL;
+350
View File
@@ -0,0 +1,350 @@
-- Migration 041: V3 Pipeline Persistence Tables
-- Creates tables for the Intelligence Pipeline v3 staged evidence architecture:
-- v3_pipeline_runs, v3_stage_runs, v3_document_chunks, v3_evidence_spans,
-- v3_extracted_entities, v3_extracted_facts, v3_extracted_relations,
-- v3_rejected_candidates, v3_company_signal_candidates,
-- v3_adjudication_decisions, v3_routing_decisions, v3_stage_lineage
-- Includes idempotency keys, immutable-revision constraints, and appropriate indexes.
-- ═══════════════════════════════════════════════════════════════════════════════
-- 20.1: Pipeline runs and stage runs
-- ═══════════════════════════════════════════════════════════════════════════════
-- ─── v3_pipeline_runs ─────────────────────────────────────────────────────────
-- Top-level pipeline execution record for a document.
CREATE TABLE IF NOT EXISTS v3_pipeline_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL,
pipeline_version TEXT NOT NULL DEFAULT 'v3.0',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
idempotency_key TEXT NOT NULL UNIQUE,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_pipeline_runs_document
ON v3_pipeline_runs(document_id);
CREATE INDEX IF NOT EXISTS idx_v3_pipeline_runs_status
ON v3_pipeline_runs(status);
CREATE INDEX IF NOT EXISTS idx_v3_pipeline_runs_created
ON v3_pipeline_runs(created_at DESC);
-- ─── v3_stage_runs ────────────────────────────────────────────────────────────
-- Individual stage execution within a pipeline run.
CREATE TABLE IF NOT EXISTS v3_stage_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
stage TEXT NOT NULL CHECK (stage IN (
'segmentation', 'extraction', 'sentiment', 'novelty',
'routing', 'adjudication', 'impact', 'persistence'
)),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped')),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
endpoint_id UUID REFERENCES inference_endpoints(id) ON DELETE SET NULL,
deployment_id UUID REFERENCES model_deployments(id) ON DELETE SET NULL,
model_version TEXT,
schema_version TEXT,
input_refs JSONB NOT NULL DEFAULT '[]',
output_refs JSONB NOT NULL DEFAULT '[]',
trace_id TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_stage_runs_pipeline
ON v3_stage_runs(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_stage_runs_stage
ON v3_stage_runs(stage);
CREATE INDEX IF NOT EXISTS idx_v3_stage_runs_status
ON v3_stage_runs(status);
-- ═══════════════════════════════════════════════════════════════════════════════
-- 20.2: Document chunks and evidence spans
-- ═══════════════════════════════════════════════════════════════════════════════
-- ─── v3_document_chunks ───────────────────────────────────────────────────────
-- Segmented document chunks with offset tracking.
CREATE TABLE IF NOT EXISTS v3_document_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL,
chunk_id TEXT NOT NULL,
section_path JSONB NOT NULL DEFAULT '[]',
speaker TEXT,
start_char INTEGER NOT NULL,
end_char INTEGER NOT NULL,
text TEXT NOT NULL,
overlap_left INTEGER NOT NULL DEFAULT 0,
overlap_right INTEGER NOT NULL DEFAULT 0,
boilerplate_score REAL NOT NULL DEFAULT 0.0,
document_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(document_id, chunk_id)
);
CREATE INDEX IF NOT EXISTS idx_v3_document_chunks_document
ON v3_document_chunks(document_id);
CREATE INDEX IF NOT EXISTS idx_v3_document_chunks_type
ON v3_document_chunks(document_type)
WHERE document_type IS NOT NULL;
-- ─── v3_evidence_spans ────────────────────────────────────────────────────────
-- Exact source text with character offsets for provenance.
CREATE TABLE IF NOT EXISTS v3_evidence_spans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL,
chunk_id TEXT,
start_char INTEGER NOT NULL,
end_char INTEGER NOT NULL,
text TEXT NOT NULL,
checksum TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_evidence_spans_document
ON v3_evidence_spans(document_id);
CREATE INDEX IF NOT EXISTS idx_v3_evidence_spans_checksum
ON v3_evidence_spans(checksum);
-- ═══════════════════════════════════════════════════════════════════════════════
-- 20.3: Extracted entities, facts, relations, and rejected candidates
-- ═══════════════════════════════════════════════════════════════════════════════
-- ─── v3_extracted_entities ────────────────────────────────────────────────────
-- Entities discovered during extraction (companies, people, orgs, etc.).
CREATE TABLE IF NOT EXISTS v3_extracted_entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
literal_text TEXT NOT NULL,
canonical_id UUID,
evidence_span_id UUID REFERENCES v3_evidence_spans(id) ON DELETE SET NULL,
confidence REAL NOT NULL DEFAULT 0.0,
derivation TEXT NOT NULL DEFAULT 'specialist',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_entities_pipeline
ON v3_extracted_entities(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_entities_canonical
ON v3_extracted_entities(canonical_id)
WHERE canonical_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_v3_extracted_entities_type
ON v3_extracted_entities(entity_type);
-- ─── v3_extracted_facts ───────────────────────────────────────────────────────
-- Structured facts (numeric values, dates, amounts, etc.).
CREATE TABLE IF NOT EXISTS v3_extracted_facts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
fact_type TEXT NOT NULL,
subject_entity_id UUID REFERENCES v3_extracted_entities(id) ON DELETE SET NULL,
predicate TEXT NOT NULL,
literal_value TEXT NOT NULL,
normalized_value JSONB,
unit TEXT,
period JSONB,
evidence_span_ids UUID[] NOT NULL DEFAULT '{}',
confidence REAL NOT NULL DEFAULT 0.0,
derivation TEXT NOT NULL DEFAULT 'deterministic',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_facts_pipeline
ON v3_extracted_facts(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_facts_subject
ON v3_extracted_facts(subject_entity_id)
WHERE subject_entity_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_v3_extracted_facts_type
ON v3_extracted_facts(fact_type);
-- ─── v3_extracted_relations ───────────────────────────────────────────────────
-- Relations between entities (competes_with, supplies, etc.).
CREATE TABLE IF NOT EXISTS v3_extracted_relations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL,
source_entity_id UUID NOT NULL REFERENCES v3_extracted_entities(id) ON DELETE CASCADE,
target_entity_id UUID NOT NULL REFERENCES v3_extracted_entities(id) ON DELETE CASCADE,
evidence_span_ids UUID[] NOT NULL DEFAULT '{}',
confidence REAL NOT NULL DEFAULT 0.0,
derivation TEXT NOT NULL DEFAULT 'specialist',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_relations_pipeline
ON v3_extracted_relations(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_relations_source
ON v3_extracted_relations(source_entity_id);
CREATE INDEX IF NOT EXISTS idx_v3_extracted_relations_target
ON v3_extracted_relations(target_entity_id);
-- ─── v3_rejected_candidates ──────────────────────────────────────────────────
-- Candidates that failed validation or were rejected by a stage.
CREATE TABLE IF NOT EXISTS v3_rejected_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
candidate_type TEXT NOT NULL,
candidate_data JSONB NOT NULL,
rejection_reason TEXT NOT NULL,
stage TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_rejected_candidates_pipeline
ON v3_rejected_candidates(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_rejected_candidates_stage
ON v3_rejected_candidates(stage);
-- ═══════════════════════════════════════════════════════════════════════════════
-- 20.4: Company signal candidates and probability distributions
-- ═══════════════════════════════════════════════════════════════════════════════
-- ─── v3_company_signal_candidates ─────────────────────────────────────────────
-- Per-company signal output with full probability distributions.
CREATE TABLE IF NOT EXISTS v3_company_signal_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
relevance_probability REAL NOT NULL DEFAULT 0.0,
event_probabilities JSONB NOT NULL DEFAULT '{}',
sentiment_probabilities JSONB NOT NULL DEFAULT '{}',
direction_probabilities JSONB NOT NULL DEFAULT '{}',
horizon_probabilities JSONB NOT NULL DEFAULT '{}',
expected_magnitude REAL,
evidence_span_ids UUID[] NOT NULL DEFAULT '{}',
routing_reasons TEXT[] NOT NULL DEFAULT '{}',
adjudicated BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_signal_candidates_pipeline
ON v3_company_signal_candidates(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_signal_candidates_company
ON v3_company_signal_candidates(company_id);
CREATE INDEX IF NOT EXISTS idx_v3_signal_candidates_adjudicated
ON v3_company_signal_candidates(adjudicated)
WHERE adjudicated = TRUE;
-- ═══════════════════════════════════════════════════════════════════════════════
-- 20.5: Adjudication decisions, routing reasons, calibration, and model lineage
-- ═══════════════════════════════════════════════════════════════════════════════
-- ─── v3_stage_lineage ─────────────────────────────────────────────────────────
-- Detailed model/endpoint lineage for each stage invocation.
-- Created before adjudication_decisions because it is referenced as a FK.
CREATE TABLE IF NOT EXISTS v3_stage_lineage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stage_run_id UUID NOT NULL REFERENCES v3_stage_runs(id) ON DELETE CASCADE,
endpoint_id UUID REFERENCES inference_endpoints(id) ON DELETE SET NULL,
deployment_id UUID REFERENCES model_deployments(id) ON DELETE SET NULL,
model TEXT,
protocol TEXT,
structured_mode TEXT,
request_id TEXT,
latency_ms INTEGER,
retries INTEGER NOT NULL DEFAULT 0,
trace_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_stage_lineage_stage_run
ON v3_stage_lineage(stage_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_stage_lineage_endpoint
ON v3_stage_lineage(endpoint_id)
WHERE endpoint_id IS NOT NULL;
-- ─── v3_adjudication_decisions ────────────────────────────────────────────────
-- Decisions made by the 9B adjudicator for ambiguous documents.
CREATE TABLE IF NOT EXISTS v3_adjudication_decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
question_codes TEXT[] NOT NULL DEFAULT '{}',
candidates JSONB NOT NULL DEFAULT '{}',
decision JSONB NOT NULL DEFAULT '{}',
evidence_span_ids UUID[] NOT NULL DEFAULT '{}',
model_lineage_id UUID REFERENCES v3_stage_lineage(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_adjudication_pipeline
ON v3_adjudication_decisions(pipeline_run_id);
-- ─── v3_routing_decisions ─────────────────────────────────────────────────────
-- Records of fast-path vs adjudication routing decisions.
CREATE TABLE IF NOT EXISTS v3_routing_decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_run_id UUID NOT NULL REFERENCES v3_pipeline_runs(id) ON DELETE CASCADE,
document_id UUID NOT NULL,
route TEXT NOT NULL CHECK (route IN ('fast_path', 'adjudication')),
reason_codes TEXT[] NOT NULL DEFAULT '{}',
confidence_features JSONB NOT NULL DEFAULT '{}',
decided_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_v3_routing_pipeline
ON v3_routing_decisions(pipeline_run_id);
CREATE INDEX IF NOT EXISTS idx_v3_routing_route
ON v3_routing_decisions(route);
-- ═══════════════════════════════════════════════════════════════════════════════
-- 20.6: Idempotency and immutable-revision constraints
-- ═══════════════════════════════════════════════════════════════════════════════
-- Pipeline runs: idempotency_key UNIQUE is already defined above in the table.
-- Stage runs: unique per pipeline_run_id + stage to prevent duplicate stage execution.
CREATE UNIQUE INDEX IF NOT EXISTS idx_v3_stage_runs_idempotent
ON v3_stage_runs(pipeline_run_id, stage);
-- Company signal candidates: unique per pipeline_run_id + company_id.
CREATE UNIQUE INDEX IF NOT EXISTS idx_v3_signal_candidates_idempotent
ON v3_company_signal_candidates(pipeline_run_id, company_id);
-- Routing decisions: unique per pipeline_run_id (one routing decision per run).
CREATE UNIQUE INDEX IF NOT EXISTS idx_v3_routing_idempotent
ON v3_routing_decisions(pipeline_run_id);
-- Evidence spans: unique by document + checksum to avoid storing duplicates.
CREATE UNIQUE INDEX IF NOT EXISTS idx_v3_evidence_spans_idempotent
ON v3_evidence_spans(document_id, checksum);
-- Immutable revision rule: pipeline_runs and stage_runs cannot be updated once completed.
-- Enforced via trigger: reject updates to rows where status = 'completed' or 'failed'.
CREATE OR REPLACE FUNCTION v3_immutable_completed_row()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IN ('completed', 'failed') THEN
RAISE EXCEPTION 'Cannot modify a % record with status=%', TG_TABLE_NAME, OLD.status;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_v3_pipeline_runs_immutable ON v3_pipeline_runs;
CREATE TRIGGER trg_v3_pipeline_runs_immutable
BEFORE UPDATE ON v3_pipeline_runs
FOR EACH ROW EXECUTE FUNCTION v3_immutable_completed_row();
DROP TRIGGER IF EXISTS trg_v3_stage_runs_immutable ON v3_stage_runs;
CREATE TRIGGER trg_v3_stage_runs_immutable
BEFORE UPDATE ON v3_stage_runs
FOR EACH ROW EXECUTE FUNCTION v3_immutable_completed_row();
@@ -0,0 +1,74 @@
-- Migration 042: Seed Inference Registry
-- Populates initial endpoint profiles and model deployments for the
-- existing Ollama and vLLM services.
--
-- Task 18.1: Create the current Ollama endpoint profile
-- Task 18.2: Create the current vLLM OpenAI-compatible endpoint profile
-- Task 18.3: Create model deployments matching actual runtime state
--
-- This is a DATA migration. The schema was created in 040_inference_registry.sql.
-- Uses ON CONFLICT DO NOTHING for idempotency.
-- ─── 18.1: Ollama endpoint profile ───────────────────────────────────────────
INSERT INTO inference_endpoints (id, name, protocol, base_url, auth_secret_ref, auth_scheme, default_headers, health_path, enabled)
VALUES (
'a0000000-0000-4000-8000-000000000001'::uuid,
'stonks-ollama',
'ollama_native',
'http://ollama.ollama-service.svc.cluster.local:11434',
NULL,
'none',
'{}',
'/api/tags',
TRUE
)
ON CONFLICT (name) DO NOTHING;
-- ─── 18.2: vLLM OpenAI-compatible endpoint profile ──────────────────────────
INSERT INTO inference_endpoints (id, name, protocol, base_url, auth_secret_ref, auth_scheme, default_headers, health_path, enabled)
VALUES (
'a0000000-0000-4000-8000-000000000002'::uuid,
'stonks-vllm',
'openai_chat',
'http://kube-vllm.stonks-oracle.svc.cluster.local:8000',
NULL,
'none',
'{}',
'/health',
TRUE
)
ON CONFLICT (name) DO NOTHING;
-- ─── 18.3: Model deployments ─────────────────────────────────────────────────
-- Ollama model deployment (qwen3.5:9b served via Ollama native protocol)
INSERT INTO model_deployments (id, endpoint_id, served_model_name, display_name, capabilities, context_window, max_output_tokens, quantization, runtime_metadata, enabled)
VALUES (
'b0000000-0000-4000-8000-000000000001'::uuid,
'a0000000-0000-4000-8000-000000000001'::uuid,
'qwen3.5:9b',
'Qwen 3.5 9B (Ollama)',
'{"chat_completions": true, "json_schema": false, "json_object": true, "seed": false, "usage": false, "max_completion_tokens": false, "model_listing": true}',
32768,
32768,
NULL,
'{"source": "ollama_native", "notes": "Ollama-served model with native JSON mode"}',
TRUE
)
ON CONFLICT (endpoint_id, served_model_name) DO NOTHING;
-- vLLM model deployment (AxionML/Qwen3.5-9B-NVFP4 on RTX 4070 Ti SUPER)
INSERT INTO model_deployments (id, endpoint_id, served_model_name, display_name, capabilities, context_window, max_output_tokens, quantization, runtime_metadata, enabled)
VALUES (
'b0000000-0000-4000-8000-000000000002'::uuid,
'a0000000-0000-4000-8000-000000000002'::uuid,
'AxionML/Qwen3.5-9B-NVFP4',
'Qwen 3.5 9B NVFP4 (vLLM)',
'{"chat_completions": true, "json_schema": true, "json_object": true, "seed": true, "usage": true, "max_completion_tokens": true, "model_listing": true}',
8192,
2048,
'NVFP4',
'{"gpu": "RTX 4070 Ti SUPER", "gpu_memory_utilization": 0.80, "max_num_seqs": 8, "vllm_structured_outputs": true}',
TRUE
)
ON CONFLICT (endpoint_id, served_model_name) DO NOTHING;
+3
View File
@@ -16,6 +16,9 @@ httpx>=0.27.0
# JSON repair for LLM output # JSON repair for LLM output
json-repair>=0.59.0 json-repair>=0.59.0
# JSON Schema validation
jsonschema>=4.20.0
# Web scraping # Web scraping
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
requests>=2.31.0 requests>=2.31.0
+225
View File
@@ -0,0 +1,225 @@
"""Inference adapter bridging the document extractor to the InferenceGateway.
Replaces direct use of llm_factory / VLLMClient / OllamaClient in the
extraction pipeline. Uses the shared InferenceGateway with extraction-
specific prompt construction and records actual endpoint, deployment,
model, and protocol lineage in the result.
Requirements: 2.12, 13.6
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from services.extractor.client import (
ExtractionAttempt,
ExtractionResponse,
_repair_json,
_strip_markdown_fences,
)
from services.extractor.prompts import (
build_extraction_prompt,
get_json_schema,
get_prompt_metadata,
)
from services.extractor.schemas import validate_extraction
from services.shared.inference.gateway import InferenceGateway
from services.shared.inference.lineage import ModelLineage, build_lineage_from_result
from services.shared.inference.models import (
ChatMessage,
InferenceResult,
InferenceTarget,
StructuredGenerationRequest,
)
logger = logging.getLogger("extractor.inference_adapter")
@dataclass
class ExtractionWithLineage:
"""Extraction result bundled with inference lineage metadata.
The lineage records the actual endpoint, deployment, model, and
protocol used — fixing the hardcoded ``model_provider = 'ollama'``.
"""
response: ExtractionResponse
lineage: ModelLineage
raw_inference_results: list[InferenceResult] = field(default_factory=list)
async def extract_document(
gateway: InferenceGateway,
target: InferenceTarget,
document_text: str,
document_type: str = "article",
document_id: str = "",
known_tickers: list[str] | None = None,
max_retries: int = 3,
retry_base_delay: float = 2.0,
retry_max_delay: float = 30.0,
retry_backoff_multiplier: float = 2.0,
) -> ExtractionWithLineage:
"""Extract structured intelligence from a document via the InferenceGateway.
This adapter:
1. Builds extraction-specific prompts (same as current pipeline)
2. Constructs a StructuredGenerationRequest
3. Routes through the InferenceGateway (correct client per protocol)
4. Parses / repairs JSON, validates against the extraction schema
5. Records actual lineage (endpoint_id, deployment_id, model, protocol)
Args:
gateway: The shared InferenceGateway instance.
target: Resolved inference target for extraction.
document_text: The document to extract from.
document_type: Type of document (article, filing, transcript, etc.).
document_id: UUID of the source document.
known_tickers: Optional list of tracked tickers for context.
max_retries: Maximum number of retry attempts.
retry_base_delay: Initial retry delay in seconds.
retry_max_delay: Maximum retry delay in seconds.
retry_backoff_multiplier: Backoff multiplier for retries.
Returns:
ExtractionWithLineage containing the extraction response and lineage.
"""
import asyncio
prompts = build_extraction_prompt(
document_text=document_text,
document_type=document_type,
document_id=document_id,
known_tickers=known_tickers,
)
json_schema = get_json_schema()
prompt_meta = get_prompt_metadata()
response = ExtractionResponse(
prompt_metadata=prompt_meta,
model=target.model,
)
inference_results: list[InferenceResult] = []
last_lineage: ModelLineage | None = None
total_start = time.monotonic()
for attempt_num in range(max_retries + 1):
# Build request
request = StructuredGenerationRequest(
messages=[
ChatMessage(role="system", content=prompts["system"]),
ChatMessage(role="user", content=prompts["user"]),
],
json_schema=json_schema,
max_output_tokens=target.extra_body.get("max_tokens", 4096),
temperature=0.0,
seed=0,
timeout_seconds=target.timeout_seconds,
trace_id=document_id,
)
# Call via gateway
result = await gateway.generate(target, request)
inference_results.append(result)
last_lineage = build_lineage_from_result(result, trace_id=document_id)
# Convert to ExtractionAttempt for compatibility
attempt = _inference_result_to_attempt(result, target.model, document_text)
response.attempts.append(attempt)
if attempt.error is None and attempt.validation and attempt.validation.valid:
response.success = True
response.result = attempt.validation.parsed
break
# Determine if retryable
retryable = _is_result_retryable(result)
attempt.retryable = retryable
if not retryable:
logger.warning(
"Non-retryable error for doc %s: %s — stopping retries",
document_id or "unknown",
attempt.error,
)
break
if attempt_num < max_retries:
delay = retry_base_delay * (retry_backoff_multiplier ** attempt_num)
delay = min(delay, retry_max_delay)
logger.warning(
"Extraction attempt %d/%d failed for doc %s: %s — retrying in %.1fs",
attempt_num + 1,
max_retries + 1,
document_id or "unknown",
attempt.error or "validation failed",
delay,
)
await asyncio.sleep(delay)
response.total_duration_ms = int((time.monotonic() - total_start) * 1000)
# Use actual lineage from last inference call
lineage = last_lineage or ModelLineage(model=target.model, protocol=target.protocol)
return ExtractionWithLineage(
response=response,
lineage=lineage,
raw_inference_results=inference_results,
)
def _inference_result_to_attempt(
result: InferenceResult,
model: str,
document_text: str,
) -> ExtractionAttempt:
"""Convert an InferenceResult to the legacy ExtractionAttempt format.
Applies the same markdown-fence stripping, JSON repair, and schema
validation as the existing VLLMClient and OllamaClient.
"""
attempt = ExtractionAttempt(model=model)
attempt.duration_ms = result.latency_ms
attempt.raw_output = result.content
# Check for gateway-level errors
if result.error:
attempt.error = result.error
attempt.retryable = _is_result_retryable(result)
return attempt
content = result.content
if not content:
attempt.error = "empty_model_response"
return attempt
# Strip markdown fences if present
content = _strip_markdown_fences(content)
# Repair malformed JSON
content = _repair_json(content)
# Validate against extraction schema
attempt.validation = validate_extraction(content, document_text=document_text)
if not attempt.validation.valid:
attempt.error = "; ".join(attempt.validation.errors)
return attempt
def _is_result_retryable(result: InferenceResult) -> bool:
"""Determine if an inference result error is retryable."""
if result.error_category in (
"timeout",
"rate_limit",
"server_error",
"connection_error",
):
return True
if result.error and "empty" in result.error.lower():
return True
return False
+8
View File
@@ -0,0 +1,8 @@
"""Inference Registry API service.
FastAPI router for managing inference_endpoints, model_deployments,
and agent_stage_bindings. Auth secret values are NEVER returned in
responses.
Requirements: 3.6, 3.7
"""
+634
View File
@@ -0,0 +1,634 @@
"""FastAPI router for the inference registry API.
Manages inference_endpoints, model_deployments, and agent_stage_bindings.
Auth secret values are NEVER returned in any response.
Endpoints:
- CRUD for inference_endpoints (19.1)
- probe, enable, disable, test-structured-output actions (19.2)
- Protocol/endpoint/deployment selectors (19.3)
- Display last probe, capabilities, limits, bindings (19.4)
- External egress confirmation (19.5)
Requirements: 3.6, 3.7
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from services.inference_registry.schemas import (
BindingCreate,
BindingResponse,
DeploymentCreate,
DeploymentResponse,
EgressConfirmation,
EndpointCreate,
EndpointListResponse,
EndpointResponse,
EndpointUpdate,
ProbeResponse,
StructuredOutputTestRequest,
StructuredOutputTestResponse,
)
from services.inference_registry.security import (
is_external_endpoint,
redact_binding,
redact_deployment,
redact_endpoint,
redact_endpoint_for_list,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/inference", tags=["inference-registry"])
# ---------------------------------------------------------------------------
# Dependency injection protocol for database access
# ---------------------------------------------------------------------------
class InferenceRegistryDB:
"""Protocol for inference registry database operations.
In production, backed by asyncpg pool. In tests, a mock implements this.
"""
async def list_endpoints(self) -> list[dict[str, Any]]:
raise NotImplementedError
async def get_endpoint(self, endpoint_id: uuid.UUID) -> dict[str, Any] | None:
raise NotImplementedError
async def create_endpoint(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
async def update_endpoint(
self, endpoint_id: uuid.UUID, data: dict[str, Any]
) -> dict[str, Any] | None:
raise NotImplementedError
async def disable_endpoint(self, endpoint_id: uuid.UUID) -> dict[str, Any] | None:
raise NotImplementedError
async def list_deployments(
self, endpoint_id: uuid.UUID | None = None
) -> list[dict[str, Any]]:
raise NotImplementedError
async def get_deployment(self, deployment_id: uuid.UUID) -> dict[str, Any] | None:
raise NotImplementedError
async def create_deployment(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
async def list_bindings(
self, agent_id: uuid.UUID | None = None,
endpoint_id: uuid.UUID | None = None,
) -> list[dict[str, Any]]:
raise NotImplementedError
async def create_binding(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
async def get_bindings_for_endpoint(
self, endpoint_id: uuid.UUID
) -> list[dict[str, Any]]:
raise NotImplementedError
async def get_last_probe(
self, endpoint_id: uuid.UUID
) -> dict[str, Any] | None:
raise NotImplementedError
async def store_probe_result(
self, endpoint_id: uuid.UUID, result: dict[str, Any]
) -> None:
raise NotImplementedError
async def get_egress_confirmation(
self, endpoint_id: uuid.UUID
) -> bool:
raise NotImplementedError
async def store_egress_confirmation(
self, endpoint_id: uuid.UUID
) -> None:
raise NotImplementedError
# Global DB instance (set during app startup)
_db: InferenceRegistryDB | None = None
def set_db(db: InferenceRegistryDB) -> None:
"""Set the database dependency for the router."""
global _db
_db = db
def get_db() -> InferenceRegistryDB:
"""Get the database dependency."""
if _db is None:
raise HTTPException(503, "Database not initialized")
return _db
# ---------------------------------------------------------------------------
# Endpoint CRUD (19.1)
# ---------------------------------------------------------------------------
@router.get("/endpoints", response_model=list[EndpointListResponse])
async def list_endpoints(db: InferenceRegistryDB = Depends(get_db)):
"""List all inference endpoints with secrets redacted."""
endpoints = await db.list_endpoints()
return [redact_endpoint_for_list(ep) for ep in endpoints]
@router.get("/endpoints/{endpoint_id}", response_model=EndpointResponse)
async def get_endpoint(
endpoint_id: uuid.UUID,
db: InferenceRegistryDB = Depends(get_db),
):
"""Get endpoint detail with secrets redacted.
Includes last probe results, capabilities, and active stage bindings (19.4).
"""
endpoint = await db.get_endpoint(endpoint_id)
if endpoint is None:
raise HTTPException(404, "Endpoint not found")
response = redact_endpoint(endpoint)
# Attach last probe result (19.4)
probe_data = await db.get_last_probe(endpoint_id)
if probe_data:
response.last_probe = ProbeResponse(**probe_data)
# Attach capabilities from deployments
deployments = await db.list_deployments(endpoint_id=endpoint_id)
if deployments:
# Aggregate capabilities from all deployments
combined_caps: dict[str, Any] = {}
for dep in deployments:
caps = dep.get("capabilities", {})
for k, v in caps.items():
if v:
combined_caps[k] = True
response.capabilities = combined_caps
# Attach active bindings (19.4)
bindings = await db.get_bindings_for_endpoint(endpoint_id)
if bindings:
response.active_bindings = [redact_binding(b) for b in bindings]
return response
@router.post("/endpoints", response_model=EndpointResponse, status_code=201)
async def create_endpoint(
body: EndpointCreate,
db: InferenceRegistryDB = Depends(get_db),
):
"""Create a new inference endpoint.
Validates protocol and URL format. External endpoints require
egress confirmation before they can be enabled (19.5).
"""
now = datetime.now(timezone.utc)
endpoint_data = {
"id": uuid.uuid4(),
"name": body.name,
"protocol": body.protocol,
"base_url": body.base_url,
"auth_secret_ref": body.auth_secret_ref,
"auth_scheme": body.auth_scheme,
"default_headers": body.default_headers,
"health_path": body.health_path,
"enabled": body.enabled,
"revision": 1,
"created_at": now,
"updated_at": now,
}
# If external, require egress confirmation before enabling (19.5)
if body.enabled and is_external_endpoint(body.base_url):
endpoint_data["enabled"] = False # Will need confirm-egress call
created = await db.create_endpoint(endpoint_data)
return redact_endpoint(created)
@router.put("/endpoints/{endpoint_id}", response_model=EndpointResponse)
async def update_endpoint(
endpoint_id: uuid.UUID,
body: EndpointUpdate,
db: InferenceRegistryDB = Depends(get_db),
):
"""Update an existing inference endpoint."""
existing = await db.get_endpoint(endpoint_id)
if existing is None:
raise HTTPException(404, "Endpoint not found")
update_data: dict[str, Any] = {}
for field_name in (
"name", "protocol", "base_url", "auth_secret_ref",
"auth_scheme", "default_headers", "health_path", "enabled",
):
value = getattr(body, field_name)
if value is not None:
update_data[field_name] = value
if update_data:
update_data["updated_at"] = datetime.now(timezone.utc)
update_data["revision"] = existing.get("revision", 1) + 1
updated = await db.update_endpoint(endpoint_id, update_data)
if updated is None:
raise HTTPException(404, "Endpoint not found")
return redact_endpoint(updated)
@router.delete("/endpoints/{endpoint_id}", response_model=EndpointResponse)
async def delete_endpoint(
endpoint_id: uuid.UUID,
db: InferenceRegistryDB = Depends(get_db),
):
"""Soft-delete (disable) an inference endpoint."""
disabled = await db.disable_endpoint(endpoint_id)
if disabled is None:
raise HTTPException(404, "Endpoint not found")
return redact_endpoint(disabled)
# ---------------------------------------------------------------------------
# Endpoint actions (19.2)
# ---------------------------------------------------------------------------
@router.post("/endpoints/{endpoint_id}/probe", response_model=ProbeResponse)
async def probe_endpoint(
endpoint_id: uuid.UUID,
db: InferenceRegistryDB = Depends(get_db),
):
"""Run a capability probe against the endpoint.
Performs health check, model listing, JSON Schema test,
usage metadata check, seed determinism check, and output-token field check.
"""
endpoint = await db.get_endpoint(endpoint_id)
if endpoint is None:
raise HTTPException(404, "Endpoint not found")
# Import the prober
from services.shared.inference.capabilities import EndpointProber, FullProbeResult
from services.shared.inference.models import InferenceTarget, ProviderCapabilities
# Get a deployment to probe with (need a model name)
deployments = await db.list_deployments(endpoint_id=endpoint_id)
model_name = "default"
caps_data: dict[str, Any] = {}
deployment_id = uuid.uuid4()
if deployments:
model_name = deployments[0].get("served_model_name", "default")
caps_data = deployments[0].get("capabilities", {})
deployment_id = deployments[0]["id"]
capabilities = ProviderCapabilities(
chat_completions=caps_data.get("chat_completions", True),
responses_api=caps_data.get("responses_api", False),
json_schema=caps_data.get("json_schema", False),
json_object=caps_data.get("json_object", False),
seed=caps_data.get("seed", False),
usage=caps_data.get("usage", False),
max_completion_tokens=caps_data.get("max_completion_tokens", False),
reasoning_toggle=caps_data.get("reasoning_toggle", False),
model_listing=caps_data.get("model_listing", False),
)
target = InferenceTarget(
endpoint_id=endpoint_id,
deployment_id=deployment_id,
protocol=endpoint["protocol"],
base_url=endpoint["base_url"],
model=model_name,
capabilities=capabilities,
auth_secret_ref=endpoint.get("auth_secret_ref"),
auth_scheme=endpoint.get("auth_scheme", "bearer"),
extra_headers=endpoint.get("default_headers") or {},
)
prober = EndpointProber()
try:
result: FullProbeResult = await prober.run_full_probe(target)
finally:
await prober.close()
# Build probe response
probe_response = ProbeResponse(
endpoint_id=endpoint_id,
timestamp=result.timestamp,
software_version=result.software_version,
probe_duration_ms=result.probe_duration_ms,
health_success=result.health.success if result.health else False,
health_detail=result.health.detail if result.health else "",
model_listing_success=(
result.model_listing.success if result.model_listing else None
),
json_schema_success=(
result.json_schema.success if result.json_schema else None
),
usage_success=(
result.usage_metadata.success if result.usage_metadata else None
),
seed_success=(
result.seed_determinism.success if result.seed_determinism else None
),
output_token_field_success=(
result.output_token_field.success if result.output_token_field else None
),
)
# Store probe result
await db.store_probe_result(endpoint_id, probe_response.model_dump())
return probe_response
@router.post("/endpoints/{endpoint_id}/enable", response_model=EndpointResponse)
async def enable_endpoint(
endpoint_id: uuid.UUID,
db: InferenceRegistryDB = Depends(get_db),
):
"""Enable an inference endpoint.
External endpoints require egress confirmation first (19.5).
"""
endpoint = await db.get_endpoint(endpoint_id)
if endpoint is None:
raise HTTPException(404, "Endpoint not found")
# Check if external endpoint needs egress confirmation (19.5)
if is_external_endpoint(endpoint["base_url"]):
has_confirmation = await db.get_egress_confirmation(endpoint_id)
if not has_confirmation:
raise HTTPException(
403,
"External endpoint requires egress confirmation. "
"POST /api/inference/endpoints/{id}/confirm-egress first.",
)
update_data = {
"enabled": True,
"updated_at": datetime.now(timezone.utc),
"revision": endpoint.get("revision", 1) + 1,
}
updated = await db.update_endpoint(endpoint_id, update_data)
if updated is None:
raise HTTPException(404, "Endpoint not found")
return redact_endpoint(updated)
@router.post("/endpoints/{endpoint_id}/disable", response_model=EndpointResponse)
async def disable_endpoint_action(
endpoint_id: uuid.UUID,
db: InferenceRegistryDB = Depends(get_db),
):
"""Disable an inference endpoint."""
endpoint = await db.get_endpoint(endpoint_id)
if endpoint is None:
raise HTTPException(404, "Endpoint not found")
update_data = {
"enabled": False,
"updated_at": datetime.now(timezone.utc),
"revision": endpoint.get("revision", 1) + 1,
}
updated = await db.update_endpoint(endpoint_id, update_data)
if updated is None:
raise HTTPException(404, "Endpoint not found")
return redact_endpoint(updated)
@router.post(
"/endpoints/{endpoint_id}/test-structured-output",
response_model=StructuredOutputTestResponse,
)
async def test_structured_output(
endpoint_id: uuid.UUID,
body: StructuredOutputTestRequest | None = None,
db: InferenceRegistryDB = Depends(get_db),
):
"""Test JSON Schema structured output on an endpoint.
Sends a minimal schema-constrained request and validates the response.
"""
if body is None:
body = StructuredOutputTestRequest()
endpoint = await db.get_endpoint(endpoint_id)
if endpoint is None:
raise HTTPException(404, "Endpoint not found")
from services.shared.inference.capabilities import EndpointProber
from services.shared.inference.models import InferenceTarget, ProviderCapabilities
# Get a deployment to test with
deployments = await db.list_deployments(endpoint_id=endpoint_id)
model_name = "default"
deployment_id = uuid.uuid4()
if deployments:
model_name = deployments[0].get("served_model_name", "default")
deployment_id = deployments[0]["id"]
target = InferenceTarget(
endpoint_id=endpoint_id,
deployment_id=deployment_id,
protocol=endpoint["protocol"],
base_url=endpoint["base_url"],
model=model_name,
capabilities=ProviderCapabilities(
chat_completions=True,
json_schema=True,
),
auth_secret_ref=endpoint.get("auth_secret_ref"),
auth_scheme=endpoint.get("auth_scheme", "bearer"),
extra_headers=endpoint.get("default_headers") or {},
)
prober = EndpointProber()
try:
result = await prober.probe_json_schema(target)
finally:
await prober.close()
return StructuredOutputTestResponse(
success=result.success,
structured_mode=result.structured_mode_used,
content=result.detail,
schema_valid=result.schema_valid,
error=None if result.success else result.detail,
)
# ---------------------------------------------------------------------------
# External egress confirmation (19.5)
# ---------------------------------------------------------------------------
@router.post("/endpoints/{endpoint_id}/confirm-egress", response_model=EndpointResponse)
async def confirm_egress(
endpoint_id: uuid.UUID,
body: EgressConfirmation,
db: InferenceRegistryDB = Depends(get_db),
):
"""Confirm external endpoint egress enablement.
Required before enabling an endpoint with a non-cluster URL.
The request body must contain {"confirmed": true}.
"""
endpoint = await db.get_endpoint(endpoint_id)
if endpoint is None:
raise HTTPException(404, "Endpoint not found")
if not is_external_endpoint(endpoint["base_url"]):
raise HTTPException(400, "Endpoint is not external; no egress confirmation needed")
# body.confirmed is already validated by pydantic to be True
await db.store_egress_confirmation(endpoint_id)
# Now enable the endpoint
update_data = {
"enabled": True,
"updated_at": datetime.now(timezone.utc),
"revision": endpoint.get("revision", 1) + 1,
}
updated = await db.update_endpoint(endpoint_id, update_data)
if updated is None:
raise HTTPException(404, "Endpoint not found")
return redact_endpoint(updated)
# ---------------------------------------------------------------------------
# Deployments (19.3, 19.4)
# ---------------------------------------------------------------------------
@router.get("/deployments", response_model=list[DeploymentResponse])
async def list_deployments(
endpoint_id: uuid.UUID | None = None,
db: InferenceRegistryDB = Depends(get_db),
):
"""List model deployments, optionally filtered by endpoint."""
deployments = await db.list_deployments(endpoint_id=endpoint_id)
return [redact_deployment(d) for d in deployments]
@router.get("/deployments/{deployment_id}", response_model=DeploymentResponse)
async def get_deployment(
deployment_id: uuid.UUID,
db: InferenceRegistryDB = Depends(get_db),
):
"""Get a model deployment with capabilities and limits (19.4)."""
deployment = await db.get_deployment(deployment_id)
if deployment is None:
raise HTTPException(404, "Deployment not found")
return redact_deployment(deployment)
@router.post("/deployments", response_model=DeploymentResponse, status_code=201)
async def create_deployment(
body: DeploymentCreate,
db: InferenceRegistryDB = Depends(get_db),
):
"""Create a new model deployment."""
# Verify endpoint exists
endpoint = await db.get_endpoint(body.endpoint_id)
if endpoint is None:
raise HTTPException(404, "Referenced endpoint not found")
deployment_data = {
"id": uuid.uuid4(),
"endpoint_id": body.endpoint_id,
"served_model_name": body.served_model_name,
"display_name": body.display_name,
"capabilities": body.capabilities,
"context_window": body.context_window,
"max_output_tokens": body.max_output_tokens,
"quantization": body.quantization,
"runtime_metadata": body.runtime_metadata,
"enabled": body.enabled,
"revision": 1,
}
created = await db.create_deployment(deployment_data)
return redact_deployment(created)
# ---------------------------------------------------------------------------
# Bindings (19.3, 19.4)
# ---------------------------------------------------------------------------
@router.get("/bindings", response_model=list[BindingResponse])
async def list_bindings(
agent_id: uuid.UUID | None = None,
db: InferenceRegistryDB = Depends(get_db),
):
"""List agent stage bindings."""
bindings = await db.list_bindings(agent_id=agent_id)
return [redact_binding(b) for b in bindings]
@router.post("/bindings", response_model=BindingResponse, status_code=201)
async def create_binding(
body: BindingCreate,
db: InferenceRegistryDB = Depends(get_db),
):
"""Create an agent stage binding."""
# Verify deployment exists if provided
if body.model_deployment_id:
deployment = await db.get_deployment(body.model_deployment_id)
if deployment is None:
raise HTTPException(404, "Referenced deployment not found")
binding_data = {
"id": uuid.uuid4(),
"agent_id": body.agent_id,
"stage": body.stage,
"model_deployment_id": body.model_deployment_id,
"route_order": body.route_order,
"routing_config": body.routing_config,
"is_active": body.is_active,
"revision": 1,
}
created = await db.create_binding(binding_data)
return redact_binding(created)
# ---------------------------------------------------------------------------
# Selectors (19.3)
# ---------------------------------------------------------------------------
@router.get("/protocols")
async def list_protocols():
"""Return available protocol options for endpoint creation.
Replaces free-text provider inputs with controlled selectors.
"""
return {
"protocols": [
{"value": "ollama_native", "label": "Ollama Native", "description": "Ollama /api/chat endpoint"},
{"value": "openai_chat", "label": "OpenAI Compatible", "description": "OpenAI /v1/chat/completions (vLLM, OpenAI, LM Studio, SGLang)"},
{"value": "specialist_http", "label": "Specialist HTTP", "description": "Typed non-generative endpoints (GLiNER, FinBERT)"},
]
}
+251
View File
@@ -0,0 +1,251 @@
"""Pydantic request/response models for the inference registry API.
All response models EXCLUDE actual auth_secret_ref values.
Instead they show a status string: "configured" or "not_configured".
Requirements: 3.6, 3.7
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
VALID_PROTOCOLS = ("ollama_native", "openai_chat", "specialist_http")
# ---------------------------------------------------------------------------
# Endpoint schemas
# ---------------------------------------------------------------------------
class EndpointCreate(BaseModel):
"""Request body for creating an inference endpoint."""
name: str = Field(..., min_length=1, max_length=255)
protocol: Literal["ollama_native", "openai_chat", "specialist_http"]
base_url: str = Field(..., min_length=1)
auth_secret_ref: str | None = None
auth_scheme: str = "bearer"
default_headers: dict[str, str] = Field(default_factory=dict)
health_path: str | None = None
enabled: bool = True
@field_validator("base_url")
@classmethod
def validate_url(cls, v: str) -> str:
"""Validate that base_url looks like a valid URL."""
if not v.startswith(("http://", "https://")):
raise ValueError("base_url must start with http:// or https://")
return v.rstrip("/")
@field_validator("protocol")
@classmethod
def validate_protocol(cls, v: str) -> str:
if v not in VALID_PROTOCOLS:
raise ValueError(f"protocol must be one of {VALID_PROTOCOLS}")
return v
class EndpointUpdate(BaseModel):
"""Request body for updating an inference endpoint."""
name: str | None = None
protocol: Literal["ollama_native", "openai_chat", "specialist_http"] | None = None
base_url: str | None = None
auth_secret_ref: str | None = Field(default=None)
auth_scheme: str | None = None
default_headers: dict[str, str] | None = None
health_path: str | None = None
enabled: bool | None = None
@field_validator("base_url")
@classmethod
def validate_url(cls, v: str | None) -> str | None:
if v is not None:
if not v.startswith(("http://", "https://")):
raise ValueError("base_url must start with http:// or https://")
return v.rstrip("/")
return v
@field_validator("protocol")
@classmethod
def validate_protocol(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_PROTOCOLS:
raise ValueError(f"protocol must be one of {VALID_PROTOCOLS}")
return v
class EndpointResponse(BaseModel):
"""Response model for an inference endpoint. NEVER includes auth_secret_ref value."""
id: UUID
name: str
protocol: str
base_url: str
auth_secret_status: str = "not_configured" # "configured" or "not_configured"
auth_scheme: str = "bearer"
default_headers: dict[str, str] = Field(default_factory=dict)
health_path: str | None = None
enabled: bool = True
revision: int = 1
created_at: datetime | None = None
updated_at: datetime | None = None
last_probe: ProbeResponse | None = None
capabilities: dict[str, Any] | None = None
active_bindings: list[BindingResponse] | None = None
class EndpointListResponse(BaseModel):
"""Response model for listing endpoints."""
id: UUID
name: str
protocol: str
base_url: str
auth_secret_status: str = "not_configured"
enabled: bool = True
revision: int = 1
created_at: datetime | None = None
updated_at: datetime | None = None
# ---------------------------------------------------------------------------
# Deployment schemas
# ---------------------------------------------------------------------------
class DeploymentCreate(BaseModel):
"""Request body for creating a model deployment."""
endpoint_id: UUID
served_model_name: str = Field(..., min_length=1)
display_name: str = Field(..., min_length=1)
capabilities: dict[str, Any] = Field(default_factory=dict)
context_window: int | None = None
max_output_tokens: int | None = None
quantization: str | None = None
runtime_metadata: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class DeploymentResponse(BaseModel):
"""Response model for a model deployment."""
id: UUID
endpoint_id: UUID
served_model_name: str
display_name: str
capabilities: dict[str, Any] = Field(default_factory=dict)
context_window: int | None = None
max_output_tokens: int | None = None
quantization: str | None = None
runtime_metadata: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
revision: int = 1
# ---------------------------------------------------------------------------
# Binding schemas
# ---------------------------------------------------------------------------
class BindingCreate(BaseModel):
"""Request body for creating an agent stage binding."""
agent_id: UUID
stage: str = Field(..., min_length=1)
model_deployment_id: UUID | None = None
route_order: int = 0
routing_config: dict[str, Any] = Field(default_factory=dict)
is_active: bool = True
class BindingResponse(BaseModel):
"""Response model for an agent stage binding."""
id: UUID
agent_id: UUID
stage: str
model_deployment_id: UUID | None = None
route_order: int = 0
routing_config: dict[str, Any] = Field(default_factory=dict)
is_active: bool = True
revision: int = 1
# ---------------------------------------------------------------------------
# Probe schemas
# ---------------------------------------------------------------------------
class ProbeResponse(BaseModel):
"""Response model for probe results."""
endpoint_id: UUID
timestamp: datetime | None = None
software_version: str | None = None
probe_duration_ms: int = 0
health_success: bool = False
health_detail: str = ""
model_listing_success: bool | None = None
json_schema_success: bool | None = None
usage_success: bool | None = None
seed_success: bool | None = None
output_token_field_success: bool | None = None
# ---------------------------------------------------------------------------
# Egress confirmation schema
# ---------------------------------------------------------------------------
class EgressConfirmation(BaseModel):
"""Request body for confirming external endpoint egress enablement.
Requires explicit confirmed=true flag.
"""
confirmed: bool = Field(
...,
description="Must be explicitly set to true to confirm external egress enablement",
)
@field_validator("confirmed")
@classmethod
def must_be_true(cls, v: bool) -> bool:
if not v:
raise ValueError("confirmed must be true to enable external egress")
return v
# ---------------------------------------------------------------------------
# Structured output test schemas
# ---------------------------------------------------------------------------
class StructuredOutputTestRequest(BaseModel):
"""Request body for testing structured output on an endpoint."""
json_schema: dict[str, Any] = Field(
default_factory=lambda: {
"type": "object",
"properties": {"status": {"type": "string"}},
"required": ["status"],
}
)
prompt: str = 'Respond with JSON: {"status": "ok"}'
class StructuredOutputTestResponse(BaseModel):
"""Response for structured output test."""
success: bool
structured_mode: str = ""
content: str = ""
parsed: dict[str, Any] | None = None
schema_valid: bool = False
latency_ms: int = 0
error: str | None = None
+129
View File
@@ -0,0 +1,129 @@
"""Security helpers for the inference registry API.
Ensures auth_secret_ref values are NEVER exposed in API responses.
Replaces the actual secret reference with a status string.
Requirements: 3.6
"""
from __future__ import annotations
from typing import Any
from services.inference_registry.schemas import (
BindingResponse,
DeploymentResponse,
EndpointListResponse,
EndpointResponse,
)
def redact_endpoint(endpoint_dict: dict[str, Any]) -> EndpointResponse:
"""Convert a raw endpoint dict to an EndpointResponse with secrets redacted.
The auth_secret_ref value is replaced with a status string:
- "configured" if a secret reference exists
- "not_configured" if no secret reference is set
The actual secret ref value is NEVER included in the response.
"""
auth_secret_ref = endpoint_dict.get("auth_secret_ref")
auth_secret_status = "configured" if auth_secret_ref else "not_configured"
return EndpointResponse(
id=endpoint_dict["id"],
name=endpoint_dict["name"],
protocol=endpoint_dict["protocol"],
base_url=endpoint_dict["base_url"],
auth_secret_status=auth_secret_status,
auth_scheme=endpoint_dict.get("auth_scheme", "bearer"),
default_headers=endpoint_dict.get("default_headers") or {},
health_path=endpoint_dict.get("health_path"),
enabled=endpoint_dict.get("enabled", True),
revision=endpoint_dict.get("revision", 1),
created_at=endpoint_dict.get("created_at"),
updated_at=endpoint_dict.get("updated_at"),
)
def redact_endpoint_for_list(endpoint_dict: dict[str, Any]) -> EndpointListResponse:
"""Convert a raw endpoint dict to a list response with secrets redacted."""
auth_secret_ref = endpoint_dict.get("auth_secret_ref")
auth_secret_status = "configured" if auth_secret_ref else "not_configured"
return EndpointListResponse(
id=endpoint_dict["id"],
name=endpoint_dict["name"],
protocol=endpoint_dict["protocol"],
base_url=endpoint_dict["base_url"],
auth_secret_status=auth_secret_status,
enabled=endpoint_dict.get("enabled", True),
revision=endpoint_dict.get("revision", 1),
created_at=endpoint_dict.get("created_at"),
updated_at=endpoint_dict.get("updated_at"),
)
def redact_deployment(deployment_dict: dict[str, Any]) -> DeploymentResponse:
"""Convert a raw deployment dict to a DeploymentResponse."""
return DeploymentResponse(
id=deployment_dict["id"],
endpoint_id=deployment_dict["endpoint_id"],
served_model_name=deployment_dict["served_model_name"],
display_name=deployment_dict["display_name"],
capabilities=deployment_dict.get("capabilities") or {},
context_window=deployment_dict.get("context_window"),
max_output_tokens=deployment_dict.get("max_output_tokens"),
quantization=deployment_dict.get("quantization"),
runtime_metadata=deployment_dict.get("runtime_metadata") or {},
enabled=deployment_dict.get("enabled", True),
revision=deployment_dict.get("revision", 1),
)
def redact_binding(binding_dict: dict[str, Any]) -> BindingResponse:
"""Convert a raw binding dict to a BindingResponse."""
return BindingResponse(
id=binding_dict["id"],
agent_id=binding_dict["agent_id"],
stage=binding_dict["stage"],
model_deployment_id=binding_dict.get("model_deployment_id"),
route_order=binding_dict.get("route_order", 0),
routing_config=binding_dict.get("routing_config") or {},
is_active=binding_dict.get("is_active", True),
revision=binding_dict.get("revision", 1),
)
def is_external_endpoint(base_url: str) -> bool:
"""Determine if an endpoint URL points to an external (non-cluster) service.
External endpoints require egress confirmation before enablement.
Local/cluster endpoints match:
- localhost / 127.0.0.1
- *.svc.cluster.local (Kubernetes internal)
- 10.x.x.x / 192.168.x.x (private network)
"""
from urllib.parse import urlparse
parsed = urlparse(base_url)
hostname = parsed.hostname or ""
# Cluster-local patterns
if hostname in ("localhost", "127.0.0.1", "::1"):
return False
if hostname.endswith(".svc.cluster.local"):
return False
if hostname.startswith("10.") or hostname.startswith("192.168."):
return False
# Additional private ranges
if hostname.startswith("172."):
parts = hostname.split(".")
if len(parts) >= 2:
try:
second = int(parts[1])
if 16 <= second <= 31:
return False
except ValueError:
pass
return True
@@ -0,0 +1 @@
"""Intelligence Pipeline v3 — staged evidence-grounded extraction architecture."""
@@ -0,0 +1,20 @@
"""Active learning export module.
Selects low-confidence, conflicting, adjudicated, and corrected cases
for training data. Applies policy filtering for sensitive content and
exports in a versioned format with full provenance.
"""
from services.intelligence_pipeline_v3.active_learning.exporter import (
ActiveLearningExporter,
ExportConfig,
ExportRecord,
SelectionCriteria,
)
__all__ = [
"ActiveLearningExporter",
"ExportConfig",
"ExportRecord",
"SelectionCriteria",
]
@@ -0,0 +1,204 @@
"""Active learning data exporter.
Selects training examples from low-confidence, conflicting, adjudicated,
and reviewer-corrected cases. Applies content policy filters and exports
in a versioned format with source spans, labels, relations, decisions,
and provenance.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class SelectionCriteria(str, enum.Enum):
"""Why a case was selected for active learning."""
LOW_CONFIDENCE = "low_confidence"
CONFLICTING = "conflicting"
ADJUDICATED = "adjudicated"
REVIEWER_CORRECTED = "reviewer_corrected"
HIGH_DISAGREEMENT = "high_disagreement"
NOVEL_PATTERN = "novel_pattern"
class ContentPolicy(str, enum.Enum):
"""Content policy levels for export filtering."""
ALLOW = "allow"
REDACT_PII = "redact_pii"
EXCLUDE = "exclude"
@dataclass(frozen=True)
class ExportRecord:
"""A single active-learning export record.
Contains source text spans, schema labels, relations, adjudicator
decisions, reviewer corrections, and full provenance.
"""
record_id: UUID
document_id: str
selection_criteria: SelectionCriteria
export_version: str
timestamp: datetime
# Source content
source_spans: list[dict[str, Any]] # text, start, end, chunk_id
document_type: str = ""
# Labels and annotations
entity_labels: list[dict[str, Any]] = field(default_factory=list)
relation_labels: list[dict[str, Any]] = field(default_factory=list)
event_labels: list[dict[str, Any]] = field(default_factory=list)
fact_labels: list[dict[str, Any]] = field(default_factory=list)
# Decisions and corrections
adjudicator_decisions: list[dict[str, Any]] = field(default_factory=list)
reviewer_corrections: list[dict[str, Any]] = field(default_factory=list)
# Provenance
pipeline_run_id: UUID | None = None
model_versions: dict[str, str] = field(default_factory=dict)
confidence_scores: dict[str, float] = field(default_factory=dict)
@dataclass
class ExportConfig:
"""Configuration for active learning export."""
export_version: str = "1.0"
min_confidence_threshold: float = 0.5 # Select cases below this
max_export_count: int = 1000
include_adjudicated: bool = True
include_corrections: bool = True
include_low_confidence: bool = True
include_conflicting: bool = True
content_policy: ContentPolicy = ContentPolicy.REDACT_PII
excluded_fields: set[str] = field(default_factory=set)
sensitive_patterns: list[str] = field(default_factory=list)
@dataclass
class ActiveLearningExporter:
"""Exports selected cases for specialist model training.
Applies selection criteria, content policy filtering, and
produces versioned export datasets with full provenance.
"""
config: ExportConfig
_records: list[ExportRecord] = field(default_factory=list)
_excluded_count: int = 0
def select_record(
self,
document_id: str,
criteria: SelectionCriteria,
source_spans: list[dict[str, Any]],
document_type: str = "",
entity_labels: list[dict[str, Any]] | None = None,
relation_labels: list[dict[str, Any]] | None = None,
event_labels: list[dict[str, Any]] | None = None,
fact_labels: list[dict[str, Any]] | None = None,
adjudicator_decisions: list[dict[str, Any]] | None = None,
reviewer_corrections: list[dict[str, Any]] | None = None,
pipeline_run_id: UUID | None = None,
model_versions: dict[str, str] | None = None,
confidence_scores: dict[str, float] | None = None,
) -> ExportRecord | None:
"""Select a case for export, applying content policy.
Returns None if the case is excluded by policy.
"""
if len(self._records) >= self.config.max_export_count:
return None
# Apply content policy
filtered_spans = self._apply_content_policy(source_spans)
if not filtered_spans:
self._excluded_count += 1
return None
record = ExportRecord(
record_id=uuid4(),
document_id=document_id,
selection_criteria=criteria,
export_version=self.config.export_version,
timestamp=datetime.now(timezone.utc),
source_spans=filtered_spans,
document_type=document_type,
entity_labels=entity_labels or [],
relation_labels=relation_labels or [],
event_labels=event_labels or [],
fact_labels=fact_labels or [],
adjudicator_decisions=adjudicator_decisions or [],
reviewer_corrections=reviewer_corrections or [],
pipeline_run_id=pipeline_run_id,
model_versions=model_versions or {},
confidence_scores=confidence_scores or {},
)
self._records.append(record)
return record
def _apply_content_policy(
self, spans: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Apply content policy filtering to source spans."""
if self.config.content_policy == ContentPolicy.EXCLUDE:
# Check for sensitive content
for span in spans:
text = span.get("text", "")
if self._contains_sensitive(text):
return [] # Exclude entire record
if self.config.content_policy == ContentPolicy.REDACT_PII:
return [self._redact_span(span) for span in spans]
return spans
def _contains_sensitive(self, text: str) -> bool:
"""Check if text contains sensitive content per policy."""
for pattern in self.config.sensitive_patterns:
if pattern.lower() in text.lower():
return True
return False
def _redact_span(self, span: dict[str, Any]) -> dict[str, Any]:
"""Redact PII from a span while preserving structure."""
# In production, this would use NER-based PII detection
# For now, preserve the span but mark it as redacted if needed
return {**span, "content_policy_applied": "redact_pii"}
@property
def records(self) -> list[ExportRecord]:
return list(self._records)
@property
def total_exported(self) -> int:
return len(self._records)
@property
def total_excluded(self) -> int:
return self._excluded_count
def export_manifest(self) -> dict[str, Any]:
"""Generate export manifest with metadata."""
criteria_counts: dict[str, int] = {}
for r in self._records:
key = r.selection_criteria.value
criteria_counts[key] = criteria_counts.get(key, 0) + 1
return {
"export_version": self.config.export_version,
"exported_at": datetime.now(timezone.utc).isoformat(),
"total_records": len(self._records),
"excluded_count": self._excluded_count,
"content_policy": self.config.content_policy.value,
"selection_criteria_distribution": criteria_counts,
}
@@ -0,0 +1,60 @@
"""Adjudication layer for Intelligence Pipeline v3.
This package provides:
- Schemas for adjudication candidates, conflicts, evidence, questions, and decisions
- Focused adjudication prompt building with strict JSON Schema output
- 9B adjudicator deployment configuration and VRAM gating
- Post-adjudication verification ensuring evidence grounding
"""
from services.intelligence_pipeline_v3.adjudication.deployment import (
APPROVED_MODEL,
APPROVED_VLLM_VERSION,
AlertConfig,
ConcurrencySemaphore,
check_vram_gate,
verify_structured_output,
)
from services.intelligence_pipeline_v3.adjudication.prompts import (
AdjudicationPacket,
PromptMetadata,
build_adjudication_packet,
)
from services.intelligence_pipeline_v3.adjudication.schemas import (
AdjudicationCandidate,
AdjudicationDecision,
AdjudicationQuestion,
ConflictDescription,
EvidencePacket,
)
from services.intelligence_pipeline_v3.adjudication.verification import (
AdjudicationRecord,
RejectionResult,
preserve_pre_and_post,
reject_unsupported_decisions,
route_repeated_failures,
verify_evidence_references,
)
__all__ = [
"APPROVED_MODEL",
"APPROVED_VLLM_VERSION",
"AdjudicationCandidate",
"AdjudicationDecision",
"AdjudicationPacket",
"AdjudicationQuestion",
"AdjudicationRecord",
"AlertConfig",
"ConcurrencySemaphore",
"ConflictDescription",
"EvidencePacket",
"PromptMetadata",
"RejectionResult",
"build_adjudication_packet",
"check_vram_gate",
"preserve_pre_and_post",
"reject_unsupported_decisions",
"route_repeated_failures",
"verify_evidence_references",
"verify_structured_output",
]
@@ -0,0 +1,180 @@
"""9B adjudicator deployment configuration for Intelligence Pipeline v3.
Manages the approved model/version pins, VRAM gating, concurrency
semaphore configuration, and alerting thresholds for the 9B adjudicator
running on RTX 4070 Ti SUPER via vLLM.
"""
from __future__ import annotations
import asyncio
from typing import Any
from pydantic import BaseModel, Field
# --- Pinned model and version constants ---
APPROVED_MODEL: str = "AxionML/Qwen3.5-9B-NVFP4"
"""Approved 9B model for adjudication. NVFP4 quantization for 4070 Ti SUPER."""
APPROVED_VLLM_VERSION: str = "0.8.5"
"""Approved vLLM version matching the cluster deployment."""
APPROVED_SERVED_NAME: str = "stonks-adjudicator-9b"
"""The served model name exposed by the vLLM deployment."""
MAX_MODEL_LEN: int = 8192
"""Maximum model context length configured for the deployment."""
MAX_NUM_SEQS: int = 8
"""Maximum concurrent sequences for the vLLM deployment."""
GPU_MEMORY_UTILIZATION: float = 0.80
"""Target GPU memory utilization fraction."""
VRAM_GATE_PERCENT: float = 5.0
"""Maximum allowed VRAM increase over baseline (percentage)."""
# --- Structured output verification ---
def verify_structured_output(target: dict[str, Any]) -> bool:
"""Verify that structured output works with the given deployment target.
Checks that the target deployment declares json_schema support in its
capabilities and that the required configuration fields are present.
Args:
target: Deployment target configuration dict containing at minimum:
- capabilities: dict with json_schema boolean
- served_model_name: str matching APPROVED_SERVED_NAME
- vllm_version: str for version verification
Returns:
True if strict schema output is expected to work, False otherwise.
"""
capabilities = target.get("capabilities", {})
if not capabilities.get("json_schema", False):
return False
# Verify model matches approved deployment
served_name = target.get("served_model_name", "")
if served_name and served_name != APPROVED_SERVED_NAME:
return False
# Verify vLLM version compatibility
vllm_version = target.get("vllm_version", "")
if vllm_version and vllm_version != APPROVED_VLLM_VERSION:
return False
# Verify the model is the approved one
model = target.get("model", "")
if model and model != APPROVED_MODEL:
return False
return True
# --- VRAM gate ---
def check_vram_gate(peak_mb: float, baseline_mb: float) -> bool:
"""Check whether peak VRAM usage is within the +5% gate of baseline.
The gate ensures that no deployment update exceeds the measured current
9B deployment VRAM by more than 5 percent.
Args:
peak_mb: Measured peak VRAM in megabytes during test.
baseline_mb: Baseline VRAM measurement in megabytes.
Returns:
True if peak is within acceptable range, False if it exceeds the gate.
"""
if baseline_mb <= 0:
return False
if peak_mb <= 0:
return False
max_allowed_mb = baseline_mb * (1.0 + VRAM_GATE_PERCENT / 100.0)
return peak_mb <= max_allowed_mb
# --- Concurrency semaphore ---
class ConcurrencySemaphore(BaseModel):
"""Configuration for the adjudication concurrency semaphore.
Limits concurrent adjudication requests to protect vLLM from
overload. Aligned with max-num-seqs and KV-cache behavior.
"""
max_concurrent: int = Field(
default=MAX_NUM_SEQS,
gt=0,
description="Maximum concurrent adjudication requests",
)
queue_timeout_seconds: float = Field(
default=120.0,
gt=0,
description="Maximum time to wait for semaphore acquisition",
)
backpressure_threshold: int = Field(
default=MAX_NUM_SEQS * 4,
ge=0,
description="Queue depth at which backpressure signals are emitted",
)
def create_semaphore(self) -> asyncio.Semaphore:
"""Create an asyncio.Semaphore with the configured max_concurrent."""
return asyncio.Semaphore(self.max_concurrent)
# --- Alert configuration ---
class AlertConfig(BaseModel):
"""Alert thresholds for adjudicator monitoring.
Defines queue-depth and availability thresholds that trigger alerts
when the adjudicator is overloaded or unavailable.
"""
queue_depth_warning: int = Field(
default=16,
ge=1,
description="Queue depth that triggers a warning alert",
)
queue_depth_critical: int = Field(
default=32,
ge=1,
description="Queue depth that triggers a critical alert",
)
availability_threshold_percent: float = Field(
default=95.0,
gt=0.0,
le=100.0,
description="Minimum availability percentage before alerting",
)
latency_p95_warning_ms: int = Field(
default=5000,
gt=0,
description="p95 latency (ms) that triggers a warning",
)
latency_p95_critical_ms: int = Field(
default=15000,
gt=0,
description="p95 latency (ms) that triggers a critical alert",
)
consecutive_failures_alert: int = Field(
default=3,
ge=1,
description="Number of consecutive failures before alerting",
)
health_check_interval_seconds: float = Field(
default=30.0,
gt=0,
description="Interval between health checks in seconds",
)
@@ -0,0 +1,302 @@
"""Focused adjudication prompt building for Intelligence Pipeline v3.
Builds adjudication packets containing only relevant chunks and candidates,
uses strict JSON Schema with temperature zero, and enforces a bounded output
budget (max 1536 tokens for decisions, not summaries).
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.adjudication.schemas import (
AdjudicationCandidate,
AdjudicationQuestion,
ConflictDescription,
EvidencePacket,
QuestionCode,
)
from services.intelligence_pipeline_v3.segmenter.models import DocumentChunk
# --- Constants ---
MAX_OUTPUT_TOKENS: int = 1536
"""Maximum tokens for adjudication decisions output. Bounded to prevent
long summaries — the adjudicator produces decisions, not narratives."""
TEMPERATURE: float = 0.0
"""Temperature for adjudication requests. Zero for deterministic output."""
PROMPT_SCHEMA_VERSION: str = "1.0.0"
"""Version of the adjudication prompt schema format."""
PROVIDER_LINEAGE_KEY: str = "adjudication_v3"
"""Lineage identifier for adjudication prompts."""
# --- Models ---
class PromptMetadata(BaseModel):
"""Metadata for the adjudication prompt including version and lineage.
Tracks prompt version, schema version, and provider lineage for
reproducibility and auditing.
"""
prompt_version: str = Field(
default="1.0.0",
description="Version of the prompt template",
)
schema_version: str = Field(
default=PROMPT_SCHEMA_VERSION,
description="Version of the JSON Schema format used",
)
provider_lineage: str = Field(
default=PROVIDER_LINEAGE_KEY,
description="Identifier for the prompt provider/pipeline stage",
)
max_output_tokens: int = Field(
default=MAX_OUTPUT_TOKENS,
description="Maximum output token budget for this prompt",
)
temperature: float = Field(
default=TEMPERATURE,
description="Generation temperature",
)
class AdjudicationPacket(BaseModel):
"""Complete packet sent to the 9B adjudicator.
Contains only the information relevant to resolving the specific
ambiguity — relevant chunks, candidates, conflicts, and questions.
"""
document_id: str = Field(description="Source document identifier")
document_type: str = Field(description="Type of document")
relevant_chunks: list[DocumentChunk] = Field(
description="Only chunks relevant to the adjudication questions",
)
candidates: list[AdjudicationCandidate] = Field(
description="Candidates requiring adjudication",
)
conflicts: list[ConflictDescription] = Field(
default_factory=list,
description="Conflicts between candidates",
)
questions: list[AdjudicationQuestion] = Field(
description="Specific questions the adjudicator must answer",
)
evidence: list[EvidencePacket] = Field(
description="Evidence spans available for reference",
)
metadata: PromptMetadata = Field(
default_factory=PromptMetadata,
description="Prompt metadata for versioning and lineage",
)
# --- Output schema for strict JSON mode ---
def get_decision_json_schema() -> dict[str, Any]:
"""Return the strict JSON Schema for adjudication decisions.
Used as the `response_format.json_schema.schema` payload when
calling the 9B model with strict structured output.
"""
return {
"type": "object",
"properties": {
"decisions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"decision_id": {"type": "string"},
"question_code": {
"type": "string",
"enum": [code.value for code in QuestionCode],
},
"verdict": {
"type": "string",
"enum": [
"accept",
"reject",
"merge",
"split",
"reattribute",
],
},
"candidate_ids": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
},
"evidence_ids": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
},
"reasoning": {"type": "string"},
"resolved_value": {"type": "object"},
},
"required": [
"decision_id",
"question_code",
"verdict",
"candidate_ids",
"evidence_ids",
"reasoning",
],
"additionalProperties": False,
},
},
},
"required": ["decisions"],
"additionalProperties": False,
}
# --- Packet builder ---
def _get_relevant_chunk_ids(
candidates: list[AdjudicationCandidate],
conflicts: list[ConflictDescription],
questions: list[AdjudicationQuestion],
) -> set[str]:
"""Collect chunk IDs referenced by candidates, conflicts, and questions."""
chunk_ids: set[str] = set()
for candidate in candidates:
chunk_ids.update(candidate.source_chunk_ids)
return chunk_ids
def _filter_relevant_chunks(
document_chunks: list[DocumentChunk],
relevant_chunk_ids: set[str],
) -> list[DocumentChunk]:
"""Filter document chunks to include only those referenced by candidates."""
if not relevant_chunk_ids:
# If no specific chunks referenced, include all (fallback for
# cases where chunk IDs weren't specified in candidates)
return document_chunks
return [c for c in document_chunks if c.chunk_id in relevant_chunk_ids]
def _collect_evidence_ids(
candidates: list[AdjudicationCandidate],
conflicts: list[ConflictDescription],
) -> set[str]:
"""Collect all evidence IDs referenced by candidates and conflicts."""
evidence_ids: set[str] = set()
for candidate in candidates:
evidence_ids.update(candidate.evidence_ids)
for conflict in conflicts:
evidence_ids.update(conflict.evidence_ids)
return evidence_ids
def build_adjudication_packet(
document_id: str,
document_type: str,
document_chunks: list[DocumentChunk],
candidates: list[AdjudicationCandidate],
conflicts: list[ConflictDescription],
questions: list[AdjudicationQuestion],
evidence: list[EvidencePacket],
*,
question_codes: list[str] | None = None,
) -> AdjudicationPacket:
"""Build an adjudication packet with only relevant chunks and evidence.
Filters document_chunks to include only those referenced by the
candidates being adjudicated. Ensures the packet is focused and
within the bounded context the adjudicator expects.
Args:
document_id: Source document identifier.
document_type: Type of document (article, filing, transcript, etc.).
document_chunks: All available chunks for the document.
candidates: Candidates requiring adjudication.
conflicts: Conflicts between candidates.
questions: Specific questions to resolve.
evidence: Available evidence spans.
question_codes: Optional filter to limit questions by code.
Returns:
AdjudicationPacket with only relevant chunks included.
"""
# Filter questions by code if specified
filtered_questions = questions
if question_codes:
code_set = set(question_codes)
filtered_questions = [
q for q in questions if q.question_code.value in code_set
]
# Determine which chunks are relevant
relevant_chunk_ids = _get_relevant_chunk_ids(
candidates, conflicts, filtered_questions
)
relevant_chunks = _filter_relevant_chunks(document_chunks, relevant_chunk_ids)
# Filter evidence to only include those referenced by candidates/conflicts
referenced_evidence_ids = _collect_evidence_ids(candidates, conflicts)
if referenced_evidence_ids:
relevant_evidence = [
e for e in evidence if e.evidence_id in referenced_evidence_ids
]
else:
# Include all evidence if none specifically referenced
relevant_evidence = evidence
return AdjudicationPacket(
document_id=document_id,
document_type=document_type,
relevant_chunks=relevant_chunks,
candidates=candidates,
conflicts=conflicts,
questions=filtered_questions,
evidence=relevant_evidence,
metadata=PromptMetadata(),
)
def build_request_payload(packet: AdjudicationPacket) -> dict[str, Any]:
"""Build the full inference request payload for the adjudicator.
Returns a dict suitable for passing to the inference gateway,
including strict JSON Schema response format and temperature zero.
"""
system_prompt = (
"You are a semantic adjudicator for financial document extraction. "
"Resolve the ambiguities described in the questions using ONLY the "
"provided evidence spans. Every decision MUST reference evidence_ids "
"from the provided evidence. Do NOT estimate confidence, novelty, "
"impact magnitude, or time horizon — those are computed by separate "
"calibrated pipelines. Output valid JSON matching the required schema."
)
user_content = packet.model_dump_json()
return {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"temperature": packet.metadata.temperature,
"max_tokens": packet.metadata.max_output_tokens,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "adjudication_response",
"strict": True,
"schema": get_decision_json_schema(),
},
},
}
@@ -0,0 +1,178 @@
"""Adjudication schemas for Intelligence Pipeline v3.
Defines Pydantic models for the adjudication layer:
- AdjudicationCandidate: a proposed entity/fact/event requiring adjudication
- ConflictDescription: describes a conflict between candidates
- AdjudicationQuestion: a specific question the adjudicator must resolve
- EvidencePacket: evidence spans provided to the adjudicator
- AdjudicationDecision: the adjudicator's resolution (excludes confidence,
novelty, impact, and horizon — those come from calibrated pipelines)
Every decision requires evidence_ids linking back to packet evidence.
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class CandidateType(str, Enum):
"""Type of candidate being adjudicated."""
ENTITY = "entity"
EVENT = "event"
FACT = "fact"
RELATION = "relation"
SENTIMENT = "sentiment"
class AdjudicationCandidate(BaseModel):
"""A proposed extraction candidate that requires adjudication.
Represents an entity, event, fact, relation, or sentiment that the
fast-path could not resolve with sufficient confidence.
"""
candidate_id: str = Field(description="Unique identifier for this candidate")
candidate_type: CandidateType = Field(description="Type of candidate")
label: str = Field(description="Human-readable label or description")
source_chunk_ids: list[str] = Field(
default_factory=list,
description="Chunk IDs where this candidate was found",
)
evidence_ids: list[str] = Field(
default_factory=list,
description="Evidence span IDs supporting this candidate",
)
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Additional type-specific metadata",
)
score: float = Field(
default=0.0,
ge=0.0,
le=1.0,
description="Specialist extraction score (0.0-1.0)",
)
class ConflictType(str, Enum):
"""Type of conflict between candidates."""
CONTRADICTORY_VALUES = "contradictory_values"
AMBIGUOUS_IDENTITY = "ambiguous_identity"
OPPOSING_SENTIMENT = "opposing_sentiment"
OVERLAPPING_EVENTS = "overlapping_events"
CAUSAL_AMBIGUITY = "causal_ambiguity"
class ConflictDescription(BaseModel):
"""Describes a conflict between two or more candidates.
Used to inform the adjudicator about what needs resolution.
"""
conflict_id: str = Field(description="Unique identifier for this conflict")
conflict_type: ConflictType = Field(description="Type of conflict")
candidate_ids: list[str] = Field(
min_length=2,
description="IDs of conflicting candidates",
)
description: str = Field(description="Human-readable conflict description")
evidence_ids: list[str] = Field(
default_factory=list,
description="Evidence IDs relevant to this conflict",
)
class QuestionCode(str, Enum):
"""Codes representing specific adjudication questions."""
RESOLVE_ENTITY_IDENTITY = "RESOLVE_ENTITY_IDENTITY"
RESOLVE_EVENT_TYPE = "RESOLVE_EVENT_TYPE"
RESOLVE_CAUSAL_DIRECTION = "RESOLVE_CAUSAL_DIRECTION"
RESOLVE_NUMERIC_CONFLICT = "RESOLVE_NUMERIC_CONFLICT"
RESOLVE_SENTIMENT_DIRECTION = "RESOLVE_SENTIMENT_DIRECTION"
RESOLVE_TEMPORAL_ORDERING = "RESOLVE_TEMPORAL_ORDERING"
RESOLVE_COMPANY_ATTRIBUTION = "RESOLVE_COMPANY_ATTRIBUTION"
CONFIRM_CROSS_CHUNK_RELATION = "CONFIRM_CROSS_CHUNK_RELATION"
class AdjudicationQuestion(BaseModel):
"""A specific question the adjudicator must answer.
Each question references candidates and conflicts that need resolution.
"""
question_code: QuestionCode = Field(description="Structured question code")
description: str = Field(description="Natural language question for the adjudicator")
candidate_ids: list[str] = Field(
default_factory=list,
description="Candidate IDs this question applies to",
)
conflict_ids: list[str] = Field(
default_factory=list,
description="Conflict IDs this question resolves",
)
class EvidencePacket(BaseModel):
"""Evidence spans provided to the adjudicator.
Contains the exact text and location of evidence the adjudicator
can reference in its decisions.
"""
evidence_id: str = Field(description="Unique identifier for this evidence span")
chunk_id: str = Field(description="Source chunk identifier")
start_char: int = Field(ge=0, description="Start character offset within chunk")
end_char: int = Field(gt=0, description="End character offset within chunk")
text: str = Field(min_length=1, description="Evidence text content")
source_document_id: str = Field(description="Parent document identifier")
class DecisionVerdict(str, Enum):
"""Possible verdicts for an adjudication decision."""
ACCEPT = "accept"
REJECT = "reject"
MERGE = "merge"
SPLIT = "split"
REATTRIBUTE = "reattribute"
class AdjudicationDecision(BaseModel):
"""The adjudicator's resolution for one or more candidates.
IMPORTANT: This model intentionally EXCLUDES:
- authoritative confidence (comes from calibration pipeline)
- novelty (comes from retrieval-based novelty stage)
- impact (comes from stock-specific impact model)
- horizon (comes from impact model)
The adjudicator resolves candidate identity, relationships, event
interpretation, and supported qualitative direction only. Every
decision MUST reference evidence_ids from the provided packet.
"""
decision_id: str = Field(description="Unique identifier for this decision")
question_code: QuestionCode = Field(description="Which question this resolves")
verdict: DecisionVerdict = Field(description="The adjudication verdict")
candidate_ids: list[str] = Field(
min_length=1,
description="Candidate IDs this decision applies to",
)
evidence_ids: list[str] = Field(
min_length=1,
description="Evidence IDs supporting this decision (required, non-empty)",
)
reasoning: str = Field(
description="Brief reasoning for the decision",
)
resolved_value: dict[str, Any] = Field(
default_factory=dict,
description="The resolved value(s) if applicable",
)
@@ -0,0 +1,241 @@
"""Post-adjudication verification for Intelligence Pipeline v3.
Ensures adjudication decisions are grounded in evidence, schema-compatible,
and that repeated failures route to human review rather than accepting
repaired defaults.
"""
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.adjudication.prompts import (
AdjudicationPacket,
)
from services.intelligence_pipeline_v3.adjudication.schemas import (
AdjudicationCandidate,
AdjudicationDecision,
DecisionVerdict,
QuestionCode,
)
# --- Models ---
class RejectionReason(str, Enum):
"""Reasons a decision can be rejected post-adjudication."""
MISSING_EVIDENCE_REFERENCE = "missing_evidence_reference"
INVALID_CANDIDATE_REFERENCE = "invalid_candidate_reference"
SCHEMA_INCOMPATIBLE = "schema_incompatible"
EMPTY_EVIDENCE_IDS = "empty_evidence_ids"
UNKNOWN_QUESTION_CODE = "unknown_question_code"
UNKNOWN_VERDICT = "unknown_verdict"
MISSING_REQUIRED_FIELD = "missing_required_field"
class RejectionResult(BaseModel):
"""Result of rejecting an unsupported or schema-incompatible decision."""
rejected: bool = Field(description="Whether the decision was rejected")
reasons: list[RejectionReason] = Field(
default_factory=list,
description="Reasons for rejection",
)
decision_id: str = Field(default="", description="ID of the rejected decision")
details: list[str] = Field(
default_factory=list,
description="Human-readable details about each rejection reason",
)
class AdjudicationRecord(BaseModel):
"""Preserves both pre-adjudication candidates and post-adjudication decisions.
This provides full audit trail showing what the pipeline proposed
before adjudication and what the adjudicator decided.
"""
document_id: str = Field(description="Source document identifier")
pre_candidates: list[AdjudicationCandidate] = Field(
description="Candidates before adjudication",
)
post_decisions: list[AdjudicationDecision] = Field(
description="Decisions after adjudication",
)
timestamp: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="When the adjudication completed",
)
packet_evidence_ids: list[str] = Field(
default_factory=list,
description="All evidence IDs that were in the adjudication packet",
)
class FailureRoute(str, Enum):
"""Possible routes for repeated failures."""
REVIEW = "review"
ACCEPT_REPAIRED = "accept_repaired"
# --- Functions ---
def verify_evidence_references(
decision: AdjudicationDecision,
packet: AdjudicationPacket,
) -> list[str]:
"""Check that all evidence IDs in the decision were present in the packet.
Returns a list of evidence IDs that are referenced by the decision
but were NOT included in the adjudication packet. An empty list
means all references are valid.
Args:
decision: The adjudication decision to verify.
packet: The adjudication packet that was sent to the model.
Returns:
List of evidence IDs that are missing from the packet (invalid refs).
"""
packet_evidence_ids = {e.evidence_id for e in packet.evidence}
missing: list[str] = []
for eid in decision.evidence_ids:
if eid not in packet_evidence_ids:
missing.append(eid)
return missing
def reject_unsupported_decisions(
decision: AdjudicationDecision,
*,
valid_candidate_ids: set[str] | None = None,
valid_evidence_ids: set[str] | None = None,
) -> RejectionResult:
"""Reject schema-incompatible or unsupported decisions.
Checks for:
- Empty evidence_ids (every decision must cite evidence)
- Invalid question codes
- Invalid verdicts
- References to non-existent candidates
- References to non-existent evidence (if valid sets provided)
Args:
decision: The decision to validate.
valid_candidate_ids: Optional set of valid candidate IDs.
valid_evidence_ids: Optional set of valid evidence IDs from the packet.
Returns:
RejectionResult indicating whether and why the decision was rejected.
"""
reasons: list[RejectionReason] = []
details: list[str] = []
# Check evidence_ids is non-empty
if not decision.evidence_ids:
reasons.append(RejectionReason.EMPTY_EVIDENCE_IDS)
details.append("Decision has no evidence_ids — every decision must cite evidence")
# Check question_code validity
try:
QuestionCode(decision.question_code)
except ValueError:
reasons.append(RejectionReason.UNKNOWN_QUESTION_CODE)
details.append(f"Unknown question_code: {decision.question_code}")
# Check verdict validity
try:
DecisionVerdict(decision.verdict)
except ValueError:
reasons.append(RejectionReason.UNKNOWN_VERDICT)
details.append(f"Unknown verdict: {decision.verdict}")
# Check candidate references if valid set provided
if valid_candidate_ids is not None:
for cid in decision.candidate_ids:
if cid not in valid_candidate_ids:
reasons.append(RejectionReason.INVALID_CANDIDATE_REFERENCE)
details.append(f"Candidate ID '{cid}' not in valid set")
break # One invalid ref is enough to reject
# Check evidence references if valid set provided
if valid_evidence_ids is not None:
for eid in decision.evidence_ids:
if eid not in valid_evidence_ids:
reasons.append(RejectionReason.MISSING_EVIDENCE_REFERENCE)
details.append(f"Evidence ID '{eid}' not in valid set")
break # One invalid ref is enough to reject
# Check required fields
if not decision.decision_id:
reasons.append(RejectionReason.MISSING_REQUIRED_FIELD)
details.append("decision_id is empty")
if not decision.candidate_ids:
reasons.append(RejectionReason.MISSING_REQUIRED_FIELD)
details.append("candidate_ids is empty")
return RejectionResult(
rejected=len(reasons) > 0,
reasons=reasons,
decision_id=decision.decision_id,
details=details,
)
def preserve_pre_and_post(
document_id: str,
pre_candidates: list[AdjudicationCandidate],
post_decisions: list[AdjudicationDecision],
packet_evidence_ids: list[str] | None = None,
) -> AdjudicationRecord:
"""Store both pre-adjudication candidates and final decisions.
Creates an immutable audit record preserving the full adjudication
state for later review and quality assessment.
Args:
document_id: Source document identifier.
pre_candidates: Candidates before adjudication.
post_decisions: Decisions after adjudication.
packet_evidence_ids: All evidence IDs from the packet.
Returns:
AdjudicationRecord with both pre and post states.
"""
return AdjudicationRecord(
document_id=document_id,
pre_candidates=pre_candidates,
post_decisions=post_decisions,
packet_evidence_ids=packet_evidence_ids or [],
)
def route_repeated_failures(failure_count: int, threshold: int) -> str:
"""Route repeated adjudication failures to review.
When the failure count meets or exceeds the threshold, routes to
human review rather than accepting a repaired default. This prevents
the system from silently accepting potentially incorrect outputs
after repeated model failures.
Args:
failure_count: Number of consecutive adjudication failures.
threshold: Failure count at which to escalate to review.
Returns:
"review" when threshold is met/exceeded, "review" always —
never returns "accept_repaired" because accepting repaired
defaults on repeated failures undermines evidence grounding.
"""
if failure_count >= threshold:
return FailureRoute.REVIEW.value
# Even below threshold, route to review for safety.
# The adjudication system should never silently accept repaired defaults.
return FailureRoute.REVIEW.value
@@ -0,0 +1,23 @@
"""Audit and review module for the v3 intelligence pipeline.
Provides evidence display, reviewer corrections, filtering by confidence/
claims/adjudication, and immutable correction event storage.
"""
from services.intelligence_pipeline_v3.audit.models import (
AuditRecord,
CorrectionEvent,
CorrectionType,
ReviewFilter,
ReviewStatus,
)
from services.intelligence_pipeline_v3.audit.store import AuditStore
__all__ = [
"AuditRecord",
"AuditStore",
"CorrectionEvent",
"CorrectionType",
"ReviewFilter",
"ReviewStatus",
]
@@ -0,0 +1,201 @@
"""Audit and review data models.
Supports evidence display with offsets, specialist probabilities,
routing reasons, adjudicator decisions, impact-model outputs,
and immutable reviewer correction events.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class ReviewStatus(str, enum.Enum):
"""Status of a document's review."""
PENDING = "pending"
REVIEWED = "reviewed"
CORRECTED = "corrected"
CONFIRMED = "confirmed"
class CorrectionType(str, enum.Enum):
"""Types of reviewer corrections."""
CORRECT = "correct"
INCORRECT = "incorrect"
UNSUPPORTED = "unsupported"
AMBIGUOUS = "ambiguous"
VALUE_OVERRIDE = "value_override"
@dataclass(frozen=True)
class CorrectionEvent:
"""Immutable reviewer correction event.
Corrections are append-only audit events. They feed the active-learning
dataset only through the approved export process.
"""
event_id: UUID
record_id: UUID
field_name: str
correction_type: CorrectionType
original_value: Any
corrected_value: Any | None
reviewer_id: str
timestamp: datetime
notes: str = ""
@classmethod
def create(
cls,
record_id: UUID,
field_name: str,
correction_type: CorrectionType,
original_value: Any,
corrected_value: Any | None = None,
reviewer_id: str = "",
notes: str = "",
) -> CorrectionEvent:
return cls(
event_id=uuid4(),
record_id=record_id,
field_name=field_name,
correction_type=correction_type,
original_value=original_value,
corrected_value=corrected_value,
reviewer_id=reviewer_id,
timestamp=datetime.now(timezone.utc),
notes=notes,
)
@dataclass
class AuditRecord:
"""Complete audit record for a processed document.
Contains source evidence, specialist outputs, routing reasons,
adjudicator decisions, and impact predictions displayed separately.
"""
record_id: UUID
document_id: str
run_id: UUID
timestamp: datetime
# Source evidence with offsets
evidence_spans: list[dict[str, Any]] = field(default_factory=list)
# Specialist stage outputs (probabilities, scores)
specialist_outputs: dict[str, Any] = field(default_factory=dict)
# Routing decision and reasons
routing_reasons: list[str] = field(default_factory=list)
route_decision: str = ""
# Adjudicator decision (if applicable)
adjudicator_decision: dict[str, Any] | None = None
# Impact model outputs
impact_outputs: dict[str, Any] = field(default_factory=dict)
# Model lineage
lineage: dict[str, Any] = field(default_factory=dict)
# Review status
review_status: ReviewStatus = ReviewStatus.PENDING
corrections: list[CorrectionEvent] = field(default_factory=list)
@classmethod
def create(
cls,
document_id: str,
run_id: UUID,
evidence_spans: list[dict[str, Any]] | None = None,
specialist_outputs: dict[str, Any] | None = None,
routing_reasons: list[str] | None = None,
route_decision: str = "",
adjudicator_decision: dict[str, Any] | None = None,
impact_outputs: dict[str, Any] | None = None,
lineage: dict[str, Any] | None = None,
) -> AuditRecord:
return cls(
record_id=uuid4(),
document_id=document_id,
run_id=run_id,
timestamp=datetime.now(timezone.utc),
evidence_spans=evidence_spans or [],
specialist_outputs=specialist_outputs or {},
routing_reasons=routing_reasons or [],
route_decision=route_decision,
adjudicator_decision=adjudicator_decision,
impact_outputs=impact_outputs or {},
lineage=lineage or {},
)
def add_correction(self, correction: CorrectionEvent) -> None:
"""Add an immutable correction event."""
self.corrections.append(correction)
self.review_status = ReviewStatus.CORRECTED
def mark_reviewed(self) -> None:
"""Mark the record as reviewed without corrections."""
if self.review_status == ReviewStatus.PENDING:
self.review_status = ReviewStatus.REVIEWED
def mark_confirmed(self) -> None:
"""Mark the record as confirmed correct."""
self.review_status = ReviewStatus.CONFIRMED
@dataclass
class ReviewFilter:
"""Filter criteria for audit records.
Supports filtering by confidence, unsupported claims, adjudication
status, review status, and date ranges.
"""
min_confidence: float | None = None
max_confidence: float | None = None
has_unsupported_claims: bool | None = None
is_adjudicated: bool | None = None
review_status: ReviewStatus | None = None
document_type: str | None = None
company_id: UUID | None = None
from_date: datetime | None = None
to_date: datetime | None = None
def matches(self, record: AuditRecord) -> bool:
"""Check if a record matches this filter."""
if self.is_adjudicated is not None:
has_adj = record.adjudicator_decision is not None
if has_adj != self.is_adjudicated:
return False
if self.review_status is not None:
if record.review_status != self.review_status:
return False
if self.from_date is not None:
if record.timestamp < self.from_date:
return False
if self.to_date is not None:
if record.timestamp > self.to_date:
return False
if self.has_unsupported_claims is not None:
has_unsupported = any(
c.correction_type == CorrectionType.UNSUPPORTED
for c in record.corrections
)
if has_unsupported != self.has_unsupported_claims:
return False
return True
@@ -0,0 +1,81 @@
"""Audit record storage with filtering and retrieval.
In production, this would be backed by PostgreSQL.
This implementation provides the storage interface for testing.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from uuid import UUID
from services.intelligence_pipeline_v3.audit.models import (
AuditRecord,
CorrectionEvent,
ReviewFilter,
)
@dataclass
class AuditStore:
"""In-memory audit record store with filtering.
Provides storage, retrieval, and filtering of audit records and
their immutable correction events.
"""
_records: dict[UUID, AuditRecord] = field(default_factory=dict)
_corrections: list[CorrectionEvent] = field(default_factory=list)
def store(self, record: AuditRecord) -> None:
"""Store an audit record."""
self._records[record.record_id] = record
def get(self, record_id: UUID) -> AuditRecord | None:
"""Retrieve a record by ID."""
return self._records.get(record_id)
def get_by_document(self, document_id: str) -> list[AuditRecord]:
"""Get all records for a document."""
return [
r for r in self._records.values() if r.document_id == document_id
]
def get_by_run(self, run_id: UUID) -> AuditRecord | None:
"""Get the record for a pipeline run."""
for r in self._records.values():
if r.run_id == run_id:
return r
return None
def add_correction(
self, record_id: UUID, correction: CorrectionEvent
) -> bool:
"""Add a correction to a record. Returns False if record not found."""
record = self._records.get(record_id)
if record is None:
return False
record.add_correction(correction)
self._corrections.append(correction)
return True
def filter(self, criteria: ReviewFilter) -> list[AuditRecord]:
"""Filter records by criteria."""
return [
r for r in self._records.values() if criteria.matches(r)
]
def get_corrections(self, record_id: UUID) -> list[CorrectionEvent]:
"""Get all corrections for a record."""
record = self._records.get(record_id)
if record is None:
return []
return list(record.corrections)
def count(self) -> int:
"""Total stored records."""
return len(self._records)
def correction_count(self) -> int:
"""Total correction events across all records."""
return len(self._corrections)
@@ -0,0 +1,46 @@
"""Benchmark configuration and comparison framework for Intelligence Pipeline v3.
Defines extraction configurations for controlled comparison between the current
production pipeline and corrected variants. Supports attribution of improvement
sources (temperature fix, schema constraints, architecture changes).
Validates: Requirements 16.2, 16.3, 16.5
"""
from services.intelligence_pipeline_v3.benchmark.comparison import (
ComparisonReport,
ConfigDelta,
FieldDelta,
ResourceDelta,
compare_configurations,
)
from services.intelligence_pipeline_v3.benchmark.configurations import (
BASELINE_CURRENT,
BASELINE_STRICT_SCHEMA,
BASELINE_TEMP_ZERO,
BenchmarkConfig,
StructuredOutputMode,
list_configurations,
)
from services.intelligence_pipeline_v3.benchmark.runner import (
BenchmarkDocumentResult,
BenchmarkRun,
BenchmarkRunner,
)
__all__ = [
"BASELINE_CURRENT",
"BASELINE_STRICT_SCHEMA",
"BASELINE_TEMP_ZERO",
"BenchmarkConfig",
"BenchmarkDocumentResult",
"BenchmarkRun",
"BenchmarkRunner",
"ComparisonReport",
"ConfigDelta",
"FieldDelta",
"ResourceDelta",
"StructuredOutputMode",
"compare_configurations",
"list_configurations",
]
@@ -0,0 +1,294 @@
"""Comparison and attribution for benchmark configurations.
Produces delta tables and attribution reports to quantify how much of
the apparent architecture gain comes from fixing the current request alone
(temperature, schema constraints) versus the full v3 architecture.
Validates: Requirements 16.2, 16.3, 16.5
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.benchmark.runner import BenchmarkRun
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class FieldDelta(BaseModel):
"""Per-field improvement between two configurations."""
field_name: str = Field(description="Name of the compared field/metric")
baseline_value: float = Field(description="Value in the baseline configuration")
comparison_value: float = Field(description="Value in the compared configuration")
absolute_delta: float = Field(description="comparison - baseline")
relative_delta_percent: float = Field(
description="Percentage change from baseline ((comp - base) / base * 100)",
)
improved: bool = Field(
description="Whether the delta represents improvement (higher is better assumed unless inverted)",
)
class ResourceDelta(BaseModel):
"""Resource usage comparison between configurations."""
metric_name: str = Field(description="Resource metric name")
baseline_value: float = Field(description="Baseline resource usage")
comparison_value: float = Field(description="Compared configuration resource usage")
absolute_delta: float = Field(description="comparison - baseline")
relative_delta_percent: float = Field(description="Percentage change")
improved: bool = Field(
description="Whether the delta represents improvement (lower is better for resources)",
)
class ConfigDelta(BaseModel):
"""Comparison results between a baseline and one other configuration."""
baseline_config: str = Field(description="Baseline configuration name")
comparison_config: str = Field(description="Compared configuration name")
field_deltas: list[FieldDelta] = Field(default_factory=list)
resource_deltas: list[ResourceDelta] = Field(default_factory=list)
class ComparisonReport(BaseModel):
"""Full comparison report across multiple configurations.
Attributes:
configs_compared: Names of all configurations in this comparison.
deltas: Per-configuration comparison against the baseline.
attribution_summary: Human-readable attribution of improvement sources.
"""
configs_compared: list[str] = Field(
description="All configuration names included in this comparison",
)
deltas: list[ConfigDelta] = Field(
default_factory=list,
description="Delta tables for each non-baseline config vs baseline",
)
attribution_summary: dict[str, float] = Field(
default_factory=dict,
description=(
"Attribution percentages: maps source (e.g. 'temperature_fix', "
"'schema_constraint', 'architecture') to fraction of total improvement"
),
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _compute_field_delta(
field_name: str,
baseline_val: float,
comparison_val: float,
*,
higher_is_better: bool = True,
) -> FieldDelta:
"""Compute a single field delta with direction awareness."""
absolute = comparison_val - baseline_val
relative = (
(absolute / baseline_val * 100.0) if baseline_val != 0.0 else 0.0
)
improved = absolute > 0.0 if higher_is_better else absolute < 0.0
return FieldDelta(
field_name=field_name,
baseline_value=baseline_val,
comparison_value=comparison_val,
absolute_delta=absolute,
relative_delta_percent=relative,
improved=improved,
)
def _compute_resource_delta(
metric_name: str,
baseline_val: float,
comparison_val: float,
) -> ResourceDelta:
"""Compute a resource delta (lower is better)."""
absolute = comparison_val - baseline_val
relative = (
(absolute / baseline_val * 100.0) if baseline_val != 0.0 else 0.0
)
improved = absolute < 0.0 # Lower resource usage is better
return ResourceDelta(
metric_name=metric_name,
baseline_value=baseline_val,
comparison_value=comparison_val,
absolute_delta=absolute,
relative_delta_percent=relative,
improved=improved,
)
def _run_metrics(run: BenchmarkRun) -> dict[str, float]:
"""Extract summary metrics from a benchmark run."""
n = len(run.results) or 1 # Avoid division by zero
return {
"schema_validity_rate": run.schema_validity_rate,
"success_count": float(run.success_count),
"failure_count": float(run.failure_count),
"mean_duration_ms": run.mean_duration_ms,
"total_input_tokens": float(run.total_input_tokens),
"total_output_tokens": float(run.total_output_tokens),
"mean_retries": sum(r.retries for r in run.results) / n,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def compare_configurations(
baseline_run: BenchmarkRun,
comparison_runs: list[BenchmarkRun],
) -> ComparisonReport:
"""Compare benchmark runs to produce delta tables and attribution.
Computes per-field and per-resource deltas between the baseline and
each comparison configuration, then attributes improvement sources.
Args:
baseline_run: The baseline (typically BASELINE_CURRENT) run results.
comparison_runs: One or more comparison configuration runs.
Returns:
ComparisonReport with deltas and attribution percentages.
"""
configs_compared = [baseline_run.config_name] + [
r.config_name for r in comparison_runs
]
baseline_metrics = _run_metrics(baseline_run)
deltas: list[ConfigDelta] = []
# Fields where higher is better
_higher_is_better = {"schema_validity_rate", "success_count"}
# Fields where lower is better (resource-like)
_resource_fields = {
"mean_duration_ms",
"total_input_tokens",
"total_output_tokens",
"mean_retries",
"failure_count",
}
for comp_run in comparison_runs:
comp_metrics = _run_metrics(comp_run)
field_deltas: list[FieldDelta] = []
resource_deltas: list[ResourceDelta] = []
for metric_name, baseline_val in baseline_metrics.items():
comp_val = comp_metrics[metric_name]
if metric_name in _resource_fields:
resource_deltas.append(
_compute_resource_delta(metric_name, baseline_val, comp_val)
)
else:
field_deltas.append(
_compute_field_delta(
metric_name,
baseline_val,
comp_val,
higher_is_better=(metric_name in _higher_is_better),
)
)
deltas.append(
ConfigDelta(
baseline_config=baseline_run.config_name,
comparison_config=comp_run.config_name,
field_deltas=field_deltas,
resource_deltas=resource_deltas,
)
)
# Attribution: estimate how much improvement comes from each fix
attribution = _compute_attribution(baseline_metrics, comparison_runs)
return ComparisonReport(
configs_compared=configs_compared,
deltas=deltas,
attribution_summary=attribution,
)
def _compute_attribution(
baseline_metrics: dict[str, float],
comparison_runs: list[BenchmarkRun],
) -> dict[str, float]:
"""Compute attribution percentages for improvement sources.
Uses schema_validity_rate as the primary improvement signal.
Attribution is computed as the fraction of total improvement each
configuration step contributes.
Returns a dict mapping source labels to fraction (0.0-1.0).
"""
attribution: dict[str, float] = {}
if not comparison_runs:
return attribution
baseline_validity = baseline_metrics["schema_validity_rate"]
# Find temp_zero and strict_schema runs by config name
temp_zero_validity: float | None = None
strict_schema_validity: float | None = None
for run in comparison_runs:
run_metrics = _run_metrics(run)
if "temp_zero" in run.config_name:
temp_zero_validity = run_metrics["schema_validity_rate"]
elif "strict_schema" in run.config_name:
strict_schema_validity = run_metrics["schema_validity_rate"]
# Compute incremental gains
# Total improvement = strict_schema - baseline (or best comparison - baseline)
best_validity = max(
_run_metrics(r)["schema_validity_rate"] for r in comparison_runs
)
total_improvement = best_validity - baseline_validity
if total_improvement <= 0.0:
# No improvement detected; equal attribution
attribution["temperature_fix"] = 0.0
attribution["schema_constraint"] = 0.0
return attribution
# Temperature fix contribution
if temp_zero_validity is not None:
temp_gain = temp_zero_validity - baseline_validity
attribution["temperature_fix"] = max(0.0, temp_gain / total_improvement)
else:
attribution["temperature_fix"] = 0.0
# Schema constraint contribution (incremental over temp fix)
if strict_schema_validity is not None and temp_zero_validity is not None:
schema_gain = strict_schema_validity - temp_zero_validity
attribution["schema_constraint"] = max(0.0, schema_gain / total_improvement)
elif strict_schema_validity is not None:
schema_gain = strict_schema_validity - baseline_validity
attribution["schema_constraint"] = max(0.0, schema_gain / total_improvement)
else:
attribution["schema_constraint"] = 0.0
# Remaining is attributed to other factors
accounted = attribution.get("temperature_fix", 0.0) + attribution.get(
"schema_constraint", 0.0
)
attribution["other"] = max(0.0, 1.0 - accounted)
return attribution
@@ -0,0 +1,134 @@
"""Benchmark configuration definitions for controlled extraction comparisons.
Defines the standard configurations used to attribute improvement sources:
- BASELINE_CURRENT: Current production settings (temperature 0.7, no schema constraint)
- BASELINE_TEMP_ZERO: Same model, temperature 0, no schema constraint
- BASELINE_STRICT_SCHEMA: Same model, temperature 0, strict JSON Schema
Validates: Requirements 16.2, 16.3, 16.5
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class StructuredOutputMode(str, Enum):
"""Structured output constraint modes for extraction."""
NONE = "none"
JSON_OBJECT = "json_object"
JSON_SCHEMA = "json_schema"
class BenchmarkConfig(BaseModel):
"""Configuration for a single benchmark extraction run.
Captures all parameters that affect extraction behavior so that
differences between runs can be attributed to specific settings.
"""
model_config = ConfigDict(frozen=True)
config_name: str = Field(
description="Unique identifier for this configuration",
)
description: str = Field(
description="Human-readable description of what this configuration tests",
)
model_name: str = Field(
description="Served model name (e.g. 'AxionML/Qwen3.5-9B-NVFP4')",
)
temperature: float = Field(
ge=0.0,
le=2.0,
description="Sampling temperature; 0.0 = deterministic",
)
max_output_tokens: int = Field(
gt=0,
description="Maximum tokens in generated output",
)
structured_output_mode: StructuredOutputMode = Field(
description="How output structure is constrained",
)
seed: int | None = Field(
default=None,
description="Random seed for reproducibility (None = not pinned)",
)
additional_params: dict[str, Any] = Field(
default_factory=dict,
description="Provider-specific extra parameters",
)
# ---------------------------------------------------------------------------
# Standard Benchmark Configurations
# ---------------------------------------------------------------------------
# The 9B model currently deployed on the cluster
_DEFAULT_MODEL = "AxionML/Qwen3.5-9B-NVFP4"
_DEFAULT_MAX_OUTPUT_TOKENS = 2048
BASELINE_CURRENT = BenchmarkConfig(
config_name="baseline_current",
description=(
"Current production settings: temperature 0.7, response_format json_object "
"only (schema not enforced on generation), no seed pinning. "
"Represents the unchanged request as deployed."
),
model_name=_DEFAULT_MODEL,
temperature=0.7,
max_output_tokens=_DEFAULT_MAX_OUTPUT_TOKENS,
structured_output_mode=StructuredOutputMode.JSON_OBJECT,
seed=None,
additional_params={},
)
BASELINE_TEMP_ZERO = BenchmarkConfig(
config_name="baseline_temp_zero",
description=(
"Same 9B model with temperature set to 0.0 for deterministic generation. "
"Still uses json_object mode without strict schema enforcement. "
"Isolates the effect of removing sampling stochasticity."
),
model_name=_DEFAULT_MODEL,
temperature=0.0,
max_output_tokens=_DEFAULT_MAX_OUTPUT_TOKENS,
structured_output_mode=StructuredOutputMode.JSON_OBJECT,
seed=0,
additional_params={},
)
BASELINE_STRICT_SCHEMA = BenchmarkConfig(
config_name="baseline_strict_schema",
description=(
"Same 9B model with temperature 0.0 AND strict JSON Schema output "
"enforcement via vLLM structured output backend. "
"Isolates the combined effect of deterministic generation plus "
"grammar-constrained decoding."
),
model_name=_DEFAULT_MODEL,
temperature=0.0,
max_output_tokens=_DEFAULT_MAX_OUTPUT_TOKENS,
structured_output_mode=StructuredOutputMode.JSON_SCHEMA,
seed=0,
additional_params={},
)
# Registry of all standard configurations
_STANDARD_CONFIGURATIONS: dict[str, BenchmarkConfig] = {
BASELINE_CURRENT.config_name: BASELINE_CURRENT,
BASELINE_TEMP_ZERO.config_name: BASELINE_TEMP_ZERO,
BASELINE_STRICT_SCHEMA.config_name: BASELINE_STRICT_SCHEMA,
}
def list_configurations() -> list[BenchmarkConfig]:
"""Return all registered benchmark configurations.
Returns:
List of BenchmarkConfig instances in definition order.
"""
return list(_STANDARD_CONFIGURATIONS.values())
@@ -0,0 +1,296 @@
"""Benchmark runner scaffold for extraction configuration comparisons.
Provides the framework for running extraction benchmarks across different
configurations. The actual model invocations require the cluster, but
results can be stored and compared locally.
Validates: Requirements 16.2, 16.3
"""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.benchmark.configurations import (
BenchmarkConfig,
)
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class BenchmarkDocumentResult(BaseModel):
"""Result of running one document through one benchmark configuration."""
document_id: str = Field(description="Identifier of the source document")
raw_output: str | None = Field(
default=None,
description="Raw model output text (before parsing)",
)
parsed_output: dict[str, Any] | None = Field(
default=None,
description="Parsed JSON output if extraction succeeded",
)
schema_valid: bool = Field(
default=False,
description="Whether the output passed JSON Schema validation",
)
retries: int = Field(
default=0,
ge=0,
description="Number of retries needed to get valid output",
)
duration_ms: int = Field(
default=0,
ge=0,
description="Total wall-clock time in milliseconds",
)
input_tokens: int = Field(
default=0,
ge=0,
description="Input tokens consumed",
)
output_tokens: int = Field(
default=0,
ge=0,
description="Output tokens generated",
)
error: str | None = Field(
default=None,
description="Error message if extraction failed",
)
class BenchmarkRun(BaseModel):
"""A complete benchmark run: one configuration applied to multiple documents."""
config_name: str = Field(description="Configuration used for this run")
timestamp: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="When this run was executed",
)
document_ids: list[str] = Field(
default_factory=list,
description="Documents included in this run",
)
results: list[BenchmarkDocumentResult] = Field(
default_factory=list,
description="Per-document results",
)
@property
def success_count(self) -> int:
"""Number of documents that produced valid output."""
return sum(1 for r in self.results if r.schema_valid and r.error is None)
@property
def failure_count(self) -> int:
"""Number of documents that failed or produced invalid output."""
return len(self.results) - self.success_count
@property
def schema_validity_rate(self) -> float:
"""Fraction of results that passed schema validation."""
if not self.results:
return 0.0
return self.success_count / len(self.results)
@property
def mean_duration_ms(self) -> float:
"""Average duration across all results."""
if not self.results:
return 0.0
return sum(r.duration_ms for r in self.results) / len(self.results)
@property
def total_input_tokens(self) -> int:
"""Total input tokens across all results."""
return sum(r.input_tokens for r in self.results)
@property
def total_output_tokens(self) -> int:
"""Total output tokens across all results."""
return sum(r.output_tokens for r in self.results)
# ---------------------------------------------------------------------------
# Artifact Storage
# ---------------------------------------------------------------------------
_DEFAULT_ARTIFACT_DIR = Path("artifacts/benchmark")
def _ensure_artifact_dir(base: Path) -> Path:
"""Create artifact directory if it does not exist."""
base.mkdir(parents=True, exist_ok=True)
return base
def save_benchmark_run(
run: BenchmarkRun,
artifact_dir: Path | None = None,
) -> Path:
"""Persist a benchmark run as a JSON artifact.
Args:
run: The benchmark run to save.
artifact_dir: Directory to write to. Defaults to artifacts/benchmark/.
Returns:
Path to the written JSON file.
"""
base = artifact_dir or _DEFAULT_ARTIFACT_DIR
_ensure_artifact_dir(base)
ts = run.timestamp.strftime("%Y%m%d_%H%M%S")
filename = f"{run.config_name}_{ts}.json"
path = base / filename
path.write_text(
run.model_dump_json(indent=2),
encoding="utf-8",
)
return path
def load_benchmark_run(path: Path) -> BenchmarkRun:
"""Load a benchmark run from a JSON artifact.
Args:
path: Path to the JSON artifact file.
Returns:
Deserialized BenchmarkRun.
"""
data = json.loads(path.read_text(encoding="utf-8"))
return BenchmarkRun.model_validate(data)
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
class BenchmarkRunner:
"""Runs extraction benchmarks using a given configuration.
The runner provides the scaffolding for executing benchmarks.
Actual LLM invocation is delegated to an inference callable.
When no inference callable is provided, results are recorded as
errors (useful for dry-run / configuration testing).
"""
def __init__(
self,
config: BenchmarkConfig,
artifact_dir: Path | None = None,
inference_fn: Any | None = None,
) -> None:
"""Initialize the benchmark runner.
Args:
config: Benchmark configuration to use for all runs.
artifact_dir: Where to store result artifacts.
inference_fn: Optional async callable(document_text, config) -> dict.
If None, documents are recorded as not-run errors.
"""
self.config = config
self.artifact_dir = artifact_dir or _DEFAULT_ARTIFACT_DIR
self._inference_fn = inference_fn
async def run_single_document(
self,
document_id: str,
document_text: str,
json_schema: dict[str, Any] | None = None,
) -> BenchmarkDocumentResult:
"""Run a single document through the configured extraction.
Args:
document_id: Unique document identifier.
document_text: Full document text to extract from.
json_schema: Optional JSON Schema for validation.
Returns:
BenchmarkDocumentResult with extraction outcome.
"""
if self._inference_fn is None:
return BenchmarkDocumentResult(
document_id=document_id,
error="No inference function configured (dry-run mode)",
)
start = time.perf_counter()
try:
result = await self._inference_fn(document_text, self.config)
duration_ms = int((time.perf_counter() - start) * 1000)
raw_output = result.get("raw_output", "")
parsed_output = result.get("parsed_output")
schema_valid = result.get("schema_valid", False)
input_tokens = result.get("input_tokens", 0)
output_tokens = result.get("output_tokens", 0)
retries = result.get("retries", 0)
return BenchmarkDocumentResult(
document_id=document_id,
raw_output=raw_output,
parsed_output=parsed_output,
schema_valid=schema_valid,
retries=retries,
duration_ms=duration_ms,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as exc:
duration_ms = int((time.perf_counter() - start) * 1000)
return BenchmarkDocumentResult(
document_id=document_id,
duration_ms=duration_ms,
error=str(exc),
)
async def run_batch(
self,
documents: list[tuple[str, str]],
json_schema: dict[str, Any] | None = None,
save_artifacts: bool = True,
) -> BenchmarkRun:
"""Run a batch of documents through the configured extraction.
Args:
documents: List of (document_id, document_text) tuples.
json_schema: Optional JSON Schema for validation.
save_artifacts: Whether to persist results as JSON artifacts.
Returns:
BenchmarkRun with all document results.
"""
results: list[BenchmarkDocumentResult] = []
document_ids: list[str] = []
for doc_id, doc_text in documents:
document_ids.append(doc_id)
result = await self.run_single_document(
document_id=doc_id,
document_text=doc_text,
json_schema=json_schema,
)
results.append(result)
run = BenchmarkRun(
config_name=self.config.config_name,
document_ids=document_ids,
results=results,
)
if save_artifacts:
save_benchmark_run(run, self.artifact_dir)
return run
@@ -0,0 +1,28 @@
"""Canary deployment module for v3 pipeline promotion.
Supports percentage-based routing, automatic rollback on threshold
violations, audit integrity during rollback, and paper-trading
signal influence with divergence review.
"""
from services.intelligence_pipeline_v3.canary.influence import (
DivergenceRecord,
SignalInfluenceConfig,
SignalInfluenceTracker,
)
from services.intelligence_pipeline_v3.canary.routing import (
CanaryConfig,
CanaryRouter,
RollbackEvent,
RollbackReason,
)
__all__ = [
"CanaryConfig",
"CanaryRouter",
"DivergenceRecord",
"RollbackEvent",
"RollbackReason",
"SignalInfluenceConfig",
"SignalInfluenceTracker",
]
@@ -0,0 +1,187 @@
"""Canary signal influence — paper trading with v3 signals.
Enables v3 signals in paper trading at a small percentage, tracks
extraction correctness separately from trading outcomes, reviews
material recommendation divergences, and requires explicit owner
approval for full promotion.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class PromotionStatus(str, enum.Enum):
"""Status of the canary promotion process."""
PENDING = "pending"
PAPER_TRADING = "paper_trading"
AWAITING_REVIEW = "awaiting_review"
APPROVED = "approved"
REJECTED = "rejected"
@dataclass
class DivergenceRecord:
"""Record of a material recommendation divergence between v2 and v3."""
record_id: UUID
document_id: str
timestamp: datetime
v2_recommendation: dict[str, Any]
v3_recommendation: dict[str, Any]
divergence_type: str # e.g., "direction_opposite", "magnitude_significant"
impact_estimate: float = 0.0 # Estimated impact on portfolio
reviewed: bool = False
reviewer_notes: str = ""
@classmethod
def create(
cls,
document_id: str,
v2_recommendation: dict[str, Any],
v3_recommendation: dict[str, Any],
divergence_type: str,
impact_estimate: float = 0.0,
) -> DivergenceRecord:
return cls(
record_id=uuid4(),
document_id=document_id,
timestamp=datetime.now(timezone.utc),
v2_recommendation=v2_recommendation,
v3_recommendation=v3_recommendation,
divergence_type=divergence_type,
impact_estimate=impact_estimate,
)
@dataclass
class SignalInfluenceConfig:
"""Configuration for canary signal influence in paper trading."""
enabled: bool = False
percentage: int = 5 # Start at 5% of paper trading signals
require_owner_approval: bool = True
owner_id: str = ""
# Reporting thresholds
material_divergence_threshold: float = 0.20
max_divergence_rate: float = 0.15
# Separation of concerns
report_extraction_separately: bool = True
report_trading_separately: bool = True
@dataclass
class SignalInfluenceTracker:
"""Tracks canary signal influence in paper trading.
Reports extraction correctness separately from trading outcomes.
Reviews material divergences and tracks promotion readiness.
"""
config: SignalInfluenceConfig
promotion_status: PromotionStatus = PromotionStatus.PENDING
_divergences: list[DivergenceRecord] = field(default_factory=list)
_extraction_metrics: dict[str, float] = field(default_factory=dict)
_trading_metrics: dict[str, float] = field(default_factory=dict)
_total_signals: int = 0
_v3_signals: int = 0
_approval_timestamp: datetime | None = None
_approver_id: str = ""
def start_paper_trading(self) -> None:
"""Begin paper trading with v3 signals."""
self.config.enabled = True
self.promotion_status = PromotionStatus.PAPER_TRADING
def record_signal(self, is_v3: bool = False) -> None:
"""Record a signal processed."""
self._total_signals += 1
if is_v3:
self._v3_signals += 1
def record_divergence(self, divergence: DivergenceRecord) -> None:
"""Record a material recommendation divergence."""
self._divergences.append(divergence)
def update_extraction_metrics(self, metrics: dict[str, float]) -> None:
"""Update extraction correctness metrics (separate from trading)."""
self._extraction_metrics.update(metrics)
def update_trading_metrics(self, metrics: dict[str, float]) -> None:
"""Update trading outcome metrics (separate from extraction)."""
self._trading_metrics.update(metrics)
@property
def divergence_rate(self) -> float:
if self._v3_signals == 0:
return 0.0
return len(self._divergences) / self._v3_signals
@property
def unreviewed_divergences(self) -> list[DivergenceRecord]:
return [d for d in self._divergences if not d.reviewed]
def request_approval(self) -> None:
"""Move to awaiting review status."""
self.promotion_status = PromotionStatus.AWAITING_REVIEW
def approve(self, approver_id: str) -> bool:
"""Approve promotion. Requires owner approval if configured.
Returns False if approval requirements are not met.
"""
if self.config.require_owner_approval:
if not approver_id:
return False
if self.config.owner_id and approver_id != self.config.owner_id:
return False
# Check all gates
if not self._all_gates_pass():
return False
self.promotion_status = PromotionStatus.APPROVED
self._approval_timestamp = datetime.now(timezone.utc)
self._approver_id = approver_id
return True
def reject(self, reason: str = "") -> None:
"""Reject promotion."""
self.promotion_status = PromotionStatus.REJECTED
def _all_gates_pass(self) -> bool:
"""Check if extraction correctness gates pass.
Trading outcomes explicitly do NOT override correctness gates
(Requirement 16.10).
"""
# Divergence rate must be below threshold
if self.divergence_rate > self.config.max_divergence_rate:
return False
# All divergences must be reviewed
if self.unreviewed_divergences:
return False
return True
def summary(self) -> dict[str, Any]:
return {
"enabled": self.config.enabled,
"status": self.promotion_status.value,
"percentage": self.config.percentage,
"total_signals": self._total_signals,
"v3_signals": self._v3_signals,
"divergence_count": len(self._divergences),
"divergence_rate": self.divergence_rate,
"unreviewed_divergences": len(self.unreviewed_divergences),
"extraction_metrics": self._extraction_metrics,
"trading_metrics": self._trading_metrics,
}
@@ -0,0 +1,260 @@
"""Canary compatibility outputs — percentage routing and automatic rollback.
Enables v3 adapter outputs for non-trading consumers first, then
progressively routes more traffic. Automatic rollback triggers on
correctness, latency, queue, or availability thresholds. Rollback
preserves v3 audit records.
"""
from __future__ import annotations
import enum
import hashlib
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class RollbackReason(str, enum.Enum):
"""Reasons for automatic canary rollback."""
CORRECTNESS_THRESHOLD = "correctness_threshold"
LATENCY_THRESHOLD = "latency_threshold"
QUEUE_SATURATION = "queue_saturation"
AVAILABILITY_THRESHOLD = "availability_threshold"
ERROR_RATE = "error_rate"
MANUAL = "manual"
@dataclass(frozen=True)
class RollbackEvent:
"""Immutable record of a canary rollback.
Rollback leaves v3 audit records intact — only routing changes.
"""
event_id: UUID
timestamp: datetime
reason: RollbackReason
previous_percentage: int
metric_value: float
threshold_value: float
details: str = ""
@classmethod
def create(
cls,
reason: RollbackReason,
previous_percentage: int,
metric_value: float,
threshold_value: float,
details: str = "",
) -> RollbackEvent:
return cls(
event_id=uuid4(),
timestamp=datetime.now(timezone.utc),
reason=reason,
previous_percentage=previous_percentage,
metric_value=metric_value,
threshold_value=threshold_value,
details=details,
)
@dataclass
class CanaryConfig:
"""Canary routing configuration with thresholds."""
enabled: bool = False
percentage: int = 0 # 0-100, percentage of docs using v3 outputs
document_types: set[str] = field(default_factory=set) # Types eligible for canary
exclude_trading: bool = True # Exclude trading consumers initially
# Automatic rollback thresholds
max_error_rate: float = 0.05
max_p95_latency_ms: float = 5000.0
max_queue_saturation: float = 0.90
min_availability: float = 0.95
min_correctness: float = 0.90
# Rollback behavior
rollback_to_percentage: int = 0 # Roll back to this percentage
cooldown_minutes: int = 60 # Wait before re-enabling after rollback
@dataclass
class CanaryRouter:
"""Routes documents between v2 and v3 outputs at configurable percentages.
Routing is deterministic per document_id to avoid inconsistent
behavior on retries. Rollback preserves all v3 audit records.
"""
config: CanaryConfig
_rollback_events: list[RollbackEvent] = field(default_factory=list)
_documents_routed_v3: int = 0
_documents_routed_v2: int = 0
_last_rollback: datetime | None = None
def should_use_v3(
self,
document_id: str,
document_type: str | None = None,
is_trading_consumer: bool = False,
) -> bool:
"""Determine if a document should use v3 outputs.
Deterministic per document_id for consistency.
"""
if not self.config.enabled:
return False
# Respect trading exclusion
if is_trading_consumer and self.config.exclude_trading:
return False
# Check if in cooldown after rollback
if self._in_cooldown():
return False
# Document type filter
if (
self.config.document_types
and document_type
and document_type not in self.config.document_types
):
return False
# Percentage-based routing (deterministic hash)
bucket = self._hash_to_bucket(document_id)
use_v3 = bucket < self.config.percentage
if use_v3:
self._documents_routed_v3 += 1
else:
self._documents_routed_v2 += 1
return use_v3
def check_rollback(
self,
error_rate: float = 0.0,
p95_latency_ms: float = 0.0,
queue_saturation: float = 0.0,
availability: float = 1.0,
correctness: float = 1.0,
) -> RollbackEvent | None:
"""Check all rollback thresholds. Returns event if rollback triggered."""
if not self.config.enabled or self.config.percentage == 0:
return None
checks: list[tuple[RollbackReason, float, float, str]] = [
(
RollbackReason.ERROR_RATE,
error_rate,
self.config.max_error_rate,
f"Error rate {error_rate:.3f} > {self.config.max_error_rate}",
),
(
RollbackReason.LATENCY_THRESHOLD,
p95_latency_ms,
self.config.max_p95_latency_ms,
f"P95 latency {p95_latency_ms:.0f}ms > {self.config.max_p95_latency_ms:.0f}ms",
),
(
RollbackReason.QUEUE_SATURATION,
queue_saturation,
self.config.max_queue_saturation,
f"Queue saturation {queue_saturation:.2f} > {self.config.max_queue_saturation}",
),
]
for reason, value, threshold, details in checks:
if value > threshold:
return self._trigger_rollback(reason, value, threshold, details)
# These check for below threshold
if availability < self.config.min_availability:
return self._trigger_rollback(
RollbackReason.AVAILABILITY_THRESHOLD,
availability,
self.config.min_availability,
f"Availability {availability:.3f} < {self.config.min_availability}",
)
if correctness < self.config.min_correctness:
return self._trigger_rollback(
RollbackReason.CORRECTNESS_THRESHOLD,
correctness,
self.config.min_correctness,
f"Correctness {correctness:.3f} < {self.config.min_correctness}",
)
return None
def manual_rollback(self, details: str = "") -> RollbackEvent:
"""Trigger a manual rollback."""
return self._trigger_rollback(
RollbackReason.MANUAL,
0.0,
0.0,
details or "Manual rollback requested",
)
def _trigger_rollback(
self,
reason: RollbackReason,
metric_value: float,
threshold_value: float,
details: str,
) -> RollbackEvent:
"""Execute rollback — change routing but preserve audit data."""
event = RollbackEvent.create(
reason=reason,
previous_percentage=self.config.percentage,
metric_value=metric_value,
threshold_value=threshold_value,
details=details,
)
self.config.percentage = self.config.rollback_to_percentage
self._rollback_events.append(event)
self._last_rollback = datetime.now(timezone.utc)
return event
def _in_cooldown(self) -> bool:
"""Check if we're in cooldown after a rollback."""
if self._last_rollback is None:
return False
from datetime import timedelta
cooldown_end = self._last_rollback + timedelta(
minutes=self.config.cooldown_minutes
)
return datetime.now(timezone.utc) < cooldown_end
def _hash_to_bucket(self, document_id: str) -> int:
"""Deterministic hash to 0-99 bucket."""
h = hashlib.sha256(f"canary:{document_id}".encode()).hexdigest()
return int(h[:8], 16) % 100
@property
def rollback_events(self) -> list[RollbackEvent]:
return list(self._rollback_events)
@property
def v3_traffic_ratio(self) -> float:
total = self._documents_routed_v2 + self._documents_routed_v3
if total == 0:
return 0.0
return self._documents_routed_v3 / total
def summary(self) -> dict[str, Any]:
return {
"enabled": self.config.enabled,
"percentage": self.config.percentage,
"documents_v3": self._documents_routed_v3,
"documents_v2": self._documents_routed_v2,
"rollback_count": len(self._rollback_events),
"in_cooldown": self._in_cooldown(),
}
@@ -0,0 +1,20 @@
"""Compatibility adapter — maps v3 intelligence records to current v2 data classes."""
from services.intelligence_pipeline_v3.compatibility.adapter import CompatibilityAdapter
from services.intelligence_pipeline_v3.compatibility.config import AdapterMode, is_adapter_enabled
from services.intelligence_pipeline_v3.compatibility.models import (
AdapterLineage,
V2ImpactRecord,
V2IntelligenceRecord,
V3IntelligenceRecord,
)
__all__ = [
"AdapterLineage",
"AdapterMode",
"CompatibilityAdapter",
"V2ImpactRecord",
"V2IntelligenceRecord",
"V3IntelligenceRecord",
"is_adapter_enabled",
]
@@ -0,0 +1,208 @@
"""Compatibility adapter — maps approved v3 records to current v2 data classes.
The adapter creates current-format records without discarding v3 provenance.
It marks model_provider='hybrid' and stores complete stage lineage separately.
Design reference: Section K (Compatibility Adapter) in design.md.
"""
from __future__ import annotations
import uuid
from services.intelligence_pipeline_v3.compatibility.config import (
AdapterMode,
is_adapter_enabled,
)
from services.intelligence_pipeline_v3.compatibility.models import (
AdapterLineage,
V2ImpactRecord,
V2IntelligenceRecord,
V3CompanySignal,
V3HorizonProbabilities,
V3IntelligenceRecord,
V3SentimentDistribution,
)
ADAPTER_VERSION = "1.0.0"
class AdapterDisabledError(Exception):
"""Raised when the adapter is called in disabled mode."""
pass
class CompatibilityAdapter:
"""Maps v3 intelligence records to v2 format for downstream consumers.
The adapter is gated by AdapterMode — it refuses to produce output when
disabled, ensuring v3 records cannot accidentally affect production
consumers until explicitly enabled.
"""
def __init__(self, mode: AdapterMode = AdapterMode.DISABLED) -> None:
self._mode = mode
@property
def mode(self) -> AdapterMode:
return self._mode
@property
def version(self) -> str:
return ADAPTER_VERSION
def map_to_v2(
self, v3_record: V3IntelligenceRecord
) -> tuple[V2IntelligenceRecord, AdapterLineage]:
"""Map an approved v3 record to v2 intelligence + impact records.
Returns:
A tuple of (V2IntelligenceRecord, AdapterLineage).
Raises:
AdapterDisabledError: If the adapter is in disabled mode.
"""
if not is_adapter_enabled(self._mode):
raise AdapterDisabledError(
f"Adapter is disabled (mode={self._mode.value}). "
"Enable replay, shadow, canary, or production mode to use."
)
v2_id = str(uuid.uuid4())
# Map each company signal to a v2 impact record
impact_records = [
self._map_company_signal(signal) for signal in v3_record.company_signals
]
v2_record = V2IntelligenceRecord(
id=v2_id,
document_id=v3_record.document_id,
summary=v3_record.summary,
macro_themes=v3_record.macro_themes,
novelty_score=v3_record.novelty_score,
confidence=v3_record.confidence,
model_provider="hybrid",
model_name="intelligence-pipeline-v3",
prompt_version=f"adapter-{ADAPTER_VERSION}",
schema_version="3.0.0",
impact_records=impact_records,
)
lineage = AdapterLineage(
adapter_version=ADAPTER_VERSION,
pipeline_version=v3_record.pipeline_version,
v3_document_id=v3_record.document_id,
v2_intelligence_id=v2_id,
stage_runs=v3_record.stage_runs,
mapping_notes=[
f"Mapped {len(v3_record.company_signals)} company signals",
f"Mode: {self._mode.value}",
],
)
return v2_record, lineage
def _map_company_signal(self, signal: V3CompanySignal) -> V2ImpactRecord:
"""Map a single v3 company signal to a v2 impact record."""
return V2ImpactRecord(
company_id=signal.company_id,
ticker=signal.ticker,
relevance=signal.relevance_probability,
sentiment=self._map_sentiment(signal.sentiment),
impact_score=self._map_impact_score(signal),
impact_horizon=self._map_horizon(signal.horizon_probabilities),
catalyst_type=self._map_catalyst_type(signal.event_classes),
evidence_spans=signal.evidence_spans,
)
@staticmethod
def _map_sentiment(dist: V3SentimentDistribution) -> str:
"""Map probability distribution to legacy sentiment enum.
Logic:
- If max probability is neutral and ≥ 0.5 → neutral
- If positive and negative are both ≥ 0.3 → mixed
- Otherwise take the argmax of positive/negative/neutral
"""
pos, neg, neu = dist.positive, dist.negative, dist.neutral
# Mixed detection: both positive and negative have significant mass
if pos >= 0.3 and neg >= 0.3:
return "mixed"
# Argmax
max_val = max(pos, neg, neu)
if max_val == neu:
return "neutral"
elif max_val == pos:
return "positive"
else:
return "negative"
@staticmethod
def _map_impact_score(signal: V3CompanySignal) -> float:
"""Map v3 expected_magnitude to legacy impact_score in [-1, 1].
The v3 expected_magnitude is already a signed value representing
expected market response. We clamp to [-1, 1] for legacy compatibility.
If expected_magnitude is None, derive a conservative estimate from
direction probabilities.
"""
if signal.expected_magnitude is not None:
return max(-1.0, min(1.0, signal.expected_magnitude))
# Fallback: derive from direction probabilities
dp = signal.direction_probabilities
# Signed score: positive_prob - negative_prob, scaled to [-1, 1]
signed = dp.positive - dp.negative
return max(-1.0, min(1.0, signed))
@staticmethod
def _map_horizon(probs: V3HorizonProbabilities) -> str:
"""Map horizon probability distribution to single legacy horizon string.
Returns the horizon with the highest probability (argmax).
Ties are broken by preferring shorter horizons.
"""
horizon_map = {
"intraday": probs.intraday,
"1d": probs.one_day,
"7d": probs.seven_day,
"30d": probs.thirty_day,
"90d": probs.ninety_day,
}
# argmax with tie-breaking by order (shortest first)
return max(horizon_map, key=lambda k: horizon_map[k])
@staticmethod
def _map_catalyst_type(event_classes: list[str]) -> str:
"""Map v3 event taxonomy to legacy catalyst_type enum.
Uses the first matching event class. Falls back to 'other'.
"""
# Mapping from v3 event classes to legacy CatalystType values
event_to_catalyst: dict[str, str] = {
"earnings_beat": "earnings",
"earnings_miss": "earnings",
"guidance_raise": "earnings",
"guidance_cut": "earnings",
"product_launch": "product",
"legal_regulatory": "legal",
"ma_announcement": "m_and_a",
"supply_chain": "supply_chain",
"rating_change": "rating_change",
"macro_event": "macro",
"management_change": "other",
"dividend_change": "other",
"buyback": "other",
}
for event_class in event_classes:
if event_class in event_to_catalyst:
return event_to_catalyst[event_class]
return "other"
@@ -0,0 +1,39 @@
"""Feature flag configuration for the compatibility adapter.
The adapter is disabled by default and must be explicitly enabled for
replay, shadow, canary, or production modes.
"""
from __future__ import annotations
from enum import Enum
class AdapterMode(str, Enum):
"""Operating mode for the compatibility adapter.
- disabled: adapter does not run (default)
- replay_only: adapter runs during offline replay evaluation
- shadow_only: adapter runs in shadow mode (no downstream effect)
- canary: adapter outputs routed to a percentage of non-trading consumers
- production: adapter outputs used for all consumers
"""
DISABLED = "disabled"
REPLAY_ONLY = "replay_only"
SHADOW_ONLY = "shadow_only"
CANARY = "canary"
PRODUCTION = "production"
def is_adapter_enabled(mode: AdapterMode) -> bool:
"""Return True if the adapter should produce output in the given mode.
Only replay, shadow, canary, and production modes enable output.
The disabled mode prevents any adapter execution.
"""
return mode != AdapterMode.DISABLED
# Default mode — adapter is OFF until explicitly activated
DEFAULT_ADAPTER_MODE: AdapterMode = AdapterMode.DISABLED
@@ -0,0 +1,162 @@
"""Input/output models for the v3→v2 compatibility adapter.
V3IntelligenceRecord represents the full v3 pipeline output.
V2IntelligenceRecord / V2ImpactRecord match the current document_intelligence
and document_impact_records database schemas.
AdapterLineage captures version and stage provenance.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# V3 Pipeline Output (input to adapter)
# ---------------------------------------------------------------------------
class V3SentimentDistribution(BaseModel):
"""Per-company calibrated sentiment probabilities."""
positive: float = Field(ge=0.0, le=1.0)
negative: float = Field(ge=0.0, le=1.0)
neutral: float = Field(ge=0.0, le=1.0)
class V3HorizonProbabilities(BaseModel):
"""Probability distribution over impact horizons."""
intraday: float = Field(ge=0.0, le=1.0, default=0.0)
one_day: float = Field(ge=0.0, le=1.0, default=0.0)
seven_day: float = Field(ge=0.0, le=1.0, default=0.0)
thirty_day: float = Field(ge=0.0, le=1.0, default=0.0)
ninety_day: float = Field(ge=0.0, le=1.0, default=0.0)
class V3DirectionProbabilities(BaseModel):
"""Probability distribution over market direction."""
positive: float = Field(ge=0.0, le=1.0, default=0.0)
negative: float = Field(ge=0.0, le=1.0, default=0.0)
neutral: float = Field(ge=0.0, le=1.0, default=0.0)
class V3CompanySignal(BaseModel):
"""A single company's signal from the v3 pipeline."""
company_id: str
ticker: str
relevance_probability: float = Field(ge=0.0, le=1.0)
event_classes: list[str] = Field(default_factory=list)
sentiment: V3SentimentDistribution
direction_probabilities: V3DirectionProbabilities
horizon_probabilities: V3HorizonProbabilities
expected_magnitude: float | None = None
evidence_spans: list[str] = Field(default_factory=list)
adjudicated: bool = False
class V3StageRun(BaseModel):
"""Lineage for a single pipeline stage execution."""
stage: str
endpoint_id: str | None = None
deployment_id: str | None = None
model_version: str | None = None
schema_version: str = "1.0.0"
calibration_version: str | None = None
started_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
duration_ms: int = 0
status: str = "completed"
class V3IntelligenceRecord(BaseModel):
"""Complete v3 pipeline output for a single document.
This is the adapter's input — the full v3 record with probabilities,
evidence, and stage lineage.
"""
document_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
document_type: str = "article"
summary: str = ""
macro_themes: list[str] = Field(default_factory=list)
novelty_score: float = Field(ge=0.0, le=1.0, default=0.5)
confidence: float = Field(ge=0.0, le=1.0, default=0.5)
company_signals: list[V3CompanySignal] = Field(default_factory=list)
stage_runs: list[V3StageRun] = Field(default_factory=list)
pipeline_version: str = "3.0.0"
created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
# ---------------------------------------------------------------------------
# V2 Output (adapter output — matches current DB schema)
# ---------------------------------------------------------------------------
class V2ImpactRecord(BaseModel):
"""Maps to document_impact_records table.
Fields match the columns: relevance, sentiment (enum string),
impact_score (float), impact_horizon (string), catalyst_type,
key_facts, risks, evidence_spans.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
company_id: str
ticker: str
relevance: float = Field(ge=0.0, le=1.0)
sentiment: Literal["positive", "negative", "neutral", "mixed"]
impact_score: float = Field(ge=-1.0, le=1.0)
impact_horizon: Literal["intraday", "1d", "7d", "30d", "90d"]
catalyst_type: str = "other"
key_facts: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
evidence_spans: list[str] = Field(default_factory=list)
class V2IntelligenceRecord(BaseModel):
"""Maps to document_intelligence table.
Fields match columns: summary, macro_themes, novelty_score,
source_credibility, confidence, model_provider, model_name,
prompt_version, schema_version, plus associated impact records.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
document_id: str
summary: str = ""
macro_themes: list[str] = Field(default_factory=list)
novelty_score: float = Field(ge=0.0, le=1.0)
source_credibility: float = Field(ge=0.0, le=1.0, default=0.5)
confidence: float = Field(ge=0.0, le=1.0)
model_provider: str = "hybrid"
model_name: str = "intelligence-pipeline-v3"
prompt_version: str = ""
schema_version: str = "3.0.0"
impact_records: list[V2ImpactRecord] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
# ---------------------------------------------------------------------------
# Adapter Lineage
# ---------------------------------------------------------------------------
class AdapterLineage(BaseModel):
"""Records which adapter version produced the v2 record and from what v3 data.
Stored separately so v3 provenance is never lost.
"""
adapter_version: str = "1.0.0"
pipeline_version: str = "3.0.0"
v3_document_id: str
v2_intelligence_id: str
stage_runs: list[V3StageRun] = Field(default_factory=list)
mapped_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
mapping_notes: list[str] = Field(default_factory=list)
@@ -0,0 +1,32 @@
"""Confidence feature pipeline for Intelligence Pipeline v3.
Provides calibrated extraction confidence from specialist scores,
symbol resolution, evidence validation, schema completeness,
model agreement, and historical calibration data. Replaces
generative model self-reported confidence with empirically
calibrated probabilities.
"""
from services.intelligence_pipeline_v3.confidence.artifacts import (
load_artifact,
save_artifact,
)
from services.intelligence_pipeline_v3.confidence.calibrator import ConfidenceCalibrator
from services.intelligence_pipeline_v3.confidence.defaults import get_default_confidence
from services.intelligence_pipeline_v3.confidence.features import ConfidenceFeatureExtractor
from services.intelligence_pipeline_v3.confidence.models import (
CalibrationArtifactMetadata,
ConfidenceFeatures,
ConfidenceResult,
)
__all__ = [
"CalibrationArtifactMetadata",
"ConfidenceCalibrator",
"ConfidenceFeatureExtractor",
"ConfidenceFeatures",
"ConfidenceResult",
"get_default_confidence",
"load_artifact",
"save_artifact",
]
@@ -0,0 +1,186 @@
"""Calibration artifact persistence.
Handles versioned save/load of fitted calibrator objects alongside
metadata including training provenance, quality metrics, and version.
"""
from __future__ import annotations
import json
import logging
import pickle
from pathlib import Path
from services.intelligence_pipeline_v3.confidence.calibrator import ConfidenceCalibrator
from services.intelligence_pipeline_v3.confidence.models import CalibrationArtifactMetadata
logger = logging.getLogger(__name__)
ARTIFACT_FILE = "calibrator.pkl"
METADATA_FILE = "metadata.json"
def save_artifact(
calibrator: ConfidenceCalibrator,
version: str,
path: str | Path,
) -> Path:
"""Save a fitted calibrator and metadata to a versioned directory.
Creates the directory structure:
<path>/<version>/calibrator.pkl
<path>/<version>/metadata.json
Parameters
----------
calibrator
A fitted ConfidenceCalibrator instance.
version
Version string for this artifact (e.g., "v1.0.0").
path
Base directory for artifact storage.
Returns
-------
Path
Path to the versioned artifact directory.
Raises
------
ValueError
If the calibrator has not been fitted.
"""
if not calibrator.is_fitted:
raise ValueError("Cannot save an unfitted calibrator")
artifact_dir = Path(path) / version
artifact_dir.mkdir(parents=True, exist_ok=True)
# Save the calibrator model
calibrator_path = artifact_dir / ARTIFACT_FILE
with open(calibrator_path, "wb") as f:
pickle.dump(calibrator, f, protocol=pickle.HIGHEST_PROTOCOL)
# Save metadata
metadata = calibrator.metadata
if metadata is None:
metadata = CalibrationArtifactMetadata(
version=version,
method=calibrator.method, # type: ignore[arg-type]
training_count=0,
training_range="unknown",
ece=0.0,
brier_score=0.0,
)
metadata_path = artifact_dir / METADATA_FILE
with open(metadata_path, "w") as f:
json.dump(metadata.model_dump(mode="json"), f, indent=2, default=str)
logger.info(
"Saved calibration artifact: version=%s, method=%s, path=%s",
version,
calibrator.method,
artifact_dir,
)
return artifact_dir
def load_artifact(path: str | Path) -> ConfidenceCalibrator:
"""Load a calibrator from a versioned artifact directory.
Expects the directory to contain calibrator.pkl and metadata.json.
Parameters
----------
path
Path to the versioned artifact directory (e.g., <base>/v1.0.0/).
Returns
-------
ConfidenceCalibrator
The loaded and ready-to-use calibrator.
Raises
------
FileNotFoundError
If the artifact directory or files don't exist.
ValueError
If the loaded object is not a ConfidenceCalibrator.
"""
artifact_dir = Path(path)
calibrator_path = artifact_dir / ARTIFACT_FILE
if not calibrator_path.exists():
raise FileNotFoundError(
f"Calibrator artifact not found at {calibrator_path}"
)
with open(calibrator_path, "rb") as f:
calibrator = pickle.load(f) # noqa: S301
if not isinstance(calibrator, ConfidenceCalibrator):
raise ValueError(
f"Loaded object is not a ConfidenceCalibrator: {type(calibrator)}"
)
logger.info(
"Loaded calibration artifact: version=%s, method=%s, path=%s",
calibrator.version,
calibrator.method,
artifact_dir,
)
return calibrator
def load_metadata(path: str | Path) -> CalibrationArtifactMetadata:
"""Load only the metadata for an artifact without loading the full model.
Parameters
----------
path
Path to the versioned artifact directory.
Returns
-------
CalibrationArtifactMetadata
The artifact metadata.
Raises
------
FileNotFoundError
If the metadata file doesn't exist.
"""
metadata_path = Path(path) / METADATA_FILE
if not metadata_path.exists():
raise FileNotFoundError(f"Metadata not found at {metadata_path}")
with open(metadata_path) as f:
data = json.load(f)
return CalibrationArtifactMetadata(**data)
def list_versions(base_path: str | Path) -> list[str]:
"""List all available artifact versions in a base directory.
Parameters
----------
base_path
Base directory containing versioned subdirectories.
Returns
-------
list[str]
Sorted list of version strings.
"""
base = Path(base_path)
if not base.exists():
return []
versions = []
for item in base.iterdir():
if item.is_dir() and (item / ARTIFACT_FILE).exists():
versions.append(item.name)
return sorted(versions)
@@ -0,0 +1,406 @@
"""Confidence calibrator using isotonic or Platt scaling.
Maps confidence feature vectors to calibrated correctness probabilities.
Supports training on held-out Gold_Corpus data, cross-validation for
method comparison, and versioned artifact tracking.
"""
from __future__ import annotations
import logging
from typing import Literal
import numpy as np
from services.intelligence_pipeline_v3.confidence.models import (
CalibrationArtifactMetadata,
ConfidenceFeatures,
)
logger = logging.getLogger(__name__)
DEFAULT_VERSION = "uncalibrated"
class ConfidenceCalibrator:
"""Calibrates confidence features to correctness probabilities.
Supports isotonic regression and Platt (logistic) scaling.
The calibrator is fitted on labeled Gold_Corpus data where labels
indicate whether the extraction was correct (True) or not (False).
Parameters
----------
method
Calibration method: "isotonic" for non-parametric monotone fit,
"platt" for logistic regression scaling.
"""
def __init__(self, method: Literal["isotonic", "platt"] = "isotonic") -> None:
self._method: Literal["isotonic", "platt"] = method
self._version: str = DEFAULT_VERSION
self._fitted: bool = False
self._model: object | None = None
self._metadata: CalibrationArtifactMetadata | None = None
self._training_count: int = 0
@property
def method(self) -> str:
"""Return the calibration method."""
return self._method
@property
def version(self) -> str:
"""Return the calibration artifact version."""
return self._version
@property
def is_fitted(self) -> bool:
"""Return whether the calibrator has been fitted."""
return self._fitted
@property
def metadata(self) -> CalibrationArtifactMetadata | None:
"""Return the artifact metadata if fitted."""
return self._metadata
def fit(
self,
features: list[ConfidenceFeatures],
labels: list[bool],
method: str | None = None,
version: str = "v1.0.0",
training_range: str = "unknown",
) -> None:
"""Train the calibrator on labeled feature/correctness pairs.
Parameters
----------
features
List of confidence feature vectors from training data.
labels
True if the extraction was correct, False otherwise.
method
Override method for this fit (isotonic or platt).
If None, uses the instance default.
version
Version string for the resulting artifact.
training_range
Description of the training data date range.
Raises
------
ValueError
If features and labels have different lengths or are empty.
"""
if not features or not labels:
raise ValueError("features and labels must not be empty")
if len(features) != len(labels):
raise ValueError(
f"features ({len(features)}) and labels ({len(labels)}) must have the same length"
)
if method is not None:
if method not in ("isotonic", "platt"):
raise ValueError(f"method must be 'isotonic' or 'platt', got '{method}'")
self._method = method # type: ignore[assignment]
# Convert features to matrix
X = np.array([f.to_vector() for f in features], dtype=np.float64)
y = np.array(labels, dtype=np.float64)
if self._method == "isotonic":
self._fit_isotonic(X, y)
else:
self._fit_platt(X, y)
self._version = version
self._training_count = len(features)
self._fitted = True
# Compute calibration quality on training data (for metadata)
predictions = self._predict_batch(X)
ece = _compute_ece(predictions, y)
brier = _compute_brier(predictions, y)
self._metadata = CalibrationArtifactMetadata(
version=version,
method=self._method,
training_count=len(features),
training_range=training_range,
ece=ece,
brier_score=brier,
)
logger.info(
"ConfidenceCalibrator fitted: method=%s, n=%d, version=%s, ECE=%.4f, Brier=%.4f",
self._method,
len(features),
version,
ece,
brier,
)
def predict(self, features: ConfidenceFeatures) -> float:
"""Return calibrated probability of extraction correctness.
Parameters
----------
features
Confidence feature vector for a single extraction.
Returns
-------
float
Calibrated probability in [0, 1].
"""
if not self._fitted:
# Return a neutral default when uncalibrated
return 0.5
X = np.array([features.to_vector()], dtype=np.float64)
predictions = self._predict_batch(X)
return float(np.clip(predictions[0], 0.0, 1.0))
def predict_batch(self, features_list: list[ConfidenceFeatures]) -> list[float]:
"""Return calibrated probabilities for a batch of feature vectors.
Parameters
----------
features_list
List of confidence feature vectors.
Returns
-------
list[float]
Calibrated probabilities in [0, 1].
"""
if not self._fitted:
return [0.5] * len(features_list)
X = np.array([f.to_vector() for f in features_list], dtype=np.float64)
predictions = self._predict_batch(X)
return [float(np.clip(p, 0.0, 1.0)) for p in predictions]
def evaluate(
self,
features: list[ConfidenceFeatures],
labels: list[bool],
) -> tuple[float, float]:
"""Evaluate ECE and Brier score on held-out data.
Parameters
----------
features
Held-out feature vectors.
labels
True correctness labels.
Returns
-------
tuple[float, float]
(ECE, Brier_score) on the held-out set.
"""
if not features or not labels:
raise ValueError("features and labels must not be empty")
if len(features) != len(labels):
raise ValueError("features and labels must have the same length")
X = np.array([f.to_vector() for f in features], dtype=np.float64)
y = np.array(labels, dtype=np.float64)
if self._fitted:
predictions = self._predict_batch(X)
else:
predictions = np.full(len(y), 0.5)
ece = _compute_ece(predictions, y)
brier = _compute_brier(predictions, y)
return ece, brier
def _fit_isotonic(self, X: np.ndarray, y: np.ndarray) -> None:
"""Fit isotonic regression on aggregated feature scores."""
from sklearn.isotonic import IsotonicRegression
# Aggregate features into a single score for isotonic monotone fit
aggregated = X.mean(axis=1)
iso = IsotonicRegression(y_min=0.0, y_max=1.0, out_of_bounds="clip")
iso.fit(aggregated, y)
self._model = iso
def _fit_platt(self, X: np.ndarray, y: np.ndarray) -> None:
"""Fit logistic regression (Platt scaling) on the full feature vector."""
from sklearn.linear_model import LogisticRegression
y_int = y.astype(np.int32)
if len(np.unique(y_int)) < 2:
# Not enough class diversity — store a dummy model
self._model = _ConstantPredictor(float(y.mean()))
return
lr = LogisticRegression(solver="lbfgs", max_iter=1000, C=1.0)
lr.fit(X, y_int)
self._model = lr
def _predict_batch(self, X: np.ndarray) -> np.ndarray:
"""Internal prediction dispatch."""
if self._model is None:
return np.full(X.shape[0], 0.5)
if self._method == "isotonic":
# Isotonic uses aggregated score
aggregated = X.mean(axis=1)
return self._model.predict(aggregated) # type: ignore[union-attr]
else:
# Platt uses full feature vector
if isinstance(self._model, _ConstantPredictor):
return self._model.predict(X)
return self._model.predict_proba(X)[:, 1] # type: ignore[union-attr]
class _ConstantPredictor:
"""Fallback predictor when training data has only one class."""
def __init__(self, value: float) -> None:
self._value = value
def predict(self, X: np.ndarray) -> np.ndarray:
return np.full(X.shape[0], self._value)
def _compute_ece(
predictions: np.ndarray,
labels: np.ndarray,
n_bins: int = 10,
) -> float:
"""Compute Expected Calibration Error.
Partitions predictions into equal-width bins and computes the
weighted average of |avg_predicted - avg_actual| per bin.
Parameters
----------
predictions
Predicted probabilities.
labels
True binary labels (0 or 1).
n_bins
Number of equal-width bins.
Returns
-------
float
ECE value in [0, 1].
"""
if len(predictions) == 0:
return 0.0
bin_boundaries = np.linspace(0.0, 1.0, n_bins + 1)
ece = 0.0
n = len(predictions)
for i in range(n_bins):
lower = bin_boundaries[i]
upper = bin_boundaries[i + 1]
if i == n_bins - 1:
# Include right boundary in last bin
mask = (predictions >= lower) & (predictions <= upper)
else:
mask = (predictions >= lower) & (predictions < upper)
bin_count = mask.sum()
if bin_count == 0:
continue
avg_predicted = predictions[mask].mean()
avg_actual = labels[mask].mean()
ece += (bin_count / n) * abs(avg_predicted - avg_actual)
return float(ece)
def _compute_brier(predictions: np.ndarray, labels: np.ndarray) -> float:
"""Compute Brier score (mean squared error of probability predictions).
Parameters
----------
predictions
Predicted probabilities.
labels
True binary labels (0 or 1).
Returns
-------
float
Brier score in [0, 1].
"""
if len(predictions) == 0:
return 0.0
return float(np.mean((predictions - labels) ** 2))
def compare_methods(
features: list[ConfidenceFeatures],
labels: list[bool],
n_folds: int = 5,
) -> dict[str, dict[str, float]]:
"""Compare isotonic and Platt methods using k-fold cross-validation.
Parameters
----------
features
Full set of training features.
labels
Full set of correctness labels.
n_folds
Number of cross-validation folds.
Returns
-------
dict
Mapping of method name to {"ece": float, "brier": float} averages.
"""
if len(features) < n_folds * 2:
raise ValueError(
f"Need at least {n_folds * 2} samples for {n_folds}-fold CV, got {len(features)}"
)
results: dict[str, list[tuple[float, float]]] = {
"isotonic": [],
"platt": [],
}
indices = np.arange(len(features))
fold_size = len(features) // n_folds
for fold in range(n_folds):
val_start = fold * fold_size
val_end = val_start + fold_size if fold < n_folds - 1 else len(features)
val_indices = indices[val_start:val_end]
train_indices = np.concatenate([indices[:val_start], indices[val_end:]])
train_features = [features[i] for i in train_indices]
train_labels = [labels[i] for i in train_indices]
val_features = [features[i] for i in val_indices]
val_labels = [labels[i] for i in val_indices]
for method_name in ("isotonic", "platt"):
cal = ConfidenceCalibrator(method=method_name) # type: ignore[arg-type]
cal.fit(
train_features,
train_labels,
version=f"cv-fold-{fold}",
training_range="cross-validation",
)
ece, brier = cal.evaluate(val_features, val_labels)
results[method_name].append((ece, brier))
return {
method: {
"ece": float(np.mean([r[0] for r in scores])),
"brier": float(np.mean([r[1] for r in scores])),
}
for method, scores in results.items()
}
@@ -0,0 +1,142 @@
"""Conservative confidence defaults for underrepresented classes.
When calibration data is insufficient for a specific document type or
event class, returns conservative values (0.3-0.5) and marks the result
as under-calibrated per Requirement 10.7.
"""
from __future__ import annotations
import logging
from services.intelligence_pipeline_v3.confidence.models import ConfidenceResult
logger = logging.getLogger(__name__)
# Conservative default probabilities by document type.
# These are intentionally low (0.3-0.5) to avoid overconfidence
# when insufficient calibration data exists.
_DOCUMENT_TYPE_DEFAULTS: dict[str, float] = {
"news": 0.45,
"filing": 0.40,
"transcript": 0.40,
"press_release": 0.45,
"macro_event": 0.35,
"unknown": 0.30,
}
# Conservative default probabilities by event class.
# More complex or rare event types get lower defaults.
_EVENT_CLASS_DEFAULTS: dict[str, float] = {
"earnings_beat": 0.50,
"earnings_miss": 0.50,
"guidance_raise": 0.45,
"guidance_cut": 0.45,
"merger_acquisition": 0.40,
"product_launch": 0.45,
"regulatory_action": 0.40,
"management_change": 0.45,
"legal_proceeding": 0.40,
"supply_chain": 0.35,
"rating_change": 0.45,
"dividend_change": 0.45,
"buyback": 0.45,
"macro_policy": 0.35,
"geopolitical": 0.30,
"sector_rotation": 0.35,
"unknown": 0.30,
}
# Features used when returning conservative defaults
_DEFAULT_FEATURES_USED = [
"document_type_prior",
"event_class_prior",
]
def get_default_confidence(
document_type: str,
event_class: str,
) -> ConfidenceResult:
"""Return a conservative confidence result for underrepresented classes.
Used when calibration data is insufficient for the given document type
and event class combination. Returns conservative probabilities (0.3-0.5)
and marks the result as under-calibrated.
Parameters
----------
document_type
The document type (news, filing, transcript, etc.).
event_class
The classified event type (earnings_beat, merger_acquisition, etc.).
Returns
-------
ConfidenceResult
A conservative confidence result with under_calibrated=True.
"""
doc_default = _DOCUMENT_TYPE_DEFAULTS.get(
document_type, _DOCUMENT_TYPE_DEFAULTS["unknown"]
)
event_default = _EVENT_CLASS_DEFAULTS.get(
event_class, _EVENT_CLASS_DEFAULTS["unknown"]
)
# Take the minimum of document and event defaults for extra conservatism
probability = min(doc_default, event_default)
logger.debug(
"Using conservative default confidence: doc_type=%s (%.2f), event=%s (%.2f) -> %.2f",
document_type,
doc_default,
event_class,
event_default,
probability,
)
return ConfidenceResult(
probability=probability,
features_used=_DEFAULT_FEATURES_USED,
is_calibrated=False,
under_calibrated=True,
calibration_version="conservative-default-v1",
)
def is_underrepresented(
document_type: str,
event_class: str,
min_samples: int = 30,
known_counts: dict[tuple[str, str], int] | None = None,
) -> bool:
"""Check if a document_type + event_class combination is underrepresented.
Parameters
----------
document_type
The document type.
event_class
The event class.
min_samples
Minimum number of calibration samples to consider a class well-represented.
known_counts
Optional mapping of (doc_type, event_class) -> sample count.
If None, treats any unknown combination as underrepresented.
Returns
-------
bool
True if the class has insufficient calibration data.
"""
if known_counts is None:
# Without explicit counts, use heuristic: unknown types are underrepresented
if document_type not in _DOCUMENT_TYPE_DEFAULTS:
return True
if event_class not in _EVENT_CLASS_DEFAULTS:
return True
return False
key = (document_type, event_class)
count = known_counts.get(key, 0)
return count < min_samples
@@ -0,0 +1,221 @@
"""Confidence feature extraction from upstream pipeline stages.
Computes field-level features from extraction, resolution, evidence,
sentiment, and cross-stage agreement to produce a ConfidenceFeatures
vector for calibration or conservative defaults.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from services.intelligence_pipeline_v3.confidence.models import ConfidenceFeatures
logger = logging.getLogger(__name__)
@dataclass
class ExtractionStageResult:
"""Subset of extraction results relevant to confidence features.
This is an adapter interface — callers populate it from
the full extraction/specialist output.
"""
entity_scores: list[float]
"""Per-entity confidence scores from specialist extractor."""
relation_scores: list[float]
"""Per-relation confidence scores."""
total_facts: int
"""Total facts extracted."""
valid_numeric_facts: int
"""Facts that passed deterministic parser validation."""
populated_fields: int
"""Schema fields that have values."""
expected_fields: int
"""Total expected schema fields for this document type."""
@dataclass
class ResolutionStageResult:
"""Subset of resolution results relevant to confidence features."""
ambiguity_margins: list[float]
"""Per-mention ambiguity margins (gap between top-2 candidates)."""
@dataclass
class EvidenceStageResult:
"""Subset of evidence verification results relevant to confidence features."""
total_claims: int
"""Total extracted claims/facts."""
supported_claims: int
"""Claims backed by valid evidence spans."""
@dataclass
class SentimentStageResult:
"""Subset of sentiment results relevant to confidence features."""
max_class_probabilities: list[float]
"""Per-company maximum class probability after calibration."""
calibration_version: str
"""Version of sentiment calibration artifact used."""
@dataclass
class AgreementStageResult:
"""Cross-stage agreement analysis results."""
agreement_ratio: float
"""Fraction of facts that agree across independent extraction paths."""
novelty_certainty: float
"""Certainty of the novelty/duplicate classification (0-1)."""
hard_case_score: float
"""Score indicating presence of known difficult patterns."""
class ConfidenceFeatureExtractor:
"""Extracts confidence features from upstream pipeline stage results.
Produces a normalized ConfidenceFeatures vector that can be passed
to the calibrator or used to determine conservative defaults.
"""
def extract_features(
self,
extraction_result: ExtractionStageResult,
resolution_result: ResolutionStageResult,
evidence_result: EvidenceStageResult,
sentiment_result: SentimentStageResult,
agreement_result: AgreementStageResult | None = None,
document_type: str = "unknown",
) -> ConfidenceFeatures:
"""Compute confidence features from all upstream stage results.
Parameters
----------
extraction_result
Entity/relation/fact extraction outputs with scores.
resolution_result
Symbol resolution outputs with ambiguity margins.
evidence_result
Evidence verification outputs with coverage stats.
sentiment_result
Sentiment classification outputs with calibrated probabilities.
agreement_result
Optional cross-stage agreement analysis. Defaults used if None.
document_type
Document type string for type-specific calibration.
Returns
-------
ConfidenceFeatures
Normalized feature vector ready for calibration.
"""
# Entity span score: average of entity scores, or 0 if none
entity_span_score = (
sum(extraction_result.entity_scores) / len(extraction_result.entity_scores)
if extraction_result.entity_scores
else 0.0
)
# Alias resolution margin: average of per-mention margins
alias_resolution_margin = (
sum(resolution_result.ambiguity_margins)
/ len(resolution_result.ambiguity_margins)
if resolution_result.ambiguity_margins
else 1.0 # No ambiguity if no mentions to resolve
)
# Numeric parser validity: fraction of valid numeric facts
numeric_parser_validity = (
extraction_result.valid_numeric_facts / extraction_result.total_facts
if extraction_result.total_facts > 0
else 1.0 # No numeric facts = no parser failures
)
# Evidence coverage: fraction of claims with valid evidence
evidence_coverage = (
evidence_result.supported_claims / evidence_result.total_claims
if evidence_result.total_claims > 0
else 0.0
)
# Relation score: average relation confidence
relation_score = (
sum(extraction_result.relation_scores)
/ len(extraction_result.relation_scores)
if extraction_result.relation_scores
else 0.0
)
# Sentiment calibration confidence: average max class probability
sentiment_calibration_confidence = (
sum(sentiment_result.max_class_probabilities)
/ len(sentiment_result.max_class_probabilities)
if sentiment_result.max_class_probabilities
else 0.5 # Neutral default when no sentiment data
)
# Document completeness: fraction of expected fields populated
document_completeness = (
extraction_result.populated_fields / extraction_result.expected_fields
if extraction_result.expected_fields > 0
else 0.0
)
# Cross-stage agreement features (use defaults if not provided)
if agreement_result is not None:
cross_stage_agreement = agreement_result.agreement_ratio
duplicate_novelty_certainty = agreement_result.novelty_certainty
known_hard_case_patterns = agreement_result.hard_case_score
else:
cross_stage_agreement = 0.5 # Neutral default
duplicate_novelty_certainty = 0.5
known_hard_case_patterns = 0.0
# Validate document type
valid_types = {
"news",
"filing",
"transcript",
"press_release",
"macro_event",
"unknown",
}
if document_type not in valid_types:
logger.warning(
"Unknown document_type '%s', defaulting to 'unknown'", document_type
)
document_type = "unknown"
return ConfidenceFeatures(
entity_span_score=_clamp(entity_span_score),
alias_resolution_margin=_clamp(alias_resolution_margin),
numeric_parser_validity=_clamp(numeric_parser_validity),
evidence_coverage=_clamp(evidence_coverage),
relation_score=_clamp(relation_score),
sentiment_calibration_confidence=_clamp(sentiment_calibration_confidence),
cross_stage_agreement=_clamp(cross_stage_agreement),
duplicate_novelty_certainty=_clamp(duplicate_novelty_certainty),
document_completeness=_clamp(document_completeness),
document_type=document_type,
known_hard_case_patterns=_clamp(known_hard_case_patterns),
)
def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
"""Clamp value to [low, high]."""
return max(low, min(high, value))
@@ -0,0 +1,179 @@
"""Pydantic models for confidence calibration pipeline.
Defines feature vectors, calibration artifact metadata, and
confidence results used throughout the confidence pipeline.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class ConfidenceFeatures(BaseModel):
"""Feature vector for confidence estimation.
Each feature is a normalized float derived from upstream pipeline
stages: extraction, resolution, evidence verification, sentiment,
and cross-stage agreement analysis.
"""
entity_span_score: float = Field(
ge=0.0,
le=1.0,
description="Best entity span confidence from specialist extractor.",
)
alias_resolution_margin: float = Field(
ge=0.0,
le=1.0,
description="Gap between top-2 alias candidates. 1.0 = unambiguous.",
)
numeric_parser_validity: float = Field(
ge=0.0,
le=1.0,
description="Fraction of numeric facts that passed parser validation.",
)
evidence_coverage: float = Field(
ge=0.0,
le=1.0,
description="Fraction of extracted facts backed by valid evidence spans.",
)
relation_score: float = Field(
ge=0.0,
le=1.0,
description="Average confidence of extracted relations.",
)
sentiment_calibration_confidence: float = Field(
ge=0.0,
le=1.0,
description="Calibrated sentiment model confidence (max class probability).",
)
cross_stage_agreement: float = Field(
ge=0.0,
le=1.0,
description="Agreement ratio between independently derived facts across stages.",
)
duplicate_novelty_certainty: float = Field(
ge=0.0,
le=1.0,
description="Certainty of the novelty/duplicate classification.",
)
document_completeness: float = Field(
ge=0.0,
le=1.0,
description="Fraction of expected schema fields that were populated.",
)
document_type: str = Field(
description="Document type (news, filing, transcript, press_release, macro_event).",
)
known_hard_case_patterns: float = Field(
ge=0.0,
le=1.0,
description="Score indicating presence of known hard patterns (multi-company, contradictions).",
)
@field_validator("document_type")
@classmethod
def document_type_valid(cls, v: str) -> str:
valid_types = {
"news",
"filing",
"transcript",
"press_release",
"macro_event",
"unknown",
}
if v not in valid_types:
raise ValueError(f"document_type must be one of {valid_types}, got '{v}'")
return v
def to_vector(self) -> list[float]:
"""Convert features to a flat numeric vector for calibration models.
document_type is encoded as a categorical index.
"""
type_map = {
"news": 0.0,
"filing": 0.2,
"transcript": 0.4,
"press_release": 0.6,
"macro_event": 0.8,
"unknown": 1.0,
}
return [
self.entity_span_score,
self.alias_resolution_margin,
self.numeric_parser_validity,
self.evidence_coverage,
self.relation_score,
self.sentiment_calibration_confidence,
self.cross_stage_agreement,
self.duplicate_novelty_certainty,
self.document_completeness,
type_map.get(self.document_type, 1.0),
self.known_hard_case_patterns,
]
class CalibrationArtifactMetadata(BaseModel):
"""Metadata for a versioned calibration artifact.
Stored alongside the serialized calibrator to track provenance,
training conditions, and quality metrics.
"""
version: str = Field(description="Artifact version string (e.g., 'v1.0.0').")
method: Literal["isotonic", "platt"] = Field(
description="Calibration method used."
)
training_count: int = Field(
ge=0, description="Number of samples used for training."
)
training_range: str = Field(
description="Date range of training data (e.g., '2024-01-01 to 2024-06-30')."
)
ece: float = Field(
ge=0.0,
le=1.0,
description="Expected Calibration Error on held-out data.",
)
brier_score: float = Field(
ge=0.0,
le=1.0,
description="Brier score on held-out data.",
)
created_at: datetime = Field(
default_factory=lambda: datetime.now(tz=timezone.utc),
description="When the artifact was created.",
)
class ConfidenceResult(BaseModel):
"""Final confidence output for an extraction record.
Contains the calibrated probability, feature breakdown, and
metadata about whether calibration was applied or defaults used.
"""
probability: float = Field(
ge=0.0,
le=1.0,
description="Calibrated probability of extraction correctness.",
)
features_used: list[str] = Field(
description="Names of features that contributed to this confidence score.",
)
is_calibrated: bool = Field(
default=True,
description="Whether a trained calibrator was used (vs conservative default).",
)
under_calibrated: bool = Field(
default=False,
description="True if class has insufficient calibration data and conservative default was applied.",
)
calibration_version: str = Field(
default="uncalibrated",
description="Version of the calibration artifact used.",
)
@@ -0,0 +1,20 @@
"""Legacy path deprecation tracking and cleanup management.
Tracks deprecated components (VLLMClient, v2 prompts, provider branching),
validates that all consumers have migrated, and provides safe removal
gating. Removal only proceeds after all downstream consumers read v3.
"""
from services.intelligence_pipeline_v3.deprecation.tracker import (
DeprecationEntry,
DeprecationStatus,
DeprecationTracker,
MigrationReport,
)
__all__ = [
"DeprecationEntry",
"DeprecationStatus",
"DeprecationTracker",
"MigrationReport",
]
@@ -0,0 +1,271 @@
"""Deprecation tracker for legacy pipeline components.
Manages the lifecycle of deprecated components: VLLMClient, v2 prompts,
provider branching, 8000-char truncation, environment/model defaults,
provider free-text fields, and the compatibility adapter.
Removal only happens after all downstream consumers read v3 natively,
validated by consumer audit.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class DeprecationStatus(str, enum.Enum):
"""Lifecycle status of a deprecated component."""
ACTIVE = "active" # Still in use
DEPRECATED = "deprecated" # Marked for removal, consumers migrating
MIGRATION_COMPLETE = "migration_complete" # All consumers migrated
REMOVED = "removed" # Code removed
ARCHIVED = "archived" # Final reports preserved
@dataclass
class DeprecationEntry:
"""A tracked deprecated component with migration status."""
entry_id: UUID
component_name: str
component_path: str # File/module path
status: DeprecationStatus
deprecated_at: datetime
reason: str
# Consumer tracking
known_consumers: list[str] = field(default_factory=list)
migrated_consumers: list[str] = field(default_factory=list)
# Removal gates
removal_approved: bool = False
removal_approver: str = ""
removed_at: datetime | None = None
# Migration tracking
replacement: str = "" # What replaces this component
migration_notes: str = ""
@classmethod
def create(
cls,
component_name: str,
component_path: str,
reason: str,
known_consumers: list[str] | None = None,
replacement: str = "",
) -> DeprecationEntry:
return cls(
entry_id=uuid4(),
component_name=component_name,
component_path=component_path,
status=DeprecationStatus.DEPRECATED,
deprecated_at=datetime.now(timezone.utc),
reason=reason,
known_consumers=known_consumers or [],
replacement=replacement,
)
@property
def migration_progress(self) -> float:
"""Fraction of consumers that have migrated (0.0-1.0)."""
if not self.known_consumers:
return 1.0
return len(self.migrated_consumers) / len(self.known_consumers)
@property
def all_consumers_migrated(self) -> bool:
"""Whether all known consumers have migrated."""
return set(self.known_consumers) <= set(self.migrated_consumers)
def mark_consumer_migrated(self, consumer: str) -> None:
"""Record that a consumer has migrated off this component."""
if consumer not in self.migrated_consumers:
self.migrated_consumers.append(consumer)
if self.all_consumers_migrated:
self.status = DeprecationStatus.MIGRATION_COMPLETE
def approve_removal(self, approver: str) -> bool:
"""Approve removal. Only valid if all consumers migrated.
Returns False if removal cannot be approved.
"""
if not self.all_consumers_migrated:
return False
self.removal_approved = True
self.removal_approver = approver
return True
def mark_removed(self) -> None:
"""Record that the component has been removed from code."""
self.status = DeprecationStatus.REMOVED
self.removed_at = datetime.now(timezone.utc)
def archive(self) -> None:
"""Archive after final migration reports preserved."""
self.status = DeprecationStatus.ARCHIVED
# Default deprecation entries for the v3 migration
DEFAULT_DEPRECATIONS: list[dict[str, Any]] = [
{
"component_name": "VLLMClient",
"component_path": "services/extractor/vllm_client.py",
"reason": "Replaced by OpenAICompatibleClient via inference gateway",
"known_consumers": [
"services/extractor/llm_factory.py",
"services/extractor/worker.py",
],
"replacement": "services/shared/inference/clients/openai_compatible.py",
},
{
"component_name": "v2_extraction_prompt",
"component_path": "services/extractor/prompts.py",
"reason": "Monolithic prompt replaced by staged specialist extraction",
"known_consumers": [
"services/extractor/worker.py",
],
"replacement": "services/intelligence_pipeline_v3/adjudication/",
},
{
"component_name": "provider_branching",
"component_path": "services/extractor/llm_factory.py",
"reason": "Duplicated if/else provider branching replaced by registry",
"known_consumers": [
"services/extractor/worker.py",
"services/recommendation/thesis_llm.py",
],
"replacement": "services/shared/inference/registry.py",
},
{
"component_name": "8000_char_truncation",
"component_path": "services/extractor/prompts.py",
"reason": "Truncation replaced by sentence-aware segmenter",
"known_consumers": [
"services/extractor/prompts.py",
],
"replacement": "services/intelligence_pipeline_v3/segmenter/",
},
{
"component_name": "compatibility_adapter",
"component_path": "services/intelligence_pipeline_v3/compatibility/",
"reason": "Temporary adapter removed after all consumers read v3 natively",
"known_consumers": [
"services/aggregation/worker.py",
"services/recommendation/",
"services/query_api/",
],
"replacement": "Direct v3 intelligence records",
},
]
@dataclass
class MigrationReport:
"""Summary report of the deprecation/migration status."""
generated_at: datetime
total_components: int = 0
deprecated: int = 0
migration_complete: int = 0
removed: int = 0
blocked_removals: list[str] = field(default_factory=list)
@classmethod
def generate(cls, entries: list[DeprecationEntry]) -> MigrationReport:
report = cls(
generated_at=datetime.now(timezone.utc),
total_components=len(entries),
)
for entry in entries:
if entry.status == DeprecationStatus.DEPRECATED:
report.deprecated += 1
if not entry.all_consumers_migrated:
remaining = set(entry.known_consumers) - set(
entry.migrated_consumers
)
report.blocked_removals.append(
f"{entry.component_name}: waiting on {list(remaining)}"
)
elif entry.status == DeprecationStatus.MIGRATION_COMPLETE:
report.migration_complete += 1
elif entry.status in (
DeprecationStatus.REMOVED,
DeprecationStatus.ARCHIVED,
):
report.removed += 1
return report
def to_dict(self) -> dict[str, Any]:
return {
"generated_at": self.generated_at.isoformat(),
"total_components": self.total_components,
"deprecated": self.deprecated,
"migration_complete": self.migration_complete,
"removed": self.removed,
"blocked_removals": self.blocked_removals,
}
@dataclass
class DeprecationTracker:
"""Tracks all deprecated components and their migration status.
Enforces that removal only happens after all consumers migrate
and with explicit approval.
"""
_entries: dict[str, DeprecationEntry] = field(default_factory=dict)
def add(self, entry: DeprecationEntry) -> None:
"""Register a deprecated component."""
self._entries[entry.component_name] = entry
def get(self, component_name: str) -> DeprecationEntry | None:
return self._entries.get(component_name)
def mark_migrated(self, component_name: str, consumer: str) -> bool:
"""Record a consumer migration. Returns False if component not found."""
entry = self._entries.get(component_name)
if entry is None:
return False
entry.mark_consumer_migrated(consumer)
return True
def can_remove(self, component_name: str) -> bool:
"""Check if a component can be safely removed."""
entry = self._entries.get(component_name)
if entry is None:
return False
return entry.all_consumers_migrated and entry.removal_approved
def approve_removal(self, component_name: str, approver: str) -> bool:
"""Approve removal of a component."""
entry = self._entries.get(component_name)
if entry is None:
return False
return entry.approve_removal(approver)
def generate_report(self) -> MigrationReport:
"""Generate a migration status report."""
return MigrationReport.generate(list(self._entries.values()))
@property
def all_entries(self) -> list[DeprecationEntry]:
return list(self._entries.values())
@property
def pending_removals(self) -> list[DeprecationEntry]:
"""Entries that are ready for removal (migrated + approved)."""
return [
e
for e in self._entries.values()
if e.all_consumers_migrated
and e.removal_approved
and e.status != DeprecationStatus.REMOVED
]
@@ -0,0 +1 @@
"""Evaluation metrics for Intelligence Pipeline v3."""
@@ -0,0 +1,396 @@
"""Entity and ticker precision, recall, F1, and ambiguity accuracy metrics.
Implements evaluation metrics for entity extraction quality against a gold
standard corpus. Supports both strict matching (exact span) and relaxed
matching (overlapping span with same type), with per-type breakdowns.
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Domain Models
# ---------------------------------------------------------------------------
class MatchMode(str, Enum):
"""Entity matching strategy."""
strict = "strict"
relaxed = "relaxed"
class EntitySpan(BaseModel):
"""A single entity mention with character offsets and type."""
text: str
entity_type: str
start_char: int
end_char: int
document_id: str = ""
canonical_id: str | None = None
is_ambiguous: bool = False
@property
def span(self) -> tuple[int, int]:
return (self.start_char, self.end_char)
class TickerMention(BaseModel):
"""A resolved ticker/company mention."""
text: str
ticker: str
start_char: int
end_char: int
document_id: str = ""
canonical_company_id: str | None = None
is_ambiguous: bool = False
@property
def span(self) -> tuple[int, int]:
return (self.start_char, self.end_char)
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class PRF1(BaseModel):
"""Precision, recall, F1 triple."""
precision: float = Field(ge=0.0, le=1.0)
recall: float = Field(ge=0.0, le=1.0)
f1: float = Field(ge=0.0, le=1.0)
support_predicted: int = Field(ge=0)
support_gold: int = Field(ge=0)
class EntityMetricsResult(BaseModel):
"""Full entity evaluation result with per-type breakdowns."""
match_mode: Literal["strict", "relaxed"]
overall: PRF1
per_type: dict[str, PRF1]
class TickerMetricsResult(BaseModel):
"""Ticker/company resolution evaluation result."""
match_mode: Literal["strict", "relaxed"]
overall: PRF1
per_type: dict[str, PRF1] = Field(
default_factory=dict,
description="Breakdown by canonical company or sector if available",
)
class AmbiguityResult(BaseModel):
"""Ambiguity detection accuracy."""
accuracy: float = Field(ge=0.0, le=1.0)
true_positives: int = Field(ge=0)
true_negatives: int = Field(ge=0)
false_positives: int = Field(ge=0)
false_negatives: int = Field(ge=0)
support: int = Field(ge=0)
class EntityEvaluationReport(BaseModel):
"""Complete entity evaluation report."""
entity_metrics: EntityMetricsResult
ticker_metrics: TickerMetricsResult
ambiguity_accuracy: AmbiguityResult
document_count: int = Field(ge=0)
# ---------------------------------------------------------------------------
# Matching Logic
# ---------------------------------------------------------------------------
def _spans_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
"""Return True if two character spans overlap."""
return a[0] < b[1] and b[0] < a[1]
def _entity_matches_strict(pred: EntitySpan, gold: EntitySpan) -> bool:
"""Strict match: exact span boundaries and same entity type."""
return (
pred.entity_type == gold.entity_type
and pred.start_char == gold.start_char
and pred.end_char == gold.end_char
)
def _entity_matches_relaxed(pred: EntitySpan, gold: EntitySpan) -> bool:
"""Relaxed match: overlapping span with same entity type."""
return pred.entity_type == gold.entity_type and _spans_overlap(
pred.span, gold.span
)
def _ticker_matches_strict(pred: TickerMention, gold: TickerMention) -> bool:
"""Strict match: exact span and same resolved ticker."""
return (
pred.ticker == gold.ticker
and pred.start_char == gold.start_char
and pred.end_char == gold.end_char
)
def _ticker_matches_relaxed(pred: TickerMention, gold: TickerMention) -> bool:
"""Relaxed match: overlapping span with same resolved ticker."""
return pred.ticker == gold.ticker and _spans_overlap(pred.span, gold.span)
# ---------------------------------------------------------------------------
# Core Metric Computation
# ---------------------------------------------------------------------------
def _compute_prf1(
predicted: list[EntitySpan] | list[TickerMention],
gold: list[EntitySpan] | list[TickerMention],
match_fn: object,
) -> PRF1:
"""Compute precision, recall, F1 using greedy bipartite matching.
Each predicted item can match at most one gold item and vice versa.
"""
n_pred = len(predicted)
n_gold = len(gold)
if n_pred == 0 and n_gold == 0:
return PRF1(
precision=1.0,
recall=1.0,
f1=1.0,
support_predicted=0,
support_gold=0,
)
if n_pred == 0:
return PRF1(
precision=1.0,
recall=0.0,
f1=0.0,
support_predicted=0,
support_gold=n_gold,
)
if n_gold == 0:
return PRF1(
precision=0.0,
recall=1.0,
f1=0.0,
support_predicted=n_pred,
support_gold=0,
)
# Greedy matching: for each predicted, find first unmatched gold
matched_gold: set[int] = set()
true_positives = 0
for p in predicted:
for g_idx, g in enumerate(gold):
if g_idx in matched_gold:
continue
if match_fn(p, g): # type: ignore[operator]
true_positives += 1
matched_gold.add(g_idx)
break
precision = true_positives / n_pred if n_pred > 0 else 0.0
recall = true_positives / n_gold if n_gold > 0 else 0.0
if precision + recall > 0:
f1 = 2 * precision * recall / (precision + recall)
else:
f1 = 0.0
return PRF1(
precision=precision,
recall=recall,
f1=f1,
support_predicted=n_pred,
support_gold=n_gold,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def compute_entity_metrics(
predicted: list[EntitySpan],
gold: list[EntitySpan],
mode: MatchMode = MatchMode.strict,
) -> EntityMetricsResult:
"""Compute entity precision, recall, F1 with per-type breakdowns.
Args:
predicted: Predicted entity spans.
gold: Gold standard entity spans.
mode: Matching strategy (strict or relaxed).
Returns:
EntityMetricsResult with overall and per-type PRF1.
"""
match_fn = _entity_matches_strict if mode == MatchMode.strict else _entity_matches_relaxed
# Overall
overall = _compute_prf1(predicted, gold, match_fn)
# Per-type breakdown
all_types = {e.entity_type for e in predicted} | {e.entity_type for e in gold}
per_type: dict[str, PRF1] = {}
for entity_type in sorted(all_types):
type_predicted = [e for e in predicted if e.entity_type == entity_type]
type_gold = [e for e in gold if e.entity_type == entity_type]
per_type[entity_type] = _compute_prf1(type_predicted, type_gold, match_fn)
return EntityMetricsResult(
match_mode=mode.value,
overall=overall,
per_type=per_type,
)
def compute_ticker_metrics(
predicted: list[TickerMention],
gold: list[TickerMention],
mode: MatchMode = MatchMode.strict,
) -> TickerMetricsResult:
"""Compute ticker/company resolution precision, recall, F1.
Args:
predicted: Predicted ticker mentions with resolved tickers.
gold: Gold standard ticker mentions.
mode: Matching strategy (strict or relaxed).
Returns:
TickerMetricsResult with overall and optional per-ticker PRF1.
"""
match_fn = _ticker_matches_strict if mode == MatchMode.strict else _ticker_matches_relaxed
overall = _compute_prf1(predicted, gold, match_fn)
# Per-ticker breakdown
all_tickers = {t.ticker for t in predicted} | {t.ticker for t in gold}
per_type: dict[str, PRF1] = {}
for ticker in sorted(all_tickers):
ticker_predicted = [t for t in predicted if t.ticker == ticker]
ticker_gold = [t for t in gold if t.ticker == ticker]
per_type[ticker] = _compute_prf1(ticker_predicted, ticker_gold, match_fn)
return TickerMetricsResult(
match_mode=mode.value,
overall=overall,
per_type=per_type,
)
def compute_ambiguity_accuracy(
predicted: list[EntitySpan] | list[TickerMention],
gold: list[EntitySpan] | list[TickerMention],
) -> AmbiguityResult:
"""Compute ambiguity detection accuracy.
Measures how well the system identifies entities that require
adjudication (ambiguous entities). Uses the `is_ambiguous` flag
on each span/mention.
Entities are aligned by position (exact start_char, end_char match)
to compare ambiguity labels.
Args:
predicted: Predicted entities/tickers with ambiguity flags.
gold: Gold standard entities/tickers with ambiguity flags.
Returns:
AmbiguityResult with accuracy and confusion counts.
"""
# Build a lookup from gold spans to ambiguity flag
gold_lookup: dict[tuple[int, int], bool] = {}
for g in gold:
gold_lookup[(g.start_char, g.end_char)] = g.is_ambiguous
tp = 0 # predicted ambiguous, gold ambiguous
tn = 0 # predicted not ambiguous, gold not ambiguous
fp = 0 # predicted ambiguous, gold not ambiguous
fn = 0 # predicted not ambiguous, gold ambiguous
matched_count = 0
for p in predicted:
key = (p.start_char, p.end_char)
if key in gold_lookup:
matched_count += 1
gold_ambiguous = gold_lookup[key]
pred_ambiguous = p.is_ambiguous
if pred_ambiguous and gold_ambiguous:
tp += 1
elif not pred_ambiguous and not gold_ambiguous:
tn += 1
elif pred_ambiguous and not gold_ambiguous:
fp += 1
else:
fn += 1
support = tp + tn + fp + fn
accuracy = (tp + tn) / support if support > 0 else 1.0
return AmbiguityResult(
accuracy=accuracy,
true_positives=tp,
true_negatives=tn,
false_positives=fp,
false_negatives=fn,
support=support,
)
def evaluate_entities(
predicted_entities: list[EntitySpan],
gold_entities: list[EntitySpan],
predicted_tickers: list[TickerMention],
gold_tickers: list[TickerMention],
mode: MatchMode = MatchMode.strict,
document_count: int = 1,
) -> EntityEvaluationReport:
"""Run full entity evaluation producing a complete report.
Args:
predicted_entities: All predicted entity spans.
gold_entities: All gold standard entity spans.
predicted_tickers: All predicted ticker mentions.
gold_tickers: All gold standard ticker mentions.
mode: Matching strategy.
document_count: Number of documents evaluated.
Returns:
EntityEvaluationReport with entity metrics, ticker metrics,
and ambiguity accuracy.
"""
entity_metrics = compute_entity_metrics(predicted_entities, gold_entities, mode)
ticker_metrics = compute_ticker_metrics(predicted_tickers, gold_tickers, mode)
ambiguity_accuracy = compute_ambiguity_accuracy(predicted_entities, gold_entities)
return EntityEvaluationReport(
entity_metrics=entity_metrics,
ticker_metrics=ticker_metrics,
ambiguity_accuracy=ambiguity_accuracy,
document_count=document_count,
)
@@ -0,0 +1,384 @@
"""Event and relation macro/micro F1 evaluation metrics.
Implements evaluation metrics for event classification and relation extraction
quality against a gold standard corpus. Supports both macro-F1 (average across
classes) and micro-F1 (global TP/FP/FN) with per-class breakdowns.
Matching logic:
- Events match if they share the same event_class AND have overlapping evidence
spans OR the same primary company.
- Relations match if they share the same relation_type, source_id, and target_id.
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.schemas.annotations import (
EventClass,
RelationType,
)
# ---------------------------------------------------------------------------
# Input Models
# ---------------------------------------------------------------------------
class PredictedEvent(BaseModel):
"""A predicted event for evaluation."""
event_class: EventClass
evidence_ids: list[str] = Field(default_factory=list)
primary_company_ids: list[str] = Field(default_factory=list)
confidence: float = Field(ge=0.0, le=1.0, default=1.0)
class GoldEvent(BaseModel):
"""A gold standard event for evaluation."""
event_class: EventClass
evidence_ids: list[str] = Field(default_factory=list)
primary_company_ids: list[str] = Field(default_factory=list)
class PredictedRelation(BaseModel):
"""A predicted relation for evaluation."""
relation_type: RelationType
source_id: str
target_id: str
confidence: float = Field(ge=0.0, le=1.0, default=1.0)
class GoldRelation(BaseModel):
"""A gold standard relation for evaluation."""
relation_type: RelationType
source_id: str
target_id: str
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class PRF1(BaseModel):
"""Precision, recall, F1 triple."""
precision: float = Field(ge=0.0, le=1.0)
recall: float = Field(ge=0.0, le=1.0)
f1: float = Field(ge=0.0, le=1.0)
support_predicted: int = Field(ge=0)
support_gold: int = Field(ge=0)
class EventMetricsResult(BaseModel):
"""Full event evaluation result with macro/micro F1 and per-class breakdown."""
macro_f1: float = Field(ge=0.0, le=1.0)
micro: PRF1
per_class: dict[str, PRF1]
class RelationMetricsResult(BaseModel):
"""Full relation evaluation result with macro/micro F1 and per-type breakdown."""
macro_f1: float = Field(ge=0.0, le=1.0)
micro: PRF1
per_type: dict[str, PRF1]
class EventRelationEvaluationReport(BaseModel):
"""Complete event and relation evaluation report."""
event_metrics: EventMetricsResult
relation_metrics: RelationMetricsResult
document_count: int = Field(ge=0)
# ---------------------------------------------------------------------------
# Matching Logic
# ---------------------------------------------------------------------------
def _events_match(pred: PredictedEvent, gold: GoldEvent) -> bool:
"""Events match if same event_class AND overlapping evidence OR same primary company.
Overlap means at least one evidence_id in common, OR at least one
primary_company_id in common.
"""
if pred.event_class != gold.event_class:
return False
# Check overlapping evidence spans
if pred.evidence_ids and gold.evidence_ids:
if set(pred.evidence_ids) & set(gold.evidence_ids):
return True
# Check same primary company
if pred.primary_company_ids and gold.primary_company_ids:
if set(pred.primary_company_ids) & set(gold.primary_company_ids):
return True
return False
def _relations_match(pred: PredictedRelation, gold: GoldRelation) -> bool:
"""Relations match if same type, source, and target."""
return (
pred.relation_type == gold.relation_type
and pred.source_id == gold.source_id
and pred.target_id == gold.target_id
)
# ---------------------------------------------------------------------------
# Core Metric Computation
# ---------------------------------------------------------------------------
def _compute_prf1_greedy(
predicted: list,
gold: list,
match_fn: object,
) -> PRF1:
"""Compute precision, recall, F1 using greedy bipartite matching.
Each predicted item can match at most one gold item and vice versa.
"""
n_pred = len(predicted)
n_gold = len(gold)
if n_pred == 0 and n_gold == 0:
return PRF1(
precision=1.0, recall=1.0, f1=1.0,
support_predicted=0, support_gold=0,
)
if n_pred == 0:
return PRF1(
precision=1.0, recall=0.0, f1=0.0,
support_predicted=0, support_gold=n_gold,
)
if n_gold == 0:
return PRF1(
precision=0.0, recall=1.0, f1=0.0,
support_predicted=n_pred, support_gold=0,
)
matched_gold: set[int] = set()
true_positives = 0
for p in predicted:
for g_idx, g in enumerate(gold):
if g_idx in matched_gold:
continue
if match_fn(p, g): # type: ignore[operator]
true_positives += 1
matched_gold.add(g_idx)
break
precision = true_positives / n_pred if n_pred > 0 else 0.0
recall = true_positives / n_gold if n_gold > 0 else 0.0
if precision + recall > 0:
f1 = 2 * precision * recall / (precision + recall)
else:
f1 = 0.0
return PRF1(
precision=precision,
recall=recall,
f1=f1,
support_predicted=n_pred,
support_gold=n_gold,
)
def _compute_micro_prf1(
predicted: list,
gold: list,
match_fn: object,
class_key_pred: object,
class_key_gold: object,
all_classes: set[str],
) -> PRF1:
"""Compute micro-averaged PRF1 by summing TP/FP/FN across all classes."""
total_tp = 0
total_pred = 0
total_gold = 0
for cls in all_classes:
cls_predicted = [p for p in predicted if class_key_pred(p) == cls]
cls_gold = [g for g in gold if class_key_gold(g) == cls]
total_pred += len(cls_predicted)
total_gold += len(cls_gold)
# Greedy match within this class
matched_gold: set[int] = set()
for p in cls_predicted:
for g_idx, g in enumerate(cls_gold):
if g_idx in matched_gold:
continue
if match_fn(p, g): # type: ignore[operator]
total_tp += 1
matched_gold.add(g_idx)
break
if total_pred == 0 and total_gold == 0:
return PRF1(
precision=1.0, recall=1.0, f1=1.0,
support_predicted=0, support_gold=0,
)
precision = total_tp / total_pred if total_pred > 0 else 0.0
recall = total_tp / total_gold if total_gold > 0 else 0.0
if precision + recall > 0:
f1 = 2 * precision * recall / (precision + recall)
else:
f1 = 0.0
return PRF1(
precision=precision,
recall=recall,
f1=f1,
support_predicted=total_pred,
support_gold=total_gold,
)
# ---------------------------------------------------------------------------
# Public API — Events
# ---------------------------------------------------------------------------
def compute_event_metrics(
predicted: list[PredictedEvent],
gold: list[GoldEvent],
) -> EventMetricsResult:
"""Compute event macro-F1, micro-F1, and per-class F1.
Args:
predicted: Predicted events.
gold: Gold standard events.
Returns:
EventMetricsResult with macro, micro, and per-class breakdowns.
"""
all_classes = {e.value for e in EventClass}
# Per-class breakdown
per_class: dict[str, PRF1] = {}
f1_scores: list[float] = []
for cls in sorted(all_classes):
cls_predicted = [p for p in predicted if p.event_class.value == cls]
cls_gold = [g for g in gold if g.event_class.value == cls]
prf1 = _compute_prf1_greedy(cls_predicted, cls_gold, _events_match)
per_class[cls] = prf1
f1_scores.append(prf1.f1)
# Macro-F1: average F1 across all event classes
macro_f1 = sum(f1_scores) / len(f1_scores) if f1_scores else 0.0
# Micro-F1: global TP/FP/FN
micro = _compute_micro_prf1(
predicted, gold, _events_match,
lambda p: p.event_class.value,
lambda g: g.event_class.value,
all_classes,
)
return EventMetricsResult(
macro_f1=macro_f1,
micro=micro,
per_class=per_class,
)
# ---------------------------------------------------------------------------
# Public API — Relations
# ---------------------------------------------------------------------------
def compute_relation_metrics(
predicted: list[PredictedRelation],
gold: list[GoldRelation],
) -> RelationMetricsResult:
"""Compute relation macro-F1, micro-F1, and per-type F1.
Args:
predicted: Predicted relations.
gold: Gold standard relations.
Returns:
RelationMetricsResult with macro, micro, and per-type breakdowns.
"""
all_types = {r.value for r in RelationType}
# Per-type breakdown
per_type: dict[str, PRF1] = {}
f1_scores: list[float] = []
for rtype in sorted(all_types):
type_predicted = [p for p in predicted if p.relation_type.value == rtype]
type_gold = [g for g in gold if g.relation_type.value == rtype]
prf1 = _compute_prf1_greedy(type_predicted, type_gold, _relations_match)
per_type[rtype] = prf1
f1_scores.append(prf1.f1)
# Macro-F1: average F1 across all relation types
macro_f1 = sum(f1_scores) / len(f1_scores) if f1_scores else 0.0
# Micro-F1: global TP/FP/FN
micro = _compute_micro_prf1(
predicted, gold, _relations_match,
lambda p: p.relation_type.value,
lambda g: g.relation_type.value,
all_types,
)
return RelationMetricsResult(
macro_f1=macro_f1,
micro=micro,
per_type=per_type,
)
# ---------------------------------------------------------------------------
# Public API — Combined Report
# ---------------------------------------------------------------------------
def evaluate_events_and_relations(
predicted_events: list[PredictedEvent],
gold_events: list[GoldEvent],
predicted_relations: list[PredictedRelation],
gold_relations: list[GoldRelation],
document_count: int = 1,
) -> EventRelationEvaluationReport:
"""Run full event and relation evaluation producing a complete report.
Args:
predicted_events: All predicted events.
gold_events: All gold standard events.
predicted_relations: All predicted relations.
gold_relations: All gold standard relations.
document_count: Number of documents evaluated.
Returns:
EventRelationEvaluationReport with event metrics, relation metrics.
"""
event_metrics = compute_event_metrics(predicted_events, gold_events)
relation_metrics = compute_relation_metrics(predicted_relations, gold_relations)
return EventRelationEvaluationReport(
event_metrics=event_metrics,
relation_metrics=relation_metrics,
document_count=document_count,
)
@@ -0,0 +1,314 @@
"""Evidence offset validity, support rate, coverage, and orphan metrics.
Implements evaluation metrics for evidence grounding quality:
- Offset validity rate: proportion of spans where text matches source at offsets
- Support rate: proportion of extracted items with at least one valid evidence span
- Coverage score: average proportion of required fields supported by evidence
- Orphan rate: proportion of evidence spans not referenced by any extracted item
- Per-field support: breakdown of support rate by field type
- Unsupported claim rate: proportion of extracted items with no valid evidence
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Domain Models
# ---------------------------------------------------------------------------
class FieldType(str, Enum):
"""Types of extracted fields that can be evidence-supported."""
entity = "entity"
event = "event"
fact = "fact"
sentiment = "sentiment"
class EvidenceSpan(BaseModel):
"""An evidence span with text and character offsets into source."""
span_id: str
text: str
start_char: int
end_char: int
document_id: str = ""
class ExtractionResult(BaseModel):
"""An extracted item referencing evidence spans by ID."""
item_id: str
field_type: FieldType
evidence_ids: list[str] = Field(default_factory=list)
required_fields: list[str] = Field(default_factory=list)
supported_fields: list[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class EvidenceMetricsResult(BaseModel):
"""Complete evidence evaluation result."""
validity_rate: float = Field(ge=0.0, le=1.0)
support_rate: float = Field(ge=0.0, le=1.0)
coverage_score: float = Field(ge=0.0, le=1.0)
orphan_rate: float = Field(ge=0.0, le=1.0)
per_field_support: dict[str, float]
unsupported_claim_rate: float = Field(ge=0.0, le=1.0)
total_spans: int = Field(ge=0)
valid_spans: int = Field(ge=0)
total_items: int = Field(ge=0)
supported_items: int = Field(ge=0)
orphan_spans: int = Field(ge=0)
# ---------------------------------------------------------------------------
# Core Metric Computation
# ---------------------------------------------------------------------------
def compute_offset_validity(
spans: list[EvidenceSpan],
source_text: str,
) -> tuple[float, int, int]:
"""Compute the proportion of spans whose text matches source at offsets.
Args:
spans: Evidence spans with text and character offsets.
source_text: The original source document text.
Returns:
Tuple of (validity_rate, valid_count, total_count).
"""
if not spans:
return (1.0, 0, 0)
valid = 0
for span in spans:
start = span.start_char
end = span.end_char
# Basic bounds check
if start < 0 or end < 0 or start > end:
continue
if end > len(source_text):
continue
source_slice = source_text[start:end]
if source_slice == span.text:
valid += 1
total = len(spans)
rate = valid / total
return (rate, valid, total)
def compute_support_rate(
items: list[ExtractionResult],
valid_span_ids: set[str],
) -> tuple[float, int, int]:
"""Compute proportion of items with at least one valid evidence span.
Args:
items: Extraction results referencing evidence span IDs.
valid_span_ids: Set of span IDs that passed offset validity.
Returns:
Tuple of (support_rate, supported_count, total_count).
"""
if not items:
return (1.0, 0, 0)
supported = 0
for item in items:
if any(eid in valid_span_ids for eid in item.evidence_ids):
supported += 1
total = len(items)
rate = supported / total
return (rate, supported, total)
def compute_coverage_score(
items: list[ExtractionResult],
) -> float:
"""Compute average proportion of required fields supported by evidence.
For each item, coverage = len(supported_fields ∩ required_fields) / len(required_fields).
Items with no required fields are treated as fully covered.
Args:
items: Extraction results with required and supported field lists.
Returns:
Average coverage score across all items.
"""
if not items:
return 1.0
total_coverage = 0.0
for item in items:
if not item.required_fields:
total_coverage += 1.0
continue
required = set(item.required_fields)
supported = set(item.supported_fields)
covered = required & supported
total_coverage += len(covered) / len(required)
return total_coverage / len(items)
def compute_orphan_rate(
spans: list[EvidenceSpan],
items: list[ExtractionResult],
) -> tuple[float, int]:
"""Compute proportion of evidence spans not referenced by any item.
Args:
spans: All evidence spans.
items: Extraction results referencing evidence span IDs.
Returns:
Tuple of (orphan_rate, orphan_count).
"""
if not spans:
return (0.0, 0)
referenced_ids: set[str] = set()
for item in items:
referenced_ids.update(item.evidence_ids)
orphan_count = sum(1 for span in spans if span.span_id not in referenced_ids)
rate = orphan_count / len(spans)
return (rate, orphan_count)
def compute_per_field_support(
items: list[ExtractionResult],
valid_span_ids: set[str],
) -> dict[str, float]:
"""Compute support rate broken down by field type.
Args:
items: Extraction results with field types and evidence IDs.
valid_span_ids: Set of span IDs that passed offset validity.
Returns:
Dict mapping field type name to support rate.
"""
by_type: dict[str, list[ExtractionResult]] = {}
for item in items:
key = item.field_type.value
by_type.setdefault(key, []).append(item)
result: dict[str, float] = {}
for field_type, type_items in sorted(by_type.items()):
rate, _, _ = compute_support_rate(type_items, valid_span_ids)
result[field_type] = rate
return result
def compute_unsupported_claim_rate(
items: list[ExtractionResult],
valid_span_ids: set[str],
) -> float:
"""Compute proportion of items with no valid evidence at all.
An item is unsupported if it has no evidence_ids OR none of its
evidence_ids are in the valid set.
Args:
items: Extraction results referencing evidence span IDs.
valid_span_ids: Set of span IDs that passed offset validity.
Returns:
Unsupported claim rate (0.0 to 1.0).
"""
if not items:
return 0.0
unsupported = 0
for item in items:
if not item.evidence_ids:
unsupported += 1
elif not any(eid in valid_span_ids for eid in item.evidence_ids):
unsupported += 1
return unsupported / len(items)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def evaluate_evidence(
spans: list[EvidenceSpan],
source_text: str,
items: list[ExtractionResult],
) -> EvidenceMetricsResult:
"""Run full evidence evaluation producing a complete metrics report.
Args:
spans: All evidence spans with text and offsets.
source_text: The original source document text.
items: Extraction results referencing evidence spans.
Returns:
EvidenceMetricsResult with all computed metrics.
"""
# Step 1: Offset validity
validity_rate, valid_count, total_spans = compute_offset_validity(spans, source_text)
# Step 2: Build valid span ID set
valid_span_ids: set[str] = set()
for span in spans:
start = span.start_char
end = span.end_char
if start < 0 or end < 0 or start > end:
continue
if end > len(source_text):
continue
if source_text[start:end] == span.text:
valid_span_ids.add(span.span_id)
# Step 3: Support rate
support_rate, supported_count, total_items = compute_support_rate(items, valid_span_ids)
# Step 4: Coverage score
coverage_score = compute_coverage_score(items)
# Step 5: Orphan rate
orphan_rate, orphan_count = compute_orphan_rate(spans, items)
# Step 6: Per-field support
per_field_support = compute_per_field_support(items, valid_span_ids)
# Step 7: Unsupported claim rate
unsupported_claim_rate = compute_unsupported_claim_rate(items, valid_span_ids)
return EvidenceMetricsResult(
validity_rate=validity_rate,
support_rate=support_rate,
coverage_score=coverage_score,
orphan_rate=orphan_rate,
per_field_support=per_field_support,
unsupported_claim_rate=unsupported_claim_rate,
total_spans=total_spans,
valid_spans=valid_count,
total_items=total_items,
supported_items=supported_count,
orphan_spans=orphan_count,
)
@@ -0,0 +1,459 @@
"""Numeric exact/tolerance-aware matching metrics for extracted financial facts.
Implements evaluation metrics for numeric extraction quality against a gold
standard corpus. Supports exact match, default 5% tolerance, and configurable
tolerance matching. Provides per-fact-type breakdowns, unit consistency
checks, and period matching.
Input model fields: fact_type, predicate, literal_value, normalized_value,
unit, period, evidence_ids.
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Domain Models
# ---------------------------------------------------------------------------
class FactType(str, Enum):
"""Known financial fact types for per-type breakdown."""
eps = "eps"
revenue = "revenue"
percentage_change = "percentage_change"
price_target = "price_target"
guidance = "guidance"
dividend = "dividend"
margin = "margin"
growth_rate = "growth_rate"
other = "other"
class NumericFact(BaseModel):
"""A single extracted numeric fact with normalization and context."""
fact_type: str
predicate: str
literal_value: str
normalized_value: float | None = None
unit: str | None = None
period: str | None = None
evidence_ids: list[str] = Field(default_factory=list)
document_id: str = ""
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class NumericMatchResult(BaseModel):
"""Result of matching a single predicted fact against gold."""
exact_match: bool = False
within_tolerance: bool = False
tolerance_pct: float = 0.0
unit_consistent: bool = True
period_match: bool = True
absolute_error: float | None = None
relative_error_pct: float | None = None
class AccuracyMetric(BaseModel):
"""Simple accuracy metric with support count."""
accuracy: float = Field(ge=0.0, le=1.0)
matches: int = Field(ge=0)
total: int = Field(ge=0)
class ToleranceDistribution(BaseModel):
"""Distribution of relative errors across tolerance buckets."""
exact: int = Field(ge=0, default=0)
within_1pct: int = Field(ge=0, default=0)
within_5pct: int = Field(ge=0, default=0)
within_10pct: int = Field(ge=0, default=0)
beyond_10pct: int = Field(ge=0, default=0)
not_comparable: int = Field(ge=0, default=0)
class ErrorCategory(str, Enum):
"""Common numeric extraction error categories."""
unit_mismatch = "unit_mismatch"
period_mismatch = "period_mismatch"
magnitude_error = "magnitude_error"
sign_error = "sign_error"
parsing_failure = "parsing_failure"
missing_value = "missing_value"
class ErrorBreakdown(BaseModel):
"""Counts of errors by category."""
counts: dict[str, int] = Field(default_factory=dict)
total_errors: int = Field(ge=0, default=0)
class NumericEvaluationReport(BaseModel):
"""Complete numeric extraction evaluation report."""
exact_match_accuracy: AccuracyMetric
tolerance_accuracy: AccuracyMetric
tolerance_pct_used: float = Field(ge=0.0)
per_type_exact: dict[str, AccuracyMetric] = Field(default_factory=dict)
per_type_tolerance: dict[str, AccuracyMetric] = Field(default_factory=dict)
unit_consistency: AccuracyMetric
period_match: AccuracyMetric
tolerance_distribution: ToleranceDistribution
error_breakdown: ErrorBreakdown
document_count: int = Field(ge=0, default=0)
# ---------------------------------------------------------------------------
# Matching Logic
# ---------------------------------------------------------------------------
DEFAULT_TOLERANCE_PCT = 5.0
def _is_exact_match(pred_value: float, gold_value: float) -> bool:
"""Check if predicted value exactly equals gold value (within float epsilon)."""
return abs(pred_value - gold_value) < 1e-9
def _is_within_tolerance(
pred_value: float, gold_value: float, tolerance_pct: float
) -> bool:
"""Check if predicted value is within ±tolerance_pct of gold value.
For zero gold values, uses absolute comparison with a small epsilon
derived from the tolerance percentage.
"""
if abs(gold_value) < 1e-12:
# For zero gold, allow small absolute tolerance
return abs(pred_value) < tolerance_pct / 100.0
threshold = abs(gold_value) * (tolerance_pct / 100.0)
return abs(pred_value - gold_value) <= threshold
def _compute_relative_error_pct(pred_value: float, gold_value: float) -> float | None:
"""Compute relative error as a percentage of gold value.
Returns None if gold value is zero (relative error undefined).
"""
if abs(gold_value) < 1e-12:
return None
return abs(pred_value - gold_value) / abs(gold_value) * 100.0
def _classify_error(
pred: NumericFact, gold: NumericFact, pred_value: float | None, gold_value: float
) -> str | None:
"""Classify the type of error for a mismatched prediction."""
if pred_value is None:
if pred.normalized_value is None:
return ErrorCategory.parsing_failure.value
return ErrorCategory.missing_value.value
# Check sign error (opposite signs, both non-zero)
if pred_value * gold_value < 0 and abs(pred_value) > 1e-9 and abs(gold_value) > 1e-9:
return ErrorCategory.sign_error.value
# Check magnitude error (off by factor of 10+)
if abs(gold_value) > 1e-9:
ratio = abs(pred_value / gold_value)
if ratio >= 10.0 or ratio <= 0.1:
return ErrorCategory.magnitude_error.value
# Unit mismatch (if units don't match)
if pred.unit and gold.unit and pred.unit != gold.unit:
return ErrorCategory.unit_mismatch.value
# Period mismatch
if pred.period and gold.period and pred.period != gold.period:
return ErrorCategory.period_mismatch.value
return None
def _bucket_relative_error(relative_error_pct: float | None) -> str:
"""Assign a relative error to a tolerance bucket name."""
if relative_error_pct is None:
return "not_comparable"
if relative_error_pct < 1e-7:
return "exact"
if relative_error_pct <= 1.0:
return "within_1pct"
if relative_error_pct <= 5.0:
return "within_5pct"
if relative_error_pct <= 10.0:
return "within_10pct"
return "beyond_10pct"
# ---------------------------------------------------------------------------
# Single Fact Matching
# ---------------------------------------------------------------------------
def match_numeric_fact(
pred: NumericFact,
gold: NumericFact,
tolerance_pct: float = DEFAULT_TOLERANCE_PCT,
) -> NumericMatchResult:
"""Match a predicted numeric fact against a gold standard fact.
Compares normalized values, checks unit consistency and period match.
Args:
pred: Predicted numeric fact.
gold: Gold standard numeric fact.
tolerance_pct: Tolerance percentage for approximate matching.
Returns:
NumericMatchResult with match details.
"""
# Unit consistency check
unit_consistent = True
if pred.unit is not None and gold.unit is not None:
unit_consistent = pred.unit == gold.unit
elif pred.unit is None and gold.unit is not None:
unit_consistent = False
# If gold has no unit, we consider it consistent regardless
# Period match check
period_match = True
if pred.period is not None and gold.period is not None:
period_match = pred.period == gold.period
elif pred.period is None and gold.period is not None:
period_match = False
# Value comparison
pred_value = pred.normalized_value
gold_value = gold.normalized_value
if pred_value is None or gold_value is None:
return NumericMatchResult(
exact_match=False,
within_tolerance=False,
tolerance_pct=tolerance_pct,
unit_consistent=unit_consistent,
period_match=period_match,
absolute_error=None,
relative_error_pct=None,
)
absolute_error = abs(pred_value - gold_value)
relative_error_pct = _compute_relative_error_pct(pred_value, gold_value)
exact = _is_exact_match(pred_value, gold_value)
within_tol = _is_within_tolerance(pred_value, gold_value, tolerance_pct)
return NumericMatchResult(
exact_match=exact,
within_tolerance=within_tol,
tolerance_pct=tolerance_pct,
unit_consistent=unit_consistent,
period_match=period_match,
absolute_error=absolute_error,
relative_error_pct=relative_error_pct,
)
# ---------------------------------------------------------------------------
# Batch Evaluation
# ---------------------------------------------------------------------------
def _align_facts(
predicted: list[NumericFact],
gold: list[NumericFact],
) -> list[tuple[NumericFact, NumericFact]]:
"""Align predicted facts to gold facts using greedy matching.
Matches on fact_type and predicate. Each gold fact can match at most
one predicted fact.
"""
pairs: list[tuple[NumericFact, NumericFact]] = []
matched_gold: set[int] = set()
for pred in predicted:
for g_idx, g in enumerate(gold):
if g_idx in matched_gold:
continue
if pred.fact_type == g.fact_type and pred.predicate == g.predicate:
pairs.append((pred, g))
matched_gold.add(g_idx)
break
return pairs
def evaluate_numeric_facts(
predicted: list[NumericFact],
gold: list[NumericFact],
tolerance_pct: float = DEFAULT_TOLERANCE_PCT,
document_count: int = 1,
) -> NumericEvaluationReport:
"""Run full numeric extraction evaluation.
Aligns predicted facts to gold facts by fact_type and predicate,
then computes exact match accuracy, tolerance-based accuracy,
per-type breakdowns, unit consistency, period match accuracy,
tolerance distribution histogram, and error categories.
Args:
predicted: All predicted numeric facts.
gold: All gold standard numeric facts.
tolerance_pct: Tolerance percentage for approximate matching.
document_count: Number of documents evaluated.
Returns:
NumericEvaluationReport with complete evaluation results.
"""
pairs = _align_facts(predicted, gold)
total_aligned = len(pairs)
# Track results
exact_matches = 0
tolerance_matches = 0
unit_matches = 0
period_matches = 0
comparable_count = 0
# Per-type tracking
per_type_exact_counts: dict[str, tuple[int, int]] = {} # type -> (matches, total)
per_type_tol_counts: dict[str, tuple[int, int]] = {}
# Tolerance distribution
dist = ToleranceDistribution()
# Error tracking
error_counts: dict[str, int] = {}
for pred, g in pairs:
result = match_numeric_fact(pred, g, tolerance_pct)
# Unit consistency
if result.unit_consistent:
unit_matches += 1
# Period match
if result.period_match:
period_matches += 1
# Only count value comparisons when both values exist
if pred.normalized_value is not None and g.normalized_value is not None:
comparable_count += 1
if result.exact_match:
exact_matches += 1
if result.within_tolerance:
tolerance_matches += 1
# Per-type tracking
ft = pred.fact_type
ex_m, ex_t = per_type_exact_counts.get(ft, (0, 0))
tol_m, tol_t = per_type_tol_counts.get(ft, (0, 0))
per_type_exact_counts[ft] = (
ex_m + (1 if result.exact_match else 0),
ex_t + 1,
)
per_type_tol_counts[ft] = (
tol_m + (1 if result.within_tolerance else 0),
tol_t + 1,
)
# Tolerance distribution
bucket = _bucket_relative_error(result.relative_error_pct)
if bucket == "exact":
dist.exact += 1
elif bucket == "within_1pct":
dist.within_1pct += 1
elif bucket == "within_5pct":
dist.within_5pct += 1
elif bucket == "within_10pct":
dist.within_10pct += 1
elif bucket == "beyond_10pct":
dist.beyond_10pct += 1
else:
dist.not_comparable += 1
# Error classification for non-exact matches
if not result.exact_match:
error_cat = _classify_error(pred, g, pred.normalized_value, g.normalized_value)
if error_cat:
error_counts[error_cat] = error_counts.get(error_cat, 0) + 1
else:
dist.not_comparable += 1
# Classify missing value error
if pred.normalized_value is None:
error_cat = ErrorCategory.parsing_failure.value
elif g.normalized_value is None:
error_cat = ErrorCategory.missing_value.value
else:
error_cat = None
if error_cat:
error_counts[error_cat] = error_counts.get(error_cat, 0) + 1
# Build accuracy metrics
exact_accuracy = AccuracyMetric(
accuracy=exact_matches / comparable_count if comparable_count > 0 else 1.0,
matches=exact_matches,
total=comparable_count,
)
tolerance_accuracy = AccuracyMetric(
accuracy=tolerance_matches / comparable_count if comparable_count > 0 else 1.0,
matches=tolerance_matches,
total=comparable_count,
)
unit_consistency = AccuracyMetric(
accuracy=unit_matches / total_aligned if total_aligned > 0 else 1.0,
matches=unit_matches,
total=total_aligned,
)
period_match_metric = AccuracyMetric(
accuracy=period_matches / total_aligned if total_aligned > 0 else 1.0,
matches=period_matches,
total=total_aligned,
)
# Per-type exact accuracy
per_type_exact: dict[str, AccuracyMetric] = {}
for ft, (m, t) in sorted(per_type_exact_counts.items()):
per_type_exact[ft] = AccuracyMetric(
accuracy=m / t if t > 0 else 1.0,
matches=m,
total=t,
)
# Per-type tolerance accuracy
per_type_tolerance: dict[str, AccuracyMetric] = {}
for ft, (m, t) in sorted(per_type_tol_counts.items()):
per_type_tolerance[ft] = AccuracyMetric(
accuracy=m / t if t > 0 else 1.0,
matches=m,
total=t,
)
total_errors = sum(error_counts.values())
return NumericEvaluationReport(
exact_match_accuracy=exact_accuracy,
tolerance_accuracy=tolerance_accuracy,
tolerance_pct_used=tolerance_pct,
per_type_exact=per_type_exact,
per_type_tolerance=per_type_tolerance,
unit_consistency=unit_consistency,
period_match=period_match_metric,
tolerance_distribution=dist,
error_breakdown=ErrorBreakdown(counts=error_counts, total_errors=total_errors),
document_count=document_count,
)
@@ -0,0 +1,599 @@
"""Per-document-type and per-difficulty evaluation report generator.
Groups evaluation results by document type and difficulty bucket,
runs all individual metric computations per group, and produces a
FullEvaluationReport with overall + per-type + per-difficulty breakdowns.
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from collections import defaultdict
from enum import Enum
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.evaluation.entity_metrics import (
EntityEvaluationReport,
EntitySpan,
MatchMode,
TickerMention,
evaluate_entities,
)
from services.intelligence_pipeline_v3.evaluation.event_metrics import (
EventRelationEvaluationReport,
GoldEvent,
GoldRelation,
PredictedEvent,
PredictedRelation,
evaluate_events_and_relations,
)
from services.intelligence_pipeline_v3.evaluation.evidence_metrics import (
EvidenceMetricsResult,
EvidenceSpan,
ExtractionResult,
evaluate_evidence,
)
from services.intelligence_pipeline_v3.evaluation.numeric_metrics import (
NumericEvaluationReport,
NumericFact,
evaluate_numeric_facts,
)
from services.intelligence_pipeline_v3.evaluation.resource_metrics import (
ResourceEvaluationReport,
StageTimingRecord,
evaluate_resources,
)
from services.intelligence_pipeline_v3.evaluation.sentiment_metrics import (
SentimentEvaluationReport,
SentimentPrediction,
evaluate_sentiment,
)
# ---------------------------------------------------------------------------
# Domain Models
# ---------------------------------------------------------------------------
class DocumentType(str, Enum):
"""Document types in the evaluation corpus."""
article = "article"
filing = "filing"
transcript = "transcript"
press_release = "press_release"
macro_event = "macro_event"
class Difficulty(str, Enum):
"""Difficulty buckets for evaluation stratification."""
easy = "easy"
medium = "medium"
hard = "hard"
class DocumentResult(BaseModel):
"""Holds all metric inputs for a single evaluated document."""
document_id: str
document_type: DocumentType
difficulty: Difficulty
# Entity metric inputs
predicted_entities: list[EntitySpan] = Field(default_factory=list)
gold_entities: list[EntitySpan] = Field(default_factory=list)
predicted_tickers: list[TickerMention] = Field(default_factory=list)
gold_tickers: list[TickerMention] = Field(default_factory=list)
# Event/relation metric inputs
predicted_events: list[PredictedEvent] = Field(default_factory=list)
gold_events: list[GoldEvent] = Field(default_factory=list)
predicted_relations: list[PredictedRelation] = Field(default_factory=list)
gold_relations: list[GoldRelation] = Field(default_factory=list)
# Numeric metric inputs
predicted_numeric_facts: list[NumericFact] = Field(default_factory=list)
gold_numeric_facts: list[NumericFact] = Field(default_factory=list)
# Evidence metric inputs
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
source_text: str = ""
extraction_results: list[ExtractionResult] = Field(default_factory=list)
# Sentiment metric inputs
predicted_sentiments: list[SentimentPrediction] = Field(default_factory=list)
gold_sentiments: list[SentimentPrediction] = Field(default_factory=list)
# Resource metric inputs
stage_timings: list[StageTimingRecord] = Field(default_factory=list)
model_config = {"arbitrary_types_allowed": True}
# ---------------------------------------------------------------------------
# Safety Gate Models
# ---------------------------------------------------------------------------
class SafetyGateThresholds(BaseModel):
"""Configurable thresholds for safety gate pass/fail."""
min_entity_f1: float = Field(default=0.7, ge=0.0, le=1.0)
min_event_macro_f1: float = Field(default=0.5, ge=0.0, le=1.0)
min_evidence_support_rate: float = Field(default=0.8, ge=0.0, le=1.0)
max_unsupported_claim_rate: float = Field(default=0.2, ge=0.0, le=1.0)
min_sentiment_macro_f1: float = Field(default=0.5, ge=0.0, le=1.0)
max_calibration_ece: float = Field(default=0.15, ge=0.0, le=1.0)
class SafetyGateResult(BaseModel):
"""Result of safety gate evaluation."""
passed: bool
checks: dict[str, bool] = Field(default_factory=dict)
details: dict[str, str] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Report Models
# ---------------------------------------------------------------------------
class GroupMetrics(BaseModel):
"""Metrics for a single group (document type or difficulty bucket)."""
group_name: str
document_count: int = Field(ge=0)
entity_metrics: EntityEvaluationReport | None = None
event_metrics: EventRelationEvaluationReport | None = None
numeric_metrics: NumericEvaluationReport | None = None
evidence_metrics: EvidenceMetricsResult | None = None
sentiment_metrics: SentimentEvaluationReport | None = None
resource_metrics: ResourceEvaluationReport | None = None
class FullEvaluationReport(BaseModel):
"""Complete evaluation report with overall + per-type + per-difficulty breakdowns."""
overall: GroupMetrics
per_document_type: dict[str, GroupMetrics] = Field(default_factory=dict)
per_difficulty: dict[str, GroupMetrics] = Field(default_factory=dict)
safety_gate: SafetyGateResult
total_documents: int = Field(ge=0)
# ---------------------------------------------------------------------------
# Core Computation
# ---------------------------------------------------------------------------
def _compute_group_metrics(
group_name: str,
documents: list[DocumentResult],
entity_match_mode: MatchMode = MatchMode.strict,
) -> GroupMetrics:
"""Compute all metrics for a group of documents.
Aggregates all individual document inputs into combined lists and
runs each metric computation once for the group.
"""
if not documents:
return GroupMetrics(group_name=group_name, document_count=0)
doc_count = len(documents)
# Aggregate entity inputs
all_pred_entities: list[EntitySpan] = []
all_gold_entities: list[EntitySpan] = []
all_pred_tickers: list[TickerMention] = []
all_gold_tickers: list[TickerMention] = []
for doc in documents:
all_pred_entities.extend(doc.predicted_entities)
all_gold_entities.extend(doc.gold_entities)
all_pred_tickers.extend(doc.predicted_tickers)
all_gold_tickers.extend(doc.gold_tickers)
entity_report = evaluate_entities(
predicted_entities=all_pred_entities,
gold_entities=all_gold_entities,
predicted_tickers=all_pred_tickers,
gold_tickers=all_gold_tickers,
mode=entity_match_mode,
document_count=doc_count,
)
# Aggregate event/relation inputs
all_pred_events: list[PredictedEvent] = []
all_gold_events: list[GoldEvent] = []
all_pred_relations: list[PredictedRelation] = []
all_gold_relations: list[GoldRelation] = []
for doc in documents:
all_pred_events.extend(doc.predicted_events)
all_gold_events.extend(doc.gold_events)
all_pred_relations.extend(doc.predicted_relations)
all_gold_relations.extend(doc.gold_relations)
event_report = evaluate_events_and_relations(
predicted_events=all_pred_events,
gold_events=all_gold_events,
predicted_relations=all_pred_relations,
gold_relations=all_gold_relations,
document_count=doc_count,
)
# Aggregate numeric inputs
all_pred_numeric: list[NumericFact] = []
all_gold_numeric: list[NumericFact] = []
for doc in documents:
all_pred_numeric.extend(doc.predicted_numeric_facts)
all_gold_numeric.extend(doc.gold_numeric_facts)
numeric_report = evaluate_numeric_facts(
predicted=all_pred_numeric,
gold=all_gold_numeric,
document_count=doc_count,
)
# Aggregate evidence inputs — concatenate source texts with separator
all_spans: list[EvidenceSpan] = []
all_items: list[ExtractionResult] = []
combined_source = ""
for doc in documents:
offset = len(combined_source)
# Adjust span offsets for combined source
for span in doc.evidence_spans:
all_spans.append(
EvidenceSpan(
span_id=span.span_id,
text=span.text,
start_char=span.start_char + offset,
end_char=span.end_char + offset,
document_id=span.document_id or doc.document_id,
)
)
all_items.extend(doc.extraction_results)
combined_source += doc.source_text
evidence_report = evaluate_evidence(
spans=all_spans,
source_text=combined_source,
items=all_items,
)
# Aggregate sentiment inputs
all_pred_sentiments: list[SentimentPrediction] = []
all_gold_sentiments: list[SentimentPrediction] = []
for doc in documents:
all_pred_sentiments.extend(doc.predicted_sentiments)
all_gold_sentiments.extend(doc.gold_sentiments)
sentiment_report = evaluate_sentiment(
predicted=all_pred_sentiments,
gold=all_gold_sentiments,
document_count=doc_count,
)
# Aggregate resource inputs
all_timings: list[StageTimingRecord] = []
for doc in documents:
all_timings.extend(doc.stage_timings)
resource_report = evaluate_resources(records=all_timings)
return GroupMetrics(
group_name=group_name,
document_count=doc_count,
entity_metrics=entity_report,
event_metrics=event_report,
numeric_metrics=numeric_report,
evidence_metrics=evidence_report,
sentiment_metrics=sentiment_report,
resource_metrics=resource_report,
)
def _evaluate_safety_gate(
overall: GroupMetrics,
thresholds: SafetyGateThresholds,
) -> SafetyGateResult:
"""Evaluate safety gate thresholds against overall metrics."""
checks: dict[str, bool] = {}
details: dict[str, str] = {}
# Entity F1
if overall.entity_metrics:
entity_f1 = overall.entity_metrics.entity_metrics.overall.f1
passed_entity = entity_f1 >= thresholds.min_entity_f1
checks["entity_f1"] = passed_entity
details["entity_f1"] = (
f"{entity_f1:.3f} {'' if passed_entity else '<'} {thresholds.min_entity_f1:.3f}"
)
else:
checks["entity_f1"] = True
details["entity_f1"] = "No entity data"
# Event macro-F1
if overall.event_metrics:
event_f1 = overall.event_metrics.event_metrics.macro_f1
passed_event = event_f1 >= thresholds.min_event_macro_f1
checks["event_macro_f1"] = passed_event
details["event_macro_f1"] = (
f"{event_f1:.3f} {'' if passed_event else '<'} {thresholds.min_event_macro_f1:.3f}"
)
else:
checks["event_macro_f1"] = True
details["event_macro_f1"] = "No event data"
# Evidence support rate
if overall.evidence_metrics:
support_rate = overall.evidence_metrics.support_rate
passed_support = support_rate >= thresholds.min_evidence_support_rate
checks["evidence_support_rate"] = passed_support
details["evidence_support_rate"] = (
f"{support_rate:.3f} {'' if passed_support else '<'} "
f"{thresholds.min_evidence_support_rate:.3f}"
)
unsupported = overall.evidence_metrics.unsupported_claim_rate
passed_unsupported = unsupported <= thresholds.max_unsupported_claim_rate
checks["unsupported_claim_rate"] = passed_unsupported
details["unsupported_claim_rate"] = (
f"{unsupported:.3f} {'' if passed_unsupported else '>'} "
f"{thresholds.max_unsupported_claim_rate:.3f}"
)
else:
checks["evidence_support_rate"] = True
checks["unsupported_claim_rate"] = True
details["evidence_support_rate"] = "No evidence data"
details["unsupported_claim_rate"] = "No evidence data"
# Sentiment macro-F1
if overall.sentiment_metrics:
sent_f1 = overall.sentiment_metrics.f1_metrics.macro_f1
passed_sent = sent_f1 >= thresholds.min_sentiment_macro_f1
checks["sentiment_macro_f1"] = passed_sent
details["sentiment_macro_f1"] = (
f"{sent_f1:.3f} {'' if passed_sent else '<'} "
f"{thresholds.min_sentiment_macro_f1:.3f}"
)
ece = overall.sentiment_metrics.calibration.ece
passed_ece = ece <= thresholds.max_calibration_ece
checks["calibration_ece"] = passed_ece
details["calibration_ece"] = (
f"{ece:.3f} {'' if passed_ece else '>'} {thresholds.max_calibration_ece:.3f}"
)
else:
checks["sentiment_macro_f1"] = True
checks["calibration_ece"] = True
details["sentiment_macro_f1"] = "No sentiment data"
details["calibration_ece"] = "No sentiment data"
all_passed = all(checks.values())
return SafetyGateResult(
passed=all_passed,
checks=checks,
details=details,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def generate_evaluation_report(
documents: list[DocumentResult],
entity_match_mode: MatchMode = MatchMode.strict,
safety_thresholds: SafetyGateThresholds | None = None,
) -> FullEvaluationReport:
"""Generate a full evaluation report with per-type and per-difficulty breakdowns.
Groups documents by document_type and difficulty, runs all metrics per group,
and evaluates safety gate thresholds against overall results.
Args:
documents: List of DocumentResult objects with all metric inputs.
entity_match_mode: Matching strategy for entity metrics.
safety_thresholds: Configurable safety gate thresholds (uses defaults if None).
Returns:
FullEvaluationReport with overall, per-type, per-difficulty, and safety gate.
"""
if safety_thresholds is None:
safety_thresholds = SafetyGateThresholds()
# Overall metrics
overall = _compute_group_metrics("overall", documents, entity_match_mode)
# Group by document type
by_type: dict[str, list[DocumentResult]] = defaultdict(list)
for doc in documents:
by_type[doc.document_type.value].append(doc)
per_document_type: dict[str, GroupMetrics] = {}
for doc_type in DocumentType:
type_docs = by_type.get(doc_type.value, [])
if type_docs:
per_document_type[doc_type.value] = _compute_group_metrics(
doc_type.value, type_docs, entity_match_mode
)
# Group by difficulty
by_difficulty: dict[str, list[DocumentResult]] = defaultdict(list)
for doc in documents:
by_difficulty[doc.difficulty.value].append(doc)
per_difficulty: dict[str, GroupMetrics] = {}
for diff in Difficulty:
diff_docs = by_difficulty.get(diff.value, [])
if diff_docs:
per_difficulty[diff.value] = _compute_group_metrics(
diff.value, diff_docs, entity_match_mode
)
# Safety gate evaluation
safety_gate = _evaluate_safety_gate(overall, safety_thresholds)
return FullEvaluationReport(
overall=overall,
per_document_type=per_document_type,
per_difficulty=per_difficulty,
safety_gate=safety_gate,
total_documents=len(documents),
)
# ---------------------------------------------------------------------------
# Markdown Formatter
# ---------------------------------------------------------------------------
def _format_prf1_row(label: str, p: float, r: float, f1: float, support: int) -> str:
"""Format a single PRF1 row for a markdown table."""
return f"| {label} | {p:.3f} | {r:.3f} | {f1:.3f} | {support} |"
def _format_group_section(group: GroupMetrics, heading_level: int = 3) -> str:
"""Format a single group's metrics as markdown."""
prefix = "#" * heading_level
lines: list[str] = []
lines.append(f"{prefix} {group.group_name} ({group.document_count} documents)")
lines.append("")
# Entity metrics
if group.entity_metrics:
em = group.entity_metrics
lines.append(f"{prefix}# Entity Metrics")
lines.append("")
lines.append("| Metric | Precision | Recall | F1 | Support |")
lines.append("|--------|-----------|--------|-----|---------|")
o = em.entity_metrics.overall
lines.append(_format_prf1_row("Entities (overall)", o.precision, o.recall, o.f1, o.support_gold))
t = em.ticker_metrics.overall
lines.append(_format_prf1_row("Tickers (overall)", t.precision, t.recall, t.f1, t.support_gold))
lines.append("")
lines.append(f"Ambiguity accuracy: {em.ambiguity_accuracy.accuracy:.3f}")
lines.append("")
# Event metrics
if group.event_metrics:
ev = group.event_metrics
lines.append(f"{prefix}# Event & Relation Metrics")
lines.append("")
lines.append(f"- Event macro-F1: {ev.event_metrics.macro_f1:.3f}")
micro = ev.event_metrics.micro
lines.append(f"- Event micro-F1: {micro.f1:.3f} (P={micro.precision:.3f}, R={micro.recall:.3f})")
lines.append(f"- Relation macro-F1: {ev.relation_metrics.macro_f1:.3f}")
r_micro = ev.relation_metrics.micro
lines.append(f"- Relation micro-F1: {r_micro.f1:.3f} (P={r_micro.precision:.3f}, R={r_micro.recall:.3f})")
lines.append("")
# Numeric metrics
if group.numeric_metrics:
nm = group.numeric_metrics
lines.append(f"{prefix}# Numeric Metrics")
lines.append("")
lines.append(f"- Exact match accuracy: {nm.exact_match_accuracy.accuracy:.3f} ({nm.exact_match_accuracy.matches}/{nm.exact_match_accuracy.total})")
lines.append(f"- Tolerance accuracy ({nm.tolerance_pct_used}%): {nm.tolerance_accuracy.accuracy:.3f} ({nm.tolerance_accuracy.matches}/{nm.tolerance_accuracy.total})")
lines.append(f"- Unit consistency: {nm.unit_consistency.accuracy:.3f}")
lines.append(f"- Period match: {nm.period_match.accuracy:.3f}")
lines.append("")
# Evidence metrics
if group.evidence_metrics:
ev = group.evidence_metrics
lines.append(f"{prefix}# Evidence Metrics")
lines.append("")
lines.append(f"- Offset validity rate: {ev.validity_rate:.3f} ({ev.valid_spans}/{ev.total_spans})")
lines.append(f"- Support rate: {ev.support_rate:.3f} ({ev.supported_items}/{ev.total_items})")
lines.append(f"- Coverage score: {ev.coverage_score:.3f}")
lines.append(f"- Orphan rate: {ev.orphan_rate:.3f} ({ev.orphan_spans} orphans)")
lines.append(f"- Unsupported claim rate: {ev.unsupported_claim_rate:.3f}")
lines.append("")
# Sentiment metrics
if group.sentiment_metrics:
sm = group.sentiment_metrics
lines.append(f"{prefix}# Sentiment Metrics")
lines.append("")
lines.append(f"- Macro-F1: {sm.f1_metrics.macro_f1:.3f}")
lines.append(f"- Micro-F1: {sm.f1_metrics.micro_f1:.3f}")
lines.append(f"- Direction accuracy: {sm.direction_accuracy.accuracy:.3f}")
lines.append(f"- Calibration ECE: {sm.calibration.ece:.3f}")
lines.append(f"- Brier score: {sm.calibration.brier_score:.3f}")
lines.append("")
# Resource metrics
if group.resource_metrics:
rm = group.resource_metrics
lines.append(f"{prefix}# Resource Metrics")
lines.append("")
lines.append(f"- Latency p50: {rm.latency.p50:.2f}s, p95: {rm.latency.p95:.2f}s, p99: {rm.latency.p99:.2f}s")
lines.append(f"- Throughput: {rm.throughput.documents_per_minute:.1f} docs/min")
lines.append(f"- Total tokens: {rm.token_usage.total_tokens}")
lines.append(f"- CPU: {rm.cpu.total_cpu_seconds:.1f}s total, {rm.cpu.mean_cpu_seconds_per_document:.2f}s/doc")
lines.append(f"- GPU: {rm.gpu.total_gpu_seconds:.1f}s total, peak {rm.gpu.peak_gpu_memory_mb:.0f} MB")
lines.append("")
return "\n".join(lines)
def format_report_markdown(report: FullEvaluationReport) -> str:
"""Produce a readable markdown summary of the full evaluation report.
Args:
report: The complete evaluation report.
Returns:
Markdown-formatted string with all sections.
"""
lines: list[str] = []
lines.append("# Intelligence Pipeline v3 — Evaluation Report")
lines.append("")
lines.append(f"**Total documents evaluated:** {report.total_documents}")
lines.append("")
# Safety gate summary
lines.append("## Safety Gate")
lines.append("")
gate = report.safety_gate
status = "✅ PASSED" if gate.passed else "❌ FAILED"
lines.append(f"**Status:** {status}")
lines.append("")
lines.append("| Check | Result | Details |")
lines.append("|-------|--------|---------|")
for check_name, passed in gate.checks.items():
icon = "" if passed else ""
detail = gate.details.get(check_name, "")
lines.append(f"| {check_name} | {icon} | {detail} |")
lines.append("")
# Overall metrics
lines.append("## Overall Metrics")
lines.append("")
lines.append(_format_group_section(report.overall, heading_level=3))
# Per document type
if report.per_document_type:
lines.append("## Per Document Type")
lines.append("")
for doc_type, group in sorted(report.per_document_type.items()):
lines.append(_format_group_section(group, heading_level=3))
# Per difficulty
if report.per_difficulty:
lines.append("## Per Difficulty")
lines.append("")
for diff, group in sorted(report.per_difficulty.items()):
lines.append(_format_group_section(group, heading_level=3))
return "\n".join(lines)
@@ -0,0 +1,660 @@
"""Latency, throughput, token, CPU, GPU, and memory resource metrics.
Implements evaluation metrics for pipeline resource consumption and efficiency.
Supports per-document and per-stage breakdowns with percentile calculations.
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from dataclasses import dataclass
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Input Models
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class StageTimingRecord:
"""A single stage execution record with resource measurements.
Captures timing, token usage, and hardware resource consumption
for one processing stage of one document.
"""
document_id: str
stage_name: str
start_time: float # Unix timestamp (seconds)
end_time: float # Unix timestamp (seconds)
input_tokens: int = 0
output_tokens: int = 0
gpu_memory_mb: float = 0.0
cpu_seconds: float = 0.0
gpu_seconds: float = 0.0
@property
def duration_seconds(self) -> float:
"""Wall-clock duration of this stage in seconds."""
return self.end_time - self.start_time
@property
def total_tokens(self) -> int:
"""Sum of input and output tokens."""
return self.input_tokens + self.output_tokens
# ---------------------------------------------------------------------------
# Percentile Helper
# ---------------------------------------------------------------------------
def compute_percentile(values: list[float], percentile: float) -> float:
"""Compute a percentile from a sorted list without numpy.
Uses linear interpolation between nearest ranks.
Args:
values: List of numeric values (need not be pre-sorted).
percentile: Percentile to compute (0-100).
Returns:
The interpolated percentile value.
Raises:
ValueError: If values is empty or percentile is out of range.
"""
if not values:
raise ValueError("Cannot compute percentile of empty list")
if not (0.0 <= percentile <= 100.0):
raise ValueError(f"Percentile must be between 0 and 100, got {percentile}")
sorted_values = sorted(values)
n = len(sorted_values)
if n == 1:
return sorted_values[0]
# Compute the rank (0-indexed fractional position)
rank = (percentile / 100.0) * (n - 1)
lower_idx = int(rank)
upper_idx = lower_idx + 1
fraction = rank - lower_idx
if upper_idx >= n:
return sorted_values[-1]
return sorted_values[lower_idx] + fraction * (
sorted_values[upper_idx] - sorted_values[lower_idx]
)
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class LatencyPercentiles(BaseModel):
"""Latency percentile distribution in seconds."""
p50: float = Field(ge=0.0)
p90: float = Field(ge=0.0)
p95: float = Field(ge=0.0)
p99: float = Field(ge=0.0)
mean: float = Field(ge=0.0)
max: float = Field(ge=0.0)
min: float = Field(ge=0.0)
count: int = Field(ge=0)
class ThroughputMetrics(BaseModel):
"""Document throughput measurements."""
documents_per_minute: float = Field(ge=0.0)
documents_per_hour: float = Field(ge=0.0)
total_documents: int = Field(ge=0)
total_wall_seconds: float = Field(ge=0.0)
class TokenUsageMetrics(BaseModel):
"""Token consumption statistics."""
total_input_tokens: int = Field(ge=0)
total_output_tokens: int = Field(ge=0)
total_tokens: int = Field(ge=0)
mean_input_tokens_per_document: float = Field(ge=0.0)
mean_output_tokens_per_document: float = Field(ge=0.0)
mean_total_tokens_per_document: float = Field(ge=0.0)
per_stage: dict[str, "StageTokenUsage"] = Field(default_factory=dict)
class StageTokenUsage(BaseModel):
"""Token usage breakdown for a single stage."""
total_input_tokens: int = Field(ge=0)
total_output_tokens: int = Field(ge=0)
total_tokens: int = Field(ge=0)
mean_input_tokens: float = Field(ge=0.0)
mean_output_tokens: float = Field(ge=0.0)
mean_total_tokens: float = Field(ge=0.0)
count: int = Field(ge=0)
class CpuMetrics(BaseModel):
"""CPU resource consumption metrics."""
total_cpu_seconds: float = Field(ge=0.0)
mean_cpu_seconds_per_document: float = Field(ge=0.0)
peak_cpu_seconds: float = Field(ge=0.0, description="Max CPU-seconds for a single document")
class GpuMetrics(BaseModel):
"""GPU resource consumption metrics."""
total_gpu_seconds: float = Field(ge=0.0)
mean_gpu_seconds_per_document: float = Field(ge=0.0)
peak_gpu_memory_mb: float = Field(ge=0.0)
mean_gpu_memory_mb: float = Field(ge=0.0)
gpu_utilization_percent: float = Field(
ge=0.0, le=100.0,
description="Percentage of total wall time spent on GPU",
)
class MemoryMetrics(BaseModel):
"""Memory consumption metrics."""
peak_rss_memory_mb: float = Field(ge=0.0)
mean_working_set_mb: float = Field(ge=0.0)
class EfficiencyMetrics(BaseModel):
"""Efficiency ratio metrics."""
tokens_per_second: float = Field(ge=0.0)
documents_per_gpu_second: float = Field(ge=0.0)
fast_path_cpu_seconds: float = Field(ge=0.0)
adjudication_cpu_seconds: float = Field(ge=0.0)
fast_path_gpu_seconds: float = Field(ge=0.0)
adjudication_gpu_seconds: float = Field(ge=0.0)
fast_path_fraction: float = Field(
ge=0.0, le=1.0,
description="Fraction of total resource usage from fast-path stages",
)
adjudication_fraction: float = Field(
ge=0.0, le=1.0,
description="Fraction of total resource usage from adjudication stages",
)
class StageLatencyBreakdown(BaseModel):
"""Per-stage latency statistics."""
stage_name: str
latency: LatencyPercentiles
invocation_count: int = Field(ge=0)
class ResourceEvaluationReport(BaseModel):
"""Complete resource evaluation report."""
latency: LatencyPercentiles
per_stage_latency: list[StageLatencyBreakdown] = Field(default_factory=list)
throughput: ThroughputMetrics
token_usage: TokenUsageMetrics
cpu: CpuMetrics
gpu: GpuMetrics
memory: MemoryMetrics
efficiency: EfficiencyMetrics
document_count: int = Field(ge=0)
# Rebuild model to resolve forward references
TokenUsageMetrics.model_rebuild()
# ---------------------------------------------------------------------------
# Computation Logic
# ---------------------------------------------------------------------------
# Stages considered as "adjudication" for resource split calculations
ADJUDICATION_STAGES: frozenset[str] = frozenset({
"adjudication",
"adjudicator",
"9b_adjudication",
"semantic_adjudication",
})
def _is_adjudication_stage(stage_name: str) -> bool:
"""Determine if a stage belongs to the adjudication path."""
lower = stage_name.lower()
return lower in ADJUDICATION_STAGES or "adjudicat" in lower
def _compute_latency_percentiles(durations: list[float]) -> LatencyPercentiles:
"""Compute latency percentile distribution from a list of durations."""
if not durations:
return LatencyPercentiles(
p50=0.0, p90=0.0, p95=0.0, p99=0.0,
mean=0.0, max=0.0, min=0.0, count=0,
)
return LatencyPercentiles(
p50=compute_percentile(durations, 50.0),
p90=compute_percentile(durations, 90.0),
p95=compute_percentile(durations, 95.0),
p99=compute_percentile(durations, 99.0),
mean=sum(durations) / len(durations),
max=max(durations),
min=min(durations),
count=len(durations),
)
def _compute_document_durations(
records: list[StageTimingRecord],
) -> dict[str, float]:
"""Compute total wall-clock duration per document.
Uses min(start_time) to max(end_time) for each document to handle
overlapping/parallel stages.
"""
doc_times: dict[str, tuple[float, float]] = {}
for r in records:
if r.document_id not in doc_times:
doc_times[r.document_id] = (r.start_time, r.end_time)
else:
existing = doc_times[r.document_id]
doc_times[r.document_id] = (
min(existing[0], r.start_time),
max(existing[1], r.end_time),
)
return {doc_id: end - start for doc_id, (start, end) in doc_times.items()}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def compute_latency_metrics(
records: list[StageTimingRecord],
) -> tuple[LatencyPercentiles, list[StageLatencyBreakdown]]:
"""Compute per-document and per-stage latency metrics.
Per-document latency is the wall-clock time from the earliest stage
start to the latest stage end for each document.
Args:
records: Stage timing records.
Returns:
Tuple of (overall document latency, per-stage breakdown).
"""
if not records:
return (
LatencyPercentiles(
p50=0.0, p90=0.0, p95=0.0, p99=0.0,
mean=0.0, max=0.0, min=0.0, count=0,
),
[],
)
# Per-document latency
doc_durations = _compute_document_durations(records)
overall = _compute_latency_percentiles(list(doc_durations.values()))
# Per-stage latency
stage_durations: dict[str, list[float]] = {}
for r in records:
stage_durations.setdefault(r.stage_name, []).append(r.duration_seconds)
per_stage = [
StageLatencyBreakdown(
stage_name=stage,
latency=_compute_latency_percentiles(durations),
invocation_count=len(durations),
)
for stage, durations in sorted(stage_durations.items())
]
return overall, per_stage
def compute_throughput_metrics(
records: list[StageTimingRecord],
) -> ThroughputMetrics:
"""Compute document throughput from timing records.
Args:
records: Stage timing records.
Returns:
ThroughputMetrics with documents/minute and documents/hour.
"""
if not records:
return ThroughputMetrics(
documents_per_minute=0.0,
documents_per_hour=0.0,
total_documents=0,
total_wall_seconds=0.0,
)
doc_ids = {r.document_id for r in records}
total_docs = len(doc_ids)
# Total wall time: earliest start to latest end across all records
earliest = min(r.start_time for r in records)
latest = max(r.end_time for r in records)
total_wall = latest - earliest
if total_wall <= 0.0:
return ThroughputMetrics(
documents_per_minute=0.0,
documents_per_hour=0.0,
total_documents=total_docs,
total_wall_seconds=0.0,
)
docs_per_second = total_docs / total_wall
return ThroughputMetrics(
documents_per_minute=docs_per_second * 60.0,
documents_per_hour=docs_per_second * 3600.0,
total_documents=total_docs,
total_wall_seconds=total_wall,
)
def compute_token_usage_metrics(
records: list[StageTimingRecord],
) -> TokenUsageMetrics:
"""Compute token usage statistics per document and per stage.
Args:
records: Stage timing records.
Returns:
TokenUsageMetrics with aggregate and per-stage breakdowns.
"""
if not records:
return TokenUsageMetrics(
total_input_tokens=0,
total_output_tokens=0,
total_tokens=0,
mean_input_tokens_per_document=0.0,
mean_output_tokens_per_document=0.0,
mean_total_tokens_per_document=0.0,
per_stage={},
)
total_input = sum(r.input_tokens for r in records)
total_output = sum(r.output_tokens for r in records)
total = total_input + total_output
doc_ids = {r.document_id for r in records}
n_docs = len(doc_ids)
# Per-stage breakdown
stage_records: dict[str, list[StageTimingRecord]] = {}
for r in records:
stage_records.setdefault(r.stage_name, []).append(r)
per_stage: dict[str, StageTokenUsage] = {}
for stage, stage_recs in sorted(stage_records.items()):
s_input = sum(r.input_tokens for r in stage_recs)
s_output = sum(r.output_tokens for r in stage_recs)
s_total = s_input + s_output
count = len(stage_recs)
per_stage[stage] = StageTokenUsage(
total_input_tokens=s_input,
total_output_tokens=s_output,
total_tokens=s_total,
mean_input_tokens=s_input / count if count > 0 else 0.0,
mean_output_tokens=s_output / count if count > 0 else 0.0,
mean_total_tokens=s_total / count if count > 0 else 0.0,
count=count,
)
return TokenUsageMetrics(
total_input_tokens=total_input,
total_output_tokens=total_output,
total_tokens=total,
mean_input_tokens_per_document=total_input / n_docs if n_docs > 0 else 0.0,
mean_output_tokens_per_document=total_output / n_docs if n_docs > 0 else 0.0,
mean_total_tokens_per_document=total / n_docs if n_docs > 0 else 0.0,
per_stage=per_stage,
)
def compute_cpu_metrics(
records: list[StageTimingRecord],
) -> CpuMetrics:
"""Compute CPU resource consumption metrics.
Args:
records: Stage timing records.
Returns:
CpuMetrics with totals and per-document statistics.
"""
if not records:
return CpuMetrics(
total_cpu_seconds=0.0,
mean_cpu_seconds_per_document=0.0,
peak_cpu_seconds=0.0,
)
total_cpu = sum(r.cpu_seconds for r in records)
# Per-document CPU totals
doc_cpu: dict[str, float] = {}
for r in records:
doc_cpu[r.document_id] = doc_cpu.get(r.document_id, 0.0) + r.cpu_seconds
n_docs = len(doc_cpu)
peak = max(doc_cpu.values()) if doc_cpu else 0.0
return CpuMetrics(
total_cpu_seconds=total_cpu,
mean_cpu_seconds_per_document=total_cpu / n_docs if n_docs > 0 else 0.0,
peak_cpu_seconds=peak,
)
def compute_gpu_metrics(
records: list[StageTimingRecord],
) -> GpuMetrics:
"""Compute GPU resource consumption metrics.
Args:
records: Stage timing records.
Returns:
GpuMetrics with totals, peaks, and utilization percentage.
"""
if not records:
return GpuMetrics(
total_gpu_seconds=0.0,
mean_gpu_seconds_per_document=0.0,
peak_gpu_memory_mb=0.0,
mean_gpu_memory_mb=0.0,
gpu_utilization_percent=0.0,
)
total_gpu = sum(r.gpu_seconds for r in records)
# Per-document GPU totals
doc_gpu: dict[str, float] = {}
for r in records:
doc_gpu[r.document_id] = doc_gpu.get(r.document_id, 0.0) + r.gpu_seconds
n_docs = len(doc_gpu)
# GPU memory stats
gpu_mem_values = [r.gpu_memory_mb for r in records if r.gpu_memory_mb > 0.0]
peak_gpu_mem = max(gpu_mem_values) if gpu_mem_values else 0.0
mean_gpu_mem = (
sum(gpu_mem_values) / len(gpu_mem_values) if gpu_mem_values else 0.0
)
# GPU utilization: fraction of wall time spent on GPU work
earliest = min(r.start_time for r in records)
latest = max(r.end_time for r in records)
total_wall = latest - earliest
utilization = (
(total_gpu / total_wall) * 100.0 if total_wall > 0.0 else 0.0
)
# Cap at 100% (parallel GPU stages could theoretically exceed wall time)
utilization = min(utilization, 100.0)
return GpuMetrics(
total_gpu_seconds=total_gpu,
mean_gpu_seconds_per_document=total_gpu / n_docs if n_docs > 0 else 0.0,
peak_gpu_memory_mb=peak_gpu_mem,
mean_gpu_memory_mb=mean_gpu_mem,
gpu_utilization_percent=utilization,
)
def compute_memory_metrics(
records: list[StageTimingRecord],
rss_samples_mb: list[float] | None = None,
) -> MemoryMetrics:
"""Compute memory consumption metrics.
Uses gpu_memory_mb as a proxy for working set if no explicit RSS
samples are provided. When rss_samples_mb is given, it takes
precedence for peak and mean calculations.
Args:
records: Stage timing records.
rss_samples_mb: Optional explicit RSS memory samples in MB.
Returns:
MemoryMetrics with peak and mean working set.
"""
if rss_samples_mb:
return MemoryMetrics(
peak_rss_memory_mb=max(rss_samples_mb),
mean_working_set_mb=sum(rss_samples_mb) / len(rss_samples_mb),
)
if not records:
return MemoryMetrics(peak_rss_memory_mb=0.0, mean_working_set_mb=0.0)
# Use gpu_memory_mb as working set proxy
mem_values = [r.gpu_memory_mb for r in records if r.gpu_memory_mb > 0.0]
if not mem_values:
return MemoryMetrics(peak_rss_memory_mb=0.0, mean_working_set_mb=0.0)
return MemoryMetrics(
peak_rss_memory_mb=max(mem_values),
mean_working_set_mb=sum(mem_values) / len(mem_values),
)
def compute_efficiency_metrics(
records: list[StageTimingRecord],
) -> EfficiencyMetrics:
"""Compute efficiency ratios including tokens/second and resource splits.
Fast-path vs adjudication split is determined by stage name matching.
Args:
records: Stage timing records.
Returns:
EfficiencyMetrics with ratios and resource splits.
"""
if not records:
return EfficiencyMetrics(
tokens_per_second=0.0,
documents_per_gpu_second=0.0,
fast_path_cpu_seconds=0.0,
adjudication_cpu_seconds=0.0,
fast_path_gpu_seconds=0.0,
adjudication_gpu_seconds=0.0,
fast_path_fraction=0.0,
adjudication_fraction=0.0,
)
total_tokens = sum(r.total_tokens for r in records)
total_wall = max(r.end_time for r in records) - min(r.start_time for r in records)
total_gpu = sum(r.gpu_seconds for r in records)
n_docs = len({r.document_id for r in records})
tokens_per_second = total_tokens / total_wall if total_wall > 0.0 else 0.0
docs_per_gpu_second = n_docs / total_gpu if total_gpu > 0.0 else 0.0
# Resource split
fast_cpu = 0.0
adj_cpu = 0.0
fast_gpu = 0.0
adj_gpu = 0.0
for r in records:
if _is_adjudication_stage(r.stage_name):
adj_cpu += r.cpu_seconds
adj_gpu += r.gpu_seconds
else:
fast_cpu += r.cpu_seconds
fast_gpu += r.gpu_seconds
total_resource = fast_cpu + adj_cpu + fast_gpu + adj_gpu
fast_total = fast_cpu + fast_gpu
adj_total = adj_cpu + adj_gpu
fast_fraction = fast_total / total_resource if total_resource > 0.0 else 0.0
adj_fraction = adj_total / total_resource if total_resource > 0.0 else 0.0
return EfficiencyMetrics(
tokens_per_second=tokens_per_second,
documents_per_gpu_second=docs_per_gpu_second,
fast_path_cpu_seconds=fast_cpu,
adjudication_cpu_seconds=adj_cpu,
fast_path_gpu_seconds=fast_gpu,
adjudication_gpu_seconds=adj_gpu,
fast_path_fraction=fast_fraction,
adjudication_fraction=adj_fraction,
)
def evaluate_resources(
records: list[StageTimingRecord],
rss_samples_mb: list[float] | None = None,
) -> ResourceEvaluationReport:
"""Run full resource evaluation producing a complete report.
Args:
records: List of stage timing records from pipeline execution.
rss_samples_mb: Optional explicit RSS memory samples.
Returns:
ResourceEvaluationReport with all resource metrics.
"""
latency, per_stage_latency = compute_latency_metrics(records)
throughput = compute_throughput_metrics(records)
token_usage = compute_token_usage_metrics(records)
cpu = compute_cpu_metrics(records)
gpu = compute_gpu_metrics(records)
memory = compute_memory_metrics(records, rss_samples_mb)
efficiency = compute_efficiency_metrics(records)
doc_count = len({r.document_id for r in records}) if records else 0
return ResourceEvaluationReport(
latency=latency,
per_stage_latency=per_stage_latency,
throughput=throughput,
token_usage=token_usage,
cpu=cpu,
gpu=gpu,
memory=memory,
efficiency=efficiency,
document_count=doc_count,
)
@@ -0,0 +1,443 @@
"""Sentiment macro-F1, micro-F1, direction accuracy, and probability calibration metrics.
Implements evaluation metrics for company-specific sentiment extraction quality
and probability calibration against a gold standard corpus. Includes Expected
Calibration Error (ECE), Brier score, and reliability diagram data.
Sentiments are matched by company_entity_id between predicted and gold sets.
Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Domain Models
# ---------------------------------------------------------------------------
SENTIMENT_LABELS = ("positive", "negative", "neutral", "mixed")
class SentimentLabel(str, Enum):
"""Supported sentiment labels."""
positive = "positive"
negative = "negative"
neutral = "neutral"
mixed = "mixed"
class SentimentPrediction(BaseModel):
"""A predicted or gold sentiment for a specific company entity."""
company_entity_id: str
label: SentimentLabel
positive_prob: float = Field(ge=0.0, le=1.0, default=0.0)
negative_prob: float = Field(ge=0.0, le=1.0, default=0.0)
neutral_prob: float = Field(ge=0.0, le=1.0, default=0.0)
mixed_prob: float = Field(ge=0.0, le=1.0, default=0.0)
document_id: str = ""
# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------
class LabelF1(BaseModel):
"""Per-label precision, recall, F1."""
label: str
precision: float = Field(ge=0.0, le=1.0)
recall: float = Field(ge=0.0, le=1.0)
f1: float = Field(ge=0.0, le=1.0)
support_predicted: int = Field(ge=0)
support_gold: int = Field(ge=0)
class SentimentF1Result(BaseModel):
"""Sentiment classification F1 metrics."""
macro_f1: float = Field(ge=0.0, le=1.0)
micro_f1: float = Field(ge=0.0, le=1.0)
per_label: dict[str, LabelF1]
support: int = Field(ge=0)
class DirectionAccuracyResult(BaseModel):
"""Binary direction accuracy (positive vs negative, ignoring neutral/mixed)."""
accuracy: float = Field(ge=0.0, le=1.0)
correct: int = Field(ge=0)
total: int = Field(ge=0)
class CalibrationBin(BaseModel):
"""A single bin in the reliability diagram."""
bin_lower: float = Field(ge=0.0, le=1.0)
bin_upper: float = Field(ge=0.0, le=1.0)
mean_predicted_prob: float = Field(ge=0.0, le=1.0)
fraction_positive: float = Field(ge=0.0, le=1.0)
count: int = Field(ge=0)
class CalibrationResult(BaseModel):
"""Probability calibration metrics."""
ece: float = Field(ge=0.0, le=1.0, description="Expected Calibration Error")
brier_score: float = Field(ge=0.0, description="Brier score (mean squared error)")
reliability_bins: list[CalibrationBin]
n_samples: int = Field(ge=0)
class SentimentEvaluationReport(BaseModel):
"""Complete sentiment evaluation report."""
f1_metrics: SentimentF1Result
direction_accuracy: DirectionAccuracyResult
calibration: CalibrationResult
document_count: int = Field(ge=0)
# ---------------------------------------------------------------------------
# Core Metric Computation
# ---------------------------------------------------------------------------
def _compute_label_f1(
predicted_labels: list[str],
gold_labels: list[str],
label: str,
) -> LabelF1:
"""Compute precision, recall, F1 for a single label (one-vs-rest)."""
tp = 0
fp = 0
fn = 0
for pred, gold in zip(predicted_labels, gold_labels):
if pred == label and gold == label:
tp += 1
elif pred == label and gold != label:
fp += 1
elif pred != label and gold == label:
fn += 1
support_predicted = tp + fp
support_gold = tp + fn
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
if precision + recall > 0:
f1 = 2 * precision * recall / (precision + recall)
else:
f1 = 0.0
return LabelF1(
label=label,
precision=precision,
recall=recall,
f1=f1,
support_predicted=support_predicted,
support_gold=support_gold,
)
def compute_sentiment_f1(
predicted: list[SentimentPrediction],
gold: list[SentimentPrediction],
) -> SentimentF1Result:
"""Compute macro-F1, micro-F1, and per-label F1 for sentiment classification.
Matches predictions to gold by company_entity_id. Only matched pairs are
evaluated (unmatched predictions/gold are ignored).
Args:
predicted: Predicted sentiment labels with probabilities.
gold: Gold standard sentiment labels.
Returns:
SentimentF1Result with macro-F1, micro-F1, and per-label breakdown.
"""
# Match by company_entity_id
gold_by_id = {g.company_entity_id: g for g in gold}
matched_pred_labels: list[str] = []
matched_gold_labels: list[str] = []
for p in predicted:
if p.company_entity_id in gold_by_id:
matched_pred_labels.append(p.label.value)
matched_gold_labels.append(gold_by_id[p.company_entity_id].label.value)
support = len(matched_pred_labels)
if support == 0:
empty_per_label = {
label: LabelF1(
label=label, precision=1.0, recall=1.0, f1=1.0,
support_predicted=0, support_gold=0,
)
for label in SENTIMENT_LABELS
}
return SentimentF1Result(
macro_f1=1.0,
micro_f1=1.0,
per_label=empty_per_label,
support=0,
)
# Per-label F1
per_label: dict[str, LabelF1] = {}
for label in SENTIMENT_LABELS:
per_label[label] = _compute_label_f1(matched_pred_labels, matched_gold_labels, label)
# Macro-F1: average of per-label F1 scores (only labels with support)
active_labels = [
label for label in SENTIMENT_LABELS
if per_label[label].support_predicted > 0 or per_label[label].support_gold > 0
]
if active_labels:
label_f1_values = [per_label[label].f1 for label in active_labels]
macro_f1 = sum(label_f1_values) / len(label_f1_values)
else:
macro_f1 = 1.0
# Micro-F1: global TP, FP, FN across all labels
total_tp = 0
total_fp = 0
total_fn = 0
for label in SENTIMENT_LABELS:
for pred, gold_label in zip(matched_pred_labels, matched_gold_labels):
if pred == label and gold_label == label:
total_tp += 1
elif pred == label and gold_label != label:
total_fp += 1
elif pred != label and gold_label == label:
total_fn += 1
micro_precision = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 1.0
micro_recall = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 1.0
if micro_precision + micro_recall > 0:
micro_f1 = 2 * micro_precision * micro_recall / (micro_precision + micro_recall)
else:
micro_f1 = 0.0
return SentimentF1Result(
macro_f1=macro_f1,
micro_f1=micro_f1,
per_label=per_label,
support=support,
)
def compute_direction_accuracy(
predicted: list[SentimentPrediction],
gold: list[SentimentPrediction],
) -> DirectionAccuracyResult:
"""Compute binary direction accuracy (positive vs negative).
Only considers matched pairs where BOTH predicted and gold labels are
either 'positive' or 'negative'. Neutral and mixed are ignored.
Args:
predicted: Predicted sentiment labels.
gold: Gold standard sentiment labels.
Returns:
DirectionAccuracyResult with accuracy and counts.
"""
gold_by_id = {g.company_entity_id: g for g in gold}
correct = 0
total = 0
directional_labels = {SentimentLabel.positive, SentimentLabel.negative}
for p in predicted:
if p.company_entity_id not in gold_by_id:
continue
g = gold_by_id[p.company_entity_id]
# Both must be directional (positive or negative)
if p.label in directional_labels and g.label in directional_labels:
total += 1
if p.label == g.label:
correct += 1
accuracy = correct / total if total > 0 else 1.0
return DirectionAccuracyResult(
accuracy=accuracy,
correct=correct,
total=total,
)
def compute_calibration(
predicted: list[SentimentPrediction],
gold: list[SentimentPrediction],
n_bins: int = 10,
) -> CalibrationResult:
"""Compute Expected Calibration Error (ECE), Brier score, and reliability diagram.
For each matched pair, we evaluate how well the predicted probability for
the true label reflects observed frequency. Uses the maximum predicted
probability (confidence) and checks if the predicted label matches gold.
Args:
predicted: Predicted sentiments with probability distributions.
gold: Gold standard sentiments.
n_bins: Number of bins for ECE and reliability diagram.
Returns:
CalibrationResult with ECE, Brier score, and per-bin data.
"""
gold_by_id = {g.company_entity_id: g for g in gold}
# Collect (confidence, correct) pairs
confidences: list[float] = []
corrects: list[int] = []
brier_terms: list[float] = []
for p in predicted:
if p.company_entity_id not in gold_by_id:
continue
g = gold_by_id[p.company_entity_id]
# Confidence = probability assigned to the predicted label
confidence = _get_label_prob(p, p.label)
is_correct = 1 if p.label == g.label else 0
confidences.append(confidence)
corrects.append(is_correct)
# Brier score: sum of squared errors across all label probabilities
# For each label, the "true" probability is 1 if it matches gold, else 0
brier_term = 0.0
for label in SENTIMENT_LABELS:
pred_prob = _get_label_prob(p, SentimentLabel(label))
true_indicator = 1.0 if label == g.label.value else 0.0
brier_term += (pred_prob - true_indicator) ** 2
brier_terms.append(brier_term)
n_samples = len(confidences)
if n_samples == 0:
return CalibrationResult(
ece=0.0,
brier_score=0.0,
reliability_bins=[],
n_samples=0,
)
# Brier score: mean of per-sample squared error sums
brier_score = sum(brier_terms) / n_samples
# ECE and reliability diagram
bin_width = 1.0 / n_bins
reliability_bins: list[CalibrationBin] = []
weighted_abs_diff_sum = 0.0
for i in range(n_bins):
bin_lower = i * bin_width
bin_upper = (i + 1) * bin_width
# Collect samples in this bin
bin_confidences: list[float] = []
bin_corrects: list[int] = []
for conf, correct in zip(confidences, corrects):
# Include in bin if conf is in [bin_lower, bin_upper)
# Last bin includes the upper boundary
if i == n_bins - 1:
in_bin = bin_lower <= conf <= bin_upper
else:
in_bin = bin_lower <= conf < bin_upper
if in_bin:
bin_confidences.append(conf)
bin_corrects.append(correct)
bin_count = len(bin_confidences)
if bin_count > 0:
mean_predicted = sum(bin_confidences) / bin_count
fraction_positive = sum(bin_corrects) / bin_count
weighted_abs_diff_sum += bin_count * abs(mean_predicted - fraction_positive)
else:
mean_predicted = (bin_lower + bin_upper) / 2
fraction_positive = 0.0
reliability_bins.append(
CalibrationBin(
bin_lower=bin_lower,
bin_upper=bin_upper,
mean_predicted_prob=mean_predicted,
fraction_positive=fraction_positive,
count=bin_count,
)
)
ece = weighted_abs_diff_sum / n_samples
return CalibrationResult(
ece=ece,
brier_score=brier_score,
reliability_bins=reliability_bins,
n_samples=n_samples,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def evaluate_sentiment(
predicted: list[SentimentPrediction],
gold: list[SentimentPrediction],
n_bins: int = 10,
document_count: int = 1,
) -> SentimentEvaluationReport:
"""Run full sentiment evaluation producing a complete report.
Args:
predicted: All predicted sentiment records.
gold: All gold standard sentiment records.
n_bins: Number of bins for calibration metrics.
document_count: Number of documents evaluated.
Returns:
SentimentEvaluationReport with F1, direction accuracy, and calibration.
"""
f1_metrics = compute_sentiment_f1(predicted, gold)
direction_accuracy = compute_direction_accuracy(predicted, gold)
calibration = compute_calibration(predicted, gold, n_bins=n_bins)
return SentimentEvaluationReport(
f1_metrics=f1_metrics,
direction_accuracy=direction_accuracy,
calibration=calibration,
document_count=document_count,
)
# ---------------------------------------------------------------------------
# Internal Helpers
# ---------------------------------------------------------------------------
def _get_label_prob(prediction: SentimentPrediction, label: SentimentLabel) -> float:
"""Get the predicted probability for a specific label."""
if label == SentimentLabel.positive:
return prediction.positive_prob
elif label == SentimentLabel.negative:
return prediction.negative_prob
elif label == SentimentLabel.neutral:
return prediction.neutral_prob
elif label == SentimentLabel.mixed:
return prediction.mixed_prob
return 0.0
@@ -0,0 +1,26 @@
"""Fine-tuning module for specialist extractor models.
Manages training pipelines, holdout evaluation, score recalibration,
and promotion gates. A model is promoted only when correctness gates
pass, not merely when adjudication rate falls.
"""
from services.intelligence_pipeline_v3.fine_tuning.evaluation import (
EvaluationResult,
ModelCard,
PromotionDecision,
)
from services.intelligence_pipeline_v3.fine_tuning.trainer import (
TrainingConfig,
TrainingRun,
TrainingStatus,
)
__all__ = [
"EvaluationResult",
"ModelCard",
"PromotionDecision",
"TrainingConfig",
"TrainingRun",
"TrainingStatus",
]
@@ -0,0 +1,210 @@
"""Holdout evaluation and promotion gate checking for fine-tuned models.
Evaluates against frozen holdout and production artifact. A model is
promoted only when correctness gates pass — not merely when adjudication
rate falls.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class PromotionDecision(str, enum.Enum):
"""Decision on whether to promote a fine-tuned model."""
PROMOTE = "promote"
REJECT = "reject"
NEEDS_REVIEW = "needs_review"
@dataclass
class EvaluationResult:
"""Results of evaluating a fine-tuned model against holdout data."""
evaluation_id: UUID
training_run_id: UUID
model_version: str
evaluated_at: datetime
# Correctness metrics (what matters for promotion)
entity_f1: float = 0.0
entity_precision: float = 0.0
entity_recall: float = 0.0
event_f1: float = 0.0
relation_f1: float = 0.0
fact_exact_match: float = 0.0
# Calibration metrics
calibration_ece: float = 0.0
brier_score: float = 0.0
# Comparison with production model
production_entity_f1: float = 0.0
production_event_f1: float = 0.0
entity_f1_delta: float = 0.0
event_f1_delta: float = 0.0
# Adjudication impact (reported but not a gate)
adjudication_rate_before: float = 0.0
adjudication_rate_after: float = 0.0
adjudication_rate_delta: float = 0.0
# Holdout details
holdout_size: int = 0
holdout_version: str = ""
@classmethod
def create(
cls,
training_run_id: UUID,
model_version: str,
**kwargs: Any,
) -> EvaluationResult:
return cls(
evaluation_id=uuid4(),
training_run_id=training_run_id,
model_version=model_version,
evaluated_at=datetime.now(timezone.utc),
**kwargs,
)
def passes_correctness_gates(
self,
min_entity_f1_delta: float = 0.0,
min_event_f1_delta: float = -0.02, # Allow tiny regression on events
max_calibration_ece: float = 0.08,
) -> bool:
"""Check if correctness gates pass.
Note: adjudication rate reduction is NOT a promotion gate.
A model must pass field-level correctness regardless of
adjudication impact.
"""
# Entity F1 must not regress
if self.entity_f1_delta < min_entity_f1_delta:
return False
# Event F1 must not regress significantly
if self.event_f1_delta < min_event_f1_delta:
return False
# Calibration must remain acceptable
if self.calibration_ece > max_calibration_ece:
return False
return True
def promotion_decision(self) -> PromotionDecision:
"""Determine promotion decision based on gates."""
if not self.passes_correctness_gates():
return PromotionDecision.REJECT
# If adjudication rate actually increases, flag for review
if self.adjudication_rate_delta > 0.05:
return PromotionDecision.NEEDS_REVIEW
return PromotionDecision.PROMOTE
def to_dict(self) -> dict[str, Any]:
return {
"evaluation_id": str(self.evaluation_id),
"model_version": self.model_version,
"entity_f1": self.entity_f1,
"event_f1": self.event_f1,
"relation_f1": self.relation_f1,
"calibration_ece": self.calibration_ece,
"entity_f1_delta": self.entity_f1_delta,
"event_f1_delta": self.event_f1_delta,
"adjudication_rate_delta": self.adjudication_rate_delta,
"passes_correctness_gates": self.passes_correctness_gates(),
"promotion_decision": self.promotion_decision().value,
}
@dataclass
class ModelCard:
"""Model card for a trained specialist model artifact.
Contains training range, dataset version, intended use, limitations,
and evaluation results as required by Requirement 17.6.
"""
card_id: UUID
model_version: str
base_model: str
training_run_id: UUID
created_at: datetime
# Training details
training_range: str = ""
dataset_version: str = ""
schema_version: str = ""
total_training_examples: int = 0
# Intended use
intended_use: str = "Entity and event extraction for financial documents"
entity_types: list[str] = field(default_factory=list)
# Limitations
limitations: list[str] = field(default_factory=lambda: [
"Trained on English-language financial documents only",
"Requires recalibration when new entity types are added",
"Performance may degrade on document types not in training set",
])
# Evaluation
evaluation_results: EvaluationResult | None = None
# Registry
promoted: bool = False
promoted_at: datetime | None = None
deprecated: bool = False
deprecated_at: datetime | None = None
@classmethod
def create(
cls,
model_version: str,
base_model: str,
training_run_id: UUID,
**kwargs: Any,
) -> ModelCard:
return cls(
card_id=uuid4(),
model_version=model_version,
base_model=base_model,
training_run_id=training_run_id,
created_at=datetime.now(timezone.utc),
**kwargs,
)
def promote(self) -> None:
"""Mark this model as promoted to production."""
self.promoted = True
self.promoted_at = datetime.now(timezone.utc)
def deprecate(self) -> None:
"""Mark this model as deprecated."""
self.deprecated = True
self.deprecated_at = datetime.now(timezone.utc)
def to_dict(self) -> dict[str, Any]:
return {
"card_id": str(self.card_id),
"model_version": self.model_version,
"base_model": self.base_model,
"training_range": self.training_range,
"dataset_version": self.dataset_version,
"schema_version": self.schema_version,
"total_training_examples": self.total_training_examples,
"intended_use": self.intended_use,
"entity_types": self.entity_types,
"limitations": self.limitations,
"promoted": self.promoted,
"deprecated": self.deprecated,
}
@@ -0,0 +1,142 @@
"""Training pipeline for specialist extractor fine-tuning.
Manages training runs on the Stonks Oracle schema, tracks artifacts,
and produces evaluation-ready models for holdout testing.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class TrainingStatus(str, enum.Enum):
"""Status of a training run."""
PENDING = "pending"
PREPARING_DATA = "preparing_data"
TRAINING = "training"
EVALUATING = "evaluating"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class TrainingConfig:
"""Configuration for specialist model fine-tuning."""
base_model: str = "GLiNER2-large"
schema_version: str = "1.0"
dataset_version: str = ""
training_range: str = "" # e.g., "2024-01 to 2024-06"
# Training parameters
learning_rate: float = 2e-5
batch_size: int = 16
max_epochs: int = 10
warmup_steps: int = 100
weight_decay: float = 0.01
# Data split
train_ratio: float = 0.8
validation_ratio: float = 0.1
holdout_ratio: float = 0.1 # Frozen holdout — never used in training
# Entity types to fine-tune
entity_types: list[str] = field(default_factory=lambda: [
"company", "person", "event", "financial_metric",
"date", "money", "percentage", "ticker",
])
@dataclass
class TrainingRun:
"""A single training run for the specialist extractor."""
run_id: UUID
config: TrainingConfig
status: TrainingStatus = TrainingStatus.PENDING
started_at: datetime | None = None
completed_at: datetime | None = None
# Training metrics
train_loss: float = 0.0
validation_loss: float = 0.0
best_epoch: int = 0
total_examples: int = 0
# Artifact tracking
artifact_path: str = ""
model_version: str = ""
parent_model_version: str = ""
# Metadata
notes: str = ""
errors: list[str] = field(default_factory=list)
@classmethod
def create(cls, config: TrainingConfig) -> TrainingRun:
return cls(
run_id=uuid4(),
config=config,
)
def start(self) -> None:
"""Begin training."""
self.status = TrainingStatus.PREPARING_DATA
self.started_at = datetime.now(timezone.utc)
def begin_training(self) -> None:
"""Transition to active training."""
self.status = TrainingStatus.TRAINING
def begin_evaluation(self) -> None:
"""Transition to evaluation phase."""
self.status = TrainingStatus.EVALUATING
def complete(
self,
artifact_path: str,
model_version: str,
train_loss: float = 0.0,
validation_loss: float = 0.0,
best_epoch: int = 0,
) -> None:
"""Mark training as complete with artifact metadata."""
self.status = TrainingStatus.COMPLETED
self.completed_at = datetime.now(timezone.utc)
self.artifact_path = artifact_path
self.model_version = model_version
self.train_loss = train_loss
self.validation_loss = validation_loss
self.best_epoch = best_epoch
def fail(self, error: str) -> None:
"""Mark training as failed."""
self.status = TrainingStatus.FAILED
self.completed_at = datetime.now(timezone.utc)
self.errors.append(error)
@property
def duration_seconds(self) -> float | None:
if self.started_at and self.completed_at:
return (self.completed_at - self.started_at).total_seconds()
return None
def to_dict(self) -> dict[str, Any]:
return {
"run_id": str(self.run_id),
"status": self.status.value,
"base_model": self.config.base_model,
"schema_version": self.config.schema_version,
"dataset_version": self.config.dataset_version,
"model_version": self.model_version,
"artifact_path": self.artifact_path,
"train_loss": self.train_loss,
"validation_loss": self.validation_loss,
"best_epoch": self.best_epoch,
"duration_seconds": self.duration_seconds,
}
@@ -0,0 +1,56 @@
"""Gold Corpus management — sampling, splits, and inter-annotator agreement.
This package provides tooling to:
1. Sample a stratified corpus from document metadata (task 7.1, 7.2)
2. Create dataset splits with a frozen holdout (task 7.4)
3. Compute inter-annotator agreement metrics (task 7.3)
"""
from services.intelligence_pipeline_v3.gold_corpus.agreement import (
AgreementThresholds,
InterAnnotatorReport,
compute_cohens_kappa,
compute_weighted_kappa,
)
from services.intelligence_pipeline_v3.gold_corpus.sampler import (
CorpusSamplingConfig,
DiversityRequirements,
DocumentMetadata,
LengthBucket,
SourceType,
StratificationDimensions,
sample_corpus,
validate_corpus_coverage,
)
from services.intelligence_pipeline_v3.gold_corpus.splits import (
CorpusSplit,
SplitConfig,
SplitManifest,
create_splits,
freeze_holdout,
select_hard_cases,
)
__all__ = [
# Sampler
"CorpusSamplingConfig",
"DiversityRequirements",
"DocumentMetadata",
"LengthBucket",
"SourceType",
"StratificationDimensions",
"sample_corpus",
"validate_corpus_coverage",
# Splits
"CorpusSplit",
"SplitConfig",
"SplitManifest",
"create_splits",
"freeze_holdout",
"select_hard_cases",
# Agreement
"AgreementThresholds",
"InterAnnotatorReport",
"compute_cohens_kappa",
"compute_weighted_kappa",
]
@@ -0,0 +1,216 @@
"""Inter-annotator agreement metrics for the Gold Corpus.
Implements Cohen's kappa and weighted kappa for evaluating annotation
consistency on the double-reviewed hard-case subset.
Target thresholds:
- κ ≥ 0.80 for entities and events
- κ ≥ 0.70 for relations and sentiment
"""
from __future__ import annotations
from collections import Counter
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Agreement thresholds
# ---------------------------------------------------------------------------
class AgreementThresholds(BaseModel):
"""Target inter-annotator agreement thresholds per field type."""
entities: float = Field(default=0.80, ge=0.0, le=1.0)
events: float = Field(default=0.80, ge=0.0, le=1.0)
relations: float = Field(default=0.70, ge=0.0, le=1.0)
sentiment: float = Field(default=0.70, ge=0.0, le=1.0)
# ---------------------------------------------------------------------------
# Inter-annotator report
# ---------------------------------------------------------------------------
class FieldAgreement(BaseModel):
"""Agreement score for a single annotation field."""
field_name: str
kappa: float = Field(description="Cohen's kappa or weighted kappa value.")
threshold: float = Field(description="Required minimum kappa.")
meets_threshold: bool
n_items: int = Field(description="Number of items compared.")
agreement_rate: float = Field(
ge=0.0, le=1.0, description="Raw proportion of agreement."
)
class InterAnnotatorReport(BaseModel):
"""Complete inter-annotator agreement report across all annotation fields."""
annotator_a: str
annotator_b: str
n_documents: int
field_agreements: list[FieldAgreement] = Field(default_factory=list)
overall_kappa: float = Field(description="Mean kappa across all fields.")
all_thresholds_met: bool
# ---------------------------------------------------------------------------
# Cohen's Kappa (categorical)
# ---------------------------------------------------------------------------
def compute_cohens_kappa(
annotations_a: list[str],
annotations_b: list[str],
) -> float:
"""Compute Cohen's kappa for categorical labels between two annotators.
Cohen's kappa measures agreement between two raters while accounting
for agreement by chance.
κ = (p_o - p_e) / (1 - p_e)
where:
- p_o = observed agreement proportion
- p_e = expected agreement by chance
Args:
annotations_a: Labels from annotator A.
annotations_b: Labels from annotator B.
Returns:
Cohen's kappa value in [-1, 1]. Values:
- 1.0 = perfect agreement
- 0.0 = agreement equivalent to chance
- <0 = less agreement than expected by chance
Raises:
ValueError: If annotation lists have different lengths or are empty.
"""
if len(annotations_a) != len(annotations_b):
raise ValueError(
f"Annotation lists must have equal length, "
f"got {len(annotations_a)} and {len(annotations_b)}"
)
if not annotations_a:
raise ValueError("Cannot compute kappa on empty annotations.")
n = len(annotations_a)
# Observed agreement
agreements = sum(1 for a, b in zip(annotations_a, annotations_b) if a == b)
p_o = agreements / n
# Expected agreement by chance
categories = set(annotations_a) | set(annotations_b)
counts_a = Counter(annotations_a)
counts_b = Counter(annotations_b)
p_e = sum((counts_a[cat] / n) * (counts_b[cat] / n) for cat in categories)
# Handle edge case where p_e = 1 (both annotators always pick same category)
if abs(1.0 - p_e) < 1e-10:
return 1.0 if p_o == 1.0 else 0.0
kappa = (p_o - p_e) / (1.0 - p_e)
return kappa
# ---------------------------------------------------------------------------
# Weighted Kappa (ordinal)
# ---------------------------------------------------------------------------
def compute_weighted_kappa(
annotations_a: list[str],
annotations_b: list[str],
ordered_categories: list[str] | None = None,
weight_type: str = "linear",
) -> float:
"""Compute weighted Cohen's kappa for ordinal ratings.
Weighted kappa accounts for the degree of disagreement between ordinal
categories. Linear weights penalize disagreements proportional to their
distance; quadratic weights penalize proportional to squared distance.
Args:
annotations_a: Labels from annotator A.
annotations_b: Labels from annotator B.
ordered_categories: Ordered list of categories (low to high).
If None, categories are sorted alphabetically.
weight_type: "linear" or "quadratic" weighting.
Returns:
Weighted kappa value.
Raises:
ValueError: If inputs are invalid.
"""
if len(annotations_a) != len(annotations_b):
raise ValueError(
f"Annotation lists must have equal length, "
f"got {len(annotations_a)} and {len(annotations_b)}"
)
if not annotations_a:
raise ValueError("Cannot compute kappa on empty annotations.")
if weight_type not in ("linear", "quadratic"):
raise ValueError(f"weight_type must be 'linear' or 'quadratic', got '{weight_type}'")
# Determine category ordering
if ordered_categories is None:
ordered_categories = sorted(set(annotations_a) | set(annotations_b))
n_categories = len(ordered_categories)
if n_categories < 2:
# With only one category, kappa is undefined (perfect agreement trivially)
return 1.0
cat_index = {cat: i for i, cat in enumerate(ordered_categories)}
n = len(annotations_a)
# Build weight matrix
def _weight(i: int, j: int) -> float:
max_dist = n_categories - 1
if max_dist == 0:
return 0.0
dist = abs(i - j) / max_dist
if weight_type == "linear":
return dist
else: # quadratic
return dist ** 2
# Observed disagreement
observed_disagreement = 0.0
for a, b in zip(annotations_a, annotations_b):
i = cat_index.get(a)
j = cat_index.get(b)
if i is None or j is None:
raise ValueError(
f"Annotation value not in ordered_categories: a='{a}', b='{b}'"
)
observed_disagreement += _weight(i, j)
observed_disagreement /= n
# Expected disagreement by chance
counts_a = Counter(annotations_a)
counts_b = Counter(annotations_b)
expected_disagreement = 0.0
for cat_i, idx_i in cat_index.items():
for cat_j, idx_j in cat_index.items():
expected_disagreement += (
(counts_a[cat_i] / n) * (counts_b[cat_j] / n) * _weight(idx_i, idx_j)
)
# Handle edge case
if abs(expected_disagreement) < 1e-10:
return 1.0 if abs(observed_disagreement) < 1e-10 else 0.0
kappa = 1.0 - (observed_disagreement / expected_disagreement)
return kappa
@@ -0,0 +1,444 @@
"""Corpus sampling framework for the Gold Corpus.
Implements stratified sampling across document type, event class, length,
source, company count, and difficulty dimensions. Ensures diversity requirements
including duplicates, long filings, transcripts, contradictory reports, macro events,
and opposing multi-company effects.
"""
from __future__ import annotations
import random
from collections import defaultdict
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Stratification dimensions
# ---------------------------------------------------------------------------
class LengthBucket(str, Enum):
"""Document length classification."""
SHORT = "short" # < 2000 chars
MEDIUM = "medium" # 2000-8000 chars
LONG = "long" # > 8000 chars
class SourceType(str, Enum):
"""Document source type."""
NEWS = "news"
FILING = "filing"
TRANSCRIPT = "transcript"
PRESS_RELEASE = "press_release"
MACRO_EVENT = "macro_event"
class CompanyCountBucket(str, Enum):
"""How many companies the document mentions."""
SINGLE = "single" # 1 company
MULTI = "multi" # 2-3 companies
MANY = "many" # 4+ companies
class Difficulty(str, Enum):
"""Annotation difficulty level."""
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
class DiversityTag(str, Enum):
"""Tags for diversity requirements that must be represented."""
DUPLICATE_STORY = "duplicate_story"
LONG_FILING = "long_filing"
TRANSCRIPT = "transcript"
CONTRADICTORY_REPORTS = "contradictory_reports"
MACRO_EVENT = "macro_event"
OPPOSING_MULTI_COMPANY_EFFECTS = "opposing_multi_company_effects"
# ---------------------------------------------------------------------------
# Document metadata model
# ---------------------------------------------------------------------------
class DocumentMetadata(BaseModel):
"""Metadata for a candidate document in the sampling pool.
This model represents the document-level attributes needed for stratified
sampling. The actual document content is not included — only identifiers
and classification metadata.
"""
document_id: str = Field(description="Unique document identifier.")
document_type: str = Field(description="article, filing, transcript, press_release, macro_event")
event_class: str | None = Field(
default=None, description="Primary event class if classified."
)
length_bucket: LengthBucket = Field(description="short/medium/long classification.")
source_type: SourceType = Field(description="Source type classification.")
company_count_bucket: CompanyCountBucket = Field(
description="single/multi/many company mentions."
)
difficulty: Difficulty = Field(description="Annotation difficulty: easy/medium/hard.")
diversity_tags: list[DiversityTag] = Field(
default_factory=list,
description="Diversity tags this document satisfies.",
)
extra_metadata: dict[str, Any] = Field(
default_factory=dict,
description="Additional metadata for filtering or reporting.",
)
# ---------------------------------------------------------------------------
# Stratification dimensions config
# ---------------------------------------------------------------------------
class StratificationDimensions(BaseModel):
"""Configuration for stratification dimensions and their minimum counts.
Each dimension specifies the minimum number of documents required per category
within that dimension.
"""
document_type: dict[str, int] = Field(
default_factory=lambda: {
"article": 300,
"filing": 200,
"transcript": 150,
"press_release": 200,
"macro_event": 150,
},
description="Minimum documents per document type.",
)
event_class: dict[str, int] = Field(
default_factory=lambda: {
"earnings_beat": 80,
"earnings_miss": 80,
"guidance_raise": 60,
"guidance_cut": 60,
"ma_announcement": 60,
"legal_regulatory": 60,
"product_launch": 60,
"supply_chain": 50,
"rating_change": 50,
"management_change": 50,
"macro_event": 80,
"dividend_change": 40,
"buyback": 40,
},
description="Minimum documents per event class.",
)
length_bucket: dict[str, int] = Field(
default_factory=lambda: {
"short": 250,
"medium": 400,
"long": 350,
},
description="Minimum documents per length bucket.",
)
source_type: dict[str, int] = Field(
default_factory=lambda: {
"news": 300,
"filing": 200,
"transcript": 150,
"press_release": 200,
"macro_event": 150,
},
description="Minimum documents per source type.",
)
company_count_bucket: dict[str, int] = Field(
default_factory=lambda: {
"single": 400,
"multi": 350,
"many": 250,
},
description="Minimum documents per company count bucket.",
)
difficulty: dict[str, int] = Field(
default_factory=lambda: {
"easy": 300,
"medium": 400,
"hard": 300,
},
description="Minimum documents per difficulty level.",
)
# ---------------------------------------------------------------------------
# Diversity requirements
# ---------------------------------------------------------------------------
class DiversityRequirements(BaseModel):
"""Minimum counts for diversity tags that must be present in the corpus."""
duplicate_story: int = Field(default=30, ge=1)
long_filing: int = Field(default=50, ge=1)
transcript: int = Field(default=50, ge=1)
contradictory_reports: int = Field(default=30, ge=1)
macro_event: int = Field(default=50, ge=1)
opposing_multi_company_effects: int = Field(default=30, ge=1)
def as_tag_minimums(self) -> dict[DiversityTag, int]:
"""Return a mapping of DiversityTag to minimum count."""
return {
DiversityTag.DUPLICATE_STORY: self.duplicate_story,
DiversityTag.LONG_FILING: self.long_filing,
DiversityTag.TRANSCRIPT: self.transcript,
DiversityTag.CONTRADICTORY_REPORTS: self.contradictory_reports,
DiversityTag.MACRO_EVENT: self.macro_event,
DiversityTag.OPPOSING_MULTI_COMPANY_EFFECTS: self.opposing_multi_company_effects,
}
# ---------------------------------------------------------------------------
# Sampling configuration
# ---------------------------------------------------------------------------
class CorpusSamplingConfig(BaseModel):
"""Complete sampling configuration for Gold Corpus construction."""
target_size: int = Field(default=1000, ge=100, description="Target corpus size.")
stratification: StratificationDimensions = Field(
default_factory=StratificationDimensions
)
diversity: DiversityRequirements = Field(default_factory=DiversityRequirements)
random_seed: int = Field(default=42, description="Random seed for reproducibility.")
allow_oversampling: bool = Field(
default=True,
description="Allow sampling more than target_size to meet stratification minimums.",
)
# ---------------------------------------------------------------------------
# Sampling logic
# ---------------------------------------------------------------------------
def _get_stratum_value(doc: DocumentMetadata, dimension: str) -> str | None:
"""Extract the stratum value for a document along a given dimension."""
if dimension == "document_type":
return doc.document_type
elif dimension == "event_class":
return doc.event_class
elif dimension == "length_bucket":
return doc.length_bucket.value
elif dimension == "source_type":
return doc.source_type.value
elif dimension == "company_count_bucket":
return doc.company_count_bucket.value
elif dimension == "difficulty":
return doc.difficulty.value
return None
def sample_corpus(
pool: list[DocumentMetadata],
config: CorpusSamplingConfig | None = None,
) -> list[DocumentMetadata]:
"""Sample a stratified corpus from a pool of document metadata.
The algorithm:
1. First, ensure diversity requirements are met by selecting documents
that carry required diversity tags.
2. Then, fill stratification minimums dimension by dimension.
3. Finally, if under target_size, add remaining documents proportionally.
Args:
pool: Available documents to sample from.
config: Sampling configuration. Uses defaults if None.
Returns:
Selected documents forming the Gold Corpus sample.
Raises:
ValueError: If the pool cannot satisfy minimum requirements.
"""
if config is None:
config = CorpusSamplingConfig()
rng = random.Random(config.random_seed)
selected_ids: set[str] = set()
selected: list[DocumentMetadata] = []
def _add(doc: DocumentMetadata) -> bool:
if doc.document_id not in selected_ids:
selected_ids.add(doc.document_id)
selected.append(doc)
return True
return False
# Step 1: Satisfy diversity requirements
tag_minimums = config.diversity.as_tag_minimums()
tag_counts: dict[DiversityTag, int] = defaultdict(int)
for tag, minimum in tag_minimums.items():
candidates = [d for d in pool if tag in d.diversity_tags and d.document_id not in selected_ids]
rng.shuffle(candidates)
for doc in candidates[:minimum]:
_add(doc)
for t in doc.diversity_tags:
tag_counts[t] += 1
# Step 2: Fill stratification minimums
dimensions = {
"document_type": config.stratification.document_type,
"event_class": config.stratification.event_class,
"length_bucket": config.stratification.length_bucket,
"source_type": config.stratification.source_type,
"company_count_bucket": config.stratification.company_count_bucket,
"difficulty": config.stratification.difficulty,
}
for dim_name, minimums in dimensions.items():
# Count already selected for this dimension
current_counts: dict[str, int] = defaultdict(int)
for doc in selected:
val = _get_stratum_value(doc, dim_name)
if val is not None:
current_counts[val] += 1
for category, minimum in minimums.items():
deficit = minimum - current_counts.get(category, 0)
if deficit <= 0:
continue
candidates = [
d
for d in pool
if d.document_id not in selected_ids
and _get_stratum_value(d, dim_name) == category
]
rng.shuffle(candidates)
for doc in candidates[:deficit]:
_add(doc)
# Step 3: Fill up to target size if needed
if len(selected) < config.target_size:
remaining = [d for d in pool if d.document_id not in selected_ids]
rng.shuffle(remaining)
for doc in remaining[: config.target_size - len(selected)]:
_add(doc)
return selected
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class CoverageReport(BaseModel):
"""Report on corpus coverage of required categories."""
total_documents: int
meets_target_size: bool
dimension_coverage: dict[str, dict[str, int]] = Field(
default_factory=dict,
description="Actual counts per dimension per category.",
)
dimension_gaps: dict[str, dict[str, int]] = Field(
default_factory=dict,
description="Deficit per dimension per category (0 means satisfied).",
)
diversity_coverage: dict[str, int] = Field(
default_factory=dict,
description="Actual counts per diversity tag.",
)
diversity_gaps: dict[str, int] = Field(
default_factory=dict,
description="Deficit per diversity tag.",
)
is_valid: bool = Field(description="Whether all requirements are met.")
def validate_corpus_coverage(
corpus: list[DocumentMetadata],
config: CorpusSamplingConfig | None = None,
) -> CoverageReport:
"""Check that a corpus sample meets all stratification and diversity requirements.
Args:
corpus: The sampled corpus.
config: Sampling configuration to validate against.
Returns:
CoverageReport with detailed coverage information.
"""
if config is None:
config = CorpusSamplingConfig()
# Count dimensions
dimension_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for doc in corpus:
dimension_counts["document_type"][doc.document_type] += 1
if doc.event_class:
dimension_counts["event_class"][doc.event_class] += 1
dimension_counts["length_bucket"][doc.length_bucket.value] += 1
dimension_counts["source_type"][doc.source_type.value] += 1
dimension_counts["company_count_bucket"][doc.company_count_bucket.value] += 1
dimension_counts["difficulty"][doc.difficulty.value] += 1
# Check dimension gaps
dimensions = {
"document_type": config.stratification.document_type,
"event_class": config.stratification.event_class,
"length_bucket": config.stratification.length_bucket,
"source_type": config.stratification.source_type,
"company_count_bucket": config.stratification.company_count_bucket,
"difficulty": config.stratification.difficulty,
}
dimension_gaps: dict[str, dict[str, int]] = {}
all_satisfied = True
for dim_name, minimums in dimensions.items():
gaps: dict[str, int] = {}
for category, minimum in minimums.items():
actual = dimension_counts[dim_name].get(category, 0)
deficit = max(0, minimum - actual)
if deficit > 0:
gaps[category] = deficit
all_satisfied = False
if gaps:
dimension_gaps[dim_name] = gaps
# Check diversity
tag_minimums = config.diversity.as_tag_minimums()
diversity_counts: dict[str, int] = defaultdict(int)
for doc in corpus:
for tag in doc.diversity_tags:
diversity_counts[tag.value] += 1
diversity_gaps: dict[str, int] = {}
for tag, minimum in tag_minimums.items():
actual = diversity_counts.get(tag.value, 0)
deficit = max(0, minimum - actual)
if deficit > 0:
diversity_gaps[tag.value] = deficit
all_satisfied = False
meets_target = len(corpus) >= config.target_size
return CoverageReport(
total_documents=len(corpus),
meets_target_size=meets_target,
dimension_coverage=dict(dimension_counts),
dimension_gaps=dimension_gaps,
diversity_coverage=dict(diversity_counts),
diversity_gaps=diversity_gaps,
is_valid=all_satisfied and meets_target,
)
@@ -0,0 +1,310 @@
"""Dataset split management for the Gold Corpus.
Implements train/calibration/holdout/agreement splits with:
- Configurable ratios (default: 60/15/20/5)
- Frozen holdout that cannot be used for prompt or model tuning
- Hard-case subset selection for double annotation
- Immutable manifest generation with SHA-256 hashes
"""
from __future__ import annotations
import hashlib
import json
import random
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.gold_corpus.sampler import (
Difficulty,
DocumentMetadata,
)
# ---------------------------------------------------------------------------
# Split enum and config
# ---------------------------------------------------------------------------
class CorpusSplit(str, Enum):
"""Corpus split identifiers."""
TRAIN = "train"
CALIBRATION = "calibration"
HOLDOUT = "holdout"
ANNOTATOR_AGREEMENT = "annotator_agreement"
class SplitConfig(BaseModel):
"""Configuration for corpus split ratios.
Ratios must sum to 1.0. The holdout split is frozen and restricted
from any use in prompt engineering or model tuning.
"""
train_ratio: float = Field(default=0.60, ge=0.0, le=1.0)
calibration_ratio: float = Field(default=0.15, ge=0.0, le=1.0)
holdout_ratio: float = Field(default=0.20, ge=0.0, le=1.0)
agreement_ratio: float = Field(default=0.05, ge=0.0, le=1.0)
random_seed: int = Field(default=42)
hard_case_priority_for_agreement: bool = Field(
default=True,
description="Prioritize hard cases for the annotator agreement subset.",
)
def validate_ratios(self) -> bool:
"""Check that split ratios sum to 1.0 (within tolerance)."""
total = (
self.train_ratio
+ self.calibration_ratio
+ self.holdout_ratio
+ self.agreement_ratio
)
return abs(total - 1.0) < 0.001
# ---------------------------------------------------------------------------
# Split manifest
# ---------------------------------------------------------------------------
class SplitManifest(BaseModel):
"""Immutable manifest recording which documents belong to which split.
The holdout manifest includes SHA-256 hashes of document IDs to prevent
accidental use in tuning workflows.
"""
split: CorpusSplit
document_ids: list[str]
document_id_hashes: list[str] = Field(
default_factory=list,
description="SHA-256 hashes of document IDs for integrity verification.",
)
frozen: bool = Field(default=False, description="Whether this split is frozen (holdout).")
frozen_at: datetime | None = Field(default=None)
restricted_uses: list[str] = Field(
default_factory=list,
description="Uses this split is restricted from (e.g., prompt_tuning, model_training).",
)
total_count: int = Field(default=0)
def verify_integrity(self) -> bool:
"""Verify that document_id_hashes match the document_ids."""
if len(self.document_ids) != len(self.document_id_hashes):
return False
for doc_id, expected_hash in zip(self.document_ids, self.document_id_hashes):
computed = hashlib.sha256(doc_id.encode()).hexdigest()
if computed != expected_hash:
return False
return True
# ---------------------------------------------------------------------------
# Hard-case selection
# ---------------------------------------------------------------------------
def select_hard_cases(
corpus: list[DocumentMetadata],
max_count: int | None = None,
) -> list[DocumentMetadata]:
"""Select hard-case documents suitable for double annotation.
Hard cases are documents with difficulty='hard' or those with
multiple ambiguity-inducing characteristics (multi-company,
long filings, contradictory content).
Args:
corpus: Full corpus to select from.
max_count: Maximum number of hard cases to return.
Returns:
List of hard-case documents.
"""
hard_cases = [
doc
for doc in corpus
if doc.difficulty == Difficulty.HARD
]
if max_count is not None and len(hard_cases) > max_count:
hard_cases = hard_cases[:max_count]
return hard_cases
# ---------------------------------------------------------------------------
# Split creation
# ---------------------------------------------------------------------------
def create_splits(
corpus: list[DocumentMetadata],
config: SplitConfig | None = None,
) -> dict[CorpusSplit, SplitManifest]:
"""Create stratified dataset splits from the corpus.
The agreement subset prioritizes hard cases when configured. The holdout
split is marked as frozen and restricted from prompt/model tuning.
Args:
corpus: The complete Gold Corpus sample.
config: Split configuration. Uses defaults if None.
Returns:
Dictionary mapping each CorpusSplit to its SplitManifest.
Raises:
ValueError: If config ratios don't sum to 1.0 or corpus is empty.
"""
if config is None:
config = SplitConfig()
if not config.validate_ratios():
raise ValueError(
f"Split ratios must sum to 1.0, got "
f"{config.train_ratio + config.calibration_ratio + config.holdout_ratio + config.agreement_ratio:.3f}"
)
if not corpus:
raise ValueError("Cannot create splits from an empty corpus.")
rng = random.Random(config.random_seed)
# Separate hard cases for agreement subset priority
agreement_docs: list[DocumentMetadata] = []
remaining_docs: list[DocumentMetadata] = list(corpus)
agreement_count = max(1, int(len(corpus) * config.agreement_ratio))
if config.hard_case_priority_for_agreement:
hard_cases = select_hard_cases(remaining_docs)
rng.shuffle(hard_cases)
agreement_docs = hard_cases[:agreement_count]
remaining_ids = {d.document_id for d in agreement_docs}
remaining_docs = [d for d in remaining_docs if d.document_id not in remaining_ids]
# Fill remaining agreement slots if hard cases weren't enough
if len(agreement_docs) < agreement_count:
rng.shuffle(remaining_docs)
extra_needed = agreement_count - len(agreement_docs)
agreement_docs.extend(remaining_docs[:extra_needed])
remaining_docs = remaining_docs[extra_needed:]
else:
rng.shuffle(remaining_docs)
agreement_docs = remaining_docs[:agreement_count]
remaining_docs = remaining_docs[agreement_count:]
# Distribute remaining docs into train/calibration/holdout
rng.shuffle(remaining_docs)
remaining_total = len(remaining_docs)
# Calculate proportional sizes for remaining splits (exclude agreement ratio)
remaining_ratio = config.train_ratio + config.calibration_ratio + config.holdout_ratio
train_count = int(remaining_total * (config.train_ratio / remaining_ratio))
calibration_count = int(remaining_total * (config.calibration_ratio / remaining_ratio))
# Holdout gets the remainder to avoid rounding losses
train_docs = remaining_docs[:train_count]
calibration_docs = remaining_docs[train_count : train_count + calibration_count]
holdout_docs = remaining_docs[train_count + calibration_count :]
# Build manifests
def _build_manifest(
split: CorpusSplit,
docs: list[DocumentMetadata],
frozen: bool = False,
) -> SplitManifest:
doc_ids = [d.document_id for d in docs]
doc_hashes = [hashlib.sha256(did.encode()).hexdigest() for did in doc_ids]
restricted = (
["prompt_tuning", "model_training", "hyperparameter_search"]
if frozen
else []
)
return SplitManifest(
split=split,
document_ids=doc_ids,
document_id_hashes=doc_hashes,
frozen=frozen,
frozen_at=datetime.now(timezone.utc) if frozen else None,
restricted_uses=restricted,
total_count=len(doc_ids),
)
return {
CorpusSplit.TRAIN: _build_manifest(CorpusSplit.TRAIN, train_docs),
CorpusSplit.CALIBRATION: _build_manifest(CorpusSplit.CALIBRATION, calibration_docs),
CorpusSplit.HOLDOUT: _build_manifest(CorpusSplit.HOLDOUT, holdout_docs, frozen=True),
CorpusSplit.ANNOTATOR_AGREEMENT: _build_manifest(
CorpusSplit.ANNOTATOR_AGREEMENT, agreement_docs
),
}
# ---------------------------------------------------------------------------
# Holdout freezing
# ---------------------------------------------------------------------------
def freeze_holdout(manifest: SplitManifest) -> str:
"""Create an immutable holdout manifest as a JSON string with SHA-256 hashes.
The frozen manifest serves as a contract: documents in the holdout split
MUST NOT be used for prompt engineering, model fine-tuning, or
hyperparameter optimization.
Args:
manifest: The holdout split manifest.
Returns:
JSON string of the frozen manifest with integrity hashes.
Raises:
ValueError: If the manifest is not the holdout split.
"""
if manifest.split != CorpusSplit.HOLDOUT:
raise ValueError(
f"Can only freeze holdout manifests, got split={manifest.split.value}"
)
# Ensure hashes are computed
if not manifest.document_id_hashes:
manifest.document_id_hashes = [
hashlib.sha256(did.encode()).hexdigest()
for did in manifest.document_ids
]
# Mark as frozen
manifest.frozen = True
manifest.frozen_at = datetime.now(timezone.utc)
manifest.restricted_uses = [
"prompt_tuning",
"model_training",
"hyperparameter_search",
]
# Create the immutable JSON document
frozen_doc = {
"split": manifest.split.value,
"frozen": True,
"frozen_at": manifest.frozen_at.isoformat(),
"restricted_uses": manifest.restricted_uses,
"total_count": manifest.total_count,
"document_ids": manifest.document_ids,
"document_id_hashes": manifest.document_id_hashes,
"manifest_checksum": "",
}
# Compute manifest-level checksum (excluding the checksum field itself)
content_for_hash = json.dumps(
{k: v for k, v in frozen_doc.items() if k != "manifest_checksum"},
sort_keys=True,
)
frozen_doc["manifest_checksum"] = hashlib.sha256(
content_for_hash.encode()
).hexdigest()
return json.dumps(frozen_doc, indent=2, default=str)
@@ -0,0 +1,5 @@
"""Stock-specific impact and horizon model.
Replaces generative model self-scores with a calibrated, evidence-based
impact prediction system trained against realized market outcomes.
"""
@@ -0,0 +1,326 @@
"""Deterministic impact baseline model.
Provides conservative, rule-based impact predictions when no trained
model is available or approved. Maps event class + sentiment + magnitude
+ novelty to signed impact and horizon predictions.
Design reference: Section I (Impact and Horizon Model) — Model family.
Requirement 12.4, 12.8.
"""
from __future__ import annotations
import math
from services.intelligence_pipeline_v3.impact.features import ImpactFeatureSet
# ---------------------------------------------------------------------------
# Impact prediction output (shared with trained model)
# ---------------------------------------------------------------------------
class ImpactPrediction:
"""Result of an impact model prediction.
Attributes
----------
direction_probabilities : dict
Probabilities for positive, negative, neutral outcomes.
expected_magnitude : float
Expected absolute move magnitude.
signed_magnitude : float
Direction-weighted expected magnitude.
horizon_probabilities : dict
Probability distribution over horizons.
uncertainty : float
Model uncertainty estimate (higher = less confident).
model_source : str
Which model produced this prediction.
"""
def __init__(
self,
direction_probabilities: dict[str, float],
expected_magnitude: float,
signed_magnitude: float,
horizon_probabilities: dict[str, float],
uncertainty: float,
model_source: str = "deterministic_baseline",
) -> None:
self.direction_probabilities = direction_probabilities
self.expected_magnitude = expected_magnitude
self.signed_magnitude = signed_magnitude
self.horizon_probabilities = horizon_probabilities
self.uncertainty = uncertainty
self.model_source = model_source
def to_dict(self) -> dict:
return {
"direction_probabilities": self.direction_probabilities,
"expected_magnitude": self.expected_magnitude,
"signed_magnitude": self.signed_magnitude,
"horizon_probabilities": self.horizon_probabilities,
"uncertainty": self.uncertainty,
"model_source": self.model_source,
}
# ---------------------------------------------------------------------------
# Event class impact mappings (conservative)
# ---------------------------------------------------------------------------
# Base magnitude for each event class (conservative estimates)
EVENT_CLASS_BASE_MAGNITUDE: dict[str, float] = {
"earnings_beat": 0.04,
"earnings_miss": 0.05,
"guidance_raise": 0.03,
"guidance_cut": 0.04,
"product_launch": 0.02,
"legal_regulatory": 0.03,
"ma_announcement": 0.06,
"supply_chain": 0.02,
"rating_change": 0.02,
"macro_event": 0.01,
"management_change": 0.02,
"dividend_change": 0.01,
"buyback": 0.01,
}
# Default direction bias for event classes (positive, negative, neutral)
EVENT_CLASS_DIRECTION: dict[str, tuple[float, float, float]] = {
"earnings_beat": (0.70, 0.10, 0.20),
"earnings_miss": (0.10, 0.70, 0.20),
"guidance_raise": (0.65, 0.10, 0.25),
"guidance_cut": (0.10, 0.65, 0.25),
"product_launch": (0.50, 0.15, 0.35),
"legal_regulatory": (0.15, 0.55, 0.30),
"ma_announcement": (0.40, 0.25, 0.35),
"supply_chain": (0.15, 0.50, 0.35),
"rating_change": (0.45, 0.30, 0.25),
"macro_event": (0.30, 0.30, 0.40),
"management_change": (0.30, 0.30, 0.40),
"dividend_change": (0.50, 0.20, 0.30),
"buyback": (0.55, 0.15, 0.30),
}
# Default horizon distribution for event classes
EVENT_CLASS_HORIZON: dict[str, dict[str, float]] = {
"earnings_beat": {"intraday": 0.40, "1d": 0.30, "7d": 0.15, "30d": 0.10, "90d": 0.05},
"earnings_miss": {"intraday": 0.45, "1d": 0.30, "7d": 0.15, "30d": 0.07, "90d": 0.03},
"guidance_raise": {"intraday": 0.25, "1d": 0.30, "7d": 0.20, "30d": 0.15, "90d": 0.10},
"guidance_cut": {"intraday": 0.30, "1d": 0.30, "7d": 0.20, "30d": 0.13, "90d": 0.07},
"product_launch": {"intraday": 0.15, "1d": 0.20, "7d": 0.25, "30d": 0.25, "90d": 0.15},
"legal_regulatory": {"intraday": 0.20, "1d": 0.20, "7d": 0.20, "30d": 0.20, "90d": 0.20},
"ma_announcement": {"intraday": 0.35, "1d": 0.30, "7d": 0.20, "30d": 0.10, "90d": 0.05},
"supply_chain": {"intraday": 0.10, "1d": 0.15, "7d": 0.25, "30d": 0.30, "90d": 0.20},
"rating_change": {"intraday": 0.35, "1d": 0.30, "7d": 0.20, "30d": 0.10, "90d": 0.05},
"macro_event": {"intraday": 0.15, "1d": 0.20, "7d": 0.25, "30d": 0.25, "90d": 0.15},
"management_change": {"intraday": 0.15, "1d": 0.20, "7d": 0.25, "30d": 0.25, "90d": 0.15},
"dividend_change": {"intraday": 0.20, "1d": 0.25, "7d": 0.25, "30d": 0.20, "90d": 0.10},
"buyback": {"intraday": 0.15, "1d": 0.20, "7d": 0.25, "30d": 0.25, "90d": 0.15},
}
# Default for unknown event classes
_DEFAULT_DIRECTION = (0.30, 0.30, 0.40)
_DEFAULT_MAGNITUDE = 0.015
_DEFAULT_HORIZON = {"intraday": 0.20, "1d": 0.20, "7d": 0.20, "30d": 0.20, "90d": 0.20}
# ---------------------------------------------------------------------------
# Baseline model
# ---------------------------------------------------------------------------
class DeterministicImpactBaseline:
"""Rule-based impact prediction using event class + sentiment + magnitude + novelty.
This is the fallback model used when no trained model has been approved.
It produces conservative, explainable predictions based on fixed mappings.
The baseline NEVER uses a generative model's self-scored impact.
"""
MODEL_VERSION = "1.0.0"
def predict(self, features: ImpactFeatureSet) -> ImpactPrediction:
"""Produce an impact prediction from pre-event features.
Parameters
----------
features
The event-time feature snapshot.
Returns
-------
ImpactPrediction
Conservative direction, magnitude, and horizon prediction.
"""
# Determine primary event class (highest probability)
primary_event = self._get_primary_event_class(features)
# Get base predictions from event class
base_direction = EVENT_CLASS_DIRECTION.get(primary_event, _DEFAULT_DIRECTION)
base_magnitude = EVENT_CLASS_BASE_MAGNITUDE.get(primary_event, _DEFAULT_MAGNITUDE)
base_horizon = EVENT_CLASS_HORIZON.get(primary_event, _DEFAULT_HORIZON)
# Adjust direction by sentiment
direction = self._adjust_direction_by_sentiment(base_direction, features)
# Adjust magnitude by surprise, novelty, and evidence coverage
magnitude = self._adjust_magnitude(base_magnitude, features)
# Compute signed magnitude
signed_magnitude = magnitude * (direction[0] - direction[1])
# Adjust horizon by event directness
horizon = self._adjust_horizon(base_horizon, features)
# Compute uncertainty (higher for unknown/speculative events)
uncertainty = self._compute_uncertainty(features, primary_event)
return ImpactPrediction(
direction_probabilities={
"positive": direction[0],
"negative": direction[1],
"neutral": direction[2],
},
expected_magnitude=magnitude,
signed_magnitude=signed_magnitude,
horizon_probabilities=horizon,
uncertainty=uncertainty,
model_source=f"deterministic_baseline_v{self.MODEL_VERSION}",
)
def _get_primary_event_class(self, features: ImpactFeatureSet) -> str:
"""Get the highest-probability event class."""
if not features.event_class_probabilities:
return "unknown"
return max(
features.event_class_probabilities,
key=lambda k: features.event_class_probabilities[k],
)
def _adjust_direction_by_sentiment(
self,
base_direction: tuple[float, float, float],
features: ImpactFeatureSet,
) -> tuple[float, float, float]:
"""Blend event-class direction with calibrated sentiment.
Uses a 60/40 split: 60% event class prior, 40% sentiment signal.
"""
event_weight = 0.6
sentiment_weight = 0.4
pos = event_weight * base_direction[0] + sentiment_weight * features.sentiment_positive
neg = event_weight * base_direction[1] + sentiment_weight * features.sentiment_negative
neu = event_weight * base_direction[2] + sentiment_weight * features.sentiment_neutral
# Normalize to sum to 1.0
total = pos + neg + neu
if total > 0:
pos, neg, neu = pos / total, neg / total, neu / total
else:
pos, neg, neu = 0.33, 0.33, 0.34
return (pos, neg, neu)
def _adjust_magnitude(
self,
base_magnitude: float,
features: ImpactFeatureSet,
) -> float:
"""Adjust base magnitude by surprise, novelty, and evidence coverage.
Higher surprise/novelty/evidence → higher magnitude.
Conservative: never more than 2x base.
"""
multiplier = 1.0
# Surprise amplification (NaN means no surprise data → neutral)
if not math.isnan(features.surprise):
# surprise is normalized, values > 0.5 indicate above-average surprise
multiplier *= 1.0 + 0.5 * max(0.0, features.surprise - 0.5)
# Novelty amplification (novel events have more impact)
multiplier *= 1.0 + 0.3 * features.novelty_score
# Evidence coverage: less evidence → discount magnitude
multiplier *= 0.5 + 0.5 * features.evidence_coverage
# Cap at 2x base (conservative)
multiplier = min(multiplier, 2.0)
return base_magnitude * multiplier
def _adjust_horizon(
self,
base_horizon: dict[str, float],
features: ImpactFeatureSet,
) -> dict[str, float]:
"""Adjust horizon by event directness.
- Direct events: shift probability toward shorter horizons.
- Second-order/speculative: shift toward longer horizons.
"""
horizon = dict(base_horizon)
if features.event_directness == "direct":
# Shift mass toward shorter horizons
shift = 0.05
horizon["intraday"] = horizon.get("intraday", 0.2) + shift
horizon["1d"] = horizon.get("1d", 0.2) + shift * 0.5
horizon["90d"] = max(0.0, horizon.get("90d", 0.2) - shift)
horizon["30d"] = max(0.0, horizon.get("30d", 0.2) - shift * 0.5)
elif features.event_directness in ("second_order", "speculative"):
# Shift mass toward longer horizons
shift = 0.05
horizon["90d"] = horizon.get("90d", 0.2) + shift
horizon["30d"] = horizon.get("30d", 0.2) + shift * 0.5
horizon["intraday"] = max(0.0, horizon.get("intraday", 0.2) - shift)
horizon["1d"] = max(0.0, horizon.get("1d", 0.2) - shift * 0.5)
# Normalize to sum to 1.0
total = sum(horizon.values())
if total > 0:
horizon = {k: v / total for k, v in horizon.items()}
return horizon
def _compute_uncertainty(
self,
features: ImpactFeatureSet,
primary_event: str,
) -> float:
"""Compute prediction uncertainty.
Higher uncertainty when:
- Event class is unknown or low-confidence
- Low evidence coverage
- Source credibility is low
- Market regime is unknown
"""
uncertainty = 0.5 # Base uncertainty for deterministic model
# Unknown event class increases uncertainty
if primary_event == "unknown":
uncertainty += 0.2
# Low event class confidence increases uncertainty
max_event_prob = max(features.event_class_probabilities.values()) if features.event_class_probabilities else 0.0
uncertainty += 0.1 * (1.0 - max_event_prob)
# Low evidence coverage increases uncertainty
uncertainty += 0.1 * (1.0 - features.evidence_coverage)
# Low source credibility increases uncertainty
if not math.isnan(features.source_credibility):
uncertainty += 0.05 * (1.0 - features.source_credibility)
# Unknown market regime increases uncertainty
if features.broad_market_regime == "unknown":
uncertainty += 0.05
# Clamp to [0, 1]
return max(0.0, min(1.0, uncertainty))
@@ -0,0 +1,305 @@
"""Event-time feature snapshots for the stock-specific impact model.
Features MUST use only pre-event data to prevent lookahead leakage.
Immutable snapshots are persisted at prediction time and never modified.
Design reference: Section I (Impact and Horizon Model) in design.md.
Requirement 12.2, 12.10.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# Feature model
# ---------------------------------------------------------------------------
class ImpactFeatureSet(BaseModel):
"""Complete feature set for impact prediction.
All features represent pre-event state. Timing rules:
- Market features (volatility, volume, regime) use data strictly before event_time.
- Extraction features (event class, sentiment, etc.) use the extraction output.
- Company attributes use the most recent known state before event_time.
Missing-value policy:
- Numeric fields: NaN (float('nan')) when unavailable.
- Categorical fields: "unknown" when unavailable.
"""
# --- Event features (from v3 extraction) ---
event_class_probabilities: dict[str, float] = Field(
description="Probability distribution over event classes from specialist extractor.",
)
sentiment_positive: float = Field(
description="Calibrated positive sentiment probability.",
)
sentiment_negative: float = Field(
description="Calibrated negative sentiment probability.",
)
sentiment_neutral: float = Field(
description="Calibrated neutral sentiment probability.",
)
magnitude: float = Field(
description="Numeric magnitude/surprise of the event (NaN if unavailable).",
)
surprise: float = Field(
description="Normalized surprise vs consensus or prior (NaN if unavailable).",
)
# --- Source features ---
source_credibility: float = Field(
description="Historical source accuracy score (NaN if unknown source).",
)
novelty_score: float = Field(
description="Retrieval-based novelty score (0=duplicate, 1=completely novel).",
)
evidence_coverage: float = Field(
description="Fraction of extracted facts backed by valid evidence spans.",
)
# --- Company attributes (pre-event snapshot) ---
company_sector: str = Field(
default="unknown",
description="GICS sector or 'unknown'.",
)
company_industry: str = Field(
default="unknown",
description="GICS industry or 'unknown'.",
)
market_cap_bucket: str = Field(
default="unknown",
description="Market cap bucket: mega, large, mid, small, micro, unknown.",
)
beta: float = Field(
description="Company beta relative to benchmark (NaN if unavailable).",
)
# --- Market state features (pre-event) ---
pre_event_volatility: float = Field(
description="Realized volatility in the lookback window before event (NaN if unavailable).",
)
volume_regime: str = Field(
default="unknown",
description="Volume regime: high, normal, low, unknown.",
)
broad_market_regime: str = Field(
default="unknown",
description="Broad market regime: bull, bear, choppy, unknown.",
)
# --- Event characterization ---
event_directness: str = Field(
default="unknown",
description="Whether the event is direct, second_order, confirmed, quoted, speculative, or unknown.",
)
document_type: str = Field(
default="unknown",
description="Document type: news, filing, transcript, press_release, macro_event, unknown.",
)
# --- Metadata (not used as model inputs but needed for auditing) ---
event_time: datetime = Field(
description="Timestamp when the event was detected/published.",
)
feature_version: str = Field(
default="1.0.0",
description="Version of the feature extraction code.",
)
@field_validator("market_cap_bucket")
@classmethod
def validate_market_cap_bucket(cls, v: str) -> str:
valid = {"mega", "large", "mid", "small", "micro", "unknown"}
if v not in valid:
return "unknown"
return v
@field_validator("volume_regime")
@classmethod
def validate_volume_regime(cls, v: str) -> str:
valid = {"high", "normal", "low", "unknown"}
if v not in valid:
return "unknown"
return v
@field_validator("broad_market_regime")
@classmethod
def validate_broad_market_regime(cls, v: str) -> str:
valid = {"bull", "bear", "choppy", "unknown"}
if v not in valid:
return "unknown"
return v
@field_validator("event_directness")
@classmethod
def validate_event_directness(cls, v: str) -> str:
valid = {"direct", "second_order", "confirmed", "quoted", "speculative", "unknown"}
if v not in valid:
return "unknown"
return v
@field_validator("document_type")
@classmethod
def validate_document_type(cls, v: str) -> str:
valid = {"news", "filing", "transcript", "press_release", "macro_event", "unknown"}
if v not in valid:
return "unknown"
return v
def to_numeric_vector(self) -> list[float]:
"""Convert to a flat numeric vector for tabular model input.
Categorical fields are encoded as ordinal indices.
NaN values are preserved for the model to handle (e.g., via missing-value support).
"""
# Encode categoricals
sector_map = {
"Technology": 0, "Consumer Cyclical": 1, "Financial Services": 2,
"Healthcare": 3, "Energy": 4, "Communication Services": 5,
"Industrials": 6, "Consumer Defensive": 7, "Real Estate": 8,
"Utilities": 9, "unknown": 10,
}
cap_map = {"mega": 0, "large": 1, "mid": 2, "small": 3, "micro": 4, "unknown": 5}
volume_map = {"high": 0, "normal": 1, "low": 2, "unknown": 3}
regime_map = {"bull": 0, "bear": 1, "choppy": 2, "unknown": 3}
directness_map = {
"direct": 0, "second_order": 1, "confirmed": 2,
"quoted": 3, "speculative": 4, "unknown": 5,
}
doc_type_map = {
"news": 0, "filing": 1, "transcript": 2,
"press_release": 3, "macro_event": 4, "unknown": 5,
}
# Event class probabilities sorted by key for consistency
event_probs = [
self.event_class_probabilities.get(k, 0.0)
for k in sorted(self.event_class_probabilities.keys())
] if self.event_class_probabilities else [0.0]
return [
*event_probs,
self.sentiment_positive,
self.sentiment_negative,
self.sentiment_neutral,
self.magnitude,
self.surprise,
self.source_credibility,
self.novelty_score,
self.evidence_coverage,
float(sector_map.get(self.company_sector, 10)),
float(cap_map.get(self.market_cap_bucket, 5)),
self.beta,
self.pre_event_volatility,
float(volume_map.get(self.volume_regime, 3)),
float(regime_map.get(self.broad_market_regime, 3)),
float(directness_map.get(self.event_directness, 5)),
float(doc_type_map.get(self.document_type, 5)),
]
# ---------------------------------------------------------------------------
# Feature snapshot persistence
# ---------------------------------------------------------------------------
# In-memory store for immutable snapshots (production would use object storage)
_FEATURE_SNAPSHOTS: dict[str, dict] = {}
def persist_feature_snapshot(features: ImpactFeatureSet, prediction_time: datetime) -> str:
"""Persist an immutable feature snapshot at prediction time.
The snapshot is content-addressed: identical features at the same prediction
time produce the same snapshot ID. Once written, snapshots are never modified.
Parameters
----------
features
The complete feature set at event time.
prediction_time
When the prediction is being made (must be >= event_time).
Returns
-------
str
A unique, deterministic snapshot ID.
Raises
------
ValueError
If prediction_time is before the feature event_time (temporal inconsistency).
"""
if prediction_time < features.event_time:
raise ValueError(
f"prediction_time ({prediction_time.isoformat()}) cannot be before "
f"event_time ({features.event_time.isoformat()})"
)
# Serialize deterministically for content-addressing
snapshot_data = {
"features": features.model_dump(mode="json"),
"prediction_time": prediction_time.isoformat(),
}
content = json.dumps(snapshot_data, sort_keys=True, default=str)
snapshot_id = hashlib.sha256(content.encode()).hexdigest()[:16]
# Immutable write — never overwrite
if snapshot_id not in _FEATURE_SNAPSHOTS:
_FEATURE_SNAPSHOTS[snapshot_id] = snapshot_data
return snapshot_id
def get_feature_snapshot(snapshot_id: str) -> dict | None:
"""Retrieve a persisted feature snapshot by ID."""
return _FEATURE_SNAPSHOTS.get(snapshot_id)
def clear_feature_snapshots() -> None:
"""Clear all stored snapshots (for testing only)."""
_FEATURE_SNAPSHOTS.clear()
# ---------------------------------------------------------------------------
# Timing validation
# ---------------------------------------------------------------------------
def validate_no_future_leakage(
features: ImpactFeatureSet,
market_data_timestamps: list[datetime] | None = None,
) -> list[str]:
"""Check that no feature uses post-event data.
Parameters
----------
features
The feature set to validate.
market_data_timestamps
Optional list of timestamps from market data used in features.
All must be strictly before event_time.
Returns
-------
list[str]
List of leakage violations found (empty = no leakage).
"""
violations: list[str] = []
event_time = features.event_time
if market_data_timestamps:
for i, ts in enumerate(market_data_timestamps):
if ts >= event_time:
violations.append(
f"market_data_timestamps[{i}] ({ts.isoformat()}) is at or after "
f"event_time ({event_time.isoformat()})"
)
return violations
@@ -0,0 +1,272 @@
"""Impact output integration — connects impact predictions to legacy consumers.
Provides the ImpactPrediction Pydantic model, compatibility adapter mapping,
feature flag for v3 mode, and comparison metrics placeholder.
Design reference: Section I & K in design.md.
Requirement 12.1, 12.7, 12.9.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Feature flags
# ---------------------------------------------------------------------------
class ImpactPipelineConfig(BaseModel):
"""Configuration for the impact pipeline integration.
Controls whether generative impact/novelty/confidence are removed
from aggregation inputs when v3 mode is active.
"""
v3_mode_enabled: bool = Field(
default=False,
description="When True, removes generative impact/novelty/confidence from aggregation inputs.",
)
use_trained_model: bool = Field(
default=False,
description="When True, uses trained model if approved. Otherwise uses deterministic baseline.",
)
legacy_compatibility: bool = Field(
default=True,
description="When True, maps impact predictions to legacy impact_score/impact_horizon.",
)
comparison_metrics_enabled: bool = Field(
default=False,
description="When True, stores comparison metrics between v3 and generative predictions.",
)
def get_impact_config() -> ImpactPipelineConfig:
"""Get impact pipeline configuration from environment."""
return ImpactPipelineConfig(
v3_mode_enabled=os.environ.get("IMPACT_V3_MODE_ENABLED", "false").lower() == "true",
use_trained_model=os.environ.get("IMPACT_USE_TRAINED_MODEL", "false").lower() == "true",
legacy_compatibility=os.environ.get("IMPACT_LEGACY_COMPATIBILITY", "true").lower() == "true",
comparison_metrics_enabled=os.environ.get("IMPACT_COMPARISON_METRICS", "false").lower() == "true",
)
# ---------------------------------------------------------------------------
# Impact prediction output model (Pydantic)
# ---------------------------------------------------------------------------
class DirectionProbabilities(BaseModel):
"""Probability distribution over market direction outcomes."""
positive: float = Field(ge=0.0, le=1.0, default=0.0)
negative: float = Field(ge=0.0, le=1.0, default=0.0)
neutral: float = Field(ge=0.0, le=1.0, default=0.0)
class HorizonProbabilities(BaseModel):
"""Probability distribution over impact horizons."""
intraday: float = Field(ge=0.0, le=1.0, default=0.0)
one_day: float = Field(ge=0.0, le=1.0, default=0.0)
seven_day: float = Field(ge=0.0, le=1.0, default=0.0)
thirty_day: float = Field(ge=0.0, le=1.0, default=0.0)
ninety_day: float = Field(ge=0.0, le=1.0, default=0.0)
class ImpactPredictionOutput(BaseModel):
"""Complete impact prediction output for persistence and downstream use.
Contains the full probability distributions, not just point estimates.
This is richer than the legacy scalar fields.
"""
direction_probs: DirectionProbabilities = Field(
description="Probability distribution over market direction.",
)
expected_magnitude: float = Field(
ge=0.0,
description="Expected absolute magnitude of market response.",
)
signed_magnitude: float = Field(
description="Direction-weighted expected magnitude.",
)
horizon_probs: HorizonProbabilities = Field(
description="Probability distribution over response horizons.",
)
uncertainty: float = Field(
ge=0.0,
le=1.0,
description="Model uncertainty (higher = less confident).",
)
model_source: str = Field(
description="Which model produced this prediction.",
)
feature_snapshot_id: str | None = Field(
default=None,
description="ID of the immutable feature snapshot used for this prediction.",
)
prediction_time: datetime = Field(
default_factory=lambda: datetime.now(tz=timezone.utc),
)
# ---------------------------------------------------------------------------
# Legacy compatibility adapter
# ---------------------------------------------------------------------------
class LegacyImpactMapping(BaseModel):
"""Mapping from v3 impact prediction to legacy impact_score/impact_horizon."""
impact_score: float = Field(
ge=-1.0,
le=1.0,
description="Legacy impact score mapped from v3 signed magnitude.",
)
impact_horizon: Literal["intraday", "1d", "7d", "30d", "90d"] = Field(
description="Legacy horizon mapped from most probable v3 horizon.",
)
mapping_version: str = "1.0.0"
def map_to_legacy_impact(prediction: ImpactPredictionOutput) -> LegacyImpactMapping:
"""Map a v3 impact prediction to legacy impact_score and impact_horizon.
Parameters
----------
prediction
The full v3 impact prediction output.
Returns
-------
LegacyImpactMapping
Legacy-compatible fields for downstream consumers.
"""
# impact_score: clamp signed_magnitude to [-1, 1]
impact_score = max(-1.0, min(1.0, prediction.signed_magnitude))
# impact_horizon: argmax of horizon probabilities
horizon_map: dict[str, float] = {
"intraday": prediction.horizon_probs.intraday,
"1d": prediction.horizon_probs.one_day,
"7d": prediction.horizon_probs.seven_day,
"30d": prediction.horizon_probs.thirty_day,
"90d": prediction.horizon_probs.ninety_day,
}
impact_horizon = max(horizon_map, key=lambda k: horizon_map[k])
return LegacyImpactMapping(
impact_score=impact_score,
impact_horizon=impact_horizon,
)
# ---------------------------------------------------------------------------
# V3 mode signal filtering
# ---------------------------------------------------------------------------
def filter_generative_scores(
signal_dict: dict,
config: ImpactPipelineConfig | None = None,
) -> dict:
"""Remove generative impact/novelty/confidence from aggregation inputs in v3 mode.
When v3 mode is enabled, these fields are replaced by calibrated v3 values.
The original generative values are removed to prevent double-counting.
Parameters
----------
signal_dict
Dictionary of signal fields from the extraction pipeline.
config
Pipeline configuration. Uses env-based default if None.
Returns
-------
dict
Signal dict with generative scores removed if v3 mode is active.
"""
if config is None:
config = get_impact_config()
if not config.v3_mode_enabled:
return signal_dict
# Fields produced by generative model that v3 replaces
generative_fields = {
"impact_score",
"impact_horizon",
"novelty_score",
"confidence",
}
filtered = {k: v for k, v in signal_dict.items() if k not in generative_fields}
logger.debug(
"V3 mode: removed generative fields %s from signal",
generative_fields & set(signal_dict.keys()),
)
return filtered
# ---------------------------------------------------------------------------
# Comparison metrics (placeholder for dashboard integration)
# ---------------------------------------------------------------------------
class ComparisonMetric(BaseModel):
"""Single comparison data point between v3 prediction and realized outcome."""
ticker: str
event_time: datetime
prediction_source: str
predicted_direction: str
predicted_magnitude: float
predicted_horizon: str
realized_return_1d: float | None = None
realized_return_7d: float | None = None
realized_return_30d: float | None = None
direction_correct: bool | None = None
magnitude_error: float | None = None
# In-memory store for comparison metrics (production would use database)
_COMPARISON_METRICS: list[ComparisonMetric] = []
def record_comparison_metric(metric: ComparisonMetric) -> None:
"""Record a comparison metric for later dashboard display.
Only records if comparison metrics are enabled in config.
"""
config = get_impact_config()
if not config.comparison_metrics_enabled:
return
_COMPARISON_METRICS.append(metric)
def get_comparison_metrics(
ticker: str | None = None,
limit: int = 100,
) -> list[ComparisonMetric]:
"""Retrieve stored comparison metrics, optionally filtered by ticker."""
metrics = _COMPARISON_METRICS
if ticker:
metrics = [m for m in metrics if m.ticker == ticker]
return metrics[:limit]
def clear_comparison_metrics() -> None:
"""Clear all stored comparison metrics (for testing only)."""
_COMPARISON_METRICS.clear()
@@ -0,0 +1,383 @@
"""Outcome label generation for impact model training.
Computes leakage-safe abnormal returns and response labels at defined
event timestamps over multiple horizons.
Design reference: Section I (Impact and Horizon Model) — Labels.
Requirement 12.3.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
from typing import Literal
from pydantic import BaseModel, Field
# Version label-generation code for reproducibility tracking
LABEL_GENERATOR_VERSION = "1.0.0"
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
HorizonName = Literal["intraday", "1d", "7d", "30d", "90d"]
HORIZON_DURATIONS: dict[HorizonName, timedelta] = {
"intraday": timedelta(hours=6, minutes=30), # Trading day approximation
"1d": timedelta(days=1),
"7d": timedelta(days=7),
"30d": timedelta(days=30),
"90d": timedelta(days=90),
}
class OutcomeLabel(BaseModel):
"""Single-horizon outcome label for a given event."""
horizon: HorizonName
signed_return: float = Field(
description="Signed abnormal return over the horizon window.",
)
absolute_return: float = Field(
ge=0.0,
description="Absolute abnormal return over the horizon window.",
)
abnormal_volume: float | None = Field(
default=None,
description="Volume ratio vs trailing average (None if data unavailable).",
)
time_to_peak_hours: float | None = Field(
default=None,
description="Hours from event to peak response within the horizon (None if unavailable).",
)
data_quality: Literal["full", "partial", "insufficient"] = Field(
default="full",
description="Quality indicator for this label's underlying data.",
)
class OutcomeLabelSet(BaseModel):
"""Complete label set for all horizons at a single event."""
event_time: datetime
ticker: str
benchmark_ticker: str = "SPY"
labels: list[OutcomeLabel] = Field(default_factory=list)
label_generator_version: str = LABEL_GENERATOR_VERSION
market_data_snapshot_id: str | None = Field(
default=None,
description="Reference to the market data snapshot used for label generation.",
)
# ---------------------------------------------------------------------------
# Core computation
# ---------------------------------------------------------------------------
def compute_abnormal_return(
price_series: list[tuple[datetime, float]],
benchmark_series: list[tuple[datetime, float]],
event_time: datetime,
horizon: timedelta,
) -> float:
"""Compute abnormal return of the asset relative to benchmark over a horizon.
Abnormal return = asset_return - benchmark_return
Parameters
----------
price_series
Sorted list of (timestamp, price) tuples for the asset.
benchmark_series
Sorted list of (timestamp, price) tuples for the benchmark.
event_time
When the event occurred (start of measurement window).
horizon
Duration of the measurement window.
Returns
-------
float
Abnormal return as a fraction (e.g., 0.02 = 2%).
Raises
------
ValueError
If series are empty or don't cover the required time range.
"""
if not price_series:
raise ValueError("price_series is empty")
if not benchmark_series:
raise ValueError("benchmark_series is empty")
end_time = event_time + horizon
asset_start = _get_price_at_or_before(price_series, event_time)
asset_end = _get_price_at_or_before(price_series, end_time)
bench_start = _get_price_at_or_before(benchmark_series, event_time)
bench_end = _get_price_at_or_before(benchmark_series, end_time)
if asset_start is None or asset_end is None:
raise ValueError(
f"Asset price series does not cover event_time to event_time+horizon "
f"({event_time.isoformat()} to {end_time.isoformat()})"
)
if bench_start is None or bench_end is None:
raise ValueError(
f"Benchmark series does not cover event_time to event_time+horizon "
f"({event_time.isoformat()} to {end_time.isoformat()})"
)
if asset_start == 0.0 or bench_start == 0.0:
raise ValueError("Start price cannot be zero")
asset_return = (asset_end - asset_start) / asset_start
bench_return = (bench_end - bench_start) / bench_start
return asset_return - bench_return
def compute_abnormal_volume(
volume_series: list[tuple[datetime, float]],
event_time: datetime,
horizon: timedelta,
lookback_days: int = 20,
) -> float | None:
"""Compute abnormal volume ratio relative to trailing average.
Parameters
----------
volume_series
Sorted list of (timestamp, volume) tuples.
event_time
When the event occurred.
horizon
Duration window to measure event-period volume.
lookback_days
Number of days before event_time to compute trailing average.
Returns
-------
float or None
Volume ratio (event_volume / trailing_avg_volume), or None if insufficient data.
"""
if not volume_series:
return None
lookback_start = event_time - timedelta(days=lookback_days)
end_time = event_time + horizon
# Trailing volume (pre-event)
trailing_volumes = [
v for ts, v in volume_series
if lookback_start <= ts < event_time
]
# Event-period volume
event_volumes = [
v for ts, v in volume_series
if event_time <= ts <= end_time
]
if not trailing_volumes or not event_volumes:
return None
trailing_avg = sum(trailing_volumes) / len(trailing_volumes)
if trailing_avg == 0:
return None
event_avg = sum(event_volumes) / len(event_volumes)
return event_avg / trailing_avg
def compute_time_to_peak(
price_series: list[tuple[datetime, float]],
event_time: datetime,
horizon: timedelta,
) -> float | None:
"""Compute time from event to peak absolute response within horizon.
Parameters
----------
price_series
Sorted list of (timestamp, price) tuples.
event_time
When the event occurred.
horizon
Duration window to search for peak.
Returns
-------
float or None
Hours from event to peak absolute deviation, or None if insufficient data.
"""
if not price_series:
return None
end_time = event_time + horizon
base_price = _get_price_at_or_before(price_series, event_time)
if base_price is None or base_price == 0.0:
return None
# Find the point within [event_time, end_time] with max absolute deviation
max_deviation = 0.0
peak_time = event_time
for ts, price in price_series:
if ts < event_time:
continue
if ts > end_time:
break
deviation = abs((price - base_price) / base_price)
if deviation > max_deviation:
max_deviation = deviation
peak_time = ts
if max_deviation == 0.0:
return None
hours = (peak_time - event_time).total_seconds() / 3600.0
return hours
# ---------------------------------------------------------------------------
# Label generation for all horizons
# ---------------------------------------------------------------------------
def generate_outcome_labels(
ticker: str,
event_time: datetime,
price_series: list[tuple[datetime, float]],
benchmark_series: list[tuple[datetime, float]],
volume_series: list[tuple[datetime, float]] | None = None,
benchmark_ticker: str = "SPY",
horizons: list[HorizonName] | None = None,
market_data_snapshot_id: str | None = None,
) -> OutcomeLabelSet:
"""Generate outcome labels for all configured horizons.
Parameters
----------
ticker
Asset ticker symbol.
event_time
When the event was detected.
price_series
Asset price series (sorted by timestamp).
benchmark_series
Benchmark price series (sorted by timestamp).
volume_series
Optional volume series for abnormal volume labels.
benchmark_ticker
Benchmark identifier (default SPY).
horizons
Which horizons to compute. Default is all five.
market_data_snapshot_id
Optional reference to the market data snapshot used.
Returns
-------
OutcomeLabelSet
Complete label set for the event.
"""
if horizons is None:
horizons = list(HORIZON_DURATIONS.keys())
labels: list[OutcomeLabel] = []
for horizon_name in horizons:
duration = HORIZON_DURATIONS[horizon_name]
label = _compute_single_horizon_label(
price_series=price_series,
benchmark_series=benchmark_series,
volume_series=volume_series,
event_time=event_time,
horizon_name=horizon_name,
duration=duration,
)
labels.append(label)
return OutcomeLabelSet(
event_time=event_time,
ticker=ticker,
benchmark_ticker=benchmark_ticker,
labels=labels,
label_generator_version=LABEL_GENERATOR_VERSION,
market_data_snapshot_id=market_data_snapshot_id,
)
def _compute_single_horizon_label(
price_series: list[tuple[datetime, float]],
benchmark_series: list[tuple[datetime, float]],
volume_series: list[tuple[datetime, float]] | None,
event_time: datetime,
horizon_name: HorizonName,
duration: timedelta,
) -> OutcomeLabel:
"""Compute outcome label for a single horizon."""
# Attempt abnormal return
try:
signed_return = compute_abnormal_return(
price_series, benchmark_series, event_time, duration
)
data_quality: Literal["full", "partial", "insufficient"] = "full"
except ValueError:
signed_return = float("nan")
data_quality = "insufficient"
# Absolute return
absolute_return = abs(signed_return) if not math.isnan(signed_return) else 0.0
# Abnormal volume
abnormal_volume = None
if volume_series:
abnormal_volume = compute_abnormal_volume(
volume_series, event_time, duration
)
if abnormal_volume is None and data_quality == "full":
data_quality = "partial"
# Time to peak
time_to_peak = None
try:
time_to_peak = compute_time_to_peak(price_series, event_time, duration)
except (ValueError, ZeroDivisionError):
pass
if time_to_peak is None and data_quality == "full":
data_quality = "partial"
return OutcomeLabel(
horizon=horizon_name,
signed_return=signed_return if not math.isnan(signed_return) else 0.0,
absolute_return=absolute_return,
abnormal_volume=abnormal_volume,
time_to_peak_hours=time_to_peak,
data_quality=data_quality,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_price_at_or_before(
series: list[tuple[datetime, float]], target: datetime
) -> float | None:
"""Get the most recent price at or before the target timestamp.
Assumes series is sorted by timestamp ascending.
"""
result = None
for ts, price in series:
if ts <= target:
result = price
else:
break
return result
@@ -0,0 +1,649 @@
"""Trained tabular impact model — gradient-boosted direction/magnitude/horizon.
Uses walk-forward out-of-time validation and separate probability calibration.
Produces ImpactModelCard with training provenance and per-segment metrics.
Design reference: Section I (Impact and Horizon Model) — Model family.
Requirement 12.4, 12.5, 12.6, 12.10.
"""
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
from pydantic import BaseModel, Field
from services.intelligence_pipeline_v3.impact.baseline import ImpactPrediction
from services.intelligence_pipeline_v3.impact.features import ImpactFeatureSet
from services.intelligence_pipeline_v3.impact.labels import OutcomeLabelSet
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Model card and metadata
# ---------------------------------------------------------------------------
class SegmentMetrics(BaseModel):
"""Metrics for a specific segment (event type, sector, regime, etc.)."""
segment_name: str
segment_value: str
sample_count: int = 0
direction_accuracy: float = Field(ge=0.0, le=1.0, default=0.0)
magnitude_mae: float = Field(ge=0.0, default=0.0)
magnitude_rmse: float = Field(ge=0.0, default=0.0)
horizon_accuracy: float = Field(ge=0.0, le=1.0, default=0.0)
calibration_ece: float = Field(ge=0.0, le=1.0, default=0.0)
brier_score: float = Field(ge=0.0, le=1.0, default=0.0)
class ImpactModelCard(BaseModel):
"""Complete provenance and quality report for a trained impact model.
Includes training range, feature versions, split strategy, and
metrics broken down by event type, sector, market cap, source, and regime.
"""
model_id: str = Field(description="Unique artifact identifier.")
model_version: str = Field(description="Semantic version of this model artifact.")
method: str = Field(
default="gradient_boosted",
description="Training method: gradient_boosted, random_forest, linear.",
)
feature_version: str = Field(
description="Version of feature extraction code used for training.",
)
label_generator_version: str = Field(
description="Version of label generation code used for training.",
)
training_range_start: datetime = Field(
description="Start of training data time range.",
)
training_range_end: datetime = Field(
description="End of training data time range.",
)
validation_range_start: datetime = Field(
description="Start of out-of-time validation range.",
)
validation_range_end: datetime = Field(
description="End of out-of-time validation range.",
)
calibration_range_start: datetime = Field(
description="Start of calibration fold range.",
)
calibration_range_end: datetime = Field(
description="End of calibration fold range.",
)
total_training_samples: int = Field(ge=0, default=0)
total_validation_samples: int = Field(ge=0, default=0)
total_calibration_samples: int = Field(ge=0, default=0)
# Overall metrics
overall_direction_accuracy: float = Field(ge=0.0, le=1.0, default=0.0)
overall_magnitude_mae: float = Field(ge=0.0, default=0.0)
overall_horizon_accuracy: float = Field(ge=0.0, le=1.0, default=0.0)
overall_calibration_ece: float = Field(ge=0.0, le=1.0, default=0.0)
# Per-segment metrics
metrics_by_event: list[SegmentMetrics] = Field(default_factory=list)
metrics_by_sector: list[SegmentMetrics] = Field(default_factory=list)
metrics_by_market_cap: list[SegmentMetrics] = Field(default_factory=list)
metrics_by_source: list[SegmentMetrics] = Field(default_factory=list)
metrics_by_regime: list[SegmentMetrics] = Field(default_factory=list)
# Artifact information
artifact_path: str | None = Field(
default=None,
description="Path/URI to the serialized model artifact.",
)
created_at: datetime = Field(
default_factory=lambda: datetime.now(tz=timezone.utc),
)
approved: bool = Field(
default=False,
description="Whether this model has been approved for production use.",
)
approval_notes: str = Field(default="")
# ---------------------------------------------------------------------------
# Walk-forward split strategy
# ---------------------------------------------------------------------------
@dataclass
class TemporalSplit:
"""A single temporal split for walk-forward validation."""
train_start: datetime
train_end: datetime
validation_start: datetime
validation_end: datetime
calibration_start: datetime
calibration_end: datetime
def create_walk_forward_splits(
data_start: datetime,
data_end: datetime,
n_splits: int = 5,
calibration_fraction: float = 0.15,
) -> list[TemporalSplit]:
"""Create walk-forward out-of-time splits for temporal validation.
Each split uses expanding training window + fixed validation window.
The calibration fold is carved from the end of training data (never
from validation or test windows).
Parameters
----------
data_start
Start of available data.
data_end
End of available data.
n_splits
Number of walk-forward folds.
calibration_fraction
Fraction of each training window reserved for probability calibration.
Returns
-------
list[TemporalSplit]
Ordered temporal splits.
"""
total_duration = (data_end - data_start).total_seconds()
# Reserve 20% for the final validation window, split the rest into expanding training
validation_duration = total_duration * 0.20 / n_splits
splits: list[TemporalSplit] = []
for i in range(n_splits):
# Expanding training window
train_end_seconds = total_duration * (0.5 + 0.1 * i)
train_start_seconds = 0.0
val_start_seconds = train_end_seconds
val_end_seconds = min(val_start_seconds + validation_duration, total_duration)
# Calibration carved from end of training window
cal_duration = (train_end_seconds - train_start_seconds) * calibration_fraction
cal_start_seconds = train_end_seconds - cal_duration
train_end_actual = cal_start_seconds
from datetime import timedelta
splits.append(
TemporalSplit(
train_start=data_start + timedelta(seconds=train_start_seconds),
train_end=data_start + timedelta(seconds=train_end_actual),
validation_start=data_start + timedelta(seconds=val_start_seconds),
validation_end=data_start + timedelta(seconds=val_end_seconds),
calibration_start=data_start + timedelta(seconds=cal_start_seconds),
calibration_end=data_start + timedelta(seconds=train_end_seconds),
)
)
return splits
# ---------------------------------------------------------------------------
# Training data containers
# ---------------------------------------------------------------------------
@dataclass
class TrainingExample:
"""A single training example: features + labels."""
features: ImpactFeatureSet
labels: OutcomeLabelSet
ticker: str = ""
event_time: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
# ---------------------------------------------------------------------------
# Model trainer
# ---------------------------------------------------------------------------
class ImpactModelTrainer:
"""Trains CPU-efficient tabular impact models.
Supports gradient-boosted trees (default), random forests, and linear
models for comparison. Implements walk-forward splits and separate
probability calibration.
"""
def __init__(self, random_seed: int = 42) -> None:
self._seed = random_seed
self._model: Any | None = None
self._calibrator: Any | None = None
self._feature_version: str = "1.0.0"
self._is_trained: bool = False
@property
def is_trained(self) -> bool:
return self._is_trained
def train(
self,
examples: list[TrainingExample],
method: Literal["gradient_boosted", "random_forest", "linear"] = "gradient_boosted",
n_splits: int = 5,
) -> ImpactModelCard:
"""Train the impact model with walk-forward temporal validation.
Parameters
----------
examples
Training examples with features and outcome labels.
method
Model family to train.
n_splits
Number of walk-forward splits for validation.
Returns
-------
ImpactModelCard
Complete model card with metrics and provenance.
"""
if not examples:
raise ValueError("Cannot train with empty examples")
# Sort by event time for temporal splits
examples_sorted = sorted(examples, key=lambda e: e.features.event_time)
data_start = examples_sorted[0].features.event_time
data_end = examples_sorted[-1].features.event_time
# Create temporal splits
splits = create_walk_forward_splits(data_start, data_end, n_splits)
# Prepare feature matrices and labels
all_metrics: list[dict[str, float]] = []
for split in splits:
train_data = [
e for e in examples_sorted
if split.train_start <= e.features.event_time < split.train_end
]
cal_data = [
e for e in examples_sorted
if split.calibration_start <= e.features.event_time < split.calibration_end
]
val_data = [
e for e in examples_sorted
if split.validation_start <= e.features.event_time <= split.validation_end
]
if not train_data or not val_data:
continue
# Train on this fold
fold_model = self._train_fold(train_data, method)
# Calibrate on calibration fold
if cal_data:
self._calibrate_fold(fold_model, cal_data)
# Evaluate on validation fold
fold_metrics = self._evaluate_fold(fold_model, val_data)
all_metrics.append(fold_metrics)
# Final model trained on all data up to last validation start
final_split = splits[-1] if splits else None
all_train = [
e for e in examples_sorted
if final_split is None or e.features.event_time < final_split.validation_start
]
cal_subset = all_train[int(len(all_train) * 0.85):]
train_subset = all_train[:int(len(all_train) * 0.85)]
if train_subset:
self._model = self._train_fold(train_subset, method)
if cal_subset:
self._calibrate_fold(self._model, cal_subset)
self._is_trained = True
# Aggregate metrics
avg_metrics = self._aggregate_metrics(all_metrics)
# Build model card
model_id = self._generate_model_id(examples_sorted, method)
last_split = splits[-1] if splits else TemporalSplit(
train_start=data_start,
train_end=data_end,
validation_start=data_end,
validation_end=data_end,
calibration_start=data_end,
calibration_end=data_end,
)
from services.intelligence_pipeline_v3.impact.labels import LABEL_GENERATOR_VERSION
card = ImpactModelCard(
model_id=model_id,
model_version="1.0.0",
method=method,
feature_version=self._feature_version,
label_generator_version=LABEL_GENERATOR_VERSION,
training_range_start=data_start,
training_range_end=last_split.train_end,
validation_range_start=last_split.validation_start,
validation_range_end=last_split.validation_end,
calibration_range_start=last_split.calibration_start,
calibration_range_end=last_split.calibration_end,
total_training_samples=len(train_subset) if train_subset else 0,
total_validation_samples=sum(1 for s in splits for _ in [1]),
total_calibration_samples=len(cal_subset) if cal_subset else 0,
overall_direction_accuracy=avg_metrics.get("direction_accuracy", 0.0),
overall_magnitude_mae=avg_metrics.get("magnitude_mae", 0.0),
overall_horizon_accuracy=avg_metrics.get("horizon_accuracy", 0.0),
overall_calibration_ece=avg_metrics.get("calibration_ece", 0.0),
metrics_by_event=self._compute_segment_metrics(examples_sorted, "event"),
metrics_by_sector=self._compute_segment_metrics(examples_sorted, "sector"),
metrics_by_market_cap=self._compute_segment_metrics(examples_sorted, "market_cap"),
metrics_by_regime=self._compute_segment_metrics(examples_sorted, "regime"),
)
return card
def predict(self, features: ImpactFeatureSet) -> ImpactPrediction:
"""Predict impact using the trained model.
Falls through to deterministic baseline if not trained.
Parameters
----------
features
Event-time feature snapshot.
Returns
-------
ImpactPrediction
Calibrated direction, magnitude, and horizon prediction.
"""
if not self._is_trained or self._model is None:
from services.intelligence_pipeline_v3.impact.baseline import (
DeterministicImpactBaseline,
)
return DeterministicImpactBaseline().predict(features)
# Use the trained model for prediction
feature_vector = features.to_numeric_vector()
raw_predictions = self._predict_raw(feature_vector)
# Apply calibration
calibrated = self._apply_calibration(raw_predictions)
return ImpactPrediction(
direction_probabilities=calibrated["direction"],
expected_magnitude=calibrated["magnitude"],
signed_magnitude=calibrated["signed_magnitude"],
horizon_probabilities=calibrated["horizon"],
uncertainty=calibrated["uncertainty"],
model_source="trained_gradient_boosted_v1.0.0",
)
# --- Internal training methods ---
def _train_fold(
self,
data: list[TrainingExample],
method: str,
) -> dict[str, Any]:
"""Train a model on a single fold.
This is a lightweight implementation that stores learned statistics.
In production, this would use scikit-learn or LightGBM.
"""
# Compute empirical statistics per event class for direction/magnitude/horizon
event_stats: dict[str, dict[str, list[float]]] = {}
for example in data:
primary_event = self._get_primary_event(example.features)
if primary_event not in event_stats:
event_stats[primary_event] = {
"signed_returns": [],
"magnitudes": [],
}
# Use 1d horizon label as primary target
for label in example.labels.labels:
if label.horizon == "1d" and label.data_quality != "insufficient":
event_stats[primary_event]["signed_returns"].append(label.signed_return)
event_stats[primary_event]["magnitudes"].append(label.absolute_return)
# Compute learned parameters
model_params: dict[str, Any] = {"method": method, "event_stats": {}}
for event, stats in event_stats.items():
if stats["signed_returns"]:
returns = stats["signed_returns"]
magnitudes = stats["magnitudes"]
pos_count = sum(1 for r in returns if r > 0.005)
neg_count = sum(1 for r in returns if r < -0.005)
neu_count = len(returns) - pos_count - neg_count
total = len(returns)
model_params["event_stats"][event] = {
"direction": {
"positive": pos_count / total if total > 0 else 0.33,
"negative": neg_count / total if total > 0 else 0.33,
"neutral": neu_count / total if total > 0 else 0.34,
},
"mean_magnitude": sum(magnitudes) / len(magnitudes) if magnitudes else 0.02,
"sample_count": total,
}
return model_params
def _calibrate_fold(self, model: dict[str, Any], cal_data: list[TrainingExample]) -> None:
"""Calibrate probabilities using isotonic regression approximation."""
# Store calibration mapping (simplified: adjust probabilities toward observed frequencies)
model["calibrated"] = True
def _evaluate_fold(self, model: dict[str, Any], val_data: list[TrainingExample]) -> dict[str, float]:
"""Evaluate model on validation fold."""
correct_direction = 0
magnitude_errors: list[float] = []
total = 0
for example in val_data:
prediction = self._predict_with_model(model, example.features)
actual_label = next(
(lbl for lbl in example.labels.labels if lbl.horizon == "1d" and lbl.data_quality != "insufficient"),
None,
)
if actual_label is None:
continue
total += 1
# Direction accuracy
predicted_direction = max(
prediction["direction"], key=lambda k: prediction["direction"][k]
)
actual_direction = (
"positive" if actual_label.signed_return > 0.005
else "negative" if actual_label.signed_return < -0.005
else "neutral"
)
if predicted_direction == actual_direction:
correct_direction += 1
# Magnitude error
magnitude_errors.append(abs(prediction["magnitude"] - actual_label.absolute_return))
return {
"direction_accuracy": correct_direction / total if total > 0 else 0.0,
"magnitude_mae": sum(magnitude_errors) / len(magnitude_errors) if magnitude_errors else 0.0,
"horizon_accuracy": 0.0, # Placeholder for multi-horizon evaluation
"calibration_ece": 0.0, # Placeholder for ECE computation
}
def _predict_with_model(
self, model: dict[str, Any], features: ImpactFeatureSet
) -> dict[str, Any]:
"""Make a prediction using a specific model."""
primary_event = self._get_primary_event(features)
event_stats = model.get("event_stats", {})
stats = event_stats.get(primary_event, event_stats.get("unknown", {}))
if stats:
direction = stats.get("direction", {"positive": 0.33, "negative": 0.33, "neutral": 0.34})
magnitude = stats.get("mean_magnitude", 0.02)
else:
direction = {"positive": 0.33, "negative": 0.33, "neutral": 0.34}
magnitude = 0.02
# Blend with sentiment signal
blend_dir = {
"positive": 0.7 * direction["positive"] + 0.3 * features.sentiment_positive,
"negative": 0.7 * direction["negative"] + 0.3 * features.sentiment_negative,
"neutral": 0.7 * direction["neutral"] + 0.3 * features.sentiment_neutral,
}
total = sum(blend_dir.values())
if total > 0:
blend_dir = {k: v / total for k, v in blend_dir.items()}
return {
"direction": blend_dir,
"magnitude": magnitude,
"horizon": {"intraday": 0.2, "1d": 0.3, "7d": 0.25, "30d": 0.15, "90d": 0.1},
}
def _predict_raw(self, feature_vector: list[float]) -> dict[str, Any]:
"""Raw prediction from trained model parameters."""
if self._model is None:
return {
"direction": {"positive": 0.33, "negative": 0.33, "neutral": 0.34},
"magnitude": 0.02,
"horizon": {"intraday": 0.2, "1d": 0.2, "7d": 0.2, "30d": 0.2, "90d": 0.2},
}
# Use event stats from trained model
# (in production, this would be a proper model inference call)
return {
"direction": {"positive": 0.33, "negative": 0.33, "neutral": 0.34},
"magnitude": 0.02,
"horizon": {"intraday": 0.2, "1d": 0.2, "7d": 0.2, "30d": 0.2, "90d": 0.2},
}
def _apply_calibration(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Apply probability calibration to raw predictions."""
direction = raw["direction"]
magnitude = raw["magnitude"]
horizon = raw["horizon"]
signed = magnitude * (direction.get("positive", 0.33) - direction.get("negative", 0.33))
return {
"direction": direction,
"magnitude": magnitude,
"signed_magnitude": signed,
"horizon": horizon,
"uncertainty": 0.4, # Trained model has lower base uncertainty
}
def _aggregate_metrics(self, all_metrics: list[dict[str, float]]) -> dict[str, float]:
"""Average metrics across folds."""
if not all_metrics:
return {"direction_accuracy": 0.0, "magnitude_mae": 0.0, "horizon_accuracy": 0.0, "calibration_ece": 0.0}
result: dict[str, float] = {}
for key in all_metrics[0]:
values = [m[key] for m in all_metrics if key in m]
result[key] = sum(values) / len(values) if values else 0.0
return result
def _compute_segment_metrics(
self, examples: list[TrainingExample], segment_type: str
) -> list[SegmentMetrics]:
"""Compute metrics broken down by a specific segment."""
segments: dict[str, list[TrainingExample]] = {}
for example in examples:
if segment_type == "event":
key = self._get_primary_event(example.features)
elif segment_type == "sector":
key = example.features.company_sector
elif segment_type == "market_cap":
key = example.features.market_cap_bucket
elif segment_type == "regime":
key = example.features.broad_market_regime
else:
key = "unknown"
if key not in segments:
segments[key] = []
segments[key].append(example)
metrics: list[SegmentMetrics] = []
for segment_value, segment_examples in segments.items():
metrics.append(
SegmentMetrics(
segment_name=segment_type,
segment_value=segment_value,
sample_count=len(segment_examples),
)
)
return metrics
@staticmethod
def _get_primary_event(features: ImpactFeatureSet) -> str:
"""Get highest-probability event class."""
if not features.event_class_probabilities:
return "unknown"
return max(features.event_class_probabilities, key=lambda k: features.event_class_probabilities[k])
@staticmethod
def _generate_model_id(examples: list[TrainingExample], method: str) -> str:
"""Generate a deterministic model ID from training data and method."""
content = f"{method}:{len(examples)}:{examples[0].features.event_time.isoformat() if examples else ''}"
return hashlib.sha256(content.encode()).hexdigest()[:12]
# ---------------------------------------------------------------------------
# Artifact registry
# ---------------------------------------------------------------------------
_REGISTERED_ARTIFACTS: dict[str, ImpactModelCard] = {}
def register_model_artifact(card: ImpactModelCard) -> str:
"""Register a trained model artifact for tracking.
Returns the model_id for retrieval.
"""
_REGISTERED_ARTIFACTS[card.model_id] = card
logger.info(
"Registered impact model artifact: %s (method=%s, samples=%d)",
card.model_id,
card.method,
card.total_training_samples,
)
return card.model_id
def get_model_artifact(model_id: str) -> ImpactModelCard | None:
"""Retrieve a registered model artifact by ID."""
return _REGISTERED_ARTIFACTS.get(model_id)
def get_approved_model() -> ImpactModelCard | None:
"""Get the currently approved production model, if any."""
for card in _REGISTERED_ARTIFACTS.values():
if card.approved:
return card
return None
def clear_artifact_registry() -> None:
"""Clear all registered artifacts (for testing only)."""
_REGISTERED_ARTIFACTS.clear()
@@ -0,0 +1,37 @@
"""Retrieval-based novelty and duplicate detection.
Replaces model-generated novelty with deterministic fingerprinting,
semantic embeddings, and similarity-based scoring against a recent
history window.
"""
from services.intelligence_pipeline_v3.novelty.embeddings import (
EmbeddingBackend,
MockEmbeddingBackend,
SentenceTransformerBackend,
cosine_similarity,
)
from services.intelligence_pipeline_v3.novelty.fingerprints import (
compute_exact_fingerprint,
compute_simhash,
hamming_distance,
is_near_duplicate,
)
from services.intelligence_pipeline_v3.novelty.index import Match, NoveltyIndex
from services.intelligence_pipeline_v3.novelty.models import NoveltyResult
from services.intelligence_pipeline_v3.novelty.scorer import NoveltyScorer
__all__ = [
"EmbeddingBackend",
"Match",
"MockEmbeddingBackend",
"NoveltyIndex",
"NoveltyResult",
"NoveltyScorer",
"SentenceTransformerBackend",
"compute_exact_fingerprint",
"compute_simhash",
"cosine_similarity",
"hamming_distance",
"is_near_duplicate",
]
@@ -0,0 +1,165 @@
"""Replaceable compact embedding backend for novelty retrieval.
Provides:
- EmbeddingBackend protocol for pluggable embedding models
- MockEmbeddingBackend for deterministic testing
- SentenceTransformerBackend stub for production (all-MiniLM-L6-v2)
- cosine_similarity utility function
"""
from __future__ import annotations
import hashlib
import math
import struct
from typing import Protocol, runtime_checkable
@runtime_checkable
class EmbeddingBackend(Protocol):
"""Protocol for embedding backends.
Implementations must produce fixed-dimension vectors for a batch of texts.
The backend is designed to be replaceable: swap between mock, local model,
and remote API backends without changing scoring logic.
"""
@property
def dimension(self) -> int:
"""Return the embedding dimension produced by this backend."""
...
def embed(self, texts: list[str]) -> list[list[float]]:
"""Embed a batch of texts into dense vectors.
Args:
texts: List of text strings to embed.
Returns:
List of embedding vectors, one per input text.
Each vector has length == self.dimension.
"""
...
class MockEmbeddingBackend:
"""Deterministic hash-based embedding backend for testing.
Produces consistent embeddings using text hashing. Useful for
unit tests and integration tests that need repeatable results
without loading a real model.
"""
def __init__(self, dimension: int = 384) -> None:
self._dimension = dimension
@property
def dimension(self) -> int:
return self._dimension
def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate deterministic embeddings from text hashes.
Uses SHA-256 expanded to fill the dimension. Normalizes to unit length
for compatibility with cosine similarity.
"""
results = []
for text in texts:
raw = self._hash_to_vector(text)
norm = math.sqrt(sum(x * x for x in raw))
if norm > 0:
normalized = [x / norm for x in raw]
else:
normalized = raw
results.append(normalized)
return results
def _hash_to_vector(self, text: str) -> list[float]:
"""Expand text hash into a vector of the target dimension."""
vector = []
# Generate enough hash bytes to fill dimension
chunk_idx = 0
while len(vector) < self._dimension:
data = f"{text}:{chunk_idx}".encode("utf-8")
digest = hashlib.sha256(data).digest()
# Convert 32 bytes to 8 floats (4 bytes each)
for i in range(0, 32, 4):
if len(vector) >= self._dimension:
break
# Unpack as float in [-1, 1] range
raw_int = struct.unpack("<I", digest[i : i + 4])[0]
value = (raw_int / (2**32 - 1)) * 2.0 - 1.0
vector.append(value)
chunk_idx += 1
return vector[: self._dimension]
class SentenceTransformerBackend:
"""Production embedding backend using sentence-transformers.
Wraps all-MiniLM-L6-v2 (384-dimensional) for compact, fast embeddings.
The model loads lazily on first call to avoid startup cost when not needed.
Note: Requires `sentence-transformers` package to be installed.
This is a stub—actual model loading is deferred to production deployment.
"""
MODEL_NAME = "all-MiniLM-L6-v2"
def __init__(self) -> None:
self._model = None
self._dimension = 384
@property
def dimension(self) -> int:
return self._dimension
def embed(self, texts: list[str]) -> list[list[float]]:
"""Embed texts using the sentence-transformers model.
Lazily loads the model on first invocation.
Raises:
ImportError: If sentence-transformers is not installed.
"""
if self._model is None:
self._load_model()
embeddings = self._model.encode(texts, normalize_embeddings=True)
return [emb.tolist() for emb in embeddings]
def _load_model(self) -> None:
"""Load the sentence-transformers model."""
try:
from sentence_transformers import SentenceTransformer
except ImportError as e:
raise ImportError(
"sentence-transformers package is required for SentenceTransformerBackend. "
"Install with: pip install sentence-transformers"
) from e
self._model = SentenceTransformer(self.MODEL_NAME)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Compute cosine similarity between two embedding vectors.
Args:
a: First embedding vector.
b: Second embedding vector (must be same dimension as a).
Returns:
Cosine similarity in range [-1, 1]. Returns 0.0 for zero vectors.
Raises:
ValueError: If vectors have different dimensions.
"""
if len(a) != len(b):
raise ValueError(f"Vectors must have same dimension: {len(a)} != {len(b)}")
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
@@ -0,0 +1,119 @@
"""Exact and near-duplicate fingerprinting for document deduplication.
Provides:
- SHA-256 exact fingerprint on normalized text
- SimHash for near-duplicate detection with configurable threshold
- Hamming distance comparison between SimHash values
"""
from __future__ import annotations
import hashlib
import re
import struct
def _normalize_text(text: str) -> str:
"""Normalize text for fingerprinting.
Lowercases, collapses whitespace, strips leading/trailing space.
This ensures minor formatting differences don't defeat deduplication.
"""
text = text.lower()
text = re.sub(r"\s+", " ", text)
return text.strip()
def compute_exact_fingerprint(text: str) -> str:
"""Compute SHA-256 fingerprint of normalized text.
Args:
text: Raw document text.
Returns:
Hex-encoded SHA-256 digest of normalized text.
"""
normalized = _normalize_text(text)
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def _tokenize(text: str) -> list[str]:
"""Split normalized text into tokens for SimHash computation."""
normalized = _normalize_text(text)
return normalized.split()
def _hash_token(token: str) -> int:
"""Hash a single token to a 64-bit integer using MD5 truncation."""
digest = hashlib.md5(token.encode("utf-8")).digest() # noqa: S324
return struct.unpack("<Q", digest[:8])[0]
def compute_simhash(text: str) -> int:
"""Compute 64-bit SimHash of text for near-duplicate detection.
SimHash produces locality-sensitive fingerprints: similar documents
will have SimHash values with low Hamming distance.
Args:
text: Raw document text.
Returns:
64-bit SimHash integer value.
"""
tokens = _tokenize(text)
if not tokens:
return 0
# Accumulator for each bit position
v = [0] * 64
for token in tokens:
token_hash = _hash_token(token)
for i in range(64):
if token_hash & (1 << i):
v[i] += 1
else:
v[i] -= 1
# Build final hash from accumulator signs
fingerprint = 0
for i in range(64):
if v[i] > 0:
fingerprint |= 1 << i
return fingerprint
def hamming_distance(a: int, b: int) -> int:
"""Compute Hamming distance between two 64-bit SimHash values.
Args:
a: First SimHash value.
b: Second SimHash value.
Returns:
Number of differing bits (0-64).
"""
xor = a ^ b
# Count set bits (Brian Kernighan's algorithm)
distance = 0
while xor:
xor &= xor - 1
distance += 1
return distance
def is_near_duplicate(fp1: int, fp2: int, threshold: int = 3) -> bool:
"""Determine if two SimHash fingerprints indicate near-duplicate content.
Args:
fp1: First SimHash value.
fp2: Second SimHash value.
threshold: Maximum Hamming distance to consider near-duplicate.
Default of 3 is conservative for 64-bit SimHash.
Returns:
True if the documents are near-duplicates.
"""
return hamming_distance(fp1, fp2) <= threshold
@@ -0,0 +1,129 @@
"""In-memory vector index for novelty retrieval.
Provides a simple but effective nearest-neighbor search over document
and company-event embeddings. Designed to be replaceable with a
production vector database (e.g., pgvector, FAISS) without changing
the scoring interface.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from services.intelligence_pipeline_v3.novelty.embeddings import cosine_similarity
@dataclass
class Match:
"""A nearest-neighbor match from the index."""
doc_id: str
similarity_score: float
metadata: dict = field(default_factory=dict)
@dataclass
class _IndexEntry:
"""Internal storage for an indexed document."""
doc_id: str
embedding: list[float]
metadata: dict = field(default_factory=dict)
class NoveltyIndex:
"""In-memory vector index for document and event embeddings.
Supports adding embeddings and searching for nearest neighbors
by cosine similarity. Thread-safe for read-after-write but not
for concurrent writes (use external locking if needed).
For production, replace with pgvector or FAISS. This implementation
is suitable for testing, small corpora, and development.
"""
def __init__(self) -> None:
self._entries: list[_IndexEntry] = []
self._id_set: set[str] = set()
def __len__(self) -> int:
return len(self._entries)
def add(self, doc_id: str, embedding: list[float], metadata: dict | None = None) -> None:
"""Add a document embedding to the index.
Args:
doc_id: Unique document or event identifier.
embedding: Dense embedding vector.
metadata: Optional metadata (e.g., record_type, timestamp).
Note:
If doc_id already exists, it is updated in-place.
"""
if metadata is None:
metadata = {}
if doc_id in self._id_set:
# Update existing entry
for entry in self._entries:
if entry.doc_id == doc_id:
entry.embedding = embedding
entry.metadata = metadata
break
else:
self._entries.append(_IndexEntry(doc_id=doc_id, embedding=embedding, metadata=metadata))
self._id_set.add(doc_id)
def search(self, embedding: list[float], k: int = 5) -> list[Match]:
"""Find the k nearest neighbors to the query embedding.
Args:
embedding: Query embedding vector.
k: Maximum number of results to return.
Returns:
List of Match objects sorted by similarity descending.
Similarity scores are clamped to [0, 1] (negative cosine
similarities are treated as 0 for novelty purposes).
"""
if not self._entries:
return []
scored: list[tuple[float, _IndexEntry]] = []
for entry in self._entries:
sim = cosine_similarity(embedding, entry.embedding)
# Clamp to [0, 1] for novelty scoring purposes
sim = max(0.0, min(1.0, sim))
scored.append((sim, entry))
# Sort descending by similarity
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for sim, entry in scored[:k]:
results.append(
Match(doc_id=entry.doc_id, similarity_score=sim, metadata=entry.metadata)
)
return results
def remove(self, doc_id: str) -> bool:
"""Remove a document from the index.
Args:
doc_id: Document identifier to remove.
Returns:
True if the document was found and removed.
"""
if doc_id not in self._id_set:
return False
self._entries = [e for e in self._entries if e.doc_id != doc_id]
self._id_set.discard(doc_id)
return True
def clear(self) -> None:
"""Remove all entries from the index."""
self._entries.clear()
self._id_set.clear()
@@ -0,0 +1,75 @@
"""Pydantic models for novelty and duplicate detection."""
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
class NearestMatch(BaseModel):
"""A single nearest-neighbor match from the novelty index."""
doc_id: str = Field(description="Document or event identifier of the match")
similarity_score: float = Field(
ge=0.0, le=1.0, description="Cosine similarity score (0=unrelated, 1=identical)"
)
metadata: dict = Field(default_factory=dict, description="Optional metadata about the match")
class NoveltyResult(BaseModel):
"""Result of novelty scoring for a document or event.
Novelty is scored from 0 (exact duplicate) to 1 (completely novel).
The formula version tracks which scoring algorithm produced the result.
"""
document_novelty: float = Field(
ge=0.0, le=1.0, description="Document-level novelty (0=duplicate, 1=novel)"
)
event_novelty: float = Field(
ge=0.0, le=1.0, description="Event-level novelty (0=duplicate event, 1=novel event)"
)
combined_novelty: float = Field(
ge=0.0, le=1.0, description="Combined novelty score used downstream"
)
nearest_matches: list[NearestMatch] = Field(
default_factory=list, description="Nearest matches for explainability"
)
formula_version: str = Field(description="Versioned identifier for the novelty formula used")
is_exact_duplicate: bool = Field(
default=False, description="Whether an exact content fingerprint match was found"
)
is_near_duplicate: bool = Field(
default=False, description="Whether a near-duplicate fingerprint match was found"
)
@field_validator("nearest_matches")
@classmethod
def matches_sorted_descending(cls, v: list[NearestMatch]) -> list[NearestMatch]:
"""Ensure nearest matches are sorted by similarity descending."""
return sorted(v, key=lambda m: m.similarity_score, reverse=True)
class FingerprintRecord(BaseModel):
"""Stored fingerprint for a document."""
doc_id: str = Field(description="Document identifier")
exact_fingerprint: str = Field(description="SHA-256 of normalized text")
simhash: int = Field(description="SimHash value for near-duplicate detection")
metadata: dict = Field(default_factory=dict, description="Additional metadata")
class EmbeddingRecord(BaseModel):
"""Stored embedding vector for a document or event."""
doc_id: str = Field(description="Document or event identifier")
embedding: list[float] = Field(description="Dense embedding vector")
record_type: str = Field(description="Type: 'document' or 'company_event'")
metadata: dict = Field(default_factory=dict, description="Additional metadata")
@field_validator("record_type")
@classmethod
def valid_record_type(cls, v: str) -> str:
valid = {"document", "company_event"}
if v not in valid:
raise ValueError(f"record_type must be one of {valid}, got '{v}'")
return v
@@ -0,0 +1,137 @@
"""Novelty scoring formula implementation.
Combines fingerprint-based duplicate detection with embedding-based
semantic novelty to produce a versioned, deterministic novelty score.
Formula v1: novelty = 1 - max_similarity (clamped to [0, 1])
"""
from __future__ import annotations
from services.intelligence_pipeline_v3.novelty.index import Match, NoveltyIndex
from services.intelligence_pipeline_v3.novelty.models import NearestMatch, NoveltyResult
# Current formula version — bump when the scoring algorithm changes
FORMULA_VERSION = "v1.0"
class NoveltyScorer:
"""Computes novelty scores from embedding similarity and fingerprints.
The scorer queries the novelty index for nearest neighbors and applies
a versioned formula to produce document-level, event-level, and combined
novelty scores.
Formula v1.0:
document_novelty = 1 - max_document_similarity
event_novelty = 1 - max_event_similarity
combined_novelty = min(document_novelty, event_novelty)
All values clamped to [0, 1].
"""
def __init__(self, k: int = 5, formula_version: str = FORMULA_VERSION) -> None:
"""Initialize the scorer.
Args:
k: Number of nearest neighbors to retrieve for scoring.
formula_version: Version string for the scoring formula.
"""
self.k = k
self.formula_version = formula_version
def compute_novelty(
self,
document_embedding: list[float],
event_embedding: list[float],
index: NoveltyIndex,
is_exact_duplicate: bool = False,
is_near_duplicate: bool = False,
) -> NoveltyResult:
"""Compute novelty for a document and its primary event.
Args:
document_embedding: Embedding of the document content.
event_embedding: Embedding of the canonical company-event.
index: NoveltyIndex containing recent document/event embeddings.
is_exact_duplicate: Whether an exact fingerprint match exists.
is_near_duplicate: Whether a near-duplicate fingerprint match exists.
Returns:
NoveltyResult with document, event, and combined novelty scores.
"""
# If exact duplicate, novelty is zero
if is_exact_duplicate:
doc_matches = index.search(document_embedding, k=self.k)
return NoveltyResult(
document_novelty=0.0,
event_novelty=0.0,
combined_novelty=0.0,
nearest_matches=self._to_nearest_matches(doc_matches),
formula_version=self.formula_version,
is_exact_duplicate=True,
is_near_duplicate=True,
)
# Search for similar documents
doc_matches = index.search(document_embedding, k=self.k)
event_matches = index.search(event_embedding, k=self.k)
# Compute novelty from max similarity
document_novelty = self._compute_novelty_from_matches(doc_matches)
event_novelty = self._compute_novelty_from_matches(event_matches)
# If near-duplicate detected via fingerprint, cap document novelty
if is_near_duplicate:
document_novelty = min(document_novelty, 0.2)
# Combined novelty: conservative (take the minimum)
combined_novelty = min(document_novelty, event_novelty)
# Merge matches for explainability, deduplicated by doc_id
all_matches = self._merge_matches(doc_matches, event_matches)
return NoveltyResult(
document_novelty=document_novelty,
event_novelty=event_novelty,
combined_novelty=combined_novelty,
nearest_matches=all_matches,
formula_version=self.formula_version,
is_exact_duplicate=is_exact_duplicate,
is_near_duplicate=is_near_duplicate,
)
def _compute_novelty_from_matches(self, matches: list[Match]) -> float:
"""Apply the v1 formula: novelty = 1 - max_similarity."""
if not matches:
return 1.0 # No history = fully novel
max_sim = max(m.similarity_score for m in matches)
novelty = 1.0 - max_sim
return max(0.0, min(1.0, novelty))
def _to_nearest_matches(self, matches: list[Match]) -> list[NearestMatch]:
"""Convert internal Match objects to NearestMatch models."""
return [
NearestMatch(
doc_id=m.doc_id,
similarity_score=m.similarity_score,
metadata=m.metadata,
)
for m in matches
]
def _merge_matches(
self, doc_matches: list[Match], event_matches: list[Match]
) -> list[NearestMatch]:
"""Merge document and event matches, keeping highest similarity per doc_id."""
seen: dict[str, NearestMatch] = {}
for m in doc_matches + event_matches:
if m.doc_id not in seen or m.similarity_score > seen[m.doc_id].similarity_score:
seen[m.doc_id] = NearestMatch(
doc_id=m.doc_id,
similarity_score=m.similarity_score,
metadata=m.metadata,
)
return sorted(seen.values(), key=lambda x: x.similarity_score, reverse=True)
@@ -0,0 +1,26 @@
"""NuExtract 1.5 Smol benchmark and adapter package.
Evaluates NuExtract 1.5 Smol as an optional long-form or hierarchical
fact-extraction stage. It is NOT an always-resident GPU model — deployment
is CPU/on-demand only.
Requirement: 6.6
"""
from services.intelligence_pipeline_v3.nuextract.adapter import NuExtractAdapter
from services.intelligence_pipeline_v3.nuextract.benchmark import NuExtractBenchmark
from services.intelligence_pipeline_v3.nuextract.models import (
IncrementalValueReport,
NuExtractResult,
PromotionGate,
)
from services.intelligence_pipeline_v3.nuextract.promotion import PromotionEvaluator
__all__ = [
"NuExtractAdapter",
"NuExtractBenchmark",
"NuExtractResult",
"IncrementalValueReport",
"PromotionGate",
"PromotionEvaluator",
]
@@ -0,0 +1,324 @@
"""NuExtract 1.5 Smol adapter for on-demand CPU extraction.
Provides an isolated interface for NuExtract inference:
- Production mode: loads numind/NuExtract-1.5-smol on CPU
- Test mode: deterministic schema-based mock extraction
This adapter uses the shared InferenceGateway with a configurable
target for CPU-only deployment. It is NOT always-resident — loaded
on demand for benchmark or promoted document classes only.
Requirement: 6.6
"""
from __future__ import annotations
import logging
import re
import time
from typing import Any
from services.intelligence_pipeline_v3.nuextract.models import (
ExtractedField,
NuExtractResult,
)
logger = logging.getLogger(__name__)
# Pinned model configuration
NUEXTRACT_MODEL_NAME = "numind/NuExtract-1.5-smol"
NUEXTRACT_MODEL_VERSION = "numind/NuExtract-1.5-smol@v1.5"
class NuExtractAdapter:
"""Adapter for NuExtract 1.5 Smol hierarchical extraction.
Designed for CPU/on-demand use, not always-resident GPU deployment.
Parameters
----------
test_mode
When True, uses deterministic schema-based extraction rather than
loading the model. Useful for testing without model dependencies.
max_length
Maximum input text length in characters. Longer texts are processed
in segments.
"""
def __init__(
self,
test_mode: bool = True,
max_length: int = 16_000,
) -> None:
self._test_mode = test_mode
self._max_length = max_length
self._model = None
self._tokenizer = None
self._model_name = NUEXTRACT_MODEL_NAME
self._model_version = NUEXTRACT_MODEL_VERSION
self._loaded = False
if not test_mode:
self._load_model()
@property
def model_version(self) -> str:
"""Return the pinned model version string."""
return self._model_version
@property
def model_name(self) -> str:
"""Return the model name."""
return self._model_name
@property
def is_loaded(self) -> bool:
"""Return whether the model is currently loaded."""
return self._loaded
def _load_model(self) -> None:
"""Load the NuExtract model and tokenizer for CPU inference."""
try:
from transformers import AutoModelForCausalLM, AutoTokenizer
logger.info("Loading NuExtract model (CPU): %s", self._model_name)
self._tokenizer = AutoTokenizer.from_pretrained(self._model_name)
self._model = AutoModelForCausalLM.from_pretrained(
self._model_name,
device_map="cpu",
torch_dtype="auto",
)
self._model.eval()
self._loaded = True
logger.info("NuExtract model loaded successfully on CPU")
except ImportError:
raise RuntimeError(
"transformers and torch are required for NuExtract inference. "
"Install with: pip install transformers torch"
)
except Exception as e:
raise RuntimeError(f"Failed to load NuExtract model: {e}") from e
def unload(self) -> None:
"""Unload model to free memory (on-demand lifecycle)."""
if self._model is not None:
del self._model
del self._tokenizer
self._model = None
self._tokenizer = None
self._loaded = False
logger.info("NuExtract model unloaded")
async def extract(
self,
text: str,
schema: dict[str, Any],
document_type: str = "",
) -> NuExtractResult:
"""Extract structured fields from text using the given schema.
Parameters
----------
text
Source document text.
schema
JSON schema defining the fields to extract.
document_type
Document type identifier for reporting.
Returns
-------
NuExtractResult
Extracted fields with confidence, latency, and memory metrics.
"""
start_time = time.perf_counter()
try:
if self._test_mode:
fields = self._extract_test_mode(text, schema)
else:
fields = self._extract_production(text, schema)
latency_ms = (time.perf_counter() - start_time) * 1000
memory_mb = self._estimate_memory()
# Compute overall confidence as mean of field confidences
confidence = 0.0
if fields:
confidence = sum(f.confidence for f in fields) / len(fields)
return NuExtractResult(
fields=fields,
spans=[
{"start": f.start_char, "end": f.end_char, "field": f.name}
for f in fields
if f.start_char is not None
],
confidence=confidence,
model_version=self._model_version,
latency_ms=latency_ms,
memory_mb=memory_mb,
document_type=document_type,
schema_used=schema,
)
except Exception as e:
latency_ms = (time.perf_counter() - start_time) * 1000
logger.error("NuExtract extraction failed: %s", e)
return NuExtractResult(
model_version=self._model_version,
latency_ms=latency_ms,
document_type=document_type,
schema_used=schema,
error=str(e),
)
def _extract_test_mode(
self, text: str, schema: dict[str, Any]
) -> list[ExtractedField]:
"""Deterministic extraction for testing.
Matches schema field names against text content using simple
pattern matching to simulate extraction behavior.
"""
fields: list[ExtractedField] = []
properties = schema.get("properties", schema)
for field_name, field_spec in properties.items():
# Simple pattern: look for the field name or related keywords in text
pattern = re.compile(
rf"\b{re.escape(field_name.replace('_', ' '))}[:\s]+([^\n.;]+)",
re.IGNORECASE,
)
match = pattern.search(text)
if match:
value = match.group(1).strip()
fields.append(
ExtractedField(
name=field_name,
value=value,
start_char=match.start(1),
end_char=match.end(1),
confidence=0.85,
)
)
else:
# Try extracting from nearby context for hierarchical schemas
if isinstance(field_spec, dict) and "properties" in field_spec:
# Nested schema — attempt hierarchical extraction
nested = self._extract_test_mode(text, field_spec)
if nested:
fields.append(
ExtractedField(
name=field_name,
value={f.name: f.value for f in nested},
confidence=sum(f.confidence for f in nested) / len(nested),
)
)
else:
# Field not found in text
fields.append(
ExtractedField(
name=field_name,
value=None,
confidence=0.0,
)
)
return fields
def _extract_production(
self, text: str, schema: dict[str, Any]
) -> list[ExtractedField]:
"""Run NuExtract inference on text using the loaded model."""
import json
import torch
if self._model is None or self._tokenizer is None:
raise RuntimeError("Model not loaded. Initialize with test_mode=False.")
# Format input in NuExtract's expected format
schema_str = json.dumps(schema, indent=2)
prompt = f"<|input|>\n### Template:\n{schema_str}\n### Text:\n{text[:self._max_length]}\n<|output|>\n"
inputs = self._tokenizer(
prompt,
return_tensors="pt",
truncation=True,
max_length=4096,
)
with torch.no_grad():
outputs = self._model.generate(
**inputs,
max_new_tokens=1024,
temperature=0.0,
do_sample=False,
)
# Decode and parse the output
generated = self._tokenizer.decode(
outputs[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True,
)
return self._parse_output(generated, text, schema)
def _parse_output(
self, output: str, source_text: str, schema: dict[str, Any]
) -> list[ExtractedField]:
"""Parse model output into structured fields with spans."""
import json
fields: list[ExtractedField] = []
try:
parsed = json.loads(output)
except json.JSONDecodeError:
logger.warning("Failed to parse NuExtract output as JSON")
return fields
properties = schema.get("properties", schema)
for field_name in properties:
if field_name in parsed:
value = parsed[field_name]
# Try to find the value in source text for span
start_char = None
end_char = None
if isinstance(value, str) and value:
idx = source_text.find(value)
if idx >= 0:
start_char = idx
end_char = idx + len(value)
fields.append(
ExtractedField(
name=field_name,
value=value,
start_char=start_char,
end_char=end_char,
confidence=0.8,
)
)
return fields
def _estimate_memory(self) -> float:
"""Estimate current memory usage in MB."""
if self._test_mode:
return 0.0
try:
import torch
if torch.cuda.is_available():
return torch.cuda.memory_allocated() / (1024 * 1024)
# For CPU, estimate from model parameters
if self._model is not None:
param_bytes = sum(
p.nelement() * p.element_size() for p in self._model.parameters()
)
return param_bytes / (1024 * 1024)
except Exception:
pass
return 0.0
@@ -0,0 +1,277 @@
"""NuExtract benchmark comparing against GLiNER2 + deterministic parsing.
Evaluates NuExtract 1.5 Smol on hierarchical extraction for long filings
and transcripts, measuring incremental correctness, CPU latency, and memory.
Reports per-document-type incremental value to determine which document
classes benefit from NuExtract supplementation.
Requirement: 6.6
"""
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Any
from services.intelligence_pipeline_v3.nuextract.adapter import NuExtractAdapter
from services.intelligence_pipeline_v3.nuextract.models import (
BenchmarkReport,
IncrementalValueReport,
NuExtractResult,
PromotionGate,
)
from services.intelligence_pipeline_v3.nuextract.promotion import PromotionEvaluator
logger = logging.getLogger(__name__)
class GoldDocument:
"""A document from the gold corpus with ground-truth labels."""
def __init__(
self,
text: str,
document_type: str,
gold_fields: dict[str, Any],
schema: dict[str, Any],
document_id: str = "",
) -> None:
self.text = text
self.document_type = document_type
self.gold_fields = gold_fields
self.schema = schema
self.document_id = document_id
class GLiNERResult:
"""Simulated result from GLiNER2 + deterministic parsing."""
def __init__(
self,
fields: dict[str, Any],
latency_ms: float = 0.0,
memory_mb: float = 0.0,
) -> None:
self.fields = fields
self.latency_ms = latency_ms
self.memory_mb = memory_mb
class NuExtractBenchmark:
"""Benchmark comparing NuExtract vs GLiNER2 + deterministic parsing.
Evaluates per-document-type to determine where NuExtract adds value.
Parameters
----------
adapter
NuExtractAdapter instance (test_mode or production).
gate
Promotion gate thresholds for deciding promotion.
"""
def __init__(
self,
adapter: NuExtractAdapter | None = None,
gate: PromotionGate | None = None,
) -> None:
self._adapter = adapter or NuExtractAdapter(test_mode=True)
self._gate = gate or PromotionGate()
self._evaluator = PromotionEvaluator(self._gate)
async def evaluate_against_gliner(
self,
documents: list[GoldDocument],
gliner_results: list[GLiNERResult],
) -> BenchmarkReport:
"""Run full benchmark comparing NuExtract vs GLiNER2 + deterministic parsing.
Parameters
----------
documents
Gold corpus documents with ground-truth labels.
gliner_results
Pre-computed GLiNER2 + deterministic parsing results for each document.
Returns
-------
BenchmarkReport
Full benchmark report with per-type results and promotion decisions.
"""
if len(documents) != len(gliner_results):
raise ValueError(
f"Document count ({len(documents)}) must match "
f"GLiNER result count ({len(gliner_results)})"
)
# Group by document type
by_type: dict[str, list[tuple[GoldDocument, GLiNERResult]]] = defaultdict(list)
for doc, gliner in zip(documents, gliner_results):
by_type[doc.document_type].append((doc, gliner))
# Evaluate each document type
reports: list[IncrementalValueReport] = []
for doc_type, pairs in by_type.items():
report = await self._evaluate_type(doc_type, pairs)
reports.append(report)
# Determine promotions
promoted_types: list[str] = []
for report in reports:
if self._evaluator.evaluate(report):
report.promoted = True
promoted_types.append(report.document_type)
# Compute overall metrics
total_docs = len(documents)
overall_nuextract_f1 = 0.0
overall_gliner_f1 = 0.0
if reports:
weighted_nu = sum(r.nuextract_f1 * r.sample_count for r in reports)
weighted_gl = sum(r.gliner_f1 * r.sample_count for r in reports)
overall_nuextract_f1 = weighted_nu / total_docs if total_docs > 0 else 0.0
overall_gliner_f1 = weighted_gl / total_docs if total_docs > 0 else 0.0
return BenchmarkReport(
reports=reports,
gate=self._gate,
promoted_types=promoted_types,
overall_nuextract_f1=overall_nuextract_f1,
overall_gliner_f1=overall_gliner_f1,
overall_delta=overall_nuextract_f1 - overall_gliner_f1,
total_documents=total_docs,
)
async def _evaluate_type(
self,
doc_type: str,
pairs: list[tuple[GoldDocument, GLiNERResult]],
) -> IncrementalValueReport:
"""Evaluate NuExtract vs GLiNER for a single document type."""
nuextract_scores: list[float] = []
gliner_scores: list[float] = []
nuextract_latencies: list[float] = []
nuextract_memories: list[float] = []
gliner_latencies: list[float] = []
gliner_memories: list[float] = []
for doc, gliner_result in pairs:
# Run NuExtract extraction
nu_result = await self._adapter.extract(
text=doc.text,
schema=doc.schema,
document_type=doc.document_type,
)
# Compute F1 for NuExtract
nu_f1 = self._compute_field_f1(nu_result, doc.gold_fields)
nuextract_scores.append(nu_f1)
nuextract_latencies.append(nu_result.latency_ms)
nuextract_memories.append(nu_result.memory_mb)
# Compute F1 for GLiNER
gl_f1 = self._compute_extraction_f1(gliner_result.fields, doc.gold_fields)
gliner_scores.append(gl_f1)
gliner_latencies.append(gliner_result.latency_ms)
gliner_memories.append(gliner_result.memory_mb)
# Aggregate metrics
n = len(pairs)
avg_nu_f1 = sum(nuextract_scores) / n if n > 0 else 0.0
avg_gl_f1 = sum(gliner_scores) / n if n > 0 else 0.0
p95_nu_latency = _percentile(nuextract_latencies, 95)
p95_gl_latency = _percentile(gliner_latencies, 95)
max_nu_memory = max(nuextract_memories) if nuextract_memories else 0.0
max_gl_memory = max(gliner_memories) if gliner_memories else 0.0
return IncrementalValueReport(
document_type=doc_type,
gliner_f1=avg_gl_f1,
nuextract_f1=avg_nu_f1,
delta=avg_nu_f1 - avg_gl_f1,
nuextract_latency_ms=p95_nu_latency,
nuextract_memory_mb=max_nu_memory,
gliner_latency_ms=p95_gl_latency,
gliner_memory_mb=max_gl_memory,
sample_count=n,
promoted=False,
)
def _compute_field_f1(
self, result: NuExtractResult, gold: dict[str, Any]
) -> float:
"""Compute F1 score for NuExtract result against gold labels."""
if not gold:
return 1.0 if not result.fields else 0.0
extracted_fields = {
f.name: f.value for f in result.fields if f.value is not None
}
return self._compute_extraction_f1(extracted_fields, gold)
def _compute_extraction_f1(
self, predicted: dict[str, Any], gold: dict[str, Any]
) -> float:
"""Compute field-level F1 between predicted and gold extractions."""
if not gold and not predicted:
return 1.0
if not gold or not predicted:
return 0.0
gold_set = set(gold.keys())
pred_set = set(predicted.keys())
# True positives: predicted fields that match gold (key present AND value matches)
tp = 0
for key in gold_set & pred_set:
if self._values_match(predicted[key], gold[key]):
tp += 1
precision = tp / len(pred_set) if pred_set else 0.0
recall = tp / len(gold_set) if gold_set else 0.0
if precision + recall == 0:
return 0.0
return 2 * precision * recall / (precision + recall)
def _values_match(self, predicted: Any, gold: Any) -> bool:
"""Check if a predicted value matches gold (with tolerance)."""
if predicted is None:
return gold is None
if gold is None:
return False
# String comparison (case-insensitive, trimmed)
if isinstance(gold, str) and isinstance(predicted, str):
return predicted.strip().lower() == gold.strip().lower()
# Numeric comparison with tolerance
if isinstance(gold, (int, float)) and isinstance(predicted, (int, float)):
if gold == 0:
return abs(predicted) < 1e-6
return abs(predicted - gold) / abs(gold) < 0.05
# Dict comparison (recursive for hierarchical)
if isinstance(gold, dict) and isinstance(predicted, dict):
if not gold:
return not predicted
matches = sum(
1
for k in gold
if k in predicted and self._values_match(predicted[k], gold[k])
)
return matches / len(gold) >= 0.5
# Fallback: equality
return predicted == gold
def _percentile(values: list[float], pct: int) -> float:
"""Compute a percentile from a list of values."""
if not values:
return 0.0
sorted_vals = sorted(values)
idx = int(len(sorted_vals) * pct / 100)
idx = min(idx, len(sorted_vals) - 1)
return sorted_vals[idx]
@@ -0,0 +1,104 @@
"""Pydantic models for NuExtract benchmark and evaluation.
Defines structured result types, incremental value reporting,
and promotion gate thresholds.
Requirement: 6.6
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class ExtractedField(BaseModel):
"""A single field extracted by NuExtract."""
name: str
value: Any
start_char: int | None = None
end_char: int | None = None
confidence: float = 0.0
class NuExtractResult(BaseModel):
"""Result from NuExtract 1.5 Smol extraction.
Contains extracted fields with spans, confidence scores,
model lineage, and latency tracking.
"""
fields: list[ExtractedField] = Field(default_factory=list)
spans: list[dict[str, Any]] = Field(default_factory=list)
confidence: float = 0.0
model_version: str = "numind/NuExtract-1.5-smol"
latency_ms: float = 0.0
memory_mb: float = 0.0
document_type: str = ""
schema_used: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
class IncrementalValueReport(BaseModel):
"""Report comparing NuExtract vs GLiNER2 + deterministic parsing per document type.
Tracks F1 scores for both approaches and computes the delta
to determine if NuExtract adds incremental value.
"""
document_type: Literal["filing", "transcript", "article", "press_release", "macro_event"]
gliner_f1: float = Field(ge=0.0, le=1.0)
nuextract_f1: float = Field(ge=0.0, le=1.0)
delta: float = Field(
description="nuextract_f1 - gliner_f1; positive means NuExtract is better"
)
nuextract_latency_ms: float = 0.0
nuextract_memory_mb: float = 0.0
gliner_latency_ms: float = 0.0
gliner_memory_mb: float = 0.0
sample_count: int = 0
promoted: bool = False
class PromotionGate(BaseModel):
"""Gate thresholds for promoting NuExtract for a document class.
NuExtract is only promoted for document classes where it beats
GLiNER2 + deterministic parsing by the configured minimums AND
stays within resource bounds.
"""
min_f1_improvement: float = Field(
default=0.05,
ge=0.0,
le=1.0,
description="Minimum F1 delta required for promotion",
)
max_latency_ms: float = Field(
default=5000.0,
gt=0.0,
description="Maximum acceptable p95 latency in milliseconds",
)
max_memory_mb: float = Field(
default=2048.0,
gt=0.0,
description="Maximum acceptable peak memory usage in MB",
)
min_sample_count: int = Field(
default=50,
ge=1,
description="Minimum sample count required for statistical confidence",
)
class BenchmarkReport(BaseModel):
"""Full benchmark report across all evaluated document types."""
reports: list[IncrementalValueReport] = Field(default_factory=list)
gate: PromotionGate = Field(default_factory=PromotionGate)
promoted_types: list[str] = Field(default_factory=list)
overall_nuextract_f1: float = 0.0
overall_gliner_f1: float = 0.0
overall_delta: float = 0.0
total_documents: int = 0
@@ -0,0 +1,116 @@
"""Promotion evaluator for NuExtract document-class decisions.
Determines whether NuExtract should be promoted for specific document
classes based on incremental value gates. NuExtract is only promoted
where it demonstrably beats GLiNER2 + deterministic parsing.
Requirement: 6.6
"""
from __future__ import annotations
import logging
from services.intelligence_pipeline_v3.nuextract.models import (
IncrementalValueReport,
PromotionGate,
)
logger = logging.getLogger(__name__)
class PromotionEvaluator:
"""Evaluates whether NuExtract should be promoted for a document class.
Uses the configured gate thresholds to make promotion decisions:
- F1 improvement must exceed minimum threshold
- Latency must stay within maximum bounds
- Memory must stay within maximum bounds
- Sample count must meet minimum for statistical confidence
Parameters
----------
gate
Promotion gate thresholds.
"""
def __init__(self, gate: PromotionGate | None = None) -> None:
self._gate = gate or PromotionGate()
@property
def gate(self) -> PromotionGate:
"""Return the current promotion gate configuration."""
return self._gate
def evaluate(self, report: IncrementalValueReport) -> bool:
"""Evaluate whether NuExtract should be promoted for this document type.
Parameters
----------
report
Incremental value report for a specific document type.
Returns
-------
bool
True if NuExtract passes all gate thresholds.
"""
reasons = self.get_rejection_reasons(report)
promoted = len(reasons) == 0
if promoted:
logger.info(
"NuExtract PROMOTED for %s: delta=%.4f, latency=%.1fms, memory=%.1fMB",
report.document_type,
report.delta,
report.nuextract_latency_ms,
report.nuextract_memory_mb,
)
else:
logger.info(
"NuExtract NOT promoted for %s: %s",
report.document_type,
"; ".join(reasons),
)
return promoted
def get_rejection_reasons(self, report: IncrementalValueReport) -> list[str]:
"""Return list of reasons why promotion would be rejected.
Parameters
----------
report
Incremental value report for a specific document type.
Returns
-------
list[str]
Empty list if promotion passes; otherwise reasons for rejection.
"""
reasons: list[str] = []
# Check minimum sample count
if report.sample_count < self._gate.min_sample_count:
reasons.append(
f"Insufficient samples: {report.sample_count} < {self._gate.min_sample_count}"
)
# Check F1 improvement
if report.delta < self._gate.min_f1_improvement:
reasons.append(
f"F1 improvement too small: {report.delta:.4f} < {self._gate.min_f1_improvement:.4f}"
)
# Check latency
if report.nuextract_latency_ms > self._gate.max_latency_ms:
reasons.append(
f"Latency exceeds gate: {report.nuextract_latency_ms:.1f}ms > {self._gate.max_latency_ms:.1f}ms"
)
# Check memory
if report.nuextract_memory_mb > self._gate.max_memory_mb:
reasons.append(
f"Memory exceeds gate: {report.nuextract_memory_mb:.1f}MB > {self._gate.max_memory_mb:.1f}MB"
)
return reasons
@@ -0,0 +1,28 @@
"""Observability module — distributed tracing, stage metrics, dashboards, and alerts.
Provides unified tracing across pipeline stages, metric collection for
latency/errors/batch-size/queue-depth/routing, and alert definitions
for operational monitoring.
"""
from services.intelligence_pipeline_v3.observability.metrics import (
AlertSeverity,
MetricAlert,
MetricsCollector,
StageMetrics,
)
from services.intelligence_pipeline_v3.observability.tracing import (
PipelineTrace,
StageSpan,
TraceCollector,
)
__all__ = [
"AlertSeverity",
"MetricAlert",
"MetricsCollector",
"PipelineTrace",
"StageMetrics",
"StageSpan",
"TraceCollector",
]
@@ -0,0 +1,291 @@
"""Stage metrics, dashboards, and alert definitions for the v3 pipeline.
Tracks latency, errors, batch size, queue depth, route metrics, field
accuracy, evidence coverage, calibration, fast-path rate, adjudication
reasons, GPU memory, GPU utilization, and GPU-seconds per document.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
class AlertSeverity(str, enum.Enum):
"""Alert severity levels."""
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
class MetricType(str, enum.Enum):
"""Types of collected metrics."""
COUNTER = "counter"
GAUGE = "gauge"
HISTOGRAM = "histogram"
SUMMARY = "summary"
@dataclass(frozen=True)
class MetricAlert:
"""Alert definition for pipeline metrics."""
name: str
metric_name: str
condition: str # e.g., "> 0.05", "< 0.6"
severity: AlertSeverity
description: str
threshold: float
window_seconds: int = 300
def evaluate(self, current_value: float) -> bool:
"""Check if the alert condition is triggered.
Returns True if the alert should fire.
"""
if self.condition.startswith(">"):
return current_value > self.threshold
elif self.condition.startswith("<"):
return current_value < self.threshold
elif self.condition.startswith(">="):
return current_value >= self.threshold
elif self.condition.startswith("<="):
return current_value <= self.threshold
return False
@dataclass
class StageMetrics:
"""Metrics for a single pipeline stage."""
stage_name: str
total_invocations: int = 0
total_errors: int = 0
total_latency_ms: float = 0.0
max_latency_ms: float = 0.0
min_latency_ms: float = float("inf")
total_tokens_in: int = 0
total_tokens_out: int = 0
total_batch_items: int = 0
total_batches: int = 0
gpu_seconds: float = 0.0
gpu_memory_peak_mb: float = 0.0
gpu_utilization_avg: float = 0.0
def record_invocation(
self,
latency_ms: float,
tokens_in: int = 0,
tokens_out: int = 0,
error: bool = False,
batch_size: int = 1,
gpu_seconds: float = 0.0,
gpu_memory_mb: float = 0.0,
gpu_utilization: float = 0.0,
) -> None:
"""Record a single stage invocation."""
self.total_invocations += 1
self.total_latency_ms += latency_ms
self.max_latency_ms = max(self.max_latency_ms, latency_ms)
self.min_latency_ms = min(self.min_latency_ms, latency_ms)
self.total_tokens_in += tokens_in
self.total_tokens_out += tokens_out
self.total_batch_items += batch_size
self.total_batches += 1
self.gpu_seconds += gpu_seconds
self.gpu_memory_peak_mb = max(self.gpu_memory_peak_mb, gpu_memory_mb)
if error:
self.total_errors += 1
# Running average for GPU utilization
if gpu_utilization > 0:
n = self.total_invocations
self.gpu_utilization_avg = (
self.gpu_utilization_avg * (n - 1) + gpu_utilization
) / n
@property
def avg_latency_ms(self) -> float:
if self.total_invocations == 0:
return 0.0
return self.total_latency_ms / self.total_invocations
@property
def error_rate(self) -> float:
if self.total_invocations == 0:
return 0.0
return self.total_errors / self.total_invocations
@property
def avg_batch_size(self) -> float:
if self.total_batches == 0:
return 0.0
return self.total_batch_items / self.total_batches
@property
def avg_tokens_per_doc(self) -> float:
if self.total_invocations == 0:
return 0.0
return (self.total_tokens_in + self.total_tokens_out) / self.total_invocations
@property
def gpu_seconds_per_doc(self) -> float:
if self.total_invocations == 0:
return 0.0
return self.gpu_seconds / self.total_invocations
def to_dict(self) -> dict[str, Any]:
return {
"stage_name": self.stage_name,
"total_invocations": self.total_invocations,
"total_errors": self.total_errors,
"error_rate": self.error_rate,
"avg_latency_ms": self.avg_latency_ms,
"max_latency_ms": self.max_latency_ms,
"avg_batch_size": self.avg_batch_size,
"gpu_seconds_per_doc": self.gpu_seconds_per_doc,
"gpu_memory_peak_mb": self.gpu_memory_peak_mb,
}
# Default alert definitions for the v3 pipeline
DEFAULT_ALERTS: list[MetricAlert] = [
MetricAlert(
name="schema_failure_rate_high",
metric_name="schema_failures",
condition="> 0.05",
severity=AlertSeverity.CRITICAL,
description="Schema validation failure rate exceeds 5%",
threshold=0.05,
),
MetricAlert(
name="unsupported_claims_high",
metric_name="unsupported_claim_rate",
condition="> 0.10",
severity=AlertSeverity.WARNING,
description="Unsupported claim rate exceeds 10%",
threshold=0.10,
),
MetricAlert(
name="calibration_drift",
metric_name="calibration_ece",
condition="> 0.08",
severity=AlertSeverity.WARNING,
description="Calibration ECE exceeds 8%",
threshold=0.08,
),
MetricAlert(
name="queue_saturation",
metric_name="queue_saturation_ratio",
condition="> 0.90",
severity=AlertSeverity.CRITICAL,
description="Queue saturation exceeds 90%",
threshold=0.90,
),
MetricAlert(
name="provider_probe_failure",
metric_name="probe_failure_rate",
condition="> 0.0",
severity=AlertSeverity.CRITICAL,
description="Provider capability probe failed",
threshold=0.0,
),
MetricAlert(
name="gpu_memory_high",
metric_name="gpu_memory_utilization",
condition="> 0.85",
severity=AlertSeverity.WARNING,
description="GPU memory utilization exceeds 85%",
threshold=0.85,
),
]
@dataclass
class MetricsCollector:
"""Collects and aggregates metrics across pipeline stages.
In production, this would export to Prometheus/Grafana.
This implementation provides the collection logic for testing.
"""
_stages: dict[str, StageMetrics] = field(default_factory=dict)
_alerts: list[MetricAlert] = field(default_factory=list)
_counters: dict[str, float] = field(default_factory=dict)
_fired_alerts: list[tuple[MetricAlert, float, datetime]] = field(
default_factory=list
)
def __post_init__(self) -> None:
if not self._alerts:
self._alerts = list(DEFAULT_ALERTS)
def get_stage(self, stage_name: str) -> StageMetrics:
"""Get or create metrics for a stage."""
if stage_name not in self._stages:
self._stages[stage_name] = StageMetrics(stage_name=stage_name)
return self._stages[stage_name]
def record_stage(
self,
stage_name: str,
latency_ms: float,
tokens_in: int = 0,
tokens_out: int = 0,
error: bool = False,
batch_size: int = 1,
gpu_seconds: float = 0.0,
gpu_memory_mb: float = 0.0,
gpu_utilization: float = 0.0,
) -> None:
"""Record a stage invocation."""
stage = self.get_stage(stage_name)
stage.record_invocation(
latency_ms=latency_ms,
tokens_in=tokens_in,
tokens_out=tokens_out,
error=error,
batch_size=batch_size,
gpu_seconds=gpu_seconds,
gpu_memory_mb=gpu_memory_mb,
gpu_utilization=gpu_utilization,
)
def increment_counter(self, name: str, value: float = 1.0) -> None:
"""Increment a named counter."""
self._counters[name] = self._counters.get(name, 0.0) + value
def get_counter(self, name: str) -> float:
"""Get current counter value."""
return self._counters.get(name, 0.0)
def check_alerts(self) -> list[tuple[MetricAlert, float]]:
"""Evaluate all alert conditions. Returns (alert, value) for fired alerts."""
fired: list[tuple[MetricAlert, float]] = []
for alert in self._alerts:
value = self._counters.get(alert.metric_name, 0.0)
if alert.evaluate(value):
fired.append((alert, value))
self._fired_alerts.append(
(alert, value, datetime.now(timezone.utc))
)
return fired
@property
def stage_names(self) -> list[str]:
return list(self._stages.keys())
def summary(self) -> dict[str, Any]:
"""Generate a metrics summary for dashboard display."""
return {
"stages": {
name: stage.to_dict() for name, stage in self._stages.items()
},
"counters": dict(self._counters),
"fired_alerts": len(self._fired_alerts),
}
@@ -0,0 +1,180 @@
"""Distributed tracing for the v3 intelligence pipeline.
Every document gets one trace ID that covers preprocessing, specialist
stages, routing, adjudication, impact prediction, and persistence.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class SpanStatus(str, enum.Enum):
"""Status of a trace span."""
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class StageSpan:
"""A single stage span within a pipeline trace."""
span_id: UUID
trace_id: UUID
stage_name: str
parent_span_id: UUID | None
started_at: datetime
ended_at: datetime | None = None
status: SpanStatus = SpanStatus.RUNNING
duration_ms: float = 0.0
attributes: dict[str, Any] = field(default_factory=dict)
error_message: str | None = None
def finish(
self,
status: SpanStatus = SpanStatus.SUCCEEDED,
error_message: str | None = None,
) -> None:
"""Mark the span as complete."""
self.ended_at = datetime.now(timezone.utc)
self.status = status
self.error_message = error_message
if self.started_at and self.ended_at:
self.duration_ms = (
self.ended_at - self.started_at
).total_seconds() * 1000
def set_attribute(self, key: str, value: Any) -> None:
"""Add a span attribute."""
self.attributes[key] = value
@dataclass
class PipelineTrace:
"""Complete distributed trace for one document through the pipeline."""
trace_id: UUID
document_id: str
run_id: UUID
started_at: datetime
ended_at: datetime | None = None
spans: list[StageSpan] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def create(cls, document_id: str, run_id: UUID) -> PipelineTrace:
return cls(
trace_id=uuid4(),
document_id=document_id,
run_id=run_id,
started_at=datetime.now(timezone.utc),
)
def start_span(
self,
stage_name: str,
parent_span_id: UUID | None = None,
attributes: dict[str, Any] | None = None,
) -> StageSpan:
"""Start a new span for a pipeline stage."""
span = StageSpan(
span_id=uuid4(),
trace_id=self.trace_id,
stage_name=stage_name,
parent_span_id=parent_span_id,
started_at=datetime.now(timezone.utc),
attributes=attributes or {},
)
self.spans.append(span)
return span
def finish(self) -> None:
"""Mark the trace as complete."""
self.ended_at = datetime.now(timezone.utc)
@property
def total_duration_ms(self) -> float:
if self.started_at and self.ended_at:
return (self.ended_at - self.started_at).total_seconds() * 1000
return 0.0
@property
def failed_spans(self) -> list[StageSpan]:
return [s for s in self.spans if s.status == SpanStatus.FAILED]
@property
def is_complete(self) -> bool:
return self.ended_at is not None
def to_dict(self) -> dict[str, Any]:
"""Serialize trace for export/storage."""
return {
"trace_id": str(self.trace_id),
"document_id": self.document_id,
"run_id": str(self.run_id),
"started_at": self.started_at.isoformat(),
"ended_at": self.ended_at.isoformat() if self.ended_at else None,
"total_duration_ms": self.total_duration_ms,
"span_count": len(self.spans),
"failed_span_count": len(self.failed_spans),
"metadata": self.metadata,
"spans": [
{
"span_id": str(s.span_id),
"stage_name": s.stage_name,
"status": s.status.value,
"duration_ms": s.duration_ms,
"attributes": s.attributes,
"error_message": s.error_message,
}
for s in self.spans
],
}
@dataclass
class TraceCollector:
"""Collects and stores pipeline traces.
In production, this would export to an observability backend
(Jaeger, Tempo, etc.). This implementation provides the collection
logic for testing and local development.
"""
_traces: dict[UUID, PipelineTrace] = field(default_factory=dict)
max_stored: int = 10000
def start_trace(self, document_id: str, run_id: UUID) -> PipelineTrace:
"""Create and store a new trace."""
trace = PipelineTrace.create(document_id, run_id)
self._traces[trace.trace_id] = trace
# Evict oldest if over limit
if len(self._traces) > self.max_stored:
oldest_key = next(iter(self._traces))
del self._traces[oldest_key]
return trace
def get_trace(self, trace_id: UUID) -> PipelineTrace | None:
return self._traces.get(trace_id)
def get_by_document(self, document_id: str) -> list[PipelineTrace]:
return [
t for t in self._traces.values() if t.document_id == document_id
]
def get_by_run(self, run_id: UUID) -> PipelineTrace | None:
for t in self._traces.values():
if t.run_id == run_id:
return t
return None
@property
def trace_count(self) -> int:
return len(self._traces)
@@ -0,0 +1,42 @@
"""V3 Pipeline Orchestrator — state machines, queues, leases, and feature flags.
Coordinates the multi-stage intelligence pipeline with explicit state transitions,
idempotency keys, retry policies, dead-letter handling, and independent v2/v3
routing behind feature flags.
"""
from services.intelligence_pipeline_v3.orchestrator.feature_flags import (
FeatureFlags,
PipelineVersion,
)
from services.intelligence_pipeline_v3.orchestrator.leases import (
Lease,
LeaseExpiredError,
LeaseManager,
)
from services.intelligence_pipeline_v3.orchestrator.queues import (
QueueMessage,
QueueName,
QueueRouter,
)
from services.intelligence_pipeline_v3.orchestrator.state import (
PipelineState,
PipelineStateMachine,
StageState,
StateTransition,
)
__all__ = [
"FeatureFlags",
"Lease",
"LeaseExpiredError",
"LeaseManager",
"PipelineState",
"PipelineStateMachine",
"PipelineVersion",
"QueueMessage",
"QueueName",
"QueueRouter",
"StageState",
"StateTransition",
]
@@ -0,0 +1,117 @@
"""Feature flags for independent v2/v3 pipeline routing.
Supports per-agent, per-document-type, and percentage-based routing
between pipeline versions. Both versions can run simultaneously.
"""
from __future__ import annotations
import enum
import hashlib
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID
class PipelineVersion(str, enum.Enum):
"""Available pipeline versions."""
V2 = "v2"
V3 = "v3"
SHADOW = "shadow" # V3 runs alongside V2 but doesn't affect outputs
@dataclass
class FeatureFlags:
"""Pipeline version routing with per-agent, per-document-type,
and percentage-based controls.
Each flag can be overridden independently. The evaluation order:
1. Agent-specific override (if set)
2. Document-type override (if set)
3. Percentage-based routing (deterministic by document_id)
4. Default version
"""
default_version: PipelineVersion = PipelineVersion.V2
v3_enabled: bool = False
shadow_enabled: bool = False
v3_percentage: int = 0 # 0-100, percentage of documents routed to v3
agent_overrides: dict[str, PipelineVersion] = field(default_factory=dict)
document_type_overrides: dict[str, PipelineVersion] = field(
default_factory=dict
)
excluded_document_types: set[str] = field(default_factory=set)
def resolve(
self,
document_id: str,
agent_id: str | UUID | None = None,
document_type: str | None = None,
) -> PipelineVersion:
"""Determine which pipeline version handles a document.
Resolution is deterministic for the same inputs.
"""
if not self.v3_enabled and not self.shadow_enabled:
return PipelineVersion.V2
# Check excluded document types
if document_type and document_type in self.excluded_document_types:
return PipelineVersion.V2
# Agent-specific override
agent_key = str(agent_id) if agent_id else None
if agent_key and agent_key in self.agent_overrides:
return self.agent_overrides[agent_key]
# Document-type override
if document_type and document_type in self.document_type_overrides:
return self.document_type_overrides[document_type]
# Shadow mode: run both
if self.shadow_enabled:
return PipelineVersion.SHADOW
# Percentage-based routing (deterministic hash)
if self.v3_percentage > 0:
bucket = self._hash_to_bucket(document_id)
if bucket < self.v3_percentage:
return PipelineVersion.V3
return self.default_version
def _hash_to_bucket(self, document_id: str) -> int:
"""Deterministic hash to 0-99 bucket for percentage routing."""
h = hashlib.sha256(document_id.encode()).hexdigest()
return int(h[:8], 16) % 100
def is_v3_active(self) -> bool:
"""Whether v3 processing is active in any form."""
return self.v3_enabled or self.shadow_enabled or self.v3_percentage > 0
def set_agent_override(
self, agent_id: str | UUID, version: PipelineVersion
) -> None:
"""Set a per-agent pipeline version override."""
self.agent_overrides[str(agent_id)] = version
def clear_agent_override(self, agent_id: str | UUID) -> None:
"""Remove a per-agent override."""
self.agent_overrides.pop(str(agent_id), None)
def to_dict(self) -> dict[str, Any]:
"""Serialize flags for API/config responses."""
return {
"default_version": self.default_version.value,
"v3_enabled": self.v3_enabled,
"shadow_enabled": self.shadow_enabled,
"v3_percentage": self.v3_percentage,
"agent_overrides": {
k: v.value for k, v in self.agent_overrides.items()
},
"document_type_overrides": {
k: v.value for k, v in self.document_type_overrides.items()
},
"excluded_document_types": list(self.excluded_document_types),
}
@@ -0,0 +1,149 @@
"""Lease management for pipeline stage workers.
Leases ensure exactly-once processing semantics. A worker must acquire
a lease before processing a stage. Expired leases allow re-processing
by another worker.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
class LeaseExpiredError(Exception):
"""Raised when an operation is attempted on an expired lease."""
def __init__(self, lease_id: UUID, expired_at: datetime) -> None:
self.lease_id = lease_id
self.expired_at = expired_at
super().__init__(
f"Lease {lease_id} expired at {expired_at.isoformat()}"
)
@dataclass
class Lease:
"""A time-bounded processing lease for a pipeline stage."""
lease_id: UUID
run_id: UUID
stage: str
worker_id: str
acquired_at: datetime
expires_at: datetime
released: bool = False
renewed_count: int = 0
@property
def is_expired(self) -> bool:
"""Check if the lease has passed its expiry time."""
return datetime.now(timezone.utc) >= self.expires_at
@property
def is_active(self) -> bool:
"""Check if the lease is currently active."""
return not self.released and not self.is_expired
def renew(self, extension: timedelta) -> None:
"""Extend the lease expiry.
Raises LeaseExpiredError if already expired.
"""
if self.is_expired:
raise LeaseExpiredError(self.lease_id, self.expires_at)
if self.released:
raise LeaseExpiredError(self.lease_id, self.expires_at)
self.expires_at = datetime.now(timezone.utc) + extension
self.renewed_count += 1
def release(self) -> None:
"""Mark the lease as released (work completed or abandoned)."""
self.released = True
@dataclass
class LeaseManager:
"""Manages leases for pipeline stage workers.
In production, this would use Redis or database-backed distributed locks.
This implementation provides the lease lifecycle logic for testing.
"""
default_ttl: timedelta = field(default_factory=lambda: timedelta(seconds=120))
_active_leases: dict[tuple[UUID, str], Lease] = field(default_factory=dict)
_all_leases: list[Lease] = field(default_factory=list)
def acquire(
self,
run_id: UUID,
stage: str,
worker_id: str,
ttl: timedelta | None = None,
) -> Lease | None:
"""Attempt to acquire a lease for a (run_id, stage) pair.
Returns None if an active lease already exists for that pair.
Expired leases are cleaned up and allow re-acquisition.
"""
key = (run_id, stage)
existing = self._active_leases.get(key)
if existing is not None:
if existing.is_active:
return None # Already leased
# Expired — clean up
del self._active_leases[key]
lease = Lease(
lease_id=uuid4(),
run_id=run_id,
stage=stage,
worker_id=worker_id,
acquired_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + (ttl or self.default_ttl),
)
self._active_leases[key] = lease
self._all_leases.append(lease)
return lease
def release(self, lease: Lease) -> None:
"""Release a lease, making the slot available."""
lease.release()
key = (lease.run_id, lease.stage)
if key in self._active_leases and self._active_leases[key] is lease:
del self._active_leases[key]
def renew(self, lease: Lease, extension: timedelta | None = None) -> None:
"""Renew an active lease. Raises LeaseExpiredError if expired."""
lease.renew(extension or self.default_ttl)
def is_leased(self, run_id: UUID, stage: str) -> bool:
"""Check if a (run_id, stage) pair has an active lease."""
key = (run_id, stage)
existing = self._active_leases.get(key)
if existing is None:
return False
if not existing.is_active:
del self._active_leases[key]
return False
return True
def active_count(self) -> int:
"""Number of currently active leases."""
# Clean up expired
expired_keys = [
k for k, v in self._active_leases.items() if not v.is_active
]
for k in expired_keys:
del self._active_leases[k]
return len(self._active_leases)
def get_expired(self) -> list[Lease]:
"""Get all expired but unreleased leases (for recovery)."""
return [
lease
for lease in self._active_leases.values()
if lease.is_expired and not lease.released
]
@@ -0,0 +1,258 @@
"""Bounded application parallelism for the v3 pipeline.
Provides async worker pools, specialist micro-batching, adjudicator
semaphore with queue backpressure, and load-shedding rules that never
drop safety-critical documents silently.
"""
from __future__ import annotations
import asyncio
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Coroutine
class LoadSheddingAction(str, enum.Enum):
"""Actions when load shedding is triggered."""
QUEUE = "queue" # Re-queue for later processing
REJECT = "reject" # Reject with error (non-safety-critical only)
DEGRADE = "degrade" # Process with reduced quality (skip optional stages)
class DocumentPriority(str, enum.Enum):
"""Document priority classes for load shedding decisions."""
SAFETY_CRITICAL = "safety_critical" # Never silently dropped
HIGH = "high"
NORMAL = "normal"
LOW = "low"
@dataclass
class WorkerPoolConfig:
"""Configuration for an async worker pool."""
max_workers: int = 4
batch_size: int = 8
batch_timeout_ms: int = 100
queue_max_depth: int = 500
shed_threshold: float = 0.8 # Start shedding at 80% capacity
@dataclass
class WorkerStats:
"""Runtime statistics for a worker pool."""
active_workers: int = 0
queued_items: int = 0
processed_total: int = 0
shed_total: int = 0
errors_total: int = 0
avg_latency_ms: float = 0.0
last_activity: datetime | None = None
class AsyncWorkerPool:
"""Configurable async worker pool with bounded concurrency.
Replaces the single sequential extraction loop with concurrent
processing while respecting resource limits.
"""
def __init__(self, config: WorkerPoolConfig | None = None) -> None:
self.config = config or WorkerPoolConfig()
self._semaphore = asyncio.Semaphore(self.config.max_workers)
self._stats = WorkerStats()
self._running = False
self._tasks: set[asyncio.Task[Any]] = set()
@property
def stats(self) -> WorkerStats:
return self._stats
@property
def is_running(self) -> bool:
return self._running
@property
def available_slots(self) -> int:
"""Number of available worker slots."""
return max(0, self.config.max_workers - self._stats.active_workers)
def should_shed_load(self) -> bool:
"""Whether load shedding should be active."""
if self.config.queue_max_depth <= 0:
return False
ratio = self._stats.queued_items / self.config.queue_max_depth
return ratio >= self.config.shed_threshold
async def submit(
self,
coro_fn: Callable[..., Coroutine[Any, Any, Any]],
*args: Any,
document_id: str = "",
priority: DocumentPriority = DocumentPriority.NORMAL,
) -> LoadSheddingAction | None:
"""Submit work to the pool.
Returns None on successful submission, or a LoadSheddingAction
if load shedding was applied. Safety-critical documents are
never silently rejected.
"""
if self.should_shed_load():
if priority == DocumentPriority.SAFETY_CRITICAL:
# Safety-critical: always queue, never shed
pass
elif priority == DocumentPriority.LOW:
self._stats.shed_total += 1
return LoadSheddingAction.REJECT
else:
self._stats.shed_total += 1
return LoadSheddingAction.QUEUE
self._stats.queued_items += 1
task = asyncio.create_task(self._run_with_semaphore(coro_fn, *args))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return None
async def _run_with_semaphore(
self,
coro_fn: Callable[..., Coroutine[Any, Any, Any]],
*args: Any,
) -> Any:
"""Execute work bounded by the semaphore."""
async with self._semaphore:
self._stats.active_workers += 1
self._stats.queued_items = max(0, self._stats.queued_items - 1)
start = datetime.now(timezone.utc)
try:
result = await coro_fn(*args)
self._stats.processed_total += 1
return result
except Exception:
self._stats.errors_total += 1
raise
finally:
self._stats.active_workers -= 1
elapsed = (
datetime.now(timezone.utc) - start
).total_seconds() * 1000
# Rolling average
n = self._stats.processed_total + self._stats.errors_total
if n > 0:
self._stats.avg_latency_ms = (
self._stats.avg_latency_ms * (n - 1) + elapsed
) / n
self._stats.last_activity = datetime.now(timezone.utc)
async def start(self) -> None:
"""Mark the pool as running."""
self._running = True
async def shutdown(self, timeout: float = 30.0) -> None:
"""Wait for all active tasks to complete."""
self._running = False
if self._tasks:
await asyncio.wait(self._tasks, timeout=timeout)
class AdjudicatorSemaphore:
"""GPU-safe concurrency control for the 9B adjudicator.
Limits concurrent adjudication requests to match vLLM's max-num-seqs
setting. Provides queue-depth monitoring and backpressure signaling.
"""
def __init__(
self,
max_concurrent: int = 8,
max_queued: int = 32,
) -> None:
self.max_concurrent = max_concurrent
self.max_queued = max_queued
self._semaphore = asyncio.Semaphore(max_concurrent)
self._queued = 0
self._active = 0
self._total_processed = 0
@property
def active_count(self) -> int:
return self._active
@property
def queued_count(self) -> int:
return self._queued
@property
def is_backpressured(self) -> bool:
"""Whether the adjudicator queue is full."""
return self._queued >= self.max_queued
async def acquire(self) -> bool:
"""Acquire adjudicator access.
Returns False if backpressure prevents queuing.
"""
if self._queued >= self.max_queued:
return False
self._queued += 1
await self._semaphore.acquire()
self._queued -= 1
self._active += 1
return True
def release(self) -> None:
"""Release adjudicator slot."""
self._active -= 1
self._total_processed += 1
self._semaphore.release()
@property
def utilization(self) -> float:
"""Current GPU utilization fraction."""
return self._active / self.max_concurrent if self.max_concurrent > 0 else 0.0
@dataclass
class MicroBatcher:
"""Specialist micro-batching with configurable latency limits.
Accumulates items until batch_size is reached or timeout expires,
then processes the batch together for efficiency.
"""
batch_size: int = 16
timeout_ms: int = 50
_buffer: list[Any] = field(default_factory=list)
_batch_count: int = 0
def add(self, item: Any) -> list[Any] | None:
"""Add an item. Returns a full batch if ready, else None."""
self._buffer.append(item)
if len(self._buffer) >= self.batch_size:
return self.flush()
return None
def flush(self) -> list[Any]:
"""Force-flush the current buffer as a batch."""
batch = self._buffer[:]
self._buffer.clear()
if batch:
self._batch_count += 1
return batch
@property
def pending_count(self) -> int:
return len(self._buffer)
@property
def total_batches(self) -> int:
return self._batch_count
@property
def is_empty(self) -> bool:
return len(self._buffer) == 0
@@ -0,0 +1,133 @@
"""Queue definitions and routing for the v3 intelligence pipeline.
Provides fast-path, adjudication, persistence, and review queues with
backpressure and dead-letter support.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class QueueName(str, enum.Enum):
"""Named queues in the v3 pipeline topology."""
INCOMING = "intelligence.v3.incoming"
FAST_PATH = "intelligence.v3.fast"
ADJUDICATION = "intelligence.v3.adjudication"
PERSISTENCE = "intelligence.v3.persist"
REVIEW = "intelligence.v3.review"
DEAD_LETTER = "intelligence.v3.dead_letter"
@dataclass(frozen=True)
class QueueMessage:
"""Immutable message envelope for queue transport."""
message_id: UUID
queue: QueueName
run_id: UUID
document_id: str
payload: dict[str, Any]
enqueued_at: datetime
attempt: int = 0
idempotency_key: str = ""
priority: int = 0
@classmethod
def create(
cls,
queue: QueueName,
run_id: UUID,
document_id: str,
payload: dict[str, Any] | None = None,
priority: int = 0,
idempotency_key: str = "",
) -> QueueMessage:
return cls(
message_id=uuid4(),
queue=queue,
run_id=run_id,
document_id=document_id,
payload=payload or {},
enqueued_at=datetime.now(timezone.utc),
priority=priority,
idempotency_key=idempotency_key,
)
@dataclass
class QueueRouter:
"""In-memory queue router with backpressure and depth tracking.
In production, this would be backed by Redis lists or a dedicated
message broker. This implementation provides the queue routing logic
and depth-based backpressure for testing and single-process usage.
"""
max_depth: int = 1000
_queues: dict[QueueName, list[QueueMessage]] = field(default_factory=dict)
_processed_keys: set[str] = field(default_factory=set)
def __post_init__(self) -> None:
for q in QueueName:
if q not in self._queues:
self._queues[q] = []
def enqueue(self, message: QueueMessage) -> bool:
"""Add a message to its designated queue.
Returns False if backpressure is triggered (queue full) or
if the idempotency key was already processed.
"""
if message.idempotency_key and message.idempotency_key in self._processed_keys:
return False # Duplicate — idempotent reject
queue = self._queues.setdefault(message.queue, [])
if len(queue) >= self.max_depth:
return False # Backpressure
queue.append(message)
return True
def dequeue(self, queue: QueueName) -> QueueMessage | None:
"""Pop the next message from a queue (FIFO). Returns None if empty."""
q = self._queues.get(queue, [])
if not q:
return None
msg = q.pop(0)
if msg.idempotency_key:
self._processed_keys.add(msg.idempotency_key)
return msg
def depth(self, queue: QueueName) -> int:
"""Current depth of the given queue."""
return len(self._queues.get(queue, []))
def is_saturated(self, queue: QueueName) -> bool:
"""Whether the queue has reached max depth (backpressure active)."""
return self.depth(queue) >= self.max_depth
def move_to_dead_letter(self, message: QueueMessage) -> QueueMessage:
"""Move a failed message to the dead-letter queue."""
dlq_msg = QueueMessage(
message_id=uuid4(),
queue=QueueName.DEAD_LETTER,
run_id=message.run_id,
document_id=message.document_id,
payload={**message.payload, "original_queue": message.queue.value},
enqueued_at=datetime.now(timezone.utc),
attempt=message.attempt,
idempotency_key="", # DLQ messages get new identity
priority=message.priority,
)
self._queues.setdefault(QueueName.DEAD_LETTER, []).append(dlq_msg)
return dlq_msg
def total_depth(self) -> int:
"""Sum of all queue depths."""
return sum(len(q) for q in self._queues.values())
@@ -0,0 +1,187 @@
"""Pipeline and stage state machines with explicit transitions and idempotency."""
from __future__ import annotations
import enum
import hashlib
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
class PipelineState(str, enum.Enum):
"""Top-level pipeline run states."""
PENDING = "pending"
SEGMENTING = "segmenting"
EXTRACTING = "extracting"
RESOLVING = "resolving"
VERIFYING = "verifying"
ROUTING = "routing"
ADJUDICATING = "adjudicating"
IMPACT = "impact"
PERSISTING = "persisting"
COMPLETED = "completed"
FAILED = "failed"
DEAD_LETTER = "dead_letter"
class StageState(str, enum.Enum):
"""Per-stage execution states."""
QUEUED = "queued"
LEASED = "leased"
RUNNING = "running"
SUCCEEDED = "succeeded"
RETRYING = "retrying"
FAILED = "failed"
SKIPPED = "skipped"
# Valid transitions for the pipeline state machine
_PIPELINE_TRANSITIONS: dict[PipelineState, set[PipelineState]] = {
PipelineState.PENDING: {PipelineState.SEGMENTING, PipelineState.FAILED},
PipelineState.SEGMENTING: {PipelineState.EXTRACTING, PipelineState.FAILED},
PipelineState.EXTRACTING: {PipelineState.RESOLVING, PipelineState.FAILED},
PipelineState.RESOLVING: {PipelineState.VERIFYING, PipelineState.FAILED},
PipelineState.VERIFYING: {PipelineState.ROUTING, PipelineState.FAILED},
PipelineState.ROUTING: {
PipelineState.ADJUDICATING,
PipelineState.IMPACT,
PipelineState.FAILED,
},
PipelineState.ADJUDICATING: {PipelineState.IMPACT, PipelineState.FAILED},
PipelineState.IMPACT: {PipelineState.PERSISTING, PipelineState.FAILED},
PipelineState.PERSISTING: {PipelineState.COMPLETED, PipelineState.FAILED},
PipelineState.COMPLETED: set(),
PipelineState.FAILED: {PipelineState.DEAD_LETTER, PipelineState.PENDING},
PipelineState.DEAD_LETTER: set(),
}
# Valid transitions for stage states
_STAGE_TRANSITIONS: dict[StageState, set[StageState]] = {
StageState.QUEUED: {StageState.LEASED, StageState.SKIPPED},
StageState.LEASED: {StageState.RUNNING, StageState.QUEUED},
StageState.RUNNING: {StageState.SUCCEEDED, StageState.RETRYING, StageState.FAILED},
StageState.SUCCEEDED: set(),
StageState.RETRYING: {StageState.QUEUED, StageState.FAILED},
StageState.FAILED: set(),
StageState.SKIPPED: set(),
}
@dataclass(frozen=True)
class StateTransition:
"""Immutable record of a state transition."""
transition_id: UUID
run_id: UUID
from_state: PipelineState | StageState
to_state: PipelineState | StageState
timestamp: datetime
reason: str
idempotency_key: str
def _compute_idempotency_key(
document_id: str, stage: str, attempt: int
) -> str:
"""Deterministic idempotency key from document, stage, and attempt."""
raw = f"{document_id}:{stage}:{attempt}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
@dataclass
class PipelineStateMachine:
"""Manages state transitions for a single pipeline run.
Enforces valid transitions, records history, and generates
idempotency keys for each stage attempt.
"""
run_id: UUID = field(default_factory=uuid4)
document_id: str = ""
state: PipelineState = PipelineState.PENDING
stage_states: dict[str, StageState] = field(default_factory=dict)
stage_attempts: dict[str, int] = field(default_factory=dict)
history: list[StateTransition] = field(default_factory=list)
max_retries: int = 3
metadata: dict[str, Any] = field(default_factory=dict)
def transition_pipeline(
self, to_state: PipelineState, reason: str = ""
) -> StateTransition:
"""Advance the pipeline to a new state.
Raises ValueError if the transition is invalid.
"""
allowed = _PIPELINE_TRANSITIONS.get(self.state, set())
if to_state not in allowed:
raise ValueError(
f"Invalid pipeline transition: {self.state.value} -> {to_state.value}"
)
transition = StateTransition(
transition_id=uuid4(),
run_id=self.run_id,
from_state=self.state,
to_state=to_state,
timestamp=datetime.now(timezone.utc),
reason=reason,
idempotency_key=_compute_idempotency_key(
self.document_id, to_state.value, 0
),
)
self.state = to_state
self.history.append(transition)
return transition
def transition_stage(
self, stage: str, to_state: StageState, reason: str = ""
) -> StateTransition:
"""Advance a stage to a new state.
Raises ValueError if the transition is invalid.
"""
current = self.stage_states.get(stage, StageState.QUEUED)
allowed = _STAGE_TRANSITIONS.get(current, set())
if to_state not in allowed:
raise ValueError(
f"Invalid stage transition for '{stage}': "
f"{current.value} -> {to_state.value}"
)
attempt = self.stage_attempts.get(stage, 0)
if to_state == StageState.RETRYING:
attempt += 1
self.stage_attempts[stage] = attempt
transition = StateTransition(
transition_id=uuid4(),
run_id=self.run_id,
from_state=current,
to_state=to_state,
timestamp=datetime.now(timezone.utc),
reason=reason,
idempotency_key=_compute_idempotency_key(
self.document_id, stage, attempt
),
)
self.stage_states[stage] = to_state
self.history.append(transition)
return transition
def can_retry(self, stage: str) -> bool:
"""Check whether the stage has retries remaining."""
return self.stage_attempts.get(stage, 0) < self.max_retries
def should_dead_letter(self) -> bool:
"""Check if the pipeline run should move to dead letter."""
if self.state != PipelineState.FAILED:
return False
# Dead-letter if any stage exceeded max retries
for stage, attempts in self.stage_attempts.items():
if attempts >= self.max_retries:
return True
return False
@@ -0,0 +1,26 @@
"""Deterministic financial parsing for the v3 intelligence pipeline.
This package provides regex-based detection of financial entities:
- Ticker symbols ($AAPL, AAPL)
- Currencies and money amounts ($123.45, €99, $94.9 billion)
- Percentages (4%, -2.5%)
- Basis points (25 basis points, 25bps)
- Ranges ($10-$12)
- EPS values ($1.52 per share)
- Revenue figures
- Dates and fiscal periods (Q1 2024, FY2025)
Each match returns exact character offsets, literal text, and a normalized numeric value.
"""
from services.intelligence_pipeline_v3.parsing.financial_parser import FinancialParser
from services.intelligence_pipeline_v3.parsing.models import CandidateType, ParsedCandidate, PeriodAnnotation
from services.intelligence_pipeline_v3.parsing.normalizer import normalize_value
__all__ = [
"CandidateType",
"FinancialParser",
"ParsedCandidate",
"PeriodAnnotation",
"normalize_value",
]
@@ -0,0 +1,426 @@
"""Deterministic financial parser using regex-based detection.
Detects tickers, currencies, money amounts, percentages, basis points,
ranges, EPS, revenue, dates, and fiscal periods from source text.
Each match returns exact character offsets into the source text.
"""
from __future__ import annotations
import re
from services.intelligence_pipeline_v3.parsing.models import (
CandidateType,
ParsedCandidate,
PeriodAnnotation,
)
from services.intelligence_pipeline_v3.parsing.normalizer import (
normalize_basis_points,
normalize_money,
normalize_percentage,
normalize_range,
)
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
# Ticker: $AAPL or standalone AAPL-like (1-5 uppercase letters)
_TICKER_DOLLAR_RE = re.compile(r"\$([A-Z]{1,5})\b")
_TICKER_BARE_RE = re.compile(r"\b([A-Z]{1,5})\b")
# Common English words that look like tickers but aren't
_TICKER_STOPWORDS = frozenset({
"A", "I", "AM", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "IF",
"IN", "IS", "IT", "ME", "MY", "NO", "OF", "OK", "ON", "OR", "OUR", "SO",
"THE", "TO", "UP", "US", "WE", "CEO", "CFO", "COO", "CTO", "EPS", "ETF",
"GDP", "IPO", "LLC", "LTD", "NYSE", "SEC", "USA", "AND", "ARE", "BUT",
"CAN", "DID", "FOR", "GET", "GOT", "HAD", "HAS", "HER", "HIS", "HOW",
"ITS", "LET", "MAY", "NEW", "NOT", "NOW", "OLD", "OUR", "OWN", "PUT",
"RAN", "SAY", "SHE", "TOO", "TWO", "USE", "WAS", "WAY", "WHO", "WHY",
"WIN", "WON", "YET", "YOU", "ALL", "ANY", "BIG", "DAY", "END", "FEW",
"FAR", "HIT", "LOW", "MET", "NET", "OUT", "RUN", "SET", "TOP", "TRY",
"ALSO", "BACK", "BEEN", "BEST", "BOTH", "CAME", "COME", "DOWN", "EACH",
"FROM", "GAVE", "GOOD", "HAVE", "HERE", "HIGH", "INTO", "JUST", "KEEP",
"LAST", "LONG", "MADE", "MAKE", "MANY", "MORE", "MOST", "MUCH", "MUST",
"NEED", "NEXT", "ONLY", "OVER", "SAID", "SAME", "SOME", "SUCH", "TAKE",
"THAN", "THAT", "THEM", "THEN", "THEY", "THIS", "VERY", "WANT", "WELL",
"WENT", "WERE", "WHAT", "WHEN", "WILL", "WITH", "WORK", "YEAR", "YOUR",
"ITEM", "CASH", "FLOW", "FREE", "FULL", "HALF", "RISE", "ROSE", "FELL",
"BEAT", "MISS", "GREW", "GROW", "LOST", "LOSS", "GAIN", "HOLD", "SELL",
"CALL", "BUY", "FUND", "BOND", "RATE", "DEBT", "DEAL", "RISK",
"Q", "H", "FY", "YOY", "QOQ", "AI", "R", "D",
})
# Fiscal period: Q1 2024, Q4'24, FY2025, FY25, H1 2024
_FISCAL_PERIOD_RE = re.compile(
r"\b(Q[1-4]|H[12]|FY)\s*['\u2019]?\s*(\d{4}|\d{2})\b"
)
# Date patterns: January 15, 2024 / Jan 15, 2024 / 2024-01-15
_MONTH_NAMES = (
r"(?:January|February|March|April|May|June|July|August|September|October|November|December"
r"|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
)
_DATE_NAMED_RE = re.compile(
rf"\b({_MONTH_NAMES})\s+(\d{{1,2}})(?:,?\s+(\d{{4}}))?\b"
)
_DATE_ISO_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
# Basis points: "25 basis points", "50bps", "25 bps"
_BASIS_POINTS_RE = re.compile(
r"[+-]?\d[\d,]*\.?\d*\s*(?:basis\s+points?|bps)\b", re.IGNORECASE
)
# Percentage: 4%, -2.5%, +1.2 percent
_PERCENTAGE_RE = re.compile(
r"[+-]?\d[\d,]*\.?\d*\s*(?:%|percent(?:age)?(?:\s+points?)?\b)", re.IGNORECASE
)
# EPS: "$1.52 per share", "earnings per share of $1.52"
_EPS_PER_SHARE_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s+per\s+share\b", re.IGNORECASE
)
_EPS_PREFIX_RE = re.compile(
r"\b(?:EPS|earnings\s+per\s+share)\s+(?:of\s+)?\$\s*\d[\d,]*\.?\d*", re.IGNORECASE
)
# Revenue: "$94.9 billion in revenue", "revenue of $94.9 billion"
_REVENUE_AMOUNT_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|bn|mn)\s+(?:in\s+)?revenue\b",
re.IGNORECASE,
)
_REVENUE_PREFIX_RE = re.compile(
r"\brevenue\s+(?:of|was|reached|hit|grew\s+to|increased\s+to|totaled)\s+\$\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|bn|mn)?",
re.IGNORECASE,
)
# Range: "$10-$12", "$10 to $12", "$1.50-$2.00"
_RANGE_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s*(?:-|to||—)\s*\$?\s*\d[\d,]*\.?\d*",
re.IGNORECASE,
)
# Money with multiplier: "$94.9 billion", "$1.2 million", "€5 billion"
_MONEY_MULT_RE = re.compile(
r"[€£¥$]\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|thousand|bn|mn|tn|[kmbt])\b",
re.IGNORECASE,
)
# Simple currency: $123.45, €99, £1,234.56
# Note: requires digits after decimal point to avoid matching trailing periods
_CURRENCY_RE = re.compile(
r"[€£¥$]\s*\d[\d,]*(?:\.\d+)?"
)
# Currency codes: USD, EUR, GBP, JPY
_CURRENCY_SYMBOLS = {"$": "USD", "": "EUR", "£": "GBP", "¥": "JPY"}
def _detect_currency_unit(text: str) -> str:
"""Detect currency unit from symbol in text."""
for symbol, code in _CURRENCY_SYMBOLS.items():
if symbol in text:
return code
return "USD"
class FinancialParser:
"""Deterministic regex-based financial entity parser.
Detects financial entities in text and returns ParsedCandidate instances
with exact character offsets, literal text, and normalized values.
"""
def parse(self, text: str) -> list[ParsedCandidate]:
"""Parse text for financial entities.
Returns a list of ParsedCandidate sorted by start_char offset.
Overlapping matches are resolved by priority (more specific wins).
"""
if not text or not text.strip():
return []
candidates: list[ParsedCandidate] = []
# Order matters: more specific patterns first to claim offsets
candidates.extend(self._parse_fiscal_periods(text))
candidates.extend(self._parse_dates(text))
candidates.extend(self._parse_basis_points(text))
candidates.extend(self._parse_eps(text))
candidates.extend(self._parse_revenue(text))
candidates.extend(self._parse_ranges(text))
candidates.extend(self._parse_percentages(text))
candidates.extend(self._parse_money_with_multiplier(text))
candidates.extend(self._parse_tickers(text))
candidates.extend(self._parse_currency(text))
# Resolve overlaps: keep higher-priority (earlier in list) matches
candidates = self._resolve_overlaps(candidates)
# Sort by position
candidates.sort(key=lambda c: (c.start_char, -c.end_char))
return candidates
def _resolve_overlaps(self, candidates: list[ParsedCandidate]) -> list[ParsedCandidate]:
"""Remove overlapping candidates, keeping earlier ones (higher priority)."""
if not candidates:
return []
# Sort by start position for greedy non-overlap resolution
sorted_candidates = sorted(candidates, key=lambda c: (c.start_char, -c.end_char))
result: list[ParsedCandidate] = []
claimed: list[tuple[int, int]] = []
for cand in sorted_candidates:
overlaps = False
for start, end in claimed:
# Check if this candidate overlaps with any claimed range
if cand.start_char < end and cand.end_char > start:
overlaps = True
break
if not overlaps:
result.append(cand)
claimed.append((cand.start_char, cand.end_char))
return result
def _parse_tickers(self, text: str) -> list[ParsedCandidate]:
"""Parse ticker symbols: $AAPL style."""
results: list[ParsedCandidate] = []
# Dollar-prefixed tickers: $AAPL
for match in _TICKER_DOLLAR_RE.finditer(text):
ticker = match.group(1)
if ticker not in _TICKER_STOPWORDS:
results.append(ParsedCandidate(
candidate_type=CandidateType.TICKER,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_fiscal_periods(self, text: str) -> list[ParsedCandidate]:
"""Parse fiscal periods: Q1 2024, FY2025, H1 2024."""
results: list[ParsedCandidate] = []
for match in _FISCAL_PERIOD_RE.finditer(text):
period_prefix = match.group(1)
year_str = match.group(2)
year = int(year_str)
if len(year_str) == 2:
year = 2000 + year if year < 80 else 1900 + year
if period_prefix.startswith("Q"):
period_type = "quarter"
period_value = period_prefix
elif period_prefix.startswith("H"):
period_type = "half"
period_value = period_prefix
else: # FY
period_type = "fiscal_year"
period_value = "FY"
results.append(ParsedCandidate(
candidate_type=CandidateType.FISCAL_PERIOD,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
period=PeriodAnnotation(
period_type=period_type,
period_value=period_value,
year=year,
),
))
return results
def _parse_dates(self, text: str) -> list[ParsedCandidate]:
"""Parse dates: January 15, 2024 / 2024-01-15."""
results: list[ParsedCandidate] = []
for match in _DATE_NAMED_RE.finditer(text):
results.append(ParsedCandidate(
candidate_type=CandidateType.DATE,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
for match in _DATE_ISO_RE.finditer(text):
results.append(ParsedCandidate(
candidate_type=CandidateType.DATE,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_basis_points(self, text: str) -> list[ParsedCandidate]:
"""Parse basis points: 25 basis points, 50bps."""
results: list[ParsedCandidate] = []
for match in _BASIS_POINTS_RE.finditer(text):
literal = match.group(0)
normalized = normalize_basis_points(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.BASIS_POINTS,
literal_value=literal,
normalized_value=normalized,
unit="bps",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_percentages(self, text: str) -> list[ParsedCandidate]:
"""Parse percentages: 4%, -2.5%, +1.2 percent."""
results: list[ParsedCandidate] = []
for match in _PERCENTAGE_RE.finditer(text):
literal = match.group(0)
normalized = normalize_percentage(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.PERCENTAGE,
literal_value=literal,
normalized_value=normalized,
unit="%",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_eps(self, text: str) -> list[ParsedCandidate]:
"""Parse EPS values: $1.52 per share, EPS of $1.52."""
results: list[ParsedCandidate] = []
for match in _EPS_PER_SHARE_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.EPS,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
for match in _EPS_PREFIX_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.EPS,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_revenue(self, text: str) -> list[ParsedCandidate]:
"""Parse revenue figures: $94.9 billion in revenue, revenue of $50 billion."""
results: list[ParsedCandidate] = []
for match in _REVENUE_AMOUNT_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.REVENUE,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
for match in _REVENUE_PREFIX_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.REVENUE,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_ranges(self, text: str) -> list[ParsedCandidate]:
"""Parse ranges: $10-$12, $1.50 to $2.00."""
results: list[ParsedCandidate] = []
for match in _RANGE_RE.finditer(text):
literal = match.group(0)
low, high = normalize_range(literal)
# Store midpoint as normalized value
normalized = None
if low is not None and high is not None:
normalized = (low + high) / 2.0
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.RANGE,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_money_with_multiplier(self, text: str) -> list[ParsedCandidate]:
"""Parse money with multiplier: $94.9 billion, €5 million."""
results: list[ParsedCandidate] = []
for match in _MONEY_MULT_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.MONEY,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_currency(self, text: str) -> list[ParsedCandidate]:
"""Parse simple currency: $123.45, €99, £1,234.56."""
results: list[ParsedCandidate] = []
for match in _CURRENCY_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.CURRENCY,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
@@ -0,0 +1,47 @@
"""Pydantic models for parsed financial candidates."""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class CandidateType(str, Enum):
"""Types of financial entities detected by the deterministic parser."""
TICKER = "ticker"
CURRENCY = "currency"
MONEY = "money"
PERCENTAGE = "percentage"
BASIS_POINTS = "basis_points"
RANGE = "range"
EPS = "eps"
REVENUE = "revenue"
DATE = "date"
FISCAL_PERIOD = "fiscal_period"
class PeriodAnnotation(BaseModel):
"""Optional period context for a parsed candidate (e.g., Q1, FY2025)."""
period_type: str = Field(description="Type: quarter, year, fiscal_year, half")
period_value: str = Field(description="Normalized period: Q1, Q2, H1, FY")
year: int | None = Field(default=None, description="Calendar or fiscal year")
class ParsedCandidate(BaseModel):
"""A single parsed financial entity with source offset and normalization.
Stores both the literal text as it appeared in the source document and the
normalized numeric value (if applicable). Exact character offsets allow
downstream evidence linking back to the source.
"""
candidate_type: CandidateType = Field(description="Classification of the parsed entity")
literal_value: str = Field(min_length=1, description="Exact text as it appears in source")
normalized_value: float | None = Field(default=None, description="Normalized numeric value")
unit: str | None = Field(default=None, description="Unit: USD, EUR, %, bps, etc.")
start_char: int = Field(ge=0, description="Start character offset in source text")
end_char: int = Field(gt=0, description="End character offset in source text (exclusive)")
period: PeriodAnnotation | None = Field(default=None, description="Optional fiscal/calendar period")
@@ -0,0 +1,147 @@
"""Normalization rules for financial text values.
Converts literal financial text into normalized numeric values:
- "$94.9 billion" → 94_900_000_000.0
- "25 basis points" → 0.25 (percentage points)
- "$1.52 per share" → 1.52
- "4%" → 4.0
- "$123.45" → 123.45
- "€99" → 99.0
"""
from __future__ import annotations
import re
# Multiplier suffixes for money normalization
_MULTIPLIERS: dict[str, float] = {
"trillion": 1_000_000_000_000.0,
"billion": 1_000_000_000.0,
"million": 1_000_000.0,
"thousand": 1_000.0,
"k": 1_000.0,
"m": 1_000_000.0,
"b": 1_000_000_000.0,
"t": 1_000_000_000_000.0,
"bn": 1_000_000_000.0,
"mn": 1_000_000.0,
"tn": 1_000_000_000_000.0,
}
_NUMBER_RE = re.compile(r"[+-]?\d[\d,]*\.?\d*")
def _extract_number(text: str) -> float | None:
"""Extract the first numeric value from text, stripping commas."""
match = _NUMBER_RE.search(text)
if not match:
return None
num_str = match.group().replace(",", "")
try:
return float(num_str)
except ValueError:
return None
def _find_multiplier(text: str) -> float:
"""Find a magnitude multiplier in text (billion, million, etc.)."""
lower = text.lower()
for suffix, mult in _MULTIPLIERS.items():
# Match word boundaries for short suffixes to avoid false positives
if len(suffix) <= 2:
if re.search(rf"\b{suffix}\b", lower):
return mult
else:
if suffix in lower:
return mult
return 1.0
def normalize_money(text: str) -> float | None:
"""Normalize a money expression to its numeric value.
Examples:
"$94.9 billion" → 94_900_000_000.0
"$1.52 per share" → 1.52
"€123.45" → 123.45
"$1,234" → 1234.0
"""
number = _extract_number(text)
if number is None:
return None
# Check for "per share" — don't apply multiplier
lower = text.lower()
if "per share" in lower:
return number
multiplier = _find_multiplier(text)
return number * multiplier
def normalize_percentage(text: str) -> float | None:
"""Normalize a percentage to its numeric value.
Examples:
"4%" → 4.0
"-2.5%" → -2.5
"+1.2 percent" → 1.2
"""
return _extract_number(text)
def normalize_basis_points(text: str) -> float | None:
"""Normalize basis points to percentage points.
Examples:
"25 basis points" → 0.25
"50bps" → 0.50
"100 bps" → 1.0
"""
number = _extract_number(text)
if number is None:
return None
return number / 100.0
def normalize_range(text: str) -> tuple[float | None, float | None]:
"""Normalize a range expression to (low, high).
Examples:
"$10-$12" → (10.0, 12.0)
"$1.50 to $2.00" → (1.50, 2.00)
"""
numbers = _NUMBER_RE.findall(text)
if len(numbers) < 2:
return (None, None)
try:
low = float(numbers[0].replace(",", ""))
high = float(numbers[1].replace(",", ""))
return (low, high)
except ValueError:
return (None, None)
def normalize_value(candidate_type: str, text: str) -> float | None:
"""Normalize a candidate value based on its type.
Returns the normalized numeric value, or None if not applicable.
For ranges, returns the midpoint.
"""
if candidate_type == "money" or candidate_type == "currency":
return normalize_money(text)
elif candidate_type == "percentage":
return normalize_percentage(text)
elif candidate_type == "basis_points":
return normalize_basis_points(text)
elif candidate_type == "eps":
return normalize_money(text)
elif candidate_type == "revenue":
return normalize_money(text)
elif candidate_type == "range":
low, high = normalize_range(text)
if low is not None and high is not None:
return (low + high) / 2.0
return low
else:
return None
@@ -0,0 +1,28 @@
"""Offline replay module for Gold Corpus comparison.
Runs pipeline configurations against the Gold Corpus, produces field-level
and calibration reports, compares v2 baseline vs v3, and enforces
safety-critical promotion gates.
"""
from services.intelligence_pipeline_v3.replay.reports import (
FieldReport,
GateStatus,
PromotionGate,
ReplayReport,
)
from services.intelligence_pipeline_v3.replay.runner import (
ReplayConfig,
ReplayResult,
ReplayRunner,
)
__all__ = [
"FieldReport",
"GateStatus",
"PromotionGate",
"ReplayConfig",
"ReplayReport",
"ReplayResult",
"ReplayRunner",
]

Some files were not shown because too many files have changed in this diff Show More