# Task 02: Signal Type Definitions

## Context

**Milestone:** 1 -- Signal Engine
**Phase:** m1p1 -- Core Type System and Schema
**Depends On:** Task 01 (uses `EntityKind`)
**Blocks:** Task 03
**Complexity:** S

## Objective

Deliver the types that declare what a signal IS in schema: `SignalTypeDef`, `DecayModel`, `Window`, and `WindowSet`. These are the *declarations*, not the runtime state. They describe how a signal decays, what windows to maintain, and whether velocity is computed. The actual signal ledger and aggregation logic are m1p4.

The critical design choice: `DecayModel::Exponential` stores the pre-computed lambda (`ln(2) / half_life_seconds`) so that every signal write and every ranking read avoids a division on the hot path. The user specifies `DecaySpec::Exponential { half_life: Duration }` (validated in Task 03). The internal `DecayModel` stores the derived lambda.

## Requirements

- `SignalTypeDef` must capture: name, target entity kind, decay model, windows, velocity flag
- `DecayModel` must support three variants: Exponential (with pre-computed lambda), Linear, Permanent
- Pre-computed lambda for exponential decay: `lambda = ln(2) / half_life_seconds`
- `Window` must enumerate exactly five variants: 1h, 24h, 7d, 30d, AllTime
- `WindowSet` must be an ordered, deduplicated collection of windows
- Signal type fields must be private with getters (constructed only through validated SchemaBuilder in Task 03)
- No new dependencies

## Technical Design

### Module Structure

```
tidal/src/schema/
  signal.rs       -- SignalTypeDef, DecayModel, Window, WindowSet
```

### Public API

```rust
// === signal.rs ===

/// A named signal type definition declared in schema.
/// This is the *declaration*, not runtime state.
#[derive(Debug, Clone)]
pub struct SignalTypeDef { /* private fields */ }

impl SignalTypeDef {
    /// Unique name within the schema (e.g., "view", "like", "skip").
    pub fn name(&self) -> &str;
    /// Which entity kind this signal targets.
    pub fn target(&self) -> EntityKind;
    /// How the signal's weight decays over time.
    pub fn decay(&self) -> &DecayModel;
    /// Which time windows to maintain aggregates for.
    pub fn windows(&self) -> &WindowSet;
    /// Whether velocity computation is enabled.
    pub fn velocity_enabled(&self) -> bool;
}

// pub(crate) constructor -- only callable from validation module
impl SignalTypeDef {
    pub(crate) fn new(
        name: String,
        target: EntityKind,
        decay: DecayModel,
        windows: WindowSet,
        velocity_enabled: bool,
    ) -> Self;
}


/// How a signal's contribution decays over time.
#[derive(Debug, Clone, PartialEq)]
pub enum DecayModel {
    /// Weight halves every `half_life`.
    /// Running score formula: S(t) = S(t_prev) * exp(-lambda * dt) + weight
    Exponential {
        half_life: std::time::Duration,
        /// Pre-computed: ln(2) / half_life.as_secs_f64()
        lambda: f64,
    },
    /// Weight drops linearly to zero over `lifetime`.
    Linear {
        lifetime: std::time::Duration,
    },
    /// Never decays. Used for permanent flags: hide, block, follow.
    Permanent,
}

impl DecayModel {
    /// Construct exponential decay with pre-computed lambda.
    /// pub(crate): bypasses validation. Use SchemaBuilder for external construction.
    pub(crate) fn exponential(half_life: Duration) -> Self;
    /// Construct linear decay.
    pub(crate) fn linear(lifetime: Duration) -> Self;
    /// Returns the lambda value for Exponential, None otherwise.
    pub fn lambda(&self) -> Option<f64>;
    /// Returns the half-life for Exponential, None otherwise.
    pub fn half_life(&self) -> Option<Duration>;
}


/// A time window for signal aggregation.
/// Fixed variants -- not configurable durations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Window {
    OneHour,
    TwentyFourHours,
    SevenDays,
    ThirtyDays,
    AllTime,
}

impl Window {
    /// The duration this window spans. AllTime returns Duration::MAX.
    pub const fn duration(&self) -> Duration;
    /// Duration in seconds as f64 (for velocity: count / duration_secs).
    pub const fn duration_secs_f64(&self) -> f64;
    /// Short label for display and key encoding ("1h", "24h", "7d", "30d", "all").
    pub const fn label(&self) -> &'static str;
}

impl fmt::Display for Window { /* delegates to label() */ }


/// An ordered, deduplicated set of windows.
/// Sorted from finest to coarsest (OneHour < ... < AllTime).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowSet { /* private Vec<Window> */ }

impl WindowSet {
    /// Construct from a slice. Deduplicates and sorts.
    pub fn new(windows: &[Window]) -> Self;
    /// Empty set. Valid only for Permanent decay signals.
    pub const fn empty() -> Self;
    pub fn is_empty(&self) -> bool;
    pub fn len(&self) -> usize;
    pub fn iter(&self) -> std::slice::Iter<'_, Window>;
    pub fn contains(&self, w: &Window) -> bool;
}
```

