Squashed 'vendor/ruvector/' content from commit b64c2172

git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
commit d803bfe2b1
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
[package]
name = "sevensense-core"
description = "Core types and traits for 7sense bioacoustic analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
keywords = ["bioacoustics", "audio-analysis", "ddd", "domain-driven-design"]
categories = ["science", "multimedia::audio"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
uuid = { version = "1.10", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"

View File

@@ -0,0 +1,213 @@
# sevensense-core
[![Crate](https://img.shields.io/badge/crates.io-sevensense--core-orange.svg)](https://crates.io/crates/sevensense-core)
[![Docs](https://img.shields.io/badge/docs-sevensense--core-blue.svg)](https://docs.rs/sevensense-core)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE)
> Shared domain primitives for the 7sense bioacoustic intelligence platform.
**sevensense-core** provides the foundational types, traits, and utilities used across all 7sense crates. It defines the core vocabulary of the domain—species identifiers, temporal boundaries, audio metadata, and error types—ensuring consistency throughout the platform.
## Features
- **Species Taxonomy**: Type-safe species identifiers with scientific/common name support
- **Temporal Primitives**: Time ranges, segments, and duration utilities for audio analysis
- **Domain Events**: Event-sourced primitives for audit trails and streaming
- **Error Handling**: Unified error types with rich context and error chains
- **Configuration**: Shared configuration primitives and validation
## Use Cases
| Use Case | Description | Example Types |
|----------|-------------|---------------|
| Species Management | Track and validate bird species | `SpeciesId`, `TaxonomicRank`, `SpeciesMetadata` |
| Time Handling | Represent audio segments and recordings | `TimeRange`, `SegmentBounds`, `Duration` |
| Audio Metadata | Describe recordings and their properties | `RecordingId`, `AudioMetadata`, `Location` |
| Error Propagation | Consistent error handling across crates | `CoreError`, `ErrorContext`, `Result<T>` |
## Installation
Add to your `Cargo.toml`:
```toml
[dependencies]
sevensense-core = "0.1"
```
## Quick Start
```rust
use sevensense_core::{SpeciesId, TimeRange, AudioMetadata};
// Create a species identifier
let species = SpeciesId::from_scientific("Turdus merula");
println!("Species: {}", species.scientific_name());
// Define a time range for analysis
let range = TimeRange::new(
chrono::Utc::now() - chrono::Duration::hours(1),
chrono::Utc::now()
);
println!("Duration: {:?}", range.duration());
```
---
<details>
<summary><b>Tutorial: Working with Species</b></summary>
### Creating Species Identifiers
```rust
use sevensense_core::{SpeciesId, TaxonomicRank};
// From scientific name
let blackbird = SpeciesId::from_scientific("Turdus merula");
// With common name
let blackbird = SpeciesId::new("Turdus merula", Some("Eurasian Blackbird"));
// Check taxonomic information
assert_eq!(blackbird.genus(), "Turdus");
assert_eq!(blackbird.species_epithet(), "merula");
```
### Species Collections
```rust
use sevensense_core::{SpeciesRegistry, SpeciesId};
let mut registry = SpeciesRegistry::new();
registry.register(SpeciesId::from_scientific("Turdus merula"));
registry.register(SpeciesId::from_scientific("Turdus philomelos"));
// Search by partial name
let thrushes = registry.search("Turdus");
println!("Found {} thrush species", thrushes.len());
```
</details>
<details>
<summary><b>Tutorial: Time Range Operations</b></summary>
### Basic Time Ranges
```rust
use sevensense_core::TimeRange;
use chrono::{Utc, Duration};
// Create a range for the last hour
let now = Utc::now();
let range = TimeRange::new(now - Duration::hours(1), now);
// Check duration
println!("Range spans: {:?}", range.duration());
// Check if a timestamp is within range
let test_time = now - Duration::minutes(30);
assert!(range.contains(test_time));
```
### Splitting Time Ranges
```rust
use sevensense_core::TimeRange;
use chrono::Duration;
let range = TimeRange::last_n_hours(24);
// Split into 1-hour windows
let windows = range.split_by_duration(Duration::hours(1));
println!("Created {} 1-hour windows", windows.len());
// Split into equal parts
let parts = range.split_into_n_parts(4);
assert_eq!(parts.len(), 4);
```
</details>
<details>
<summary><b>Tutorial: Error Handling</b></summary>
### Using Core Errors
```rust
use sevensense_core::{CoreError, CoreResult, ErrorContext};
fn process_audio(path: &str) -> CoreResult<()> {
// Operations that might fail
let file = std::fs::File::open(path)
.map_err(|e| CoreError::io(e).with_context("opening audio file"))?;
Ok(())
}
fn main() {
match process_audio("missing.wav") {
Ok(_) => println!("Success!"),
Err(e) => {
eprintln!("Error: {}", e);
if let Some(ctx) = e.context() {
eprintln!("Context: {}", ctx);
}
}
}
}
```
### Error Chains
```rust
use sevensense_core::{CoreError, CoreResult};
fn outer_function() -> CoreResult<()> {
inner_function()
.map_err(|e| e.with_context("in outer_function"))?;
Ok(())
}
fn inner_function() -> CoreResult<()> {
Err(CoreError::validation("Invalid input"))
}
```
</details>
---
## API Overview
### Core Types
| Type | Description |
|------|-------------|
| `SpeciesId` | Unique identifier for a bird species |
| `RecordingId` | UUID-based recording identifier |
| `TimeRange` | Start/end time boundary |
| `Location` | Geographic coordinates (lat/lon) |
| `AudioMetadata` | Recording metadata (format, channels, etc.) |
### Traits
| Trait | Description |
|-------|-------------|
| `Identifiable` | Types with unique identifiers |
| `Timestamped` | Types with timestamp information |
| `Bounded` | Types with time boundaries |
## Links
- **Homepage**: [ruv.io](https://ruv.io)
- **Repository**: [github.com/ruvnet/ruvector](https://github.com/ruvnet/ruvector)
- **Crates.io**: [crates.io/crates/sevensense-core](https://crates.io/crates/sevensense-core)
- **Documentation**: [docs.rs/sevensense-core](https://docs.rs/sevensense-core)
## License
MIT License - see [LICENSE](../../LICENSE) for details.
---
*Part of the [7sense Bioacoustic Intelligence Platform](https://ruv.io) by rUv*

View File

@@ -0,0 +1,511 @@
//! # Configuration Module
//!
//! Configuration management for the 7sense platform.
//!
//! This module provides:
//! - Typed configuration structures
//! - Environment variable loading
//! - Configuration file parsing
//! - Default values and validation
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use crate::domain::errors::ConfigurationError;
/// Main application configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
/// Application name.
pub name: String,
/// Application environment (development, staging, production).
pub environment: Environment,
/// Logging configuration.
pub logging: LoggingConfig,
/// Audio processing configuration.
pub audio: AudioConfig,
/// Embedding configuration.
pub embedding: EmbeddingConfig,
/// Vector database configuration.
pub vector_db: VectorDbConfig,
/// API server configuration.
pub api: ApiConfig,
/// Storage configuration.
pub storage: StorageConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
name: "sevensense".to_string(),
environment: Environment::default(),
logging: LoggingConfig::default(),
audio: AudioConfig::default(),
embedding: EmbeddingConfig::default(),
vector_db: VectorDbConfig::default(),
api: ApiConfig::default(),
storage: StorageConfig::default(),
}
}
}
impl AppConfig {
/// Loads configuration from environment and files.
///
/// # Errors
///
/// Returns an error if configuration cannot be loaded or is invalid.
pub fn load() -> Result<Self, ConfigurationError> {
// Load .env file if present
let _ = dotenvy::dotenv();
let mut builder = config::Config::builder();
// Add default values
builder = builder.add_source(config::Config::try_from(&Self::default()).map_err(|e| {
ConfigurationError::ParseError {
reason: e.to_string(),
}
})?);
// Try to load from config file
let config_path = std::env::var("SEVENSENSE_CONFIG")
.unwrap_or_else(|_| "config/default.toml".to_string());
if std::path::Path::new(&config_path).exists() {
builder = builder.add_source(config::File::with_name(&config_path));
}
// Override with environment variables (SEVENSENSE_ prefix)
builder = builder.add_source(
config::Environment::with_prefix("SEVENSENSE")
.separator("__")
.try_parsing(true),
);
let config = builder
.build()
.map_err(|e| ConfigurationError::ParseError {
reason: e.to_string(),
})?;
config
.try_deserialize()
.map_err(|e| ConfigurationError::ParseError {
reason: e.to_string(),
})
}
/// Validates the configuration.
///
/// # Errors
///
/// Returns an error if any configuration value is invalid.
pub fn validate(&self) -> Result<(), ConfigurationError> {
// Validate audio settings
if self.audio.min_segment_duration_ms >= self.audio.max_segment_duration_ms {
return Err(ConfigurationError::Invalid {
key: "audio.min_segment_duration_ms".to_string(),
reason: "must be less than max_segment_duration_ms".to_string(),
});
}
// Validate embedding dimensions
if self.embedding.dimensions == 0 {
return Err(ConfigurationError::Invalid {
key: "embedding.dimensions".to_string(),
reason: "must be greater than 0".to_string(),
});
}
// Validate API settings
if self.api.port == 0 {
return Err(ConfigurationError::Invalid {
key: "api.port".to_string(),
reason: "must be a valid port number".to_string(),
});
}
Ok(())
}
/// Returns whether this is a production environment.
#[must_use]
pub fn is_production(&self) -> bool {
matches!(self.environment, Environment::Production)
}
/// Returns whether this is a development environment.
#[must_use]
pub fn is_development(&self) -> bool {
matches!(self.environment, Environment::Development)
}
}
/// Application environment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
/// Development environment.
#[default]
Development,
/// Staging environment.
Staging,
/// Production environment.
Production,
}
impl Environment {
/// Returns the environment name as a string.
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Development => "development",
Self::Staging => "staging",
Self::Production => "production",
}
}
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for Environment {
type Err = ConfigurationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"development" | "dev" => Ok(Self::Development),
"staging" | "stage" => Ok(Self::Staging),
"production" | "prod" => Ok(Self::Production),
_ => Err(ConfigurationError::Invalid {
key: "environment".to_string(),
reason: format!("unknown environment: {s}"),
}),
}
}
}
/// Logging configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoggingConfig {
/// Log level (trace, debug, info, warn, error).
pub level: String,
/// Output format (json, pretty).
pub format: LogFormat,
/// Whether to include source code location.
pub include_location: bool,
/// Whether to include span events.
pub include_spans: bool,
/// OpenTelemetry configuration.
pub opentelemetry: Option<OpenTelemetryConfig>,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
format: LogFormat::default(),
include_location: false,
include_spans: true,
opentelemetry: None,
}
}
}
/// Log output format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
/// JSON format (for structured logging).
#[default]
Json,
/// Pretty format (human-readable, for development).
Pretty,
/// Compact format (single line, minimal).
Compact,
}
/// OpenTelemetry configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenTelemetryConfig {
/// OTLP endpoint URL.
pub endpoint: String,
/// Service name for traces.
pub service_name: String,
/// Sampling ratio (0.0 to 1.0).
pub sampling_ratio: f64,
}
/// Audio processing configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AudioConfig {
/// Supported input sample rates in Hz.
pub supported_sample_rates: Vec<u32>,
/// Target sample rate for processing in Hz.
pub target_sample_rate: u32,
/// Maximum file size in bytes.
pub max_file_size_bytes: u64,
/// Minimum segment duration in milliseconds.
pub min_segment_duration_ms: u64,
/// Maximum segment duration in milliseconds.
pub max_segment_duration_ms: u64,
/// Default segment overlap ratio (0.0 to 1.0).
pub segment_overlap_ratio: f32,
/// Energy threshold for segment detection.
pub energy_threshold: f32,
/// Frequency range for analysis (Hz).
pub frequency_range: (f32, f32),
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
supported_sample_rates: vec![16000, 22050, 44100, 48000, 96000],
target_sample_rate: 48000,
max_file_size_bytes: 500 * 1024 * 1024, // 500 MB
min_segment_duration_ms: 100,
max_segment_duration_ms: 30000, // 30 seconds
segment_overlap_ratio: 0.25,
energy_threshold: 0.01,
frequency_range: (50.0, 15000.0),
}
}
}
/// Embedding model configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddingConfig {
/// Model name or path.
pub model_name: String,
/// Model version.
pub model_version: String,
/// Embedding dimensions.
pub dimensions: u32,
/// Batch size for embedding generation.
pub batch_size: usize,
/// Whether to use GPU acceleration.
pub use_gpu: bool,
/// Model inference timeout.
#[serde(with = "humantime_serde")]
pub inference_timeout: Duration,
}
impl Default for EmbeddingConfig {
fn default() -> Self {
Self {
model_name: "birdnet-v2.4".to_string(),
model_version: "2.4.0".to_string(),
dimensions: 1024,
batch_size: 32,
use_gpu: false,
inference_timeout: Duration::from_secs(30),
}
}
}
/// Vector database configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VectorDbConfig {
/// Vector database URL.
pub url: String,
/// API key (if required).
pub api_key: Option<String>,
/// Collection name for embeddings.
pub collection_name: String,
/// Number of vectors to return in searches.
pub default_limit: u32,
/// HNSW index parameters.
pub hnsw: HnswConfig,
/// Connection timeout.
#[serde(with = "humantime_serde")]
pub connection_timeout: Duration,
}
impl Default for VectorDbConfig {
fn default() -> Self {
Self {
url: "http://localhost:6334".to_string(),
api_key: None,
collection_name: "sevensense_embeddings".to_string(),
default_limit: 10,
hnsw: HnswConfig::default(),
connection_timeout: Duration::from_secs(10),
}
}
}
/// HNSW index configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HnswConfig {
/// Number of edges per node.
pub m: u32,
/// Size of the dynamic candidate list during construction.
pub ef_construct: u32,
/// Size of the dynamic candidate list during search.
pub ef_search: u32,
}
impl Default for HnswConfig {
fn default() -> Self {
Self {
m: 16,
ef_construct: 100,
ef_search: 64,
}
}
}
/// API server configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ApiConfig {
/// Host to bind to.
pub host: String,
/// Port to listen on.
pub port: u16,
/// Request body size limit in bytes.
pub body_limit_bytes: usize,
/// Request timeout.
#[serde(with = "humantime_serde")]
pub request_timeout: Duration,
/// CORS allowed origins.
pub cors_origins: Vec<String>,
/// Rate limiting configuration.
pub rate_limit: RateLimitConfig,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".to_string(),
port: 8080,
body_limit_bytes: 100 * 1024 * 1024, // 100 MB
request_timeout: Duration::from_secs(300),
cors_origins: vec!["*".to_string()],
rate_limit: RateLimitConfig::default(),
}
}
}
/// Rate limiting configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitConfig {
/// Whether rate limiting is enabled.
pub enabled: bool,
/// Maximum requests per second.
pub requests_per_second: u32,
/// Burst size.
pub burst_size: u32,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
requests_per_second: 100,
burst_size: 200,
}
}
}
/// Storage configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
/// Storage backend type.
pub backend: StorageBackend,
/// Local storage path (for filesystem backend).
pub local_path: PathBuf,
/// S3 configuration (for S3 backend).
pub s3: Option<S3Config>,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
backend: StorageBackend::default(),
local_path: PathBuf::from("./data/storage"),
s3: None,
}
}
}
/// Storage backend type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StorageBackend {
/// Local filesystem storage.
#[default]
Filesystem,
/// Amazon S3 or S3-compatible storage.
S3,
}
/// S3 storage configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct S3Config {
/// S3 bucket name.
pub bucket: String,
/// AWS region.
pub region: String,
/// S3 endpoint URL (for S3-compatible services).
pub endpoint: Option<String>,
/// Path prefix within the bucket.
pub prefix: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert_eq!(config.name, "sevensense");
assert!(config.is_development());
}
#[test]
fn test_config_validation() {
let mut config = AppConfig::default();
assert!(config.validate().is_ok());
// Invalid: min >= max
config.audio.min_segment_duration_ms = 5000;
config.audio.max_segment_duration_ms = 1000;
assert!(config.validate().is_err());
}
#[test]
fn test_environment_parsing() {
assert_eq!(
"development".parse::<Environment>().unwrap(),
Environment::Development
);
assert_eq!(
"prod".parse::<Environment>().unwrap(),
Environment::Production
);
assert!("invalid".parse::<Environment>().is_err());
}
#[test]
fn test_config_serialization() {
let config = AppConfig::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.name, parsed.name);
}
}

