//! Relationship graph edges between entities. //! //! Implemented in Task 2 (p1-02). use crate::schema::{EntityId, Timestamp}; /// The type of relationship between two entities. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] pub enum RelationshipType { Follows = 0x01, Blocks = 0x02, InteractionWeight = 0x03, Hide = 0x04, /// PERSISTED-BUT-INERT (tracked): a `Mute` edge is durably stored and /// round-trips through [`as_byte`](Self::as_byte) / [`from_byte`](Self::from_byte), /// and the rebuild path counts it, but it has **no effect** on ranking yet — /// there is no muted-set in `UserStateIndex` and no predicate consults it. /// /// Implementing it fully requires a muted-set on `UserStateIndex` plus /// application in the unseen/unblocked predicates, and wiring in /// `db/relationships.rs` (`write_relationship` / `delete_relationship`) and /// `db/state_rebuild.rs` (`rebuild_entity_state`). Until then the no-op is /// **deliberate and greppable**: the catch-all arms in `db/relationships.rs` /// and the counter-only arm in `db/state_rebuild.rs` should be (or are) /// explicit `RelationshipType::Mute => { /* tracked: not yet applied */ }` /// arms rather than absorbed by a bare `_`. /// /// Tracking: implement muted-set application (M7 production hardening). Mute = 0x05, // `from_byte` returns `None` for any other byte, so adding a variant here // forces every match in this module to be revisited (no silent default). } impl RelationshipType { /// Parse a byte back into a `RelationshipType`. #[must_use] pub const fn from_byte(b: u8) -> Option { match b { 0x01 => Some(Self::Follows), 0x02 => Some(Self::Blocks), 0x03 => Some(Self::InteractionWeight), 0x04 => Some(Self::Hide), 0x05 => Some(Self::Mute), _ => None, } } /// The discriminant byte. #[must_use] pub const fn as_byte(self) -> u8 { self as u8 } } /// A directed, weighted edge between two entities. #[derive(Debug, Clone)] pub struct RelationshipEdge { pub from: EntityId, pub to: EntityId, pub rel_type: RelationshipType, pub weight: f64, pub timestamp: Timestamp, } /// Encode a relationship key (19 bytes). /// /// Format: `[from_entity_id: 8 BE][0x00][Tag::Rel: 0x04][rel_type: 1 byte][to_entity_id: 8 BE]` /// /// Total: 8 + 1 + 1 + 1 + 8 = 19 bytes. #[must_use] pub fn encode_relationship_key( from: EntityId, rel_type: RelationshipType, to: EntityId, ) -> Vec { let mut key = Vec::with_capacity(19); key.extend_from_slice(&from.to_be_bytes()); key.push(0x00); // separator key.push(0x04); // Tag::Rel key.push(rel_type.as_byte()); key.extend_from_slice(&to.to_be_bytes()); key } /// Build a prefix for scanning all relationships of a given type from an entity. /// /// Returns 11 bytes: `[from_id: 8 BE][0x00][0x04][rel_type]` #[must_use] pub fn relationship_prefix(from: EntityId, rel_type: RelationshipType) -> Vec { let mut prefix = Vec::with_capacity(11); prefix.extend_from_slice(&from.to_be_bytes()); prefix.push(0x00); prefix.push(0x04); prefix.push(rel_type.as_byte()); prefix } /// Serialize relationship value (16 bytes). /// /// Format: `[weight: 8 bytes f64 LE][timestamp_nanos: 8 bytes u64 LE]` #[must_use] pub fn serialize_relationship_value(weight: f64, timestamp: Timestamp) -> Vec { let mut buf = Vec::with_capacity(16); buf.extend_from_slice(&weight.to_le_bytes()); buf.extend_from_slice(×tamp.as_nanos().to_le_bytes()); buf } /// Deserialize relationship value. Returns `(weight, timestamp_nanos)`. #[must_use] pub fn deserialize_relationship_value(bytes: &[u8]) -> Option<(f64, u64)> { if bytes.len() < 16 { return None; } let weight = f64::from_le_bytes([ bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], ]); let ts_nanos = u64::from_le_bytes([ bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], ]); Some((weight, ts_nanos)) } /// Parse the `to` entity ID from a full relationship key (19 bytes). /// /// The `to` entity starts at offset 11: 8 (from) + 1 (NUL) + 1 (`Tag::Rel`) + 1 (`rel_type`). #[must_use] pub fn parse_relationship_to(key: &[u8]) -> Option { if key.len() < 19 { return None; } let to = u64::from_be_bytes([ key[11], key[12], key[13], key[14], key[15], key[16], key[17], key[18], ]); Some(EntityId::new(to)) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn relationship_type_roundtrip() { let types = [ RelationshipType::Follows, RelationshipType::Blocks, RelationshipType::InteractionWeight, RelationshipType::Hide, RelationshipType::Mute, ]; for rt in types { let byte = rt.as_byte(); let parsed = RelationshipType::from_byte(byte).unwrap(); assert_eq!(parsed, rt); } } #[test] fn encode_relationship_key_length() { let key = encode_relationship_key( EntityId::new(1), RelationshipType::Follows, EntityId::new(2), ); assert_eq!(key.len(), 19); } #[test] fn relationship_value_roundtrip() { let ts = Timestamp::from_nanos(1_000_000_000); let bytes = serialize_relationship_value(0.75, ts); let (w, t) = deserialize_relationship_value(&bytes).unwrap(); assert!((w - 0.75).abs() < f64::EPSILON); assert_eq!(t, 1_000_000_000); } #[test] fn parse_to_from_key() { let key = encode_relationship_key( EntityId::new(100), RelationshipType::Blocks, EntityId::new(200), ); let to = parse_relationship_to(&key).unwrap(); assert_eq!(to, EntityId::new(200)); } #[test] fn relationship_prefix_length() { let prefix = relationship_prefix(EntityId::new(1), RelationshipType::Follows); assert_eq!(prefix.len(), 11); } mod proptests { use proptest::prelude::*; use super::*; fn arb_relationship_type() -> impl Strategy { prop_oneof![ Just(RelationshipType::Follows), Just(RelationshipType::Blocks), Just(RelationshipType::InteractionWeight), Just(RelationshipType::Hide), Just(RelationshipType::Mute), ] } proptest! { /// Relationship key encode/decode roundtrip: from, rel_type, and to are all recovered. #[test] fn key_roundtrip( from_id in any::(), to_id in any::(), rel_type in arb_relationship_type(), ) { let key = encode_relationship_key( EntityId::new(from_id), rel_type, EntityId::new(to_id), ); prop_assert_eq!(key.len(), 19); // Parse back to_id. let parsed_to = parse_relationship_to(&key).unwrap(); prop_assert_eq!(parsed_to, EntityId::new(to_id)); // Parse back from_id (first 8 bytes, big-endian). let parsed_from = u64::from_be_bytes([ key[0], key[1], key[2], key[3], key[4], key[5], key[6], key[7], ]); prop_assert_eq!(parsed_from, from_id); // Parse back rel_type (byte at offset 10). let parsed_rel = RelationshipType::from_byte(key[10]).unwrap(); prop_assert_eq!(parsed_rel, rel_type); } /// Relationship value encode/decode roundtrip. #[test] fn value_roundtrip( weight in any::().prop_filter("finite", |w| w.is_finite()), ts_nanos in any::(), ) { let ts = Timestamp::from_nanos(ts_nanos); let bytes = serialize_relationship_value(weight, ts); let (dec_weight, dec_ts) = deserialize_relationship_value(&bytes).unwrap(); prop_assert!((dec_weight - weight).abs() < f64::EPSILON); prop_assert_eq!(dec_ts, ts_nanos); } } } }