### Internal Design

**Window is an enum with 5 fixed variants, not configurable durations.** The research doc shows exactly these five windows used across all 14 use cases and 25+ sort modes. The storage engine pre-allocates bucketed counters per window. The materializer schedules rollups at window boundaries. Arbitrary durations would force dynamic allocation and unpredictable rollup schedules. If a sixth window is ever needed, it is a schema migration.

**The Ord derivation on Window sorts by temporal duration.** `OneHour < TwentyFourHours < SevenDays < ThirtyDays < AllTime`. This matches the enum variant declaration order and is relied upon by WindowSet for canonical ordering.

**DecayModel constructors are `pub(crate)`.** External users construct signals through `SchemaBuilder` (Task 03), which validates inputs before calling these constructors. Making them `pub(crate)` prevents construction that bypasses validation.

**SignalTypeDef fields are private with getters.** Once validated and constructed by the SchemaBuilder, signal type definitions are immutable. Private fields + getters enforce this.

**`WindowSet::empty()` uses `const fn` with `Vec::new()`.** As of Rust edition 2024, `Vec::new()` is const. This allows `WindowSet::empty()` to be a const function.

### Error Handling

No errors in this task. All constructors are `pub(crate)` and infallible -- validation happens in the SchemaBuilder (Task 03). `WindowSet::new()` cannot fail (it deduplicates and sorts silently).

## Test Strategy

### Property Tests

```rust
// Lambda is correctly computed from half-life
proptest! {
    #[test]
    fn decay_lambda_correct(secs in 1u64..=31_536_000u64) {
        let half_life = Duration::from_secs(secs);
        let model = DecayModel::exponential(half_life);
        if let DecayModel::Exponential { lambda, .. } = model {
            let expected = std::f64::consts::LN_2 / half_life.as_secs_f64();
            prop_assert!((lambda - expected).abs() < 1e-15);
        }
    }
}

// Lambda * half_life = ln(2)  (the defining property)
proptest! {
    #[test]
    fn lambda_times_halflife_is_ln2(secs in 1u64..=31_536_000u64) {
        let half_life = Duration::from_secs(secs);
        let model = DecayModel::exponential(half_life);
        if let DecayModel::Exponential { lambda, .. } = model {
            let product = lambda * half_life.as_secs_f64();
            prop_assert!((product - std::f64::consts::LN_2).abs() < 1e-10);
        }
    }
}
```

### Unit Tests

```rust
// Window ordering
#[test]
fn window_ordering() {
    assert!(Window::OneHour < Window::TwentyFourHours);
    assert!(Window::TwentyFourHours < Window::SevenDays);
    assert!(Window::SevenDays < Window::ThirtyDays);
    assert!(Window::ThirtyDays < Window::AllTime);
}

// Window durations are correct
#[test]
fn window_durations() {
    assert_eq!(Window::OneHour.duration(), Duration::from_secs(3_600));
    assert_eq!(Window::TwentyFourHours.duration(), Duration::from_secs(86_400));
    assert_eq!(Window::SevenDays.duration(), Duration::from_secs(604_800));
    assert_eq!(Window::ThirtyDays.duration(), Duration::from_secs(2_592_000));
    assert_eq!(Window::AllTime.duration(), Duration::MAX);
}

// Window labels for key encoding
#[test]
fn window_labels() {
    assert_eq!(Window::OneHour.label(), "1h");
    assert_eq!(Window::TwentyFourHours.label(), "24h");
    assert_eq!(Window::SevenDays.label(), "7d");
    assert_eq!(Window::ThirtyDays.label(), "30d");
    assert_eq!(Window::AllTime.label(), "all");
}

// WindowSet deduplication and sorting
#[test]
fn window_set_dedup_and_sort() {
    let ws = WindowSet::new(&[Window::SevenDays, Window::OneHour, Window::SevenDays, Window::AllTime]);
    assert_eq!(ws.len(), 3);
    let windows: Vec<_> = ws.iter().copied().collect();
    assert_eq!(windows, vec![Window::OneHour, Window::SevenDays, Window::AllTime]);
}

// WindowSet empty
#[test]
fn window_set_empty() {
    let ws = WindowSet::empty();
    assert!(ws.is_empty());
    assert_eq!(ws.len(), 0);
}

// DecayModel exponential stores half-life and lambda
#[test]
fn decay_model_exponential() {
    let model = DecayModel::exponential(Duration::from_secs(604_800)); // 7 days
    assert!(matches!(model, DecayModel::Exponential { .. }));
    let lambda = model.lambda().unwrap();
    let expected = std::f64::consts::LN_2 / 604_800.0;
    assert!((lambda - expected).abs() < 1e-20);
}

// DecayModel permanent has no lambda
#[test]
fn decay_model_permanent() {
    assert_eq!(DecayModel::Permanent.lambda(), None);
    assert_eq!(DecayModel::Permanent.half_life(), None);
}

// SignalTypeDef getters
#[test]
fn signal_type_def_getters() {
    let def = SignalTypeDef::new(
        "view".into(),
        EntityKind::Item,
        DecayModel::exponential(Duration::from_secs(604_800)),
        WindowSet::new(&[Window::OneHour, Window::AllTime]),
        true,
    );
    assert_eq!(def.name(), "view");
    assert_eq!(def.target(), EntityKind::Item);
    assert!(def.velocity_enabled());
    assert_eq!(def.windows().len(), 2);
    assert!(def.decay().lambda().is_some());
}

// Edge case: very small half-life (lambda is very large)
#[test]
fn decay_model_tiny_halflife() {
    let model = DecayModel::exponential(Duration::from_nanos(1)); // 1 nanosecond
    let lambda = model.lambda().unwrap();
    // lambda should be enormous, signals decay instantly
    assert!(lambda > 1e8);
}

// Edge case: very large half-life (lambda is very small)
#[test]
fn decay_model_huge_halflife() {
    let model = DecayModel::exponential(Duration::from_secs(365 * 24 * 3600)); // 1 year
    let lambda = model.lambda().unwrap();
    assert!(lambda > 0.0);
    assert!(lambda < 1e-6);
}
```