View File

@@ -0,0 +1,934 @@
//! # Domain Entities
//!
//! Core domain entities and value objects for the 7sense bioacoustics platform.
//!
//! ## Entity Types
//!
//! - `RecordingId`: Unique identifier for audio recordings
//! - `SegmentId`: Unique identifier for audio segments (portions of recordings)
//! - `EmbeddingId`: Unique identifier for vector embeddings
//! - `ClusterId`: Unique identifier for species/sound clusters
//! - `TaxonId`: Scientific taxonomy identifier for species
//!
//! ## Value Objects
//!
//! - `Timestamp`: UTC timestamp with nanosecond precision
//! - `GeoLocation`: Geographic coordinates with optional elevation
//! - `AudioMetadata`: Audio file technical specifications
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;
// =============================================================================
// Identity Types (Entity IDs)
// =============================================================================
/// Unique identifier for an audio recording.
///
/// A recording represents a single continuous audio capture session,
/// which may contain multiple segments with different species vocalizations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RecordingId(Uuid);
impl RecordingId {
/// Creates a new random `RecordingId`.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Creates a `RecordingId` from an existing UUID.
#[must_use]
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
/// Returns the inner UUID value.
#[must_use]
pub const fn inner(&self) -> Uuid {
self.0
}
/// Parses a `RecordingId` from a string.
///
/// # Errors
///
/// Returns an error if the string is not a valid UUID.
pub fn parse(s: &str) -> Result<Self, uuid::Error> {
Ok(Self(Uuid::parse_str(s)?))
}
}
impl Default for RecordingId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for RecordingId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for RecordingId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
impl From<RecordingId> for Uuid {
fn from(id: RecordingId) -> Self {
id.0
}
}
/// Unique identifier for an audio segment.
///
/// A segment is a time-bounded portion of a recording that contains
/// a single vocalization or sound event of interest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SegmentId(Uuid);
impl SegmentId {
/// Creates a new random `SegmentId`.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Creates a `SegmentId` from an existing UUID.
#[must_use]
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
/// Returns the inner UUID value.
#[must_use]
pub const fn inner(&self) -> Uuid {
self.0
}
/// Parses a `SegmentId` from a string.
///
/// # Errors
///
/// Returns an error if the string is not a valid UUID.
pub fn parse(s: &str) -> Result<Self, uuid::Error> {
Ok(Self(Uuid::parse_str(s)?))
}
}
impl Default for SegmentId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for SegmentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for SegmentId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
/// Unique identifier for a vector embedding.
///
/// An embedding is a dense vector representation of an audio segment,
/// used for similarity search and clustering operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EmbeddingId(Uuid);
impl EmbeddingId {
/// Creates a new random `EmbeddingId`.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Creates an `EmbeddingId` from an existing UUID.
#[must_use]
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
/// Returns the inner UUID value.
#[must_use]
pub const fn inner(&self) -> Uuid {
self.0
}
/// Parses an `EmbeddingId` from a string.
///
/// # Errors
///
/// Returns an error if the string is not a valid UUID.
pub fn parse(s: &str) -> Result<Self, uuid::Error> {
Ok(Self(Uuid::parse_str(s)?))
}
}
impl Default for EmbeddingId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for EmbeddingId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for EmbeddingId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
/// Unique identifier for a cluster of similar sounds.
///
/// Clusters group together embeddings that likely represent
/// the same species or sound type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ClusterId(Uuid);
impl ClusterId {
/// Creates a new random `ClusterId`.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Creates a `ClusterId` from an existing UUID.
#[must_use]
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
/// Returns the inner UUID value.
#[must_use]
pub const fn inner(&self) -> Uuid {
self.0
}
/// Parses a `ClusterId` from a string.
///
/// # Errors
///
/// Returns an error if the string is not a valid UUID.
pub fn parse(s: &str) -> Result<Self, uuid::Error> {
Ok(Self(Uuid::parse_str(s)?))
}
}
impl Default for ClusterId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ClusterId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for ClusterId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
/// Taxonomic identifier for a species.
///
/// Uses scientific naming conventions (e.g., "Turdus_migratorius" for American Robin).
/// Can also represent higher taxonomic levels (genus, family, order).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TaxonId(String);
impl TaxonId {
/// Creates a new `TaxonId` from a string.
///
/// # Arguments
///
/// * `id` - The taxonomic identifier string
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
/// Returns the inner string value.
#[must_use]
pub fn inner(&self) -> &str {
&self.0
}
/// Returns the inner string, consuming self.
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
/// Checks if this is a species-level taxon (contains underscore).
#[must_use]
pub fn is_species(&self) -> bool {
self.0.contains('_')
}
/// Extracts the genus from a species-level taxon.
///
/// Returns `None` if this is not a species-level taxon.
#[must_use]
pub fn genus(&self) -> Option<&str> {
if self.is_species() {
self.0.split('_').next()
} else {
None
}
}
/// Extracts the specific epithet from a species-level taxon.
///
/// Returns `None` if this is not a species-level taxon.
#[must_use]
pub fn specific_epithet(&self) -> Option<&str> {
if self.is_species() {
self.0.split('_').nth(1)
} else {
None
}
}
}
impl fmt::Display for TaxonId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for TaxonId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for TaxonId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl AsRef<str> for TaxonId {
fn as_ref(&self) -> &str {
&self.0
}
}
// =============================================================================
// Value Objects
// =============================================================================
/// A UTC timestamp with nanosecond precision.
///
/// Used for recording timestamps, event times, and temporal queries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Timestamp(DateTime<Utc>);
impl Timestamp {
/// Creates a `Timestamp` for the current moment.
#[must_use]
pub fn now() -> Self {
Self(Utc::now())
}
/// Creates a `Timestamp` from a `DateTime<Utc>`.
#[must_use]
pub const fn from_datetime(dt: DateTime<Utc>) -> Self {
Self(dt)
}
/// Returns the inner `DateTime<Utc>` value.
#[must_use]
pub const fn inner(&self) -> DateTime<Utc> {
self.0
}
/// Returns the Unix timestamp in seconds.
#[must_use]
pub fn unix_timestamp(&self) -> i64 {
self.0.timestamp()
}
/// Returns the Unix timestamp in milliseconds.
#[must_use]
pub fn unix_timestamp_millis(&self) -> i64 {
self.0.timestamp_millis()
}
/// Parses a `Timestamp` from an RFC 3339 string.
///
/// # Errors
///
/// Returns an error if the string is not a valid RFC 3339 timestamp.
pub fn parse_rfc3339(s: &str) -> Result<Self, chrono::ParseError> {
Ok(Self(DateTime::parse_from_rfc3339(s)?.with_timezone(&Utc)))
}
/// Formats the timestamp as an RFC 3339 string.
#[must_use]
pub fn to_rfc3339(&self) -> String {
self.0.to_rfc3339()
}
}
impl Default for Timestamp {
fn default() -> Self {
Self::now()
}
}
impl fmt::Display for Timestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.to_rfc3339())
}
}
impl From<DateTime<Utc>> for Timestamp {
fn from(dt: DateTime<Utc>) -> Self {
Self(dt)
}
}
impl From<Timestamp> for DateTime<Utc> {
fn from(ts: Timestamp) -> Self {
ts.0
}
}
/// Geographic location with optional elevation.
///
/// Coordinates use WGS84 datum (standard GPS coordinates).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GeoLocation {
/// Latitude in decimal degrees (-90 to 90).
lat: f64,
/// Longitude in decimal degrees (-180 to 180).
lon: f64,
/// Elevation above sea level in meters.
elevation_m: Option<f32>,
}
impl GeoLocation {
/// Creates a new `GeoLocation`.
///
/// # Arguments
///
/// * `lat` - Latitude in decimal degrees
/// * `lon` - Longitude in decimal degrees
/// * `elevation_m` - Optional elevation in meters
///
/// # Panics
///
/// Panics if latitude is not in range [-90, 90] or longitude is not in range [-180, 180].
#[must_use]
pub fn new(lat: f64, lon: f64, elevation_m: Option<f32>) -> Self {
assert!(
(-90.0..=90.0).contains(&lat),
"Latitude must be between -90 and 90 degrees"
);
assert!(
(-180.0..=180.0).contains(&lon),
"Longitude must be between -180 and 180 degrees"
);
Self {
lat,
lon,
elevation_m,
}
}
/// Creates a new `GeoLocation`, returning `None` if coordinates are invalid.
#[must_use]
pub fn try_new(lat: f64, lon: f64, elevation_m: Option<f32>) -> Option<Self> {
if (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon) {
Some(Self {
lat,
lon,
elevation_m,
})
} else {
None
}
}
/// Returns the latitude.
#[must_use]
pub const fn lat(&self) -> f64 {
self.lat
}
/// Returns the longitude.
#[must_use]
pub const fn lon(&self) -> f64 {
self.lon
}
/// Returns the elevation in meters, if available.
#[must_use]
pub const fn elevation_m(&self) -> Option<f32> {
self.elevation_m
}
/// Calculates the Haversine distance to another location in meters.
#[must_use]
pub fn distance_to(&self, other: &Self) -> f64 {
const EARTH_RADIUS_M: f64 = 6_371_000.0;
let lat1_rad = self.lat.to_radians();
let lat2_rad = other.lat.to_radians();
let delta_lat = (other.lat - self.lat).to_radians();
let delta_lon = (other.lon - self.lon).to_radians();
let a = (delta_lat / 2.0).sin().powi(2)
+ lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
let c = 2.0 * a.sqrt().asin();
EARTH_RADIUS_M * c
}
}
impl fmt::Display for GeoLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.elevation_m {
Some(elev) => write!(f, "({:.6}, {:.6}, {:.1}m)", self.lat, self.lon, elev),
None => write!(f, "({:.6}, {:.6})", self.lat, self.lon),
}
}
}
/// Supported audio formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AudioFormat {
/// WAV format (uncompressed PCM).
Wav,
/// FLAC format (lossless compression).
Flac,
/// MP3 format (lossy compression).
Mp3,
/// Ogg Vorbis format (lossy compression).
Ogg,
/// Opus format (lossy compression, optimized for speech/audio).
Opus,
}
impl AudioFormat {
/// Returns the file extension for this format.
#[must_use]
pub const fn extension(&self) -> &'static str {
match self {
Self::Wav => "wav",
Self::Flac => "flac",
Self::Mp3 => "mp3",
Self::Ogg => "ogg",
Self::Opus => "opus",
}
}
/// Returns the MIME type for this format.
#[must_use]
pub const fn mime_type(&self) -> &'static str {
match self {
Self::Wav => "audio/wav",
Self::Flac => "audio/flac",
Self::Mp3 => "audio/mpeg",
Self::Ogg => "audio/ogg",
Self::Opus => "audio/opus",
}
}
/// Returns whether this format uses lossless compression.
#[must_use]
pub const fn is_lossless(&self) -> bool {
matches!(self, Self::Wav | Self::Flac)
}
}
impl fmt::Display for AudioFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.extension().to_uppercase())
}
}
/// Technical metadata for an audio file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AudioMetadata {
/// Sample rate in Hz (e.g., 44100, 48000).
sample_rate: u32,
/// Number of audio channels (1 = mono, 2 = stereo).
channels: u8,
/// Duration in milliseconds.
duration_ms: u64,
/// Audio file format.
format: AudioFormat,
}
impl AudioMetadata {
/// Creates new `AudioMetadata`.
///
/// # Arguments
///
/// * `sample_rate` - Sample rate in Hz
/// * `channels` - Number of audio channels
/// * `duration_ms` - Duration in milliseconds
/// * `format` - Audio file format
#[must_use]
pub const fn new(sample_rate: u32, channels: u8, duration_ms: u64, format: AudioFormat) -> Self {
Self {
sample_rate,
channels,
duration_ms,
format,
}
}
/// Returns the sample rate in Hz.
#[must_use]
pub const fn sample_rate(&self) -> u32 {
self.sample_rate
}
/// Returns the number of channels.
#[must_use]
pub const fn channels(&self) -> u8 {
self.channels
}
/// Returns the duration in milliseconds.
#[must_use]
pub const fn duration_ms(&self) -> u64 {
self.duration_ms
}
/// Returns the duration in seconds.
#[must_use]
pub fn duration_secs(&self) -> f64 {
self.duration_ms as f64 / 1000.0
}
/// Returns the audio format.
#[must_use]
pub const fn format(&self) -> AudioFormat {
self.format
}
/// Returns the total number of samples (all channels combined).
#[must_use]
pub fn total_samples(&self) -> u64 {
let samples_per_channel =
(self.sample_rate as u64 * self.duration_ms) / 1000;
samples_per_channel * self.channels as u64
}
/// Returns whether this is mono audio.
#[must_use]
pub const fn is_mono(&self) -> bool {
self.channels == 1
}
/// Returns whether this is stereo audio.
#[must_use]
pub const fn is_stereo(&self) -> bool {
self.channels == 2
}
}
impl fmt::Display for AudioMetadata {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {}Hz {}ch {:.2}s",
self.format,
self.sample_rate,
self.channels,
self.duration_secs()
)
}
}
/// Time range within an audio recording.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TimeRange {
/// Start time in milliseconds from the beginning of the recording.
start_ms: u64,
/// End time in milliseconds from the beginning of the recording.
end_ms: u64,
}
impl TimeRange {
/// Creates a new `TimeRange`.
///
/// # Arguments
///
/// * `start_ms` - Start time in milliseconds
/// * `end_ms` - End time in milliseconds
///
/// # Panics
///
/// Panics if `start_ms` >= `end_ms`.
#[must_use]
pub fn new(start_ms: u64, end_ms: u64) -> Self {
assert!(start_ms < end_ms, "Start time must be before end time");
Self { start_ms, end_ms }
}
/// Creates a new `TimeRange`, returning `None` if invalid.
#[must_use]
pub fn try_new(start_ms: u64, end_ms: u64) -> Option<Self> {
if start_ms < end_ms {
Some(Self { start_ms, end_ms })
} else {
None
}
}
/// Returns the start time in milliseconds.
#[must_use]
pub const fn start_ms(&self) -> u64 {
self.start_ms
}
/// Returns the end time in milliseconds.
#[must_use]
pub const fn end_ms(&self) -> u64 {
self.end_ms
}
/// Returns the duration in milliseconds.
#[must_use]
pub const fn duration_ms(&self) -> u64 {
self.end_ms - self.start_ms
}
/// Returns the duration in seconds.
#[must_use]
pub fn duration_secs(&self) -> f64 {
self.duration_ms() as f64 / 1000.0
}
/// Checks if this range overlaps with another.
#[must_use]
pub const fn overlaps(&self, other: &Self) -> bool {
self.start_ms < other.end_ms && other.start_ms < self.end_ms
}
/// Checks if this range contains a point in time.
#[must_use]
pub const fn contains(&self, time_ms: u64) -> bool {
time_ms >= self.start_ms && time_ms < self.end_ms
}
}
impl fmt::Display for TimeRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{:.3}s - {:.3}s",
self.start_ms as f64 / 1000.0,
self.end_ms as f64 / 1000.0
)
}
}
/// Confidence score for predictions (0.0 to 1.0).
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Confidence(f32);
impl Confidence {
/// Creates a new `Confidence` score.
///
/// # Arguments
///
/// * `value` - Confidence value between 0.0 and 1.0
///
/// # Panics
///
/// Panics if value is not in range [0.0, 1.0].
#[must_use]
pub fn new(value: f32) -> Self {
assert!(
(0.0..=1.0).contains(&value),
"Confidence must be between 0.0 and 1.0"
);
Self(value)
}
/// Creates a new `Confidence` score, clamping to valid range.
#[must_use]
pub fn clamped(value: f32) -> Self {
Self(value.clamp(0.0, 1.0))
}
/// Returns the inner value.
#[must_use]
pub const fn value(&self) -> f32 {
self.0
}
/// Returns the confidence as a percentage (0-100).
#[must_use]
pub fn percentage(&self) -> f32 {
self.0 * 100.0
}
/// Checks if this is a high confidence prediction (>= 0.8).
#[must_use]
pub fn is_high(&self) -> bool {
self.0 >= 0.8
}
/// Checks if this is a medium confidence prediction (>= 0.5 and < 0.8).
#[must_use]
pub fn is_medium(&self) -> bool {
self.0 >= 0.5 && self.0 < 0.8
}
/// Checks if this is a low confidence prediction (< 0.5).
#[must_use]
pub fn is_low(&self) -> bool {
self.0 < 0.5
}
}
impl fmt::Display for Confidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.1}%", self.percentage())
}
}
impl Default for Confidence {
fn default() -> Self {
Self(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recording_id_new() {
let id1 = RecordingId::new();
let id2 = RecordingId::new();
assert_ne!(id1, id2);
}
#[test]
fn test_recording_id_parse() {
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
let id = RecordingId::parse(uuid_str).unwrap();
assert_eq!(id.to_string(), uuid_str);
}
#[test]
fn test_taxon_id_species() {
let taxon = TaxonId::new("Turdus_migratorius");
assert!(taxon.is_species());
assert_eq!(taxon.genus(), Some("Turdus"));
assert_eq!(taxon.specific_epithet(), Some("migratorius"));
}
#[test]
fn test_taxon_id_genus() {
let taxon = TaxonId::new("Turdus");
assert!(!taxon.is_species());
assert_eq!(taxon.genus(), None);
}
#[test]
fn test_geo_location_valid() {
let loc = GeoLocation::new(37.7749, -122.4194, Some(10.0));
assert_eq!(loc.lat(), 37.7749);
assert_eq!(loc.lon(), -122.4194);
assert_eq!(loc.elevation_m(), Some(10.0));
}
#[test]
#[should_panic(expected = "Latitude must be between")]
fn test_geo_location_invalid_lat() {
GeoLocation::new(91.0, 0.0, None);
}
#[test]
fn test_geo_location_distance() {
let sf = GeoLocation::new(37.7749, -122.4194, None);
let la = GeoLocation::new(34.0522, -118.2437, None);
let distance = sf.distance_to(&la);
// Distance should be approximately 559 km
assert!((distance - 559_000.0).abs() < 10_000.0);
}
#[test]
fn test_audio_metadata() {
let meta = AudioMetadata::new(48000, 1, 30000, AudioFormat::Wav);
assert_eq!(meta.sample_rate(), 48000);
assert_eq!(meta.channels(), 1);
assert_eq!(meta.duration_ms(), 30000);
assert_eq!(meta.duration_secs(), 30.0);
assert!(meta.is_mono());
assert!(!meta.is_stereo());
assert_eq!(meta.total_samples(), 48000 * 30);
}
#[test]
fn test_time_range() {
let range = TimeRange::new(1000, 5000);
assert_eq!(range.duration_ms(), 4000);
assert_eq!(range.duration_secs(), 4.0);
assert!(range.contains(2000));
assert!(!range.contains(5000));
}
#[test]
fn test_time_range_overlap() {
let range1 = TimeRange::new(1000, 5000);
let range2 = TimeRange::new(4000, 8000);
let range3 = TimeRange::new(6000, 9000);
assert!(range1.overlaps(&range2));
assert!(!range1.overlaps(&range3));
}
#[test]
fn test_confidence() {
let high = Confidence::new(0.9);
let medium = Confidence::new(0.6);
let low = Confidence::new(0.3);
assert!(high.is_high());
assert!(medium.is_medium());
assert!(low.is_low());
assert_eq!(high.percentage(), 90.0);
}
#[test]
fn test_timestamp() {
let ts = Timestamp::now();
let parsed = Timestamp::parse_rfc3339(&ts.to_rfc3339()).unwrap();
assert_eq!(ts, parsed);
}
}

View File

@@ -0,0 +1,725 @@
//! # Domain Errors
//!
//! Strongly-typed error types for domain operations.
//!
//! This module provides error types that follow these principles:
//! - Each error type represents a specific failure mode
//! - Errors carry enough context for debugging and user messaging
//! - Errors implement `std::error::Error` for ecosystem compatibility
//! - Errors are serializable for API responses
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::entities::{ClusterId, EmbeddingId, RecordingId, SegmentId, TaxonId};
/// Top-level error type for all domain operations.
#[derive(Debug, Error)]
pub enum DomainError {
/// Error related to recording operations.
#[error("Recording error: {0}")]
Recording(#[from] RecordingError),
/// Error related to segment operations.
#[error("Segment error: {0}")]
Segment(#[from] SegmentError),
/// Error related to embedding operations.
#[error("Embedding error: {0}")]
Embedding(#[from] EmbeddingError),
/// Error related to cluster operations.
#[error("Cluster error: {0}")]
Cluster(#[from] ClusterError),
/// Error related to analysis operations.
#[error("Analysis error: {0}")]
Analysis(#[from] AnalysisError),
/// Error related to configuration.
#[error("Configuration error: {0}")]
Configuration(#[from] ConfigurationError),
/// Error related to validation.
#[error("Validation error: {0}")]
Validation(#[from] ValidationError),
/// Internal error (unexpected condition).
#[error("Internal error: {0}")]
Internal(String),
}
impl DomainError {
/// Creates a new internal error.
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
/// Returns an error code for API responses.
#[must_use]
pub fn error_code(&self) -> &'static str {
match self {
Self::Recording(e) => e.error_code(),
Self::Segment(e) => e.error_code(),
Self::Embedding(e) => e.error_code(),
Self::Cluster(e) => e.error_code(),
Self::Analysis(e) => e.error_code(),
Self::Configuration(e) => e.error_code(),
Self::Validation(e) => e.error_code(),
Self::Internal(_) => "INTERNAL_ERROR",
}
}
/// Returns the HTTP status code for this error.
#[must_use]
pub fn status_code(&self) -> u16 {
match self {
Self::Recording(e) => e.status_code(),
Self::Segment(e) => e.status_code(),
Self::Embedding(e) => e.status_code(),
Self::Cluster(e) => e.status_code(),
Self::Analysis(e) => e.status_code(),
Self::Configuration(_) => 500,
Self::Validation(_) => 400,
Self::Internal(_) => 500,
}
}
}
// =============================================================================
// Recording Errors
// =============================================================================
/// Errors related to recording operations.
#[derive(Debug, Error)]
pub enum RecordingError {
/// Recording not found.
#[error("Recording not found: {0}")]
NotFound(RecordingId),
/// Recording already exists.
#[error("Recording already exists: {0}")]
AlreadyExists(RecordingId),
/// Invalid audio format.
#[error("Invalid audio format: {format}. Supported formats: {supported}")]
InvalidFormat {
/// The invalid format.
format: String,
/// Comma-separated list of supported formats.
supported: String,
},
/// File too large.
#[error("File too large: {size_bytes} bytes (max: {max_bytes} bytes)")]
FileTooLarge {
/// Actual file size.
size_bytes: u64,
/// Maximum allowed size.
max_bytes: u64,
},
/// Invalid duration.
#[error("Invalid duration: {duration_ms}ms (min: {min_ms}ms, max: {max_ms}ms)")]
InvalidDuration {
/// Actual duration.
duration_ms: u64,
/// Minimum allowed duration.
min_ms: u64,
/// Maximum allowed duration.
max_ms: u64,
},
/// Corrupted audio file.
#[error("Corrupted audio file: {reason}")]
Corrupted {
/// Reason for corruption.
reason: String,
},
/// Storage error.
#[error("Storage error: {0}")]
Storage(String),
/// Processing error.
#[error("Processing error: {0}")]
Processing(String),
}
impl RecordingError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::NotFound(_) => "RECORDING_NOT_FOUND",
Self::AlreadyExists(_) => "RECORDING_ALREADY_EXISTS",
Self::InvalidFormat { .. } => "INVALID_AUDIO_FORMAT",
Self::FileTooLarge { .. } => "FILE_TOO_LARGE",
Self::InvalidDuration { .. } => "INVALID_DURATION",
Self::Corrupted { .. } => "CORRUPTED_FILE",
Self::Storage(_) => "STORAGE_ERROR",
Self::Processing(_) => "PROCESSING_ERROR",
}
}
/// Returns the HTTP status code.
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::NotFound(_) => 404,
Self::AlreadyExists(_) => 409,
Self::InvalidFormat { .. } => 415,
Self::FileTooLarge { .. } => 413,
Self::InvalidDuration { .. } => 400,
Self::Corrupted { .. } => 400,
Self::Storage(_) => 503,
Self::Processing(_) => 500,
}
}
}
// =============================================================================
// Segment Errors
// =============================================================================
/// Errors related to segment operations.
#[derive(Debug, Error)]
pub enum SegmentError {
/// Segment not found.
#[error("Segment not found: {0}")]
NotFound(SegmentId),
/// Invalid time range.
#[error("Invalid time range: start={start_ms}ms, end={end_ms}ms")]
InvalidTimeRange {
/// Start time.
start_ms: u64,
/// End time.
end_ms: u64,
},
/// Time range out of bounds.
#[error("Time range out of bounds: segment ends at {end_ms}ms but recording is {duration_ms}ms")]
OutOfBounds {
/// Segment end time.
end_ms: u64,
/// Recording duration.
duration_ms: u64,
},
/// Segment too short.
#[error("Segment too short: {duration_ms}ms (min: {min_ms}ms)")]
TooShort {
/// Actual duration.
duration_ms: u64,
/// Minimum required duration.
min_ms: u64,
},
/// Overlapping segments.
#[error("Segment overlaps with existing segment: {existing_id}")]
Overlapping {
/// Existing segment ID.
existing_id: SegmentId,
},
/// Classification failed.
#[error("Classification failed: {reason}")]
ClassificationFailed {
/// Failure reason.
reason: String,
},
}
impl SegmentError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::NotFound(_) => "SEGMENT_NOT_FOUND",
Self::InvalidTimeRange { .. } => "INVALID_TIME_RANGE",
Self::OutOfBounds { .. } => "TIME_RANGE_OUT_OF_BOUNDS",
Self::TooShort { .. } => "SEGMENT_TOO_SHORT",
Self::Overlapping { .. } => "OVERLAPPING_SEGMENT",
Self::ClassificationFailed { .. } => "CLASSIFICATION_FAILED",
}
}
/// Returns the HTTP status code.
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::NotFound(_) => 404,
Self::InvalidTimeRange { .. } => 400,
Self::OutOfBounds { .. } => 400,
Self::TooShort { .. } => 400,
Self::Overlapping { .. } => 409,
Self::ClassificationFailed { .. } => 500,
}
}
}
// =============================================================================
// Embedding Errors
// =============================================================================
/// Errors related to embedding operations.
#[derive(Debug, Error)]
pub enum EmbeddingError {
/// Embedding not found.
#[error("Embedding not found: {0}")]
NotFound(EmbeddingId),
/// Model not available.
#[error("Embedding model not available: {model_name}")]
ModelNotAvailable {
/// Model name.
model_name: String,
},
/// Dimension mismatch.
#[error("Embedding dimension mismatch: expected {expected}, got {actual}")]
DimensionMismatch {
/// Expected dimension.
expected: u32,
/// Actual dimension.
actual: u32,
},
/// Generation failed.
#[error("Embedding generation failed: {reason}")]
GenerationFailed {
/// Failure reason.
reason: String,
},
/// Indexing failed.
#[error("Embedding indexing failed: {reason}")]
IndexingFailed {
/// Failure reason.
reason: String,
},
/// Search failed.
#[error("Embedding search failed: {reason}")]
SearchFailed {
/// Failure reason.
reason: String,
},
/// Vector database error.
#[error("Vector database error: {0}")]
VectorDb(String),
}
impl EmbeddingError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::NotFound(_) => "EMBEDDING_NOT_FOUND",
Self::ModelNotAvailable { .. } => "MODEL_NOT_AVAILABLE",
Self::DimensionMismatch { .. } => "DIMENSION_MISMATCH",
Self::GenerationFailed { .. } => "GENERATION_FAILED",
Self::IndexingFailed { .. } => "INDEXING_FAILED",
Self::SearchFailed { .. } => "SEARCH_FAILED",
Self::VectorDb(_) => "VECTOR_DB_ERROR",
}
}
/// Returns the HTTP status code.
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::NotFound(_) => 404,
Self::ModelNotAvailable { .. } => 503,
Self::DimensionMismatch { .. } => 400,
Self::GenerationFailed { .. } => 500,
Self::IndexingFailed { .. } => 500,
Self::SearchFailed { .. } => 500,
Self::VectorDb(_) => 503,
}
}
}
// =============================================================================
// Cluster Errors
// =============================================================================
/// Errors related to cluster operations.
#[derive(Debug, Error)]
pub enum ClusterError {
/// Cluster not found.
#[error("Cluster not found: {0}")]
NotFound(ClusterId),
/// Empty cluster (no members).
#[error("Cannot perform operation on empty cluster: {0}")]
Empty(ClusterId),
/// Invalid merge (clusters too dissimilar).
#[error("Cannot merge clusters: similarity {similarity} below threshold {threshold}")]
InvalidMerge {
/// Actual similarity.
similarity: f32,
/// Required threshold.
threshold: f32,
},
/// Cluster already identified.
#[error("Cluster already identified as: {taxon_id}")]
AlreadyIdentified {
/// Existing taxon.
taxon_id: TaxonId,
},
/// Insufficient members for operation.
#[error("Insufficient cluster members: {count} (min: {min_required})")]
InsufficientMembers {
/// Actual count.
count: u32,
/// Minimum required.
min_required: u32,
},
}
impl ClusterError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::NotFound(_) => "CLUSTER_NOT_FOUND",
Self::Empty(_) => "CLUSTER_EMPTY",
Self::InvalidMerge { .. } => "INVALID_MERGE",
Self::AlreadyIdentified { .. } => "CLUSTER_ALREADY_IDENTIFIED",
Self::InsufficientMembers { .. } => "INSUFFICIENT_MEMBERS",
}
}
/// Returns the HTTP status code.
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::NotFound(_) => 404,
Self::Empty(_) => 400,
Self::InvalidMerge { .. } => 400,
Self::AlreadyIdentified { .. } => 409,
Self::InsufficientMembers { .. } => 400,
}
}
}
// =============================================================================
// Analysis Errors
// =============================================================================
/// Errors related to analysis operations.
#[derive(Debug, Error)]
pub enum AnalysisError {
/// Analysis request not found.
#[error("Analysis request not found: {request_id}")]
NotFound {
/// Request ID.
request_id: String,
},
/// Analysis already in progress.
#[error("Analysis already in progress for target")]
AlreadyInProgress,
/// Invalid region.
#[error("Invalid region: radius {radius_m}m exceeds maximum {max_radius_m}m")]
InvalidRegion {
/// Requested radius.
radius_m: f64,
/// Maximum allowed radius.
max_radius_m: f64,
},
/// Invalid time period.
#[error("Invalid time period: {reason}")]
InvalidTimePeriod {
/// Reason.
reason: String,
},
/// Insufficient data.
#[error("Insufficient data for analysis: {reason}")]
InsufficientData {
/// Reason.
reason: String,
},
/// Analysis timeout.
#[error("Analysis timed out after {timeout_secs} seconds")]
Timeout {
/// Timeout duration.
timeout_secs: u64,
},
/// Analysis failed.
#[error("Analysis failed: {reason}")]
Failed {
/// Failure reason.
reason: String,
},
}
impl AnalysisError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::NotFound { .. } => "ANALYSIS_NOT_FOUND",
Self::AlreadyInProgress => "ANALYSIS_IN_PROGRESS",
Self::InvalidRegion { .. } => "INVALID_REGION",
Self::InvalidTimePeriod { .. } => "INVALID_TIME_PERIOD",
Self::InsufficientData { .. } => "INSUFFICIENT_DATA",
Self::Timeout { .. } => "ANALYSIS_TIMEOUT",
Self::Failed { .. } => "ANALYSIS_FAILED",
}
}
/// Returns the HTTP status code.
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::NotFound { .. } => 404,
Self::AlreadyInProgress => 409,
Self::InvalidRegion { .. } => 400,
Self::InvalidTimePeriod { .. } => 400,
Self::InsufficientData { .. } => 400,
Self::Timeout { .. } => 504,
Self::Failed { .. } => 500,
}
}
}
// =============================================================================
// Configuration Errors
// =============================================================================
/// Errors related to configuration.
#[derive(Debug, Error)]
pub enum ConfigurationError {
/// Missing required configuration.
#[error("Missing required configuration: {key}")]
Missing {
/// Configuration key.
key: String,
},
/// Invalid configuration value.
#[error("Invalid configuration value for {key}: {reason}")]
Invalid {
/// Configuration key.
key: String,
/// Reason.
reason: String,
},
/// Configuration file not found.
#[error("Configuration file not found: {path}")]
FileNotFound {
/// File path.
path: String,
},
/// Configuration parse error.
#[error("Failed to parse configuration: {reason}")]
ParseError {
/// Reason.
reason: String,
},
}
impl ConfigurationError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::Missing { .. } => "CONFIG_MISSING",
Self::Invalid { .. } => "CONFIG_INVALID",
Self::FileNotFound { .. } => "CONFIG_FILE_NOT_FOUND",
Self::ParseError { .. } => "CONFIG_PARSE_ERROR",
}
}
}
// =============================================================================
// Validation Errors
// =============================================================================
/// Errors related to input validation.
#[derive(Debug, Error)]
pub enum ValidationError {
/// Required field missing.
#[error("Required field missing: {field}")]
RequiredField {
/// Field name.
field: String,
},
/// Field value out of range.
#[error("Field {field} out of range: {value} (min: {min}, max: {max})")]
OutOfRange {
/// Field name.
field: String,
/// Actual value.
value: String,
/// Minimum allowed.
min: String,
/// Maximum allowed.
max: String,
},
/// Invalid field format.
#[error("Invalid format for field {field}: {reason}")]
InvalidFormat {
/// Field name.
field: String,
/// Reason.
reason: String,
},
/// Multiple validation errors.
#[error("Multiple validation errors: {}", .errors.join(", "))]
Multiple {
/// List of error messages.
errors: Vec<String>,
},
}
impl ValidationError {
/// Returns an error code.
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::RequiredField { .. } => "REQUIRED_FIELD_MISSING",
Self::OutOfRange { .. } => "VALUE_OUT_OF_RANGE",
Self::InvalidFormat { .. } => "INVALID_FORMAT",
Self::Multiple { .. } => "VALIDATION_ERRORS",
}
}
/// Creates a validation error for a required field.
pub fn required(field: impl Into<String>) -> Self {
Self::RequiredField {
field: field.into(),
}
}
/// Creates a validation error for an out-of-range value.
pub fn out_of_range<T: std::fmt::Display>(
field: impl Into<String>,
value: T,
min: T,
max: T,
) -> Self {
Self::OutOfRange {
field: field.into(),
value: value.to_string(),
min: min.to_string(),
max: max.to_string(),
}
}
/// Creates a validation error for an invalid format.
pub fn invalid_format(field: impl Into<String>, reason: impl Into<String>) -> Self {
Self::InvalidFormat {
field: field.into(),
reason: reason.into(),
}
}
}
// =============================================================================
// API Error Response
// =============================================================================
/// Serializable error response for API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
/// Error code for programmatic handling.
pub code: String,
/// Human-readable error message.
pub message: String,
/// Additional error details.
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
/// Request ID for tracing.
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
impl ErrorResponse {
/// Creates a new error response.
#[must_use]
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: None,
request_id: None,
}
}
/// Adds details to the error response.
#[must_use]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
/// Adds a request ID to the error response.
#[must_use]
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
}
impl From<&DomainError> for ErrorResponse {
fn from(error: &DomainError) -> Self {
Self::new(error.error_code(), error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recording_error_codes() {
let err = RecordingError::NotFound(RecordingId::new());
assert_eq!(err.error_code(), "RECORDING_NOT_FOUND");
assert_eq!(err.status_code(), 404);
}
#[test]
fn test_domain_error_conversion() {
let recording_err = RecordingError::NotFound(RecordingId::new());
let domain_err = DomainError::from(recording_err);
assert_eq!(domain_err.error_code(), "RECORDING_NOT_FOUND");
assert_eq!(domain_err.status_code(), 404);
}
#[test]
fn test_validation_error_builders() {
let err = ValidationError::required("name");
assert_eq!(err.error_code(), "REQUIRED_FIELD_MISSING");
let err = ValidationError::out_of_range("age", 150, 0, 120);
assert_eq!(err.error_code(), "VALUE_OUT_OF_RANGE");
}
#[test]
fn test_error_response_serialization() {
let response = ErrorResponse::new("TEST_ERROR", "Test error message")
.with_request_id("req-123");
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("TEST_ERROR"));
assert!(json.contains("req-123"));
}
}

View File

@@ -0,0 +1,730 @@
//! # Domain Events
//!
//! Domain events represent things that have happened in the system.
//! They are used for event sourcing, audit logging, and inter-service communication.
//!
//! ## Event Categories
//!
//! - **Recording Events**: Lifecycle of audio recordings
//! - **Segment Events**: Audio segment detection and processing
//! - **Embedding Events**: Vector embedding generation
//! - **Cluster Events**: Species/sound clustering operations
//! - **Analysis Events**: Interpretation and analysis results
use serde::{Deserialize, Serialize};
use super::entities::{
ClusterId, Confidence, EmbeddingId, GeoLocation, RecordingId, SegmentId, TaxonId, TimeRange,
Timestamp,
};
/// Unique identifier for a domain event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EventId(uuid::Uuid);
impl EventId {
/// Creates a new random `EventId`.
#[must_use]
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
/// Returns the inner UUID value.
#[must_use]
pub const fn inner(&self) -> uuid::Uuid {
self.0
}
}
impl Default for EventId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for EventId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Metadata common to all domain events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventMetadata {
/// Unique identifier for this event.
pub event_id: EventId,
/// When this event occurred.
pub timestamp: Timestamp,
/// Correlation ID for tracing related events.
pub correlation_id: Option<String>,
/// Causation ID (the event that caused this event).
pub causation_id: Option<EventId>,
/// Version of the event schema.
pub schema_version: u32,
}
impl EventMetadata {
/// Creates new event metadata with default values.
#[must_use]
pub fn new() -> Self {
Self {
event_id: EventId::new(),
timestamp: Timestamp::now(),
correlation_id: None,
causation_id: None,
schema_version: 1,
}
}
/// Creates new event metadata with a correlation ID.
#[must_use]
pub fn with_correlation(correlation_id: impl Into<String>) -> Self {
Self {
correlation_id: Some(correlation_id.into()),
..Self::new()
}
}
/// Sets the causation ID.
#[must_use]
pub fn with_causation(mut self, causation_id: EventId) -> Self {
self.causation_id = Some(causation_id);
self
}
}
impl Default for EventMetadata {
fn default() -> Self {
Self::new()
}
}
/// All domain events in the system.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DomainEvent {
// =========================================================================
// Recording Events
// =========================================================================
/// A new recording has been uploaded to the system.
RecordingUploaded(RecordingUploadedEvent),
/// A recording has been validated and is ready for processing.
RecordingValidated(RecordingValidatedEvent),
/// A recording has failed validation.
RecordingValidationFailed(RecordingValidationFailedEvent),
/// A recording has been fully processed.
RecordingProcessed(RecordingProcessedEvent),
/// A recording has been archived.
RecordingArchived(RecordingArchivedEvent),
/// A recording has been deleted.
RecordingDeleted(RecordingDeletedEvent),
// =========================================================================
// Segment Events
// =========================================================================
/// A segment has been detected within a recording.
SegmentDetected(SegmentDetectedEvent),
/// A segment has been classified.
SegmentClassified(SegmentClassifiedEvent),
/// A segment has been rejected (e.g., noise, artifact).
SegmentRejected(SegmentRejectedEvent),
/// A segment has been manually verified by a user.
SegmentVerified(SegmentVerifiedEvent),
// =========================================================================
// Embedding Events
// =========================================================================
/// An embedding has been generated for a segment.
EmbeddingGenerated(EmbeddingGeneratedEvent),
/// An embedding has been indexed in the vector database.
EmbeddingIndexed(EmbeddingIndexedEvent),
/// Similar embeddings have been found.
SimilarEmbeddingsFound(SimilarEmbeddingsFoundEvent),
// =========================================================================
// Cluster Events
// =========================================================================
/// A new cluster has been created.
ClusterCreated(ClusterCreatedEvent),
/// An embedding has been added to a cluster.
ClusterMemberAdded(ClusterMemberAddedEvent),
/// A cluster has been merged with another.
ClusterMerged(ClusterMergedEvent),
/// A cluster has been identified as a species.
ClusterIdentified(ClusterIdentifiedEvent),
/// A cluster has been split into multiple clusters.
ClusterSplit(ClusterSplitEvent),
// =========================================================================
// Analysis Events
// =========================================================================
/// Analysis has been requested for a recording or region.
AnalysisRequested(AnalysisRequestedEvent),
/// Analysis has completed.
AnalysisCompleted(AnalysisCompletedEvent),
/// A species has been detected.
SpeciesDetected(SpeciesDetectedEvent),
/// A biodiversity report has been generated.
BiodiversityReportGenerated(BiodiversityReportGeneratedEvent),
}
impl DomainEvent {
/// Returns the event type name.
#[must_use]
pub fn event_type(&self) -> &'static str {
match self {
Self::RecordingUploaded(_) => "recording_uploaded",
Self::RecordingValidated(_) => "recording_validated",
Self::RecordingValidationFailed(_) => "recording_validation_failed",
Self::RecordingProcessed(_) => "recording_processed",
Self::RecordingArchived(_) => "recording_archived",
Self::RecordingDeleted(_) => "recording_deleted",
Self::SegmentDetected(_) => "segment_detected",
Self::SegmentClassified(_) => "segment_classified",
Self::SegmentRejected(_) => "segment_rejected",
Self::SegmentVerified(_) => "segment_verified",
Self::EmbeddingGenerated(_) => "embedding_generated",
Self::EmbeddingIndexed(_) => "embedding_indexed",
Self::SimilarEmbeddingsFound(_) => "similar_embeddings_found",
Self::ClusterCreated(_) => "cluster_created",
Self::ClusterMemberAdded(_) => "cluster_member_added",
Self::ClusterMerged(_) => "cluster_merged",
Self::ClusterIdentified(_) => "cluster_identified",
Self::ClusterSplit(_) => "cluster_split",
Self::AnalysisRequested(_) => "analysis_requested",
Self::AnalysisCompleted(_) => "analysis_completed",
Self::SpeciesDetected(_) => "species_detected",
Self::BiodiversityReportGenerated(_) => "biodiversity_report_generated",
}
}
/// Returns the event metadata.
#[must_use]
pub fn metadata(&self) -> &EventMetadata {
match self {
Self::RecordingUploaded(e) => &e.metadata,
Self::RecordingValidated(e) => &e.metadata,
Self::RecordingValidationFailed(e) => &e.metadata,
Self::RecordingProcessed(e) => &e.metadata,
Self::RecordingArchived(e) => &e.metadata,
Self::RecordingDeleted(e) => &e.metadata,
Self::SegmentDetected(e) => &e.metadata,
Self::SegmentClassified(e) => &e.metadata,
Self::SegmentRejected(e) => &e.metadata,
Self::SegmentVerified(e) => &e.metadata,
Self::EmbeddingGenerated(e) => &e.metadata,
Self::EmbeddingIndexed(e) => &e.metadata,
Self::SimilarEmbeddingsFound(e) => &e.metadata,
Self::ClusterCreated(e) => &e.metadata,
Self::ClusterMemberAdded(e) => &e.metadata,
Self::ClusterMerged(e) => &e.metadata,
Self::ClusterIdentified(e) => &e.metadata,
Self::ClusterSplit(e) => &e.metadata,
Self::AnalysisRequested(e) => &e.metadata,
Self::AnalysisCompleted(e) => &e.metadata,
Self::SpeciesDetected(e) => &e.metadata,
Self::BiodiversityReportGenerated(e) => &e.metadata,
}
}
}
// =============================================================================
// Recording Events
// =============================================================================
/// Event emitted when a new recording is uploaded.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingUploadedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Original filename.
pub filename: String,
/// File size in bytes.
pub file_size_bytes: u64,
/// MIME type of the uploaded file.
pub mime_type: String,
/// Geographic location where the recording was made.
pub location: Option<GeoLocation>,
/// When the recording was captured.
pub recorded_at: Option<Timestamp>,
}
/// Event emitted when a recording passes validation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingValidatedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Sample rate in Hz.
pub sample_rate: u32,
/// Number of channels.
pub channels: u8,
/// Duration in milliseconds.
pub duration_ms: u64,
}
/// Event emitted when a recording fails validation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingValidationFailedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Reason for validation failure.
pub reason: String,
/// Error code for programmatic handling.
pub error_code: String,
}
/// Event emitted when a recording has been fully processed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingProcessedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Number of segments detected.
pub segment_count: u32,
/// Number of unique species detected.
pub species_count: u32,
/// Processing time in milliseconds.
pub processing_time_ms: u64,
}
/// Event emitted when a recording is archived.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingArchivedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Archive location (e.g., S3 URI).
pub archive_location: String,
}
/// Event emitted when a recording is deleted.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingDeletedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Reason for deletion.
pub reason: Option<String>,
}
// =============================================================================
// Segment Events
// =============================================================================
/// Event emitted when a segment is detected in a recording.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SegmentDetectedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The segment ID.
pub segment_id: SegmentId,
/// Parent recording ID.
pub recording_id: RecordingId,
/// Time range within the recording.
pub time_range: TimeRange,
/// Frequency range in Hz (low, high).
pub frequency_range: Option<(f32, f32)>,
/// Detection confidence.
pub confidence: Confidence,
}
/// Event emitted when a segment is classified.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SegmentClassifiedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The segment ID.
pub segment_id: SegmentId,
/// Predicted taxon.
pub taxon_id: TaxonId,
/// Classification confidence.
pub confidence: Confidence,
/// Top alternative predictions with confidences.
pub alternatives: Vec<(TaxonId, Confidence)>,
}
/// Event emitted when a segment is rejected.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SegmentRejectedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The segment ID.
pub segment_id: SegmentId,
/// Reason for rejection.
pub reason: SegmentRejectionReason,
}
/// Reasons why a segment might be rejected.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SegmentRejectionReason {
/// Background noise or environmental sounds.
Noise,
/// Recording artifact (clipping, distortion).
Artifact,
/// Human speech or activity.
HumanActivity,
/// Mechanical or artificial sound.
Mechanical,
/// Too short to analyze.
TooShort,
/// Below confidence threshold.
LowConfidence,
/// Other reason with description.
Other(String),
}
/// Event emitted when a segment is manually verified.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SegmentVerifiedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The segment ID.
pub segment_id: SegmentId,
/// Verified taxon (may differ from prediction).
pub verified_taxon: TaxonId,
/// User who verified.
pub verified_by: String,
/// Whether the prediction was correct.
pub prediction_correct: bool,
}
// =============================================================================
// Embedding Events
// =============================================================================
/// Event emitted when an embedding is generated.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EmbeddingGeneratedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The embedding ID.
pub embedding_id: EmbeddingId,
/// Source segment ID.
pub segment_id: SegmentId,
/// Embedding model used.
pub model_name: String,
/// Model version.
pub model_version: String,
/// Embedding dimensionality.
pub dimensions: u32,
}
/// Event emitted when an embedding is indexed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EmbeddingIndexedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The embedding ID.
pub embedding_id: EmbeddingId,
/// Vector database collection name.
pub collection_name: String,
/// Point ID in the vector database.
pub point_id: String,
}
/// Event emitted when similar embeddings are found.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SimilarEmbeddingsFoundEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// Query embedding ID.
pub query_embedding_id: EmbeddingId,
/// Similar embeddings with scores.
pub similar: Vec<SimilarEmbedding>,
/// Search parameters used.
pub search_params: SearchParams,
}
/// A similar embedding result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SimilarEmbedding {
/// The embedding ID.
pub embedding_id: EmbeddingId,
/// Similarity score (0.0 to 1.0).
pub score: f32,
/// Associated taxon if known.
pub taxon_id: Option<TaxonId>,
}
/// Parameters for similarity search.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchParams {
/// Number of results to return.
pub limit: u32,
/// Minimum similarity threshold.
pub min_score: f32,
/// Whether to use approximate search.
pub approximate: bool,
}
// =============================================================================
// Cluster Events
// =============================================================================
/// Event emitted when a new cluster is created.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClusterCreatedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The cluster ID.
pub cluster_id: ClusterId,
/// Initial centroid embedding ID.
pub centroid_embedding_id: EmbeddingId,
/// Clustering algorithm used.
pub algorithm: String,
}
/// Event emitted when an embedding joins a cluster.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClusterMemberAddedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The cluster ID.
pub cluster_id: ClusterId,
/// The embedding ID.
pub embedding_id: EmbeddingId,
/// Distance to cluster centroid.
pub distance_to_centroid: f32,
}
/// Event emitted when clusters are merged.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClusterMergedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The resulting cluster ID.
pub target_cluster_id: ClusterId,
/// Clusters that were merged in.
pub source_cluster_ids: Vec<ClusterId>,
/// Number of members in merged cluster.
pub member_count: u32,
}
/// Event emitted when a cluster is identified as a species.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClusterIdentifiedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The cluster ID.
pub cluster_id: ClusterId,
/// Identified taxon.
pub taxon_id: TaxonId,
/// Identification confidence.
pub confidence: Confidence,
/// Method used for identification.
pub identification_method: IdentificationMethod,
}
/// Methods for cluster identification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentificationMethod {
/// Automatic classification by model.
Automatic,
/// Manual identification by expert.
Manual,
/// Consensus from multiple verifications.
Consensus,
/// Reference library match.
ReferenceMatch,
}
/// Event emitted when a cluster is split.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClusterSplitEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// Original cluster ID.
pub source_cluster_id: ClusterId,
/// New cluster IDs created from split.
pub new_cluster_ids: Vec<ClusterId>,
/// Reason for split.
pub reason: String,
}
// =============================================================================
// Analysis Events
// =============================================================================
/// Event emitted when analysis is requested.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisRequestedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// Analysis request ID.
pub request_id: String,
/// Type of analysis requested.
pub analysis_type: AnalysisType,
/// Target (recording ID or location).
pub target: AnalysisTarget,
}
/// Types of analysis.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnalysisType {
/// Species detection.
SpeciesDetection,
/// Biodiversity assessment.
BiodiversityAssessment,
/// Temporal activity patterns.
ActivityPattern,
/// Acoustic index calculation.
AcousticIndices,
/// Custom analysis.
Custom(String),
}
/// Target of analysis.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnalysisTarget {
/// Single recording.
Recording(RecordingId),
/// Geographic region.
Region {
/// Center location.
center: GeoLocation,
/// Radius in meters.
radius_m: f64,
},
/// Time period.
TimePeriod {
/// Start time.
start: Timestamp,
/// End time.
end: Timestamp,
},
}
/// Event emitted when analysis completes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisCompletedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// Analysis request ID.
pub request_id: String,
/// Time taken in milliseconds.
pub duration_ms: u64,
/// Summary of results.
pub summary: String,
/// Location of full results.
pub results_location: String,
}
/// Event emitted when a species is detected.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpeciesDetectedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// The recording ID.
pub recording_id: RecordingId,
/// Detected taxon.
pub taxon_id: TaxonId,
/// Detection confidence.
pub confidence: Confidence,
/// Time ranges where species was detected.
pub time_ranges: Vec<TimeRange>,
/// Location of detection.
pub location: Option<GeoLocation>,
}
/// Event emitted when a biodiversity report is generated.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BiodiversityReportGeneratedEvent {
/// Event metadata.
pub metadata: EventMetadata,
/// Report ID.
pub report_id: String,
/// Geographic region covered.
pub region: GeoLocation,
/// Radius in meters.
pub radius_m: f64,
/// Time period covered.
pub time_period: (Timestamp, Timestamp),
/// Number of species detected.
pub species_count: u32,
/// Shannon diversity index.
pub shannon_index: f64,
/// Location of full report.
pub report_location: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_metadata_creation() {
let meta = EventMetadata::new();
assert!(meta.correlation_id.is_none());
assert!(meta.causation_id.is_none());
assert_eq!(meta.schema_version, 1);
}
#[test]
fn test_event_metadata_with_correlation() {
let meta = EventMetadata::with_correlation("test-correlation");
assert_eq!(meta.correlation_id, Some("test-correlation".to_string()));
}
#[test]
fn test_domain_event_type() {
let event = DomainEvent::RecordingUploaded(RecordingUploadedEvent {
metadata: EventMetadata::new(),
recording_id: RecordingId::new(),
filename: "test.wav".to_string(),
file_size_bytes: 1024,
mime_type: "audio/wav".to_string(),
location: None,
recorded_at: None,
});
assert_eq!(event.event_type(), "recording_uploaded");
}
#[test]
fn test_event_serialization() {
let event = DomainEvent::SegmentDetected(SegmentDetectedEvent {
metadata: EventMetadata::new(),
segment_id: SegmentId::new(),
recording_id: RecordingId::new(),
time_range: TimeRange::new(1000, 5000),
frequency_range: Some((200.0, 8000.0)),
confidence: Confidence::new(0.95),
});
let json = serde_json::to_string(&event).unwrap();
let deserialized: DomainEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event.event_type(), deserialized.event_type());
}
}

