tidaldb/tidal/src/entities/relationship.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
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
2026-06-08 10:28:34 -06:00

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(&timestamp.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);
}
}
}
}