## Acceptance Criteria

- [ ] `SignalTypeDef` stores name, target, decay model, windows, and velocity flag with private fields and pub getters
- [ ] `DecayModel::Exponential` stores pre-computed `lambda = ln(2) / half_life.as_secs_f64()`
- [ ] `DecayModel::Linear` stores lifetime duration
- [ ] `DecayModel::Permanent` has no parameters
- [ ] `Window` has exactly 5 variants with correct durations and labels
- [ ] `WindowSet` deduplicates and sorts windows from finest to coarsest
- [ ] `DecayModel` constructors are `pub(crate)` (external construction through SchemaBuilder only)
- [ ] Property test: `lambda * half_life = ln(2)` for all positive half-life values
- [ ] `cargo clippy -- -D warnings` passes
- [ ] All property tests and unit tests pass

## Research References

- [docs/research/tidaldb_signal_ledger.md](../../../research/tidaldb_signal_ledger.md) -- decay formula (`S(t) = S(t_prev) * exp(-lambda * dt) + weight`), lambda = `ln(2) / half_life_seconds`, EntityState struct showing `decay_scores: [f64; 3]`
- [docs/research/phase1_1_type_system.md](../../../research/phase1_1_type_system.md) -- Section 2 (Duration handling for half-life: `std::time::Duration` vs raw f64, precision analysis), Section 5 (f64 for decay scores and atomic operations), Section 7 (Window enum design: fixed 5-variant enum vs configurable durations, production system survey)
- [CODING_GUIDELINES.md](../../../../CODING_GUIDELINES.md) -- Section 3 (Signal System: "Decay is a type, not a formula you call", running decay scores O(1) update O(1) read)
- [API.md](../../../../API.md) -- SignalDef struct (Decay::Exponential, Decay::Linear, Decay::Permanent, Window variants)
- [USE_CASES.md](../../../../USE_CASES.md) -- Appendix C (Signal Reference: decay rates per signal type)

## Spec References

- [docs/specs/03-signal-system.md](../../../specs/03-signal-system.md) -- Section 2 (signal type declaration: name, target_kind, decay, windows, velocity_enabled), Section 3 (HotSignalState struct using decay_scores with pre-computed lambda), Section 4 (decay computation: `dt = (event_time_ns - prev_time) as f64 / 1e9`), lambda precomputation table, 40 signal types reference with decay rates and windows
- [docs/specs/11-schema.md](../../../specs/11-schema.md) -- Schema definition API (signal type registration, validation rules), DecaySpec vs DecayModel separation, SchemaBuilder pattern
- [docs/specs/09-ranking-scoring.md](../../../specs/09-ranking-scoring.md) -- Boost types referencing signal windows and decay scores (e.g., `SignalBoost { signal: "view", window: "24h" }`), score normalization pipeline
- [docs/specs/01-storage-engine.md](../../../specs/01-storage-engine.md) -- Section 5 (key encoding: `SIG:{signal_name}:{window_label}` suffix format, window labels used in storage keys)

## Implementation Notes

- The `PartialEq` on `DecayModel` compares `f64` lambda values. This is sound because lambda is deterministically computed from the same half-life Duration -- the same input always produces the same bits. Two DecayModels with the same half-life will have bitwise-equal lambdas.
- `Window::duration_secs_f64()` for `AllTime` should return `f64::MAX` or `f64::INFINITY`. Choose `f64::INFINITY` -- velocity = count / infinity = 0.0, which is correct (all-time counts don't have a meaningful rate).
- Do NOT implement the actual decay computation (`S(t) = S(t_prev) * exp(-lambda * dt) + weight`) here. That is m1p4. This task only stores the lambda value.
- Do NOT add serde derives. Serialization is m1p3+.
