# Task 01: Core Identity and Temporal Types

## Context

**Milestone:** 1 -- Signal Engine
**Phase:** m1p1 -- Core Type System and Schema
**Depends On:** None
**Blocks:** Task 02, Task 03
**Complexity:** S

## Objective

Deliver the foundational identity and temporal types that every module in the codebase will import: `EntityId`, `EntityKind`, `Timestamp`, and `Score`. These are the zero-cost newtypes that prevent type confusion (mixing entity IDs with raw u64 values, mixing timestamps with byte counts) and provide the ordering guarantees the storage engine requires (big-endian encoding where byte-lexicographic order matches numeric order).

## Requirements

- `EntityId` must be a u64 newtype with `Display`, `Hash`, `Eq`, `Ord`, `Copy`
- `EntityKind` must enumerate exactly three kinds: Item, User, Creator
- `Timestamp` must store nanoseconds since Unix epoch as u64
- `Score` must wrap f64 with a finiteness invariant (rejects NaN/Infinity) and implement `Ord`
- Big-endian byte encoding on `EntityId` and `Timestamp` must preserve numeric ordering
- All types must be `Send + Sync` (they are, since they contain only primitives)
- No new dependencies -- standard library only

## Technical Design

### Module Structure

```
tidal/src/schema/
  entity.rs       -- EntityId, EntityKind
  timestamp.rs    -- Timestamp
  score.rs        -- Score
```

### Public API

```rust
// === entity.rs ===

/// Unique identifier for any entity. Internally a u64.
/// Does NOT carry EntityKind -- kind is determined by storage namespace.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EntityId(u64);

impl EntityId {
    pub const fn new(id: u64) -> Self;
    pub const fn as_u64(self) -> u64;
    /// Big-endian bytes for key construction. Byte order matches numeric order.
    pub const fn to_be_bytes(self) -> [u8; 8];
}

impl fmt::Display for EntityId { /* formats as the raw number */ }
impl fmt::Debug for EntityId { /* formats as EntityId(N) */ }
impl From<u64> for EntityId { /* zero-cost conversion */ }

/// The three entity kinds. Fixed, not extensible.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityKind {
    Item,
    User,
    Creator,
}

impl fmt::Display for EntityKind { /* "item", "user", "creator" */ }


// === timestamp.rs ===

/// Nanoseconds since Unix epoch. u64 overflows in year 2554.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(u64);

impl Timestamp {
    pub const fn from_nanos(nanos: u64) -> Self;
    /// Current wall-clock time via SystemTime::now().
    pub fn now() -> Self;
    pub const fn as_nanos(self) -> u64;
    /// Seconds elapsed as f64 (for decay math: lambda * dt).
    pub fn seconds_since(self, now: Timestamp) -> f64;
    /// Duration elapsed since this timestamp.
    pub fn elapsed_since(self, now: Timestamp) -> std::time::Duration;
    /// Big-endian bytes for key construction.
    pub const fn to_be_bytes(self) -> [u8; 8];
}

impl fmt::Display for Timestamp { /* "seconds.nanos" format */ }
impl fmt::Debug for Timestamp { /* Timestamp(Nns) */ }


// === score.rs ===

/// A ranking score. Guaranteed finite (not NaN, not infinite).
/// Implements Ord (unlike raw f64) for use in sorting and priority queues.
/// NOT bounded to [0, 1] -- ranking scores can exceed 1.0 after boosting.
#[derive(Clone, Copy, PartialEq)]
pub struct Score(f64);

impl Score {
    pub fn new(value: f64) -> Option<Self>;
    pub const ZERO: Self;
    pub const fn as_f64(self) -> f64;
}

impl Eq for Score {}
impl Ord for Score { /* uses f64::total_cmp, safe because both values are finite */ }
impl PartialOrd for Score { /* delegates to Ord */ }
impl fmt::Display for Score { /* 6 decimal places */ }
impl fmt::Debug for Score { /* Score(N.NNNNNN) */ }
```

### Internal Design