View File

@@ -0,0 +1,17 @@
//! # Domain Module
//!
//! Core domain types following Domain-Driven Design principles.
//!
//! This module contains:
//! - **Entities**: Objects with identity that persist over time
//! - **Value Objects**: Immutable objects defined by their attributes
//! - **Domain Events**: Events that represent something that happened in the domain
//! - **Domain Errors**: Strongly-typed errors for domain operations
pub mod entities;
pub mod errors;
pub mod events;
pub use entities::*;
pub use errors::*;
pub use events::*;

View File

@@ -0,0 +1,38 @@
//! Common error types for 7sense.
/// Domain-level errors.
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
/// Invalid configuration.
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
/// IO error.
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// Entity not found.
#[error("Not found: {entity_type} with id {id}")]
NotFound {
/// Entity type.
entity_type: &'static str,
/// Entity ID.
id: String,
},
/// Validation error.
#[error("Validation failed: {0}")]
Validation(String),
}
impl CoreError {
/// Creates a NotFound error.
pub fn not_found(entity_type: &'static str, id: impl ToString) -> Self {
Self::NotFound {
entity_type,
id: id.to_string(),
}
}
/// Creates a Validation error.
pub fn validation(message: impl ToString) -> Self {
Self::Validation(message.to_string())
}
}

