Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ rayon = "1.7"
clap = { version = "4.5", features = ["derive"] }
anyhow = "1.0"
tokio = { version = "1.40", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
reqwest = { version = "0.11", features = ["json", "blocking"] }
lru = "0.9"
redis = { version = "0.25", features = ["tokio-comp", "connection-manager"] }
sha2 = "0.10"
base64 = "0.22"
Expand All @@ -37,6 +38,7 @@ criterion = "0.5"
proptest = "1.6"
rand = "0.8"
rand_distr = "0.4"
mockito = "1.0"

[[bench]]
name = "composite_slo_dag"
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

---

## Release v0.2.0 — Advanced ML / GenAI Features

This release adds advanced monitoring and explanation capabilities for ML and GenAI workloads: model degradation detection, pluggable semantic similarity scorers, LLM-specific metrics (token cost tracking and hallucination detection), and feature drift explanation with lightweight SHAP approximations. See [docs/advanced-mlgenai-features.md](docs/advanced-mlgenai-features.md) for details and examples.


## 🎯 The Problem Nobody Talks About

Your Prometheus rules say availability is **99.97%**. Your incident logs say **98.2%**. Your auditor asks: "Which one is correct?"
Expand Down
23 changes: 23 additions & 0 deletions docs/internal/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ Release entries are maintained automatically by the CD workflow on tagged releas
- Automatic validation: Ensures first 3 windows in descending order to prevent alert storms
- Comprehensive test suite: 5 test suites validating threshold calculations, error budget %, config structure, duration parsing, and real-world scenarios
- New guide: `docs/guides/multi-burn-rate-alerting.md` with theory, configuration examples, real incident timelines, and tuning guidelines
- **Degradation Detection & Similarity Scoring (Experimental)**
- Added `src/degradation.rs`: ADWIN-like gradual drift detector, Z-score spike detector, oscillation heuristic, and `DegradationDetector` API (`add_metric`, `detect_change`, `reset`, `exponential_histogram`).
- Added `DegradationType` and `DegradationEvent` (serde serializable) for structured degradation reporting.
- Added comprehensive unit tests in `tests/degradation_tests.rs` covering gradual drift, sudden spikes, oscillation, NaN handling, window behavior, and property tests via `proptest`.
- Added `src/similarity_traits.rs`: `SimilarityScorer` trait and `ScorerRegistry` with thread-safe registry and global `default_registry()`.
- Added example scorer `src/similarity_implementations.rs::DummyScorer` and tests `tests/similarity_registry_tests.rs`.
- Added GitHub Actions workflow `.github/workflows/rust.yml` to run `cargo build` and `cargo test` on push/PR.

## [0.2.0] - 2026-07-04

### Added

- Degradation detection primitives (`src/degradation.rs`) with ADWIN-like gradual drift detection, spike detection, and oscillation heuristics. Includes `DegradationDetector`, `DegradationEvent`, and comprehensive unit tests.
- Pluggable similarity scoring API (`src/similarity_traits.rs`) and implementations (`src/similarity_implementations.rs`) including `BertScorer`, `RobertaScorer`, `CosineSimScorer`, LRU caching and HTTP fallback.
- Advanced hallucination detection utilities (`src/hallucination_advanced.rs`) for entailment, contradiction and ungroundedness heuristics.
- Feature drift analysis and explanation (`src/drift_explanation.rs`) with KS two-sample tests, top-feature ranking, and SHAP-style `ShapExplainer` (`src/shap_integration.rs`).
- Python bindings and convenience wrappers: PyO3 bindings in `src/python.rs` and convenience modules under `python/neuralbudget/` for degradation, similarity, hallucination and drift explainers.
- Prometheus exporter extensions (`src/exporter.rs`) to observe degradation, hallucination, and token cost metrics.

### Changed

- Bumped package version to `0.2.0` in `Cargo.toml` and `pyproject.toml`.


- **Documentation Organization**
- Created `docs/cli/` directory with all CLI documentation
Expand Down
21 changes: 21 additions & 0 deletions examples/custom_similarity_scorer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Example demonstrating registration and use of a similarity scorer."""
from neuralbudget.similarity import SimilarityRegistry
import neuralbudget


def main():
reg = SimilarityRegistry(default_name="dummy_default")

# Use the built-in DummyScorer (provided by the Rust lib) for examples
dummy = neuralbudget.DummyScorer()
reg.register("dummy", dummy)

scorer = reg.get_scorer("dummy")
score = scorer.score("The quick brown fox", "The quick brown fox")
print("Exact match score:", score)

print("Available scorers:", reg.list_scorers())


if __name__ == "__main__":
main()
21 changes: 21 additions & 0 deletions examples/degradation_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Simple example showing the DegradationDetector convenience wrapper."""
from neuralbudget.degradation import DegradationDetector


def main():
d = DegradationDetector(window_size=60, drift_threshold=0.10, spike_threshold=2.0)

# warm-up: stable values
for _ in range(40):
d.add_metric(50.0)

# introduce a gradual drift
for i in range(20):
d.add_metric(50.0 + i * 1.5)

ev = d.detect_change()
print("Degradation event:", ev)


if __name__ == "__main__":
main()
51 changes: 51 additions & 0 deletions python/neuralbudget/degradation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
Convenience wrapper for the Degradation detector exposed by the Rust extension.
This module provides a small, Pythonic adapter around the PyO3 class so callers
get plain dicts and simple methods.
"""
from typing import Optional, Dict, Any

import neuralbudget


class DegradationDetector:
"""Python convenience wrapper around the Rust `DegradationDetector`.

Parameters
- window_size: rolling window size (int)
- drift_threshold: threshold used for gradual drift detection (float)
- spike_threshold: z-score multiplier for spike detection (float)
"""

def __init__(self, window_size: int = 100, drift_threshold: float = 0.15, spike_threshold: float = 2.5):
self._inner = neuralbudget.DegradationDetector(window_size, drift_threshold, spike_threshold)

def add_metric(self, value: float) -> None:
"""Add a new metric sample to the detector."""
return self._inner.add_metric(value)

def detect_change(self) -> Optional[Dict[str, Any]]:
"""Run detection and return either None or a dict describing the event.

The returned dict uses plain Python primitives for easy consumption.
"""
ev = self._inner.detect_change()
if ev is None:
return None
# Convert the Rust/PyO3 object to a simple dict (best-effort attribute access)
return {
"timestamp": getattr(ev, "timestamp", None),
"degradation_type": str(getattr(ev, "degradation_type", getattr(ev, "kind", None))),
"magnitude": getattr(ev, "magnitude", None),
"confidence": getattr(ev, "confidence", None),
"duration_samples": getattr(ev, "duration_samples", None),
"affected_metric": getattr(ev, "affected_metric", None),
}

def reset(self) -> None:
"""Reset internal detector state and history."""
return self._inner.reset()

def history_len(self) -> int:
"""Return the length of the internal history buffer."""
return int(self._inner.history_len())
26 changes: 26 additions & 0 deletions python/neuralbudget/hallucination_advanced.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Convenience helpers for hallucination/groundedness analysis.
This adapter exposes a tiny, stable surface that converts Rust results to plain
Python dicts for easier consumption in examples and notebooks.
"""
from typing import Any, Dict, List
import neuralbudget


def analyze_hallucination(response: str, documents: List[str]) -> Dict[str, Any]:
"""Run the higher-level hallucination analysis and return a dict.

This is a convenience wrapper that uses the library's default detector.
"""
# There is a Rust-side `HallucinationDetector` that returns a struct of
# metrics; call that and convert to a dict.
detector = neuralbudget.HallucinationDetector()
metrics = detector.analyze(response, documents)

return {
"factual_consistency": getattr(metrics, "factual_consistency", None),
"hallucination_rate": getattr(metrics, "hallucination_rate", None),
"grounded_count": getattr(metrics, "grounded_count", None),
"hallucinated_count": getattr(metrics, "hallucinated_count", None),
"summary": getattr(metrics, "summary", None),
}
38 changes: 38 additions & 0 deletions python/neuralbudget/similarity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Convenience helpers for the similarity scorer registry and scorers.
This module wraps the low-level PyO3 objects to present a compact Python API.
"""
from typing import List
import neuralbudget


class SimilarityRegistry:
"""Thin wrapper around the Rust-backed `SimilarityRegistry`.

Example:
reg = SimilarityRegistry("dummy_default")
reg.register("dummy", neuralbudget.DummyScorer())
s = reg.get_scorer("dummy")
print(s.score("a","b"))
"""

def __init__(self, default_name: str = "default"):
self._inner = neuralbudget.SimilarityRegistry(default_name)

def register(self, name: str, scorer) -> None:
return self._inner.register(name, scorer)

def get_scorer(self, name: str):
return self._inner.get_scorer(name)

def list_scorers(self) -> List[str]:
return list(self._inner.list_scorers())

def set_default(self, name: str) -> None:
return self._inner.set_default(name)

def default_name(self) -> str:
return str(self._inner.default_name())

def remove_scorer(self, name: str) -> None:
return self._inner.remove_scorer(name)
156 changes: 156 additions & 0 deletions src/cost_slo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,162 @@

use crate::NeuralBudgetError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

/// Token pricing configuration for a model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenCostConfig {
pub model: String,
pub input_cost_per_1k: f64,
pub output_cost_per_1k: f64,
pub pricing_date: u64,
pub currency: String,
}

impl TokenCostConfig {
pub fn new(model: String, input_cost_per_1k: f64, output_cost_per_1k: f64, pricing_date: u64, currency: String) -> Self {
Self { model, input_cost_per_1k, output_cost_per_1k, pricing_date, currency }
}
}

/// Errors specific to dynamic cost calculator.
#[derive(Debug)]
pub enum TokenCostError {
ModelNotFound(String),
InvalidTokenCount(String),
}

impl From<TokenCostError> for NeuralBudgetError {
fn from(e: TokenCostError) -> Self {
match e {
TokenCostError::ModelNotFound(m) => NeuralBudgetError::ConfigError(format!("model not found: {}", m)),
TokenCostError::InvalidTokenCount(s) => NeuralBudgetError::EvaluationError(format!("invalid token count: {}", s)),
}
}
}

/// Breakdown of costs for reporting.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostMetrics {
pub cost_breakdown: HashMap<String, f64>,
pub model_used: String,
pub pricing_date: u64,
}

impl CostMetrics {
pub fn new(model_used: String, pricing_date: u64, input_cost: f64, output_cost: f64) -> Self {
let mut map = HashMap::new();
map.insert("input".to_string(), input_cost);
map.insert("output".to_string(), output_cost);
map.insert("total".to_string(), input_cost + output_cost);
Self { cost_breakdown: map, model_used, pricing_date }
}
}

/// Dynamic cost calculator holding pricing configs.
#[derive(Clone)]
pub struct DynamicCostCalculator {
configs: Arc<RwLock<HashMap<String, TokenCostConfig>>>,
last_updated: Arc<RwLock<u64>>,
}

impl DynamicCostCalculator {
pub fn new() -> Self {
Self { configs: Arc::new(RwLock::new(HashMap::new())), last_updated: Arc::new(RwLock::new(0)) }
}

/// Update pricing for a model (overwrites existing entry).
pub fn update_pricing(&self, model: String, config: TokenCostConfig) -> Result<(), NeuralBudgetError> {
let mut cfg = self.configs.write().map_err(|e| NeuralBudgetError::EvaluationError(format!("lock poisoned: {}", e)))?;
cfg.insert(model.clone(), config.clone());
let mut lu = self.last_updated.write().map_err(|e| NeuralBudgetError::EvaluationError(format!("lock poisoned: {}", e)))?;
*lu = config.pricing_date;
Ok(())
}

/// Get pricing config for a model; exact match preferred, then wildcard.
pub fn get_pricing(&self, model: &str) -> Result<TokenCostConfig, NeuralBudgetError> {
let cfg = self.configs.read().map_err(|e| NeuralBudgetError::EvaluationError(format!("lock poisoned: {}", e)))?;
if let Some(c) = cfg.get(model) {
return Ok(c.clone());
}
// wildcard matching: prefer longest prefix/suffix match
// patterns stored as keys may include '*'
// exact match failed; search patterns
let mut best: Option<(usize, TokenCostConfig)> = None;
for (k, v) in cfg.iter() {
if k.contains('*') {
// convert simple wildcard to prefix/suffix checks
if wildcard_match(k, model) {
let score = k.len();
if best.is_none() || score > best.as_ref().unwrap().0 {
best = Some((score, v.clone()));
}
}
}
}
if let Some((_, c)) = best {
return Ok(c);
}
Err(TokenCostError::ModelNotFound(model.to_string()).into())
}

/// Calculate cost for given model and token counts.
pub fn calculate_cost(&self, model: &str, input_tokens: u64, output_tokens: u64) -> Result<f64, NeuralBudgetError> {
if input_tokens as i128 < 0 || output_tokens as i128 < 0 {
return Err(TokenCostError::InvalidTokenCount("negative token count".to_string()).into());
}
let cfg = self.get_pricing(model)?;
let input_cost = (input_tokens as f64 / 1000.0) * cfg.input_cost_per_1k;
let output_cost = (output_tokens as f64 / 1000.0) * cfg.output_cost_per_1k;
Ok(input_cost + output_cost)
}

/// List known models (keys)
pub fn list_models(&self) -> Vec<String> {
if let Ok(cfg) = self.configs.read() {
cfg.keys().cloned().collect()
} else {
Vec::new()
}
}
}

/// Simple wildcard matcher supporting '*' anywhere.
fn wildcard_match(pattern: &str, text: &str) -> bool {
if pattern == "*" {
return true;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
let mut idx = 0usize;
// if pattern doesn't start with *, first part must match at start
if !pattern.starts_with('*') {
if !text.starts_with(parts[0]) {
return false;
}
idx = parts[0].len();
}
for (i, part) in parts.iter().enumerate() {
if part.is_empty() { continue; }
if i == parts.len() - 1 && !pattern.ends_with('*') {
// last part must match at end
if !text.ends_with(part) {
return false;
}
} else {
if let Some(pos) = text[idx..].find(part) {
idx += pos + part.len();
} else {
return false;
}
}
}
true
}

/// Cost budget configuration for input/output tokens.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
Expand Down
Loading
Loading