**EntityId does NOT carry EntityKind.** The kind is always known from context -- which storage namespace (column family) you are reading from, which query target was specified, which signal definition targets which kind. Embedding the kind would waste bits of the u64 and force every ID comparison to also compare kind. The key encoding `{entity_id}\x00{TAG}:{suffix}` already isolates by namespace.

**Timestamp uses u64 nanoseconds, not i64.** All signal events are present-tense engagement events. Pre-epoch timestamps are never needed. u64 gives the full range to year 2554 (vs i64's 2262 limit used by InfluxDB). `seconds_since()` returns f64 for direct use in decay math: `exp(-lambda * dt)` where `dt = self.seconds_since(now)`.

**Score enforces finiteness, not bounds.** NaN breaks `Ord` (the reason f64 doesn't implement it). Score guarantees finiteness at construction, enabling total ordering. It is NOT bounded to [0, 1] because ranking profiles apply boosts (multiplication), penalties (subtraction), and diversity reordering that produce scores outside that range.

### Error Handling

No errors in this task. All constructors either cannot fail (`EntityId::new`, `Timestamp::from_nanos`) or return `Option` (`Score::new`). Error types are defined in Task 03.

## Test Strategy

### Property Tests

```rust
// EntityId big-endian round-trip
proptest! {
    #[test]
    fn entity_id_roundtrip(id: u64) {
        let eid = EntityId::new(id);
        let bytes = eid.to_be_bytes();
        prop_assert_eq!(id, u64::from_be_bytes(bytes));
    }
}

// EntityId ordering matches byte ordering (critical for storage scans)
proptest! {
    #[test]
    fn entity_id_ordering_matches_bytes(a: u64, b: u64) {
        let ea = EntityId::new(a);
        let eb = EntityId::new(b);
        prop_assert_eq!(ea.cmp(&eb), ea.to_be_bytes().cmp(&eb.to_be_bytes()));
    }
}

// Timestamp ordering matches byte ordering
proptest! {
    #[test]
    fn timestamp_ordering_matches_bytes(a: u64, b: u64) {
        let ta = Timestamp::from_nanos(a);
        let tb = Timestamp::from_nanos(b);
        prop_assert_eq!(ta.cmp(&tb), ta.to_be_bytes().cmp(&tb.to_be_bytes()));
    }
}

// Timestamp seconds_since is non-negative
proptest! {
    #[test]
    fn timestamp_seconds_non_negative(a: u64, b: u64) {
        let ta = Timestamp::from_nanos(a);
        let tb = Timestamp::from_nanos(b);
        prop_assert!(ta.seconds_since(tb) >= 0.0);
    }
}

// Score total ordering consistency
proptest! {
    #[test]
    fn score_ordering_consistent(a in proptest::num::f64::NORMAL, b in proptest::num::f64::NORMAL) {
        if let (Some(sa), Some(sb)) = (Score::new(a), Score::new(b)) {
            prop_assert_eq!(sa.partial_cmp(&sb), Some(sa.cmp(&sb)));
        }
    }
}
```

### Unit Tests

```rust
// Score rejects non-finite values
#[test]
fn score_rejects_nan_and_infinity() {
    assert!(Score::new(f64::NAN).is_none());
    assert!(Score::new(f64::INFINITY).is_none());
    assert!(Score::new(f64::NEG_INFINITY).is_none());
    assert!(Score::new(0.0).is_some());
    assert!(Score::new(-1.5).is_some());
    assert!(Score::new(100.0).is_some());
}

// EntityId display format
#[test]
fn entity_id_display() {
    assert_eq!(EntityId::new(42).to_string(), "42");
    assert_eq!(format!("{:?}", EntityId::new(42)), "EntityId(42)");
}

// EntityKind display format
#[test]
fn entity_kind_display() {
    assert_eq!(EntityKind::Item.to_string(), "item");
    assert_eq!(EntityKind::User.to_string(), "user");
    assert_eq!(EntityKind::Creator.to_string(), "creator");
}

// Timestamp now() returns a reasonable value
#[test]
fn timestamp_now_reasonable() {
    let ts = Timestamp::now();
    // Must be after 2020-01-01
    let min = 1_577_836_800_000_000_000u64; // 2020-01-01 in nanos
    assert!(ts.as_nanos() > min);
}

// Timestamp seconds_since arithmetic
#[test]
fn timestamp_seconds_since() {
    let t1 = Timestamp::from_nanos(1_000_000_000); // 1 second
    let t2 = Timestamp::from_nanos(3_500_000_000); // 3.5 seconds
    let dt = t1.seconds_since(t2);
    assert!((dt - 2.5).abs() < 1e-9);
}
```

## Acceptance Criteria

- [ ] `EntityId` is a u64 newtype with `Display`, `Hash`, `Eq`, `Ord`, `Copy`, `Clone`, `Debug`
- [ ] `EntityKind` has exactly three variants: `Item`, `User`, `Creator`
- [ ] `Timestamp` stores nanoseconds as u64; `Timestamp::now()` returns current time
- [ ] `Timestamp::seconds_since()` returns f64 delta for decay math
- [ ] `Score` rejects NaN and infinities; implements `Ord`
- [ ] Big-endian byte encoding on `EntityId` and `Timestamp` preserves numeric ordering (property tested)
- [ ] All types live in `tidal/src/schema/` submodules
- [ ] `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) -- `entity_id: u64`, `last_update_ns: u64` in EntityState struct, f64 precision analysis confirming adequacy through year 18,000
- [docs/research/phase1_1_type_system.md](../../../research/phase1_1_type_system.md) -- Section 1 (EntityId newtype pattern: hand-implement vs derive_more vs nutype), Section 6 (Timestamp precision: u64 nanoseconds, production system survey of InfluxDB/QuestDB/ClickHouse/Sonnerie), Section 5 (f64 for decay scores and atomic operations)
- [CODING_GUIDELINES.md](../../../../CODING_GUIDELINES.md) -- Section 1 (memory layout), Section 2 (key encoding: big-endian for byte-lexicographic ordering)
- [thoughts.md](../../../../thoughts.md) -- Part V.12 (subject-prefix keys require byte-ordered entity IDs)

## Spec References

- [docs/specs/02-entity-model.md](../../../specs/02-entity-model.md) -- EntityKind (Item/User/Creator), entity ID encoding as u64 big-endian with 0x01/0x02/0x03 kind bytes, storage representation key layout `[entity_kind: u8][entity_id: u64 BE][0x00][TAG]:[suffix]`
- [docs/specs/01-storage-engine.md](../../../specs/01-storage-engine.md) -- Section 5 (key encoding scheme: big-endian entity IDs for lexicographic ordering, NUL separator, tag-based routing), Section 5.5 (byte-level example), Appendix C invariant 9 (big-endian encoding preserves numeric ordering)
- [docs/specs/03-signal-system.md](../../../specs/03-signal-system.md) -- Section 3 (HotSignalState struct using `entity_id: u64` and `last_update_ns: AtomicU64`), Section 4 (Timestamp used in decay computation: `dt = (event_time_ns - prev_time) as f64 / 1e9`)
- [docs/specs/09-ranking-scoring.md](../../../specs/09-ranking-scoring.md) -- Section 8 (Score normalization to [0.0, 1.0] range, confirming Score type must support values outside that range before normalization)

## Implementation Notes

- `#[repr(transparent)]` is NOT needed on newtypes that don't cross FFI boundaries. The compiler optimizes these identically without it.
- The `expect()` in `Timestamp::now()` is acceptable -- a system clock before Unix epoch is a hardware fault, not a recoverable error.
- `Score::ZERO` uses `const` construction. This requires knowing the value is finite at compile time, which 0.0 trivially is.
- Do NOT add `serde` derives yet. Serialization is m1p3's concern when types need to go to disk.
- Do NOT add `#[repr(C, align(64))]` to any type. Cache-line alignment is m1p4's concern for the hot-path `EntitySignalState` struct.