View File

@@ -0,0 +1,89 @@
//! Strongly-typed entity identifiers.
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;
/// Macro to generate strongly-typed ID wrappers around UUID.
macro_rules! define_id {
($name:ident, $doc:literal) => {
#[doc = $doc]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(Uuid);
impl $name {
/// Creates a new random ID.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Creates an ID from an existing UUID.
#[must_use]
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
/// Returns the underlying UUID.
#[must_use]
pub const fn as_uuid(&self) -> &Uuid {
&self.0
}
/// Parses an ID from a string.
pub fn parse_str(s: &str) -> Result<Self, uuid::Error> {
Ok(Self(Uuid::parse_str(s)?))
}
/// Creates a nil (all zeros) ID.
#[must_use]
pub const fn nil() -> Self {
Self(Uuid::nil())
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for $name {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
};
}
define_id!(RecordingId, "Unique identifier for an audio recording.");
define_id!(SegmentId, "Unique identifier for a call segment.");
define_id!(EmbeddingId, "Unique identifier for an embedding vector.");
define_id!(AnalysisId, "Unique identifier for an analysis result.");
define_id!(SpeciesId, "Unique identifier for a species.");
define_id!(ModelId, "Unique identifier for a trained model.");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_id_creation() {
let id1 = RecordingId::new();
let id2 = RecordingId::new();
assert_ne!(id1, id2);
}
#[test]
fn test_id_display() {
let id = RecordingId::nil();
assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
}
}

View File

@@ -0,0 +1,20 @@
//! Core types and traits for 7sense bioacoustic analysis.
//!
//! This crate provides foundational types shared across all bounded contexts:
//! - Entity identifiers (strongly-typed IDs)
//! - Value objects (GeoLocation, Timestamp, AudioMetadata)
//! - Common error types
//! - Domain entities and events
#![warn(missing_docs)]
#![warn(clippy::all)]
pub mod identifiers;
pub mod values;
pub mod error;
pub mod domain;
// Re-export commonly used types
pub use identifiers::*;
pub use values::*;
pub use error::*;

View File

@@ -0,0 +1,357 @@
//! # Telemetry Module
//!
//! Observability infrastructure for the 7sense platform.
//!
//! This module provides:
//! - Structured logging with tracing
//! - Distributed tracing with OpenTelemetry
//! - Metrics collection
//! - Health check utilities
use std::time::Duration;
use opentelemetry::trace::TracerProvider;
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing::Level;
use tracing_subscriber::{
fmt::{self, format::FmtSpan},
layer::SubscriberExt,
util::SubscriberInitExt,
EnvFilter, Layer,
};
use crate::config::{LogFormat, LoggingConfig, OpenTelemetryConfig};
/// Telemetry guard that cleans up on drop.
pub struct TelemetryGuard {
_tracer_provider: Option<SdkTracerProvider>,
}
impl Drop for TelemetryGuard {
fn drop(&mut self) {
if let Some(provider) = self._tracer_provider.take() {
if let Err(e) = provider.shutdown() {
eprintln!("Error shutting down tracer provider: {e:?}");
}
}
}
}
/// Initializes telemetry with the given configuration.
///
/// # Errors
///
/// Returns an error if telemetry initialization fails.
pub fn init(config: &LoggingConfig) -> Result<TelemetryGuard, TelemetryError> {
// Build the env filter
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.level));
// Create the formatting layer based on config
let fmt_layer = match config.format {
LogFormat::Json => {
fmt::layer()
.json()
.with_target(true)
.with_thread_ids(true)
.with_file(config.include_location)
.with_line_number(config.include_location)
.with_span_events(if config.include_spans {
FmtSpan::NEW | FmtSpan::CLOSE
} else {
FmtSpan::NONE
})
.boxed()
}
LogFormat::Pretty => {
fmt::layer()
.pretty()
.with_target(true)
.with_thread_names(true)
.with_file(config.include_location)
.with_line_number(config.include_location)
.with_span_events(if config.include_spans {
FmtSpan::NEW | FmtSpan::CLOSE
} else {
FmtSpan::NONE
})
.boxed()
}
LogFormat::Compact => {
fmt::layer()
.compact()
.with_target(true)
.with_span_events(FmtSpan::NONE)
.boxed()
}
};
// Initialize OpenTelemetry if configured
let (otel_layer, tracer_provider) = if let Some(otel_config) = &config.opentelemetry {
let (layer, provider) = init_opentelemetry(otel_config)?;
(Some(layer), Some(provider))
} else {
(None, None)
};
// Build and set the subscriber
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer);
if let Some(otel_layer) = otel_layer {
subscriber.with(otel_layer).init();
} else {
subscriber.init();
}
tracing::info!(
target: "sevensense::telemetry",
level = %config.level,
format = ?config.format,
"Telemetry initialized"
);
Ok(TelemetryGuard {
_tracer_provider: tracer_provider,
})
}
/// Initializes OpenTelemetry tracing.
fn init_opentelemetry(
config: &OpenTelemetryConfig,
) -> Result<(impl Layer<tracing_subscriber::Registry> + Send + Sync, SdkTracerProvider), TelemetryError> {
use opentelemetry::KeyValue;
use opentelemetry_sdk::{
trace::{Config as TraceConfig, Sampler},
Resource,
};
// Create the OTLP exporter
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.endpoint)
.with_timeout(Duration::from_secs(10))
.build()
.map_err(|e| TelemetryError::OpenTelemetry(e.to_string()))?;
// Create the tracer provider
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_config(
TraceConfig::default()
.with_sampler(Sampler::TraceIdRatioBased(config.sampling_ratio))
.with_resource(Resource::new(vec![
KeyValue::new("service.name", config.service_name.clone()),
KeyValue::new("service.version", crate::VERSION),
])),
)
.build();
// Create the tracing layer
let tracer = provider.tracer(config.service_name.clone());
let layer = tracing_opentelemetry::layer().with_tracer(tracer);
Ok((layer, provider))
}
/// Telemetry errors.
#[derive(Debug, thiserror::Error)]
pub enum TelemetryError {
/// Failed to initialize OpenTelemetry.
#[error("OpenTelemetry initialization failed: {0}")]
OpenTelemetry(String),
/// Failed to initialize logging.
#[error("Logging initialization failed: {0}")]
Logging(String),
}
/// Creates a new span for an operation.
#[macro_export]
macro_rules! span {
($level:expr, $name:expr) => {
tracing::span!($level, $name)
};
($level:expr, $name:expr, $($field:tt)*) => {
tracing::span!($level, $name, $($field)*)
};
}
/// Logs an event with timing information.
#[macro_export]
macro_rules! timed {
($name:expr, $block:expr) => {{
let start = std::time::Instant::now();
let result = $block;
let elapsed = start.elapsed();
tracing::debug!(
target: "sevensense::timing",
operation = $name,
duration_ms = elapsed.as_millis() as u64,
"Operation completed"
);
result
}};
}
/// Health check status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HealthStatus {
/// Service is healthy.
Healthy,
/// Service is degraded but functional.
Degraded,
/// Service is unhealthy.
Unhealthy,
}
impl HealthStatus {
/// Returns whether the service is operational (healthy or degraded).
#[must_use]
pub const fn is_operational(&self) -> bool {
matches!(self, Self::Healthy | Self::Degraded)
}
}
/// Component health check result.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ComponentHealth {
/// Component name.
pub name: String,
/// Health status.
pub status: HealthStatus,
/// Optional status message.
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
/// Response time in milliseconds.
#[serde(skip_serializing_if = "Option::is_none")]
pub response_time_ms: Option<u64>,
}
impl ComponentHealth {
/// Creates a healthy component status.
#[must_use]
pub fn healthy(name: impl Into<String>) -> Self {
Self {
name: name.into(),
status: HealthStatus::Healthy,
message: None,
response_time_ms: None,
}
}
/// Creates a degraded component status.
#[must_use]
pub fn degraded(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
status: HealthStatus::Degraded,
message: Some(message.into()),
response_time_ms: None,
}
}
/// Creates an unhealthy component status.
#[must_use]
pub fn unhealthy(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
status: HealthStatus::Unhealthy,
message: Some(message.into()),
response_time_ms: None,
}
}
/// Sets the response time.
#[must_use]
pub fn with_response_time(mut self, ms: u64) -> Self {
self.response_time_ms = Some(ms);
self
}
}
/// Overall system health check result.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SystemHealth {
/// Overall health status.
pub status: HealthStatus,
/// Application version.
pub version: String,
/// Individual component health.
pub components: Vec<ComponentHealth>,
}
impl SystemHealth {
/// Creates a new system health check result.
#[must_use]
pub fn new(components: Vec<ComponentHealth>) -> Self {
let status = components
.iter()
.map(|c| c.status)
.fold(HealthStatus::Healthy, |acc, s| match (acc, s) {
(HealthStatus::Unhealthy, _) | (_, HealthStatus::Unhealthy) => {
HealthStatus::Unhealthy
}
(HealthStatus::Degraded, _) | (_, HealthStatus::Degraded) => HealthStatus::Degraded,
_ => HealthStatus::Healthy,
});
Self {
status,
version: crate::VERSION.to_string(),
components,
}
}
}
/// Trait for components that support health checks.
#[async_trait::async_trait]
pub trait HealthCheck: Send + Sync {
/// Returns the component name.
fn name(&self) -> &str;
/// Performs a health check.
async fn check(&self) -> ComponentHealth;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_health_status() {
assert!(HealthStatus::Healthy.is_operational());
assert!(HealthStatus::Degraded.is_operational());
assert!(!HealthStatus::Unhealthy.is_operational());
}
#[test]
fn test_component_health() {
let healthy = ComponentHealth::healthy("test");
assert_eq!(healthy.status, HealthStatus::Healthy);
assert!(healthy.message.is_none());
let unhealthy = ComponentHealth::unhealthy("test", "connection failed");
assert_eq!(unhealthy.status, HealthStatus::Unhealthy);
assert!(unhealthy.message.is_some());
}
#[test]
fn test_system_health_aggregation() {
let components = vec![
ComponentHealth::healthy("db"),
ComponentHealth::degraded("cache", "high latency"),
];
let health = SystemHealth::new(components);
assert_eq!(health.status, HealthStatus::Degraded);
let components = vec![
ComponentHealth::healthy("db"),
ComponentHealth::unhealthy("cache", "connection refused"),
];
let health = SystemHealth::new(components);
assert_eq!(health.status, HealthStatus::Unhealthy);
}
}

