Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
257 lines
8.4 KiB
Rust
257 lines
8.4 KiB
Rust
//! 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<Self> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<EntityId> {
|
|
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<Value = RelationshipType> {
|
|
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::<u64>(),
|
|
to_id in any::<u64>(),
|
|
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::<f64>().prop_filter("finite", |w| w.is_finite()),
|
|
ts_nanos in any::<u64>(),
|
|
) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|