View File

@@ -0,0 +1,161 @@
//! Common traits for the 7sense platform.
//!
//! This module defines cross-cutting traits used by multiple bounded contexts.
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Serialize};
use std::fmt::Debug;
/// Marker trait for domain entities.
pub trait Entity: Debug + Clone + Send + Sync {
/// The type of the entity's identifier.
type Id: Debug + Clone + Eq + std::hash::Hash + Send + Sync;
/// Returns the entity's identifier.
fn id(&self) -> &Self::Id;
}
/// Marker trait for value objects.
pub trait ValueObject: Debug + Clone + PartialEq + Send + Sync {}
/// Trait for objects that can be serialized to/from JSON.
pub trait JsonSerializable: Serialize + DeserializeOwned {
/// Serializes the object to a JSON string.
fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
/// Serializes the object to a pretty-printed JSON string.
fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
/// Deserializes an object from a JSON string.
fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
/// Blanket implementation for all serializable types.
impl<T: Serialize + DeserializeOwned> JsonSerializable for T {}
/// Trait for domain events.
pub trait DomainEvent: Debug + Clone + Send + Sync + Serialize {
/// Returns the event's unique identifier.
fn event_id(&self) -> &str;
/// Returns the timestamp when the event occurred.
fn occurred_at(&self) -> chrono::DateTime<chrono::Utc>;
/// Returns the event type name for routing.
fn event_type(&self) -> &'static str;
}
/// Trait for domain event handlers.
#[async_trait]
pub trait EventHandler<E: DomainEvent>: Send + Sync {
/// Handles a domain event.
async fn handle(&self, event: &E) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}
/// Trait for unit of work pattern.
#[async_trait]
pub trait UnitOfWork: Send + Sync {
/// Commits all pending changes.
async fn commit(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
/// Rolls back all pending changes.
async fn rollback(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}
/// Trait for paginated queries.
pub trait Paginated {
/// Returns the current page number (0-indexed).
fn page(&self) -> usize;
/// Returns the page size.
fn page_size(&self) -> usize;
/// Returns the offset for database queries.
fn offset(&self) -> usize {
self.page() * self.page_size()
}
}
/// A page of results.
#[derive(Debug, Clone, Serialize)]
pub struct Page<T> {
/// The items in this page.
pub items: Vec<T>,
/// Current page number (0-indexed).
pub page: usize,
/// Page size.
pub page_size: usize,
/// Total number of items across all pages.
pub total_items: usize,
}
impl<T> Page<T> {
/// Creates a new page of results.
#[must_use]
pub fn new(items: Vec<T>, page: usize, page_size: usize, total_items: usize) -> Self {
Self {
items,
page,
page_size,
total_items,
}
}
/// Returns the total number of pages.
#[must_use]
pub fn total_pages(&self) -> usize {
if self.page_size == 0 {
0
} else {
(self.total_items + self.page_size - 1) / self.page_size
}
}
/// Returns true if there is a next page.
#[must_use]
pub fn has_next(&self) -> bool {
self.page + 1 < self.total_pages()
}
/// Returns true if there is a previous page.
#[must_use]
pub fn has_previous(&self) -> bool {
self.page > 0
}
/// Maps the items to a different type.
pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Page<U> {
Page {
items: self.items.into_iter().map(f).collect(),
page: self.page,
page_size: self.page_size,
total_items: self.total_items,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_page_calculations() {
let page: Page<i32> = Page::new(vec![1, 2, 3], 0, 3, 10);
assert_eq!(page.total_pages(), 4);
assert!(page.has_next());
assert!(!page.has_previous());
}
#[test]
fn test_page_map() {
let page = Page::new(vec![1, 2, 3], 0, 3, 3);
let mapped = page.map(|x| x * 2);
assert_eq!(mapped.items, vec![2, 4, 6]);
}
}

View File

@@ -0,0 +1,256 @@
//! Value objects for the 7sense domain.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// A geographic location with latitude, longitude, and optional elevation.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GeoLocation {
/// Latitude in degrees (-90 to 90).
latitude: f64,
/// Longitude in degrees (-180 to 180).
longitude: f64,
/// Elevation above sea level in meters.
elevation_m: Option<f64>,
}
impl GeoLocation {
/// Creates a new GeoLocation with validation.
pub fn new(latitude: f64, longitude: f64, elevation_m: Option<f64>) -> Result<Self, GeoLocationError> {
if !(-90.0..=90.0).contains(&latitude) {
return Err(GeoLocationError::InvalidLatitude(latitude));
}
if !(-180.0..=180.0).contains(&longitude) {
return Err(GeoLocationError::InvalidLongitude(longitude));
}
Ok(Self { latitude, longitude, elevation_m })
}
/// Creates a GeoLocation without validation.
#[must_use]
pub const fn new_unchecked(latitude: f64, longitude: f64, elevation_m: Option<f64>) -> Self {
Self { latitude, longitude, elevation_m }
}
/// Returns the latitude.
#[must_use]
pub const fn latitude(&self) -> f64 {
self.latitude
}
/// Returns the longitude.
#[must_use]
pub const fn longitude(&self) -> f64 {
self.longitude
}
/// Returns the elevation in meters.
#[must_use]
pub const fn elevation_m(&self) -> Option<f64> {
self.elevation_m
}
/// Calculates Haversine distance to another location in km.
#[must_use]
pub fn distance_km(&self, other: &GeoLocation) -> f64 {
const R: f64 = 6371.0;
let lat1 = self.latitude.to_radians();
let lat2 = other.latitude.to_radians();
let dlat = (other.latitude - self.latitude).to_radians();
let dlon = (other.longitude - self.longitude).to_radians();
let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
R * 2.0 * a.sqrt().asin()
}
}
/// Errors for GeoLocation creation.
#[derive(Debug, Clone, thiserror::Error)]
pub enum GeoLocationError {
/// Invalid latitude value.
#[error("Invalid latitude {0}: must be between -90 and 90")]
InvalidLatitude(f64),
/// Invalid longitude value.
#[error("Invalid longitude {0}: must be between -180 and 180")]
InvalidLongitude(f64),
}
/// A timestamp wrapper with domain operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Timestamp(DateTime<Utc>);
impl Timestamp {
/// Creates a Timestamp for now.
#[must_use]
pub fn now() -> Self {
Self(Utc::now())
}
/// Creates from DateTime.
#[must_use]
pub const fn from_datetime(dt: DateTime<Utc>) -> Self {
Self(dt)
}
/// Returns the DateTime.
#[must_use]
pub const fn as_datetime(&self) -> &DateTime<Utc> {
&self.0
}
/// Returns Unix timestamp in seconds.
#[must_use]
pub fn unix_timestamp(&self) -> i64 {
self.0.timestamp()
}
/// Returns Unix timestamp in milliseconds.
#[must_use]
pub fn unix_timestamp_millis(&self) -> i64 {
self.0.timestamp_millis()
}
}
impl Default for Timestamp {
fn default() -> Self {
Self::now()
}
}
impl std::fmt::Display for Timestamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.format("%Y-%m-%dT%H:%M:%S%.3fZ"))
}
}
impl From<DateTime<Utc>> for Timestamp {
fn from(dt: DateTime<Utc>) -> Self {
Self(dt)
}
}
/// Metadata about an audio file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AudioMetadata {
/// Sample rate in Hz.
pub sample_rate: u32,
/// Number of channels.
pub channels: u16,
/// Bits per sample.
pub bits_per_sample: u16,
/// Duration in milliseconds.
pub duration_ms: u64,
/// File format (e.g., "wav", "flac").
pub format: String,
/// File size in bytes.
pub file_size_bytes: u64,
/// Codec information.
pub codec: Option<String>,
}
impl AudioMetadata {
/// Creates new AudioMetadata.
#[must_use]
pub fn new(
sample_rate: u32,
channels: u16,
bits_per_sample: u16,
duration_ms: u64,
format: String,
file_size_bytes: u64,
) -> Self {
Self {
sample_rate,
channels,
bits_per_sample,
duration_ms,
format,
file_size_bytes,
codec: None,
}
}
/// Sets codec information.
#[must_use]
pub fn with_codec(mut self, codec: impl Into<String>) -> Self {
self.codec = Some(codec.into());
self
}
/// Returns duration as std Duration.
#[must_use]
pub fn duration(&self) -> Duration {
Duration::from_millis(self.duration_ms)
}
/// Returns total sample count.
#[must_use]
pub fn total_samples(&self) -> u64 {
(self.sample_rate as u64 * self.duration_ms) / 1000
}
}
/// Confidence score (0.0 to 1.0).
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct Confidence(f32);
impl Confidence {
/// Creates a new Confidence with validation.
pub fn new(value: f32) -> Result<Self, ConfidenceError> {
if !(0.0..=1.0).contains(&value) {
return Err(ConfidenceError::OutOfRange(value));
}
Ok(Self(value))
}
/// Creates a Confidence, clamping to valid range.
#[must_use]
pub fn clamped(value: f32) -> Self {
Self(value.clamp(0.0, 1.0))
}
/// Returns the value.
#[must_use]
pub const fn value(&self) -> f32 {
self.0
}
/// Returns as percentage.
#[must_use]
pub fn percentage(&self) -> f32 {
self.0 * 100.0
}
}
/// Errors for Confidence creation.
#[derive(Debug, Clone, thiserror::Error)]
pub enum ConfidenceError {
/// Value out of range.
#[error("Confidence {0} out of range [0.0, 1.0]")]
OutOfRange(f32),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_geolocation() {
let loc = GeoLocation::new(45.5, -122.6, None).unwrap();
assert_eq!(loc.latitude(), 45.5);
}
#[test]
fn test_audio_metadata() {
let meta = AudioMetadata::new(32000, 1, 16, 5000, "wav".to_string(), 320000);
assert_eq!(meta.total_samples(), 160000);
}
#[test]
fn test_confidence() {
let conf = Confidence::new(0.85).unwrap();
assert_eq!(conf.percentage(), 85.0);
}
}