This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "federation-net"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Generic peer-to-peer networking engine built on Iroh"
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/federation-net"
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
||||
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
|
||||
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::{Semaphore, mpsc, watch};
|
||||
@@ -1041,6 +1041,15 @@ impl<M: Message> NetworkEngine<M> {
|
||||
self.shared.endpoint.id()
|
||||
}
|
||||
|
||||
/// Signs application data with this peer's persistent transport identity.
|
||||
///
|
||||
/// The secret key never leaves the engine. Protocols can attach the
|
||||
/// returned signature to records that may be forwarded by other peers;
|
||||
/// recipients verify it against [`Self::endpoint_id`].
|
||||
pub fn sign_identity(&self, message: &[u8]) -> Signature {
|
||||
self.shared.endpoint.secret_key().sign(message)
|
||||
}
|
||||
|
||||
/// Returns the network this engine participates in.
|
||||
pub fn network_id(&self) -> NetworkId {
|
||||
self.shared.config.network_id
|
||||
|
||||
@@ -78,6 +78,6 @@ pub use ticket::{PeerTicket, TICKET_VERSION};
|
||||
|
||||
// Re-exported Iroh types that appear in the public API.
|
||||
pub use iroh::endpoint::{RecvStream, SendStream};
|
||||
pub use iroh::{EndpointAddr, EndpointId, SecretKey};
|
||||
pub use iroh::{EndpointAddr, EndpointId, SecretKey, Signature};
|
||||
// Re-exported so applications can use the generic ticket helpers.
|
||||
pub use iroh_tickets::Ticket;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "music-dht"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/music-dht"
|
||||
|
||||
@@ -91,6 +91,22 @@ Results may carry a shared 128-bit SimHash of their embedding. Clients can use
|
||||
its Hamming distance to suppress near-duplicate recordings across peers without
|
||||
transmitting every result vector.
|
||||
|
||||
`music_dht::similarity_lsh` and `music_dht::similarity_dht` add decentralized
|
||||
peer routing without putting models or vector storage into this crate. Every
|
||||
client computes the same 256-bit SimHash, groups its local signatures into a
|
||||
fixed two-level LSH (`12 × 20` bits, split into a 10-bit DHT key and a compact
|
||||
10-bit suffix), and publishes one anonymous summary per peer and coarse bucket.
|
||||
The summaries contain no track metadata, only the owner's self-contained route,
|
||||
and are signed by its existing iroh identity, so a forwarding peer cannot alter
|
||||
or impersonate them.
|
||||
|
||||
The routing overlay has its own schema-independent ALPN. Old clients therefore
|
||||
remain compatible with ordinary catalog federation while upgraded peers find
|
||||
one another and replicate expiring LSH summaries without a coordinator or a
|
||||
global calibration file. Applications provide local routing signatures through
|
||||
`SimilarityDht::sync_local_signatures`, use `SimilarityDht::find_peers` before
|
||||
the direct similarity stream, and continue to perform exact search locally.
|
||||
|
||||
## Trusted-device sync and listening history
|
||||
|
||||
`music_dht::device_sync` is the canonical wire contract shared by Furumi
|
||||
|
||||
@@ -33,6 +33,8 @@ pub const MUSIC_DHT_ID: &str = "music_dht";
|
||||
pub const CATALOG_ID: &str = "catalog";
|
||||
/// Stable protocol identifier for music-similarity streams.
|
||||
pub const SIMILARITY_ID: &str = "similarity";
|
||||
/// Stable protocol identifier for DHT-routed similarity discovery.
|
||||
pub const SIMILARITY_DHT_ID: &str = "similarity_dht";
|
||||
/// Stable protocol identifier for personal-device synchronization.
|
||||
pub const DEVICE_SYNC_ID: &str = "device_sync";
|
||||
/// Stable protocol identifier for Jam playback control.
|
||||
@@ -73,6 +75,10 @@ impl CapabilityManifest {
|
||||
SIMILARITY_ID.to_string(),
|
||||
crate::similarity::SIMILARITY_PROTOCOL_VERSION,
|
||||
);
|
||||
protocols.insert(
|
||||
SIMILARITY_DHT_ID.to_string(),
|
||||
crate::similarity_lsh::SIMILARITY_DHT_PROTOCOL_VERSION,
|
||||
);
|
||||
protocols.insert(
|
||||
DEVICE_SYNC_ID.to_string(),
|
||||
crate::device_sync::DEVICE_SYNC_PROTOCOL_VERSION,
|
||||
@@ -212,6 +218,7 @@ mod tests {
|
||||
MUSIC_DHT_ID,
|
||||
CATALOG_ID,
|
||||
SIMILARITY_ID,
|
||||
SIMILARITY_DHT_ID,
|
||||
DEVICE_SYNC_ID,
|
||||
JAM_ID,
|
||||
] {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
//! the exact same audio bytes.
|
||||
//! * [`similarity`] defines the bounded, model-neutral stream contract used by
|
||||
//! clients that independently generate compatible music embeddings.
|
||||
//! * [`similarity_lsh`] turns those embeddings into deterministic compact
|
||||
//! routing summaries, while [`similarity_dht`] stores and discovers signed
|
||||
//! peer summaries without owning model inference or an embedding database.
|
||||
//! * Records are replicated to the `K` nodes whose ids are XOR-closest to
|
||||
//! each key. Publishers pick the targets from their routing table and send
|
||||
//! batched store requests (one pipeline per peer), so even a large library
|
||||
@@ -94,6 +97,10 @@ mod request;
|
||||
mod routing;
|
||||
mod service;
|
||||
pub mod similarity;
|
||||
pub mod similarity_dht;
|
||||
pub mod similarity_lsh;
|
||||
|
||||
pub use similarity_lsh::SimilarityRouteEntry;
|
||||
|
||||
pub use config::{
|
||||
DEFAULT_EXPIRE_INTERVAL, DEFAULT_LOOKUP_TIMEOUT, DEFAULT_REPUBLISH_INTERVAL,
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use federation_net::{
|
||||
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, PeerTicket, SchemaId,
|
||||
SecretKey, StreamAcceptor,
|
||||
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkId, PeerTicket,
|
||||
SchemaId, SecretKey, Signature, StreamAcceptor,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
@@ -335,6 +335,17 @@ impl MusicDhtService {
|
||||
self.node.endpoint_id
|
||||
}
|
||||
|
||||
/// Returns the federation network this service participates in.
|
||||
pub fn network_id(&self) -> NetworkId {
|
||||
self.node.config.network_id
|
||||
}
|
||||
|
||||
/// Signs a forwarded application record with this peer's persistent
|
||||
/// transport identity without exposing the secret key.
|
||||
pub fn sign_identity(&self, message: &[u8]) -> Signature {
|
||||
self.node.engine.sign_identity(message)
|
||||
}
|
||||
|
||||
/// Returns the DHT identifier of this peer.
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
self.node.node_id
|
||||
@@ -402,6 +413,24 @@ impl MusicDhtService {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Opens a raw byte stream using a self-contained peer ticket.
|
||||
///
|
||||
/// This is used by schema-independent overlays whose contacts can be
|
||||
/// learned independently of the main music-DHT routing table.
|
||||
pub async fn open_stream_to(&self, ticket: &PeerTicket, alpn: &[u8]) -> Result<ByteStream> {
|
||||
self.node.ensure_running()?;
|
||||
if ticket.network_id != self.node.config.network_id {
|
||||
return Err(MusicDhtError::Network(
|
||||
"peer ticket belongs to another network".to_string(),
|
||||
));
|
||||
}
|
||||
self.node
|
||||
.engine
|
||||
.open_stream(ticket.endpoint_addr.clone(), alpn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Synchronizes the published library with `specs`: the desired set of
|
||||
/// items this peer wants to share.
|
||||
///
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,665 @@
|
||||
//! Deterministic, model-neutral LSH routing for federated similarity search.
|
||||
//!
|
||||
//! Applications still own embedding generation and exact local search. This
|
||||
//! module turns normalized embeddings into compact routing signatures and
|
||||
//! groups them into bounded, signed peer summaries suitable for a DHT.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use federation_net::{EndpointId, NetworkId, PeerTicket, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{MusicDhtError, Result};
|
||||
|
||||
/// ALPN of the schema-independent similarity-routing overlay.
|
||||
pub const SIMILARITY_DHT_ALPN: &[u8] = b"furumi-fd/similarity-dht/1";
|
||||
/// Current similarity-routing wire and record version.
|
||||
pub const SIMILARITY_DHT_PROTOCOL_VERSION: u16 = 1;
|
||||
/// Stable identifier advertised in capability manifests.
|
||||
pub const SIMILARITY_DHT_ID: &str = "similarity_dht";
|
||||
/// Size of the routing SimHash. It is separate from the shorter result
|
||||
/// signature used only for near-duplicate filtering.
|
||||
pub const ROUTING_SIGNATURE_BYTES: usize = 32;
|
||||
/// Number of independent LSH tables published by every peer.
|
||||
pub const ROUTING_TABLES: usize = 12;
|
||||
/// Bits in the fine bucket of one table.
|
||||
pub const ROUTING_BITS: usize = 20;
|
||||
/// Prefix bits forming the DHT key. The remaining bits are carried compactly
|
||||
/// inside the peer's signed record.
|
||||
pub const ROUTING_PRIMARY_BITS: usize = 10;
|
||||
/// Fine-bucket suffix bits stored inside a primary-bucket record.
|
||||
pub const ROUTING_SUFFIX_BITS: usize = ROUTING_BITS - ROUTING_PRIMARY_BITS;
|
||||
/// Query-side primary buckets per table: exact plus the lowest-margin
|
||||
/// one-bit neighbor.
|
||||
pub const ROUTING_PRIMARY_PROBES: usize = 2;
|
||||
/// Query-side fine suffixes checked inside a record: exact plus eight
|
||||
/// lowest-margin one-bit neighbors.
|
||||
pub const ROUTING_SUFFIX_PROBES: usize = 9;
|
||||
/// Maximum profile fingerprint length accepted on the routing wire.
|
||||
pub const MAX_ROUTING_PROFILE_BYTES: usize = 128;
|
||||
/// Maximum self-contained owner ticket accepted in a routing record.
|
||||
pub const MAX_ROUTING_TICKET_BYTES: usize = 16 * 1024;
|
||||
/// Maximum fine buckets in one peer summary. Ten suffix bits make this a
|
||||
/// natural hard bound independent of library size.
|
||||
pub const MAX_ROUTING_ENTRIES: usize = 1 << ROUTING_SUFFIX_BITS;
|
||||
/// Routing records expire together with ordinary active library records.
|
||||
pub const ROUTING_RECORD_TTL: Duration = crate::ACTIVE_RECORD_TTL;
|
||||
|
||||
const ROUTING_SIGNATURE_BITS: usize = ROUTING_SIGNATURE_BYTES * 8;
|
||||
const SIGNATURE_DOMAIN: &[u8] = b"frid-similarity-simhash-v1";
|
||||
const TABLE_DOMAIN: &[u8] = b"frid-similarity-lsh-table-v1\0";
|
||||
const KEY_DOMAIN: &[u8] = b"frid-similarity-lsh-key-v1\0";
|
||||
const RECORD_DOMAIN: &[u8] = b"frid-similarity-lsh-record-v1\0";
|
||||
|
||||
/// A 256-bit point in the similarity-routing DHT key space.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct SimilarityDhtKey([u8; 32]);
|
||||
|
||||
impl SimilarityDhtKey {
|
||||
/// Derives a network-, profile-, table- and primary-bucket-specific key.
|
||||
pub fn derive(
|
||||
network_id: &NetworkId,
|
||||
profile_id: &str,
|
||||
table: u8,
|
||||
primary_bucket: u16,
|
||||
) -> Result<Self> {
|
||||
validate_profile(profile_id)?;
|
||||
if table as usize >= ROUTING_TABLES
|
||||
|| primary_bucket as usize >= (1 << ROUTING_PRIMARY_BITS)
|
||||
{
|
||||
return Err(protocol_error("invalid similarity DHT bucket"));
|
||||
}
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(KEY_DOMAIN);
|
||||
hasher.update(network_id.as_bytes());
|
||||
hasher.update(&(profile_id.len() as u16).to_le_bytes());
|
||||
hasher.update(profile_id.as_bytes());
|
||||
hasher.update(&[table]);
|
||||
hasher.update(&primary_bucket.to_le_bytes());
|
||||
Ok(Self(*hasher.finalize().as_bytes()))
|
||||
}
|
||||
|
||||
/// Creates a key from raw bytes received on the wire.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Returns the raw DHT key bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Query-side routing projection. Margins are intentionally local-only: they
|
||||
/// choose useful neighboring probes but are never sent over the network.
|
||||
#[derive(Clone)]
|
||||
pub struct SimilarityRoutingQuery {
|
||||
signature: [u8; ROUTING_SIGNATURE_BYTES],
|
||||
margins: [f32; ROUTING_SIGNATURE_BITS],
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SimilarityRoutingQuery {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SimilarityRoutingQuery")
|
||||
.field("signature", &self.signature)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl SimilarityRoutingQuery {
|
||||
/// Compact routing signature of the normalized query embedding.
|
||||
pub fn signature(&self) -> &[u8; ROUTING_SIGNATURE_BYTES] {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
/// Returns the two coarse DHT buckets and nine fine suffixes to probe for
|
||||
/// one table.
|
||||
pub fn probes(&self, profile_id: &str, table: u8) -> Result<SimilarityTableProbes> {
|
||||
let positions = table_bit_positions(profile_id, table)?;
|
||||
let primary_positions = &positions[..ROUTING_PRIMARY_BITS];
|
||||
let suffix_positions = &positions[ROUTING_PRIMARY_BITS..];
|
||||
let primary = extract_bits(&self.signature, primary_positions);
|
||||
let suffix = extract_bits(&self.signature, suffix_positions);
|
||||
|
||||
let mut primary_by_margin: Vec<(usize, f32)> = primary_positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(bucket_bit, signature_bit)| (bucket_bit, self.margins[*signature_bit as usize]))
|
||||
.collect();
|
||||
primary_by_margin.sort_by(|left, right| left.1.total_cmp(&right.1));
|
||||
let mut primary_buckets = vec![primary];
|
||||
for (bit, _) in primary_by_margin
|
||||
.into_iter()
|
||||
.take(ROUTING_PRIMARY_PROBES.saturating_sub(1))
|
||||
{
|
||||
primary_buckets.push(primary ^ (1 << bit));
|
||||
}
|
||||
|
||||
let mut suffix_by_margin: Vec<(usize, f32)> = suffix_positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(bucket_bit, signature_bit)| (bucket_bit, self.margins[*signature_bit as usize]))
|
||||
.collect();
|
||||
suffix_by_margin.sort_by(|left, right| left.1.total_cmp(&right.1));
|
||||
let mut suffix_buckets = vec![suffix];
|
||||
for (bit, _) in suffix_by_margin
|
||||
.into_iter()
|
||||
.take(ROUTING_SUFFIX_PROBES.saturating_sub(1))
|
||||
{
|
||||
suffix_buckets.push(suffix ^ (1 << bit));
|
||||
}
|
||||
|
||||
Ok(SimilarityTableProbes {
|
||||
table,
|
||||
primary_buckets,
|
||||
suffix_buckets,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded query probes for one LSH table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SimilarityTableProbes {
|
||||
/// LSH table number.
|
||||
pub table: u8,
|
||||
/// Exact and one low-margin neighboring DHT bucket.
|
||||
pub primary_buckets: Vec<u16>,
|
||||
/// Exact and eight low-margin fine buckets checked locally.
|
||||
pub suffix_buckets: Vec<u16>,
|
||||
}
|
||||
|
||||
/// One fine bucket and its anonymous representative routing signature.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SimilarityRouteEntry {
|
||||
/// Ten-bit suffix inside the record's primary bucket.
|
||||
pub suffix: u16,
|
||||
/// A stable representative of tracks in this peer's fine bucket.
|
||||
pub representative: [u8; ROUTING_SIGNATURE_BYTES],
|
||||
}
|
||||
|
||||
/// Unsigned local summary built from a peer's embeddings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SimilarityRouteSpec {
|
||||
/// Exact model/preprocessing compatibility fingerprint.
|
||||
pub profile_id: String,
|
||||
/// LSH table number.
|
||||
pub table: u8,
|
||||
/// Coarse ten-bit DHT bucket.
|
||||
pub primary_bucket: u16,
|
||||
/// Sorted, unique fine-bucket representatives.
|
||||
pub entries: Vec<SimilarityRouteEntry>,
|
||||
}
|
||||
|
||||
impl SimilarityRouteSpec {
|
||||
/// Derives this summary's DHT key for `network_id`.
|
||||
pub fn key(&self, network_id: &NetworkId) -> Result<SimilarityDhtKey> {
|
||||
SimilarityDhtKey::derive(
|
||||
network_id,
|
||||
&self.profile_id,
|
||||
self.table,
|
||||
self.primary_bucket,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed immutable payload owned by one peer. Forwarders cannot change its
|
||||
/// profile, buckets, representatives or issue time without invalidating the
|
||||
/// Ed25519 identity signature.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SimilarityRoutePayload {
|
||||
/// Record format version.
|
||||
pub version: u16,
|
||||
/// Network scope; prevents replay into another federation.
|
||||
pub network_id: NetworkId,
|
||||
/// Peer that owns and signed the summary.
|
||||
pub owner: EndpointId,
|
||||
/// Self-contained route to the owner. This is required because a lookup
|
||||
/// may discover a peer that is absent from the caller's routing table.
|
||||
pub owner_ticket: String,
|
||||
/// Exact model/preprocessing compatibility fingerprint.
|
||||
pub profile_id: String,
|
||||
/// LSH table number.
|
||||
pub table: u8,
|
||||
/// Coarse ten-bit DHT bucket.
|
||||
pub primary_bucket: u16,
|
||||
/// Sorted, unique fine-bucket representatives.
|
||||
pub entries: Vec<SimilarityRouteEntry>,
|
||||
/// Unix time at which the owner issued this version.
|
||||
pub issued_at_ms: u64,
|
||||
}
|
||||
|
||||
impl SimilarityRoutePayload {
|
||||
/// Builds a payload from a validated local summary.
|
||||
pub fn from_spec(
|
||||
spec: SimilarityRouteSpec,
|
||||
network_id: NetworkId,
|
||||
owner: EndpointId,
|
||||
owner_ticket: String,
|
||||
issued_at_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let payload = Self {
|
||||
version: SIMILARITY_DHT_PROTOCOL_VERSION,
|
||||
network_id,
|
||||
owner,
|
||||
owner_ticket,
|
||||
profile_id: spec.profile_id,
|
||||
table: spec.table,
|
||||
primary_bucket: spec.primary_bucket,
|
||||
entries: spec.entries,
|
||||
issued_at_ms,
|
||||
};
|
||||
payload.validate(&payload.network_id)?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Derives the DHT key this payload must be stored under.
|
||||
pub fn key(&self) -> Result<SimilarityDhtKey> {
|
||||
SimilarityDhtKey::derive(
|
||||
&self.network_id,
|
||||
&self.profile_id,
|
||||
self.table,
|
||||
self.primary_bucket,
|
||||
)
|
||||
}
|
||||
|
||||
/// Validates all untrusted bounds and the expected network scope.
|
||||
pub fn validate(&self, expected_network: &NetworkId) -> Result<()> {
|
||||
if self.version != SIMILARITY_DHT_PROTOCOL_VERSION {
|
||||
return Err(protocol_error(
|
||||
"unsupported similarity route record version",
|
||||
));
|
||||
}
|
||||
if &self.network_id != expected_network {
|
||||
return Err(protocol_error(
|
||||
"similarity route belongs to another network",
|
||||
));
|
||||
}
|
||||
if self.owner_ticket.len() > MAX_ROUTING_TICKET_BYTES {
|
||||
return Err(protocol_error("invalid similarity route owner ticket"));
|
||||
}
|
||||
let ticket = self
|
||||
.owner_ticket
|
||||
.parse::<PeerTicket>()
|
||||
.map_err(|_| protocol_error("invalid similarity route owner ticket"))?;
|
||||
if ticket.endpoint_id() != self.owner || &ticket.network_id != expected_network {
|
||||
return Err(protocol_error("similarity route owner ticket mismatch"));
|
||||
}
|
||||
validate_profile(&self.profile_id)?;
|
||||
if self.table as usize >= ROUTING_TABLES
|
||||
|| self.primary_bucket as usize >= (1 << ROUTING_PRIMARY_BITS)
|
||||
|| self.entries.is_empty()
|
||||
|| self.entries.len() > MAX_ROUTING_ENTRIES
|
||||
|| self.issued_at_ms == 0
|
||||
{
|
||||
return Err(protocol_error("invalid similarity route bounds"));
|
||||
}
|
||||
let mut previous = None;
|
||||
for entry in &self.entries {
|
||||
if entry.suffix as usize >= (1 << ROUTING_SUFFIX_BITS)
|
||||
|| previous.is_some_and(|value| value >= entry.suffix)
|
||||
{
|
||||
return Err(protocol_error("invalid similarity route entries"));
|
||||
}
|
||||
previous = Some(entry.suffix);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end signed routing summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SignedSimilarityRoute {
|
||||
/// Immutable owner payload.
|
||||
pub payload: SimilarityRoutePayload,
|
||||
/// Ed25519 signature made by `payload.owner`.
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
impl SignedSimilarityRoute {
|
||||
/// Signs a validated payload using an identity-backed signer.
|
||||
pub fn sign(
|
||||
payload: SimilarityRoutePayload,
|
||||
signer: impl FnOnce(&[u8]) -> Signature,
|
||||
) -> Result<Self> {
|
||||
payload.validate(&payload.network_id)?;
|
||||
let bytes = signing_bytes(&payload)?;
|
||||
Ok(Self {
|
||||
payload,
|
||||
signature: signer(&bytes),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies payload bounds, network scope and the owner's signature.
|
||||
pub fn verify(&self, expected_network: &NetworkId) -> Result<()> {
|
||||
self.payload.validate(expected_network)?;
|
||||
let bytes = signing_bytes(&self.payload)?;
|
||||
self.payload
|
||||
.owner
|
||||
.verify(&bytes, &self.signature)
|
||||
.map_err(|_| protocol_error("invalid similarity route signature"))
|
||||
}
|
||||
|
||||
/// DHT key under which this record must be stored.
|
||||
pub fn key(&self) -> Result<SimilarityDhtKey> {
|
||||
self.payload.key()
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the stable 256-bit routing signature of a normalized embedding.
|
||||
pub fn routing_signature(vector: &[f32]) -> Result<[u8; ROUTING_SIGNATURE_BYTES]> {
|
||||
Ok(routing_query(vector)?.signature)
|
||||
}
|
||||
|
||||
/// Computes a routing signature and local projection margins for multi-probe
|
||||
/// lookup. No model-specific constants or calibration data are used.
|
||||
pub fn routing_query(vector: &[f32]) -> Result<SimilarityRoutingQuery> {
|
||||
validate_vector(vector)?;
|
||||
let signs = hyperplane_signs();
|
||||
let mut projections = [0.0f32; ROUTING_SIGNATURE_BITS];
|
||||
for (dimension, value) in vector.iter().copied().enumerate() {
|
||||
for (projection, sign) in projections.iter_mut().zip(&signs[dimension]) {
|
||||
*projection += value * *sign;
|
||||
}
|
||||
}
|
||||
let mut signature = [0u8; ROUTING_SIGNATURE_BYTES];
|
||||
let mut margins = [0.0f32; ROUTING_SIGNATURE_BITS];
|
||||
for (bit, projection) in projections.into_iter().enumerate() {
|
||||
if projection >= 0.0 {
|
||||
signature[bit / 8] |= 1 << (bit % 8);
|
||||
}
|
||||
margins[bit] = projection.abs();
|
||||
}
|
||||
Ok(SimilarityRoutingQuery { signature, margins })
|
||||
}
|
||||
|
||||
/// Builds one bounded peer summary per occupied `(table, primary bucket)`.
|
||||
/// Multiple tracks in the same fine bucket collapse to one deterministic
|
||||
/// representative, so a large library cannot flood a hot DHT key.
|
||||
pub fn build_route_specs(
|
||||
profile_id: &str,
|
||||
signatures: &[[u8; ROUTING_SIGNATURE_BYTES]],
|
||||
) -> Result<Vec<SimilarityRouteSpec>> {
|
||||
validate_profile(profile_id)?;
|
||||
if signatures.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let positions = (0..ROUTING_TABLES)
|
||||
.map(|table| table_bit_positions(profile_id, table as u8))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut groups: BTreeMap<(u8, u16), BTreeMap<u16, [u8; ROUTING_SIGNATURE_BYTES]>> =
|
||||
BTreeMap::new();
|
||||
for signature in signatures {
|
||||
for (table, table_positions) in positions.iter().enumerate() {
|
||||
let primary = extract_bits(signature, &table_positions[..ROUTING_PRIMARY_BITS]);
|
||||
let suffix = extract_bits(signature, &table_positions[ROUTING_PRIMARY_BITS..]);
|
||||
groups
|
||||
.entry((table as u8, primary))
|
||||
.or_default()
|
||||
.entry(suffix)
|
||||
.and_modify(|representative| {
|
||||
if signature < representative {
|
||||
*representative = *signature;
|
||||
}
|
||||
})
|
||||
.or_insert(*signature);
|
||||
}
|
||||
}
|
||||
Ok(groups
|
||||
.into_iter()
|
||||
.map(|((table, primary_bucket), entries)| SimilarityRouteSpec {
|
||||
profile_id: profile_id.to_string(),
|
||||
table,
|
||||
primary_bucket,
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(suffix, representative)| SimilarityRouteEntry {
|
||||
suffix,
|
||||
representative,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Hamming distance between two routing signatures.
|
||||
pub fn routing_signature_distance(
|
||||
left: &[u8; ROUTING_SIGNATURE_BYTES],
|
||||
right: &[u8; ROUTING_SIGNATURE_BYTES],
|
||||
) -> u32 {
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.map(|(left, right)| (left ^ right).count_ones())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn hyperplane_signs() -> &'static Vec<[f32; ROUTING_SIGNATURE_BITS]> {
|
||||
static SIGNS: OnceLock<Vec<[f32; ROUTING_SIGNATURE_BITS]>> = OnceLock::new();
|
||||
SIGNS.get_or_init(|| {
|
||||
(0..crate::similarity::MAX_SIMILARITY_DIMENSIONS)
|
||||
.map(|dimension| {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(SIGNATURE_DOMAIN);
|
||||
hasher.update(&(dimension as u64).to_le_bytes());
|
||||
let digest = hasher.finalize();
|
||||
std::array::from_fn(|bit| {
|
||||
if digest.as_bytes()[bit / 8] & (1 << (bit % 8)) != 0 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn table_bit_positions(profile_id: &str, table: u8) -> Result<[u8; ROUTING_BITS]> {
|
||||
validate_profile(profile_id)?;
|
||||
if table as usize >= ROUTING_TABLES {
|
||||
return Err(protocol_error("invalid similarity routing table"));
|
||||
}
|
||||
let mut selected = Vec::with_capacity(ROUTING_BITS);
|
||||
let mut seen = HashSet::with_capacity(ROUTING_BITS);
|
||||
let mut counter = 0u32;
|
||||
while selected.len() < ROUTING_BITS {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(TABLE_DOMAIN);
|
||||
hasher.update(profile_id.as_bytes());
|
||||
hasher.update(&(table as u16).to_le_bytes());
|
||||
hasher.update(&counter.to_le_bytes());
|
||||
counter += 1;
|
||||
for candidate in hasher.finalize().as_bytes() {
|
||||
if seen.insert(*candidate) {
|
||||
selected.push(*candidate);
|
||||
if selected.len() == ROUTING_BITS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
.try_into()
|
||||
.map_err(|_| protocol_error("failed to derive similarity routing table"))
|
||||
}
|
||||
|
||||
fn extract_bits(signature: &[u8; ROUTING_SIGNATURE_BYTES], positions: &[u8]) -> u16 {
|
||||
positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0u16, |value, (bucket_bit, signature_bit)| {
|
||||
let bit = (signature[*signature_bit as usize / 8] >> (*signature_bit as usize % 8)) & 1;
|
||||
value | (u16::from(bit) << bucket_bit)
|
||||
})
|
||||
}
|
||||
|
||||
fn signing_bytes(payload: &SimilarityRoutePayload) -> Result<Vec<u8>> {
|
||||
let encoded = postcard::to_stdvec(payload).map_err(protocol_error)?;
|
||||
let mut bytes = Vec::with_capacity(RECORD_DOMAIN.len() + encoded.len());
|
||||
bytes.extend_from_slice(RECORD_DOMAIN);
|
||||
bytes.extend_from_slice(&encoded);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn validate_profile(profile_id: &str) -> Result<()> {
|
||||
if profile_id.is_empty() || profile_id.len() > MAX_ROUTING_PROFILE_BYTES {
|
||||
Err(protocol_error("invalid similarity routing profile id"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_vector(vector: &[f32]) -> Result<()> {
|
||||
if vector.is_empty()
|
||||
|| vector.len() > crate::similarity::MAX_SIMILARITY_DIMENSIONS
|
||||
|| !vector.iter().all(|value| value.is_finite())
|
||||
{
|
||||
return Err(protocol_error("invalid vector for similarity routing"));
|
||||
}
|
||||
let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
|
||||
if !norm.is_finite() || (norm - 1.0).abs() > 0.05 {
|
||||
return Err(protocol_error(
|
||||
"similarity routing vector is not L2-normalized",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn protocol_error(error: impl std::fmt::Display) -> MusicDhtError {
|
||||
MusicDhtError::Protocol(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use federation_net::{
|
||||
EndpointAddr, PROTOCOL_VERSION, PeerTicket, SchemaId, SecretKey, TICKET_VERSION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn normalized(values: &mut [f32]) {
|
||||
let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
|
||||
for value in values {
|
||||
*value /= norm;
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket(network: NetworkId, owner: EndpointId) -> String {
|
||||
PeerTicket {
|
||||
ticket_version: TICKET_VERSION,
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
network_id: network,
|
||||
schema_id: SchemaId::from_name("similarity-test"),
|
||||
endpoint_addr: EndpointAddr::new(owner),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_signature_is_stable_and_extends_result_simhash() {
|
||||
let mut vector = vec![0.0; 64];
|
||||
for (index, value) in vector.iter_mut().enumerate() {
|
||||
*value = index as f32 - 31.5;
|
||||
}
|
||||
normalized(&mut vector);
|
||||
let routing = routing_signature(&vector).unwrap();
|
||||
let compact = crate::similarity::embedding_signature(&vector).unwrap();
|
||||
assert_eq!(&routing[..compact.len()], &compact);
|
||||
assert_eq!(routing, routing_signature(&vector).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_specs_are_bounded_sorted_and_deterministic() {
|
||||
let signatures = [[0u8; 32], [0xff; 32], [0u8; 32]];
|
||||
let first = build_route_specs("sim1:test", &signatures).unwrap();
|
||||
let second = build_route_specs("sim1:test", &signatures).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(!first.is_empty());
|
||||
assert!(first.len() <= ROUTING_TABLES * signatures.len());
|
||||
assert!(first.iter().all(|spec| {
|
||||
!spec.entries.is_empty()
|
||||
&& spec.entries.len() <= MAX_ROUTING_ENTRIES
|
||||
&& spec
|
||||
.entries
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].suffix < pair[1].suffix)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_route_cannot_be_forged_or_replayed_between_networks() {
|
||||
let network = NetworkId::from_name("a");
|
||||
let other = NetworkId::from_name("b");
|
||||
let key = SecretKey::from_bytes(&[7; 32]);
|
||||
let spec = build_route_specs("sim1:test", &[[0x55; 32]])
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
let payload = SimilarityRoutePayload::from_spec(
|
||||
spec,
|
||||
network,
|
||||
key.public(),
|
||||
ticket(network, key.public()),
|
||||
42,
|
||||
)
|
||||
.unwrap();
|
||||
let signed = SignedSimilarityRoute::sign(payload, |bytes| key.sign(bytes)).unwrap();
|
||||
signed.verify(&network).unwrap();
|
||||
assert!(signed.verify(&other).is_err());
|
||||
|
||||
let mut forged = signed.clone();
|
||||
forged.payload.entries[0].representative[0] ^= 1;
|
||||
assert!(forged.verify(&network).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_payload_rejects_unbounded_wire_fields() {
|
||||
let network = NetworkId::from_name("bounds");
|
||||
let owner = SecretKey::from_bytes(&[6; 32]).public();
|
||||
let spec = build_route_specs("sim1:test", &[[0x33; 32]])
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
let payload =
|
||||
SimilarityRoutePayload::from_spec(spec, network, owner, ticket(network, owner), 1)
|
||||
.unwrap();
|
||||
|
||||
let mut oversized_profile = payload.clone();
|
||||
oversized_profile.profile_id = "x".repeat(MAX_ROUTING_PROFILE_BYTES + 1);
|
||||
assert!(oversized_profile.validate(&network).is_err());
|
||||
|
||||
let mut oversized_entries = payload;
|
||||
oversized_entries.entries = (0..=MAX_ROUTING_ENTRIES)
|
||||
.map(|suffix| SimilarityRouteEntry {
|
||||
suffix: suffix as u16,
|
||||
representative: [0; ROUTING_SIGNATURE_BYTES],
|
||||
})
|
||||
.collect();
|
||||
assert!(oversized_entries.validate(&network).is_err());
|
||||
|
||||
let mut mismatched_ticket = oversized_profile;
|
||||
mismatched_ticket.profile_id = "sim1:test".into();
|
||||
mismatched_ticket.owner_ticket = ticket(network, SecretKey::from_bytes(&[5; 32]).public());
|
||||
assert!(mismatched_ticket.validate(&network).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_probes_are_bounded_and_include_exact_bucket() {
|
||||
let query = routing_query(&[0.5; 4]).unwrap();
|
||||
let probes = query.probes("sim1:test", 0).unwrap();
|
||||
assert_eq!(probes.primary_buckets.len(), ROUTING_PRIMARY_PROBES);
|
||||
assert_eq!(probes.suffix_buckets.len(), ROUTING_SUFFIX_PROBES);
|
||||
assert!(
|
||||
probes
|
||||
.primary_buckets
|
||||
.iter()
|
||||
.all(|bucket| *bucket < (1 << ROUTING_PRIMARY_BITS))
|
||||
);
|
||||
assert!(
|
||||
probes
|
||||
.suffix_buckets
|
||||
.iter()
|
||||
.all(|bucket| *bucket < (1 << ROUTING_SUFFIX_BITS))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::similarity_lsh::{SIMILARITY_DHT_ALPN, routing_signature};
|
||||
use music_dht::{MusicDhtConfig, MusicDhtService, NetworkId};
|
||||
|
||||
const TEST_DIRECT_ALPN: &[u8] = b"music-dht-test/similarity-owner/1";
|
||||
|
||||
async fn start_node(
|
||||
directory: &std::path::Path,
|
||||
network: NetworkId,
|
||||
) -> (Arc<MusicDhtService>, tokio::task::JoinHandle<()>) {
|
||||
std::fs::create_dir_all(directory).unwrap();
|
||||
let config = MusicDhtConfig::builder()
|
||||
.data_dir(directory)
|
||||
.network_id(network)
|
||||
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||
.schema_independent_stream_protocol(TEST_DIRECT_ALPN)
|
||||
.request_timeout(Duration::from_secs(2))
|
||||
.lookup_timeout(Duration::from_secs(5))
|
||||
.transport_timeout(Duration::from_secs(5))
|
||||
.dial_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
let (service, mut events) = MusicDhtService::start(config).await.unwrap();
|
||||
let task = tokio::spawn(async move { while events.recv().await.is_some() {} });
|
||||
(Arc::new(service), task)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn signed_lsh_summary_routes_a_query_to_another_peer() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let network = NetworkId::from_name("similarity-dht-integration");
|
||||
let (first, first_events) = start_node(&temp.path().join("first"), network).await;
|
||||
let (second, second_events) = start_node(&temp.path().join("second"), network).await;
|
||||
|
||||
let first_acceptor = first.stream_acceptor(SIMILARITY_DHT_ALPN).unwrap();
|
||||
let second_acceptor = second.stream_acceptor(SIMILARITY_DHT_ALPN).unwrap();
|
||||
let mut second_direct_acceptor = second.stream_acceptor(TEST_DIRECT_ALPN).unwrap();
|
||||
let first_routing = SimilarityDht::open(
|
||||
Arc::clone(&first),
|
||||
temp.path().join("first-routing.sqlite3"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let second_routing = SimilarityDht::open(
|
||||
Arc::clone(&second),
|
||||
temp.path().join("second-routing.sqlite3"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let first_serve = tokio::spawn(Arc::clone(&first_routing).serve(first_acceptor));
|
||||
let second_serve = tokio::spawn(Arc::clone(&second_routing).serve(second_acceptor));
|
||||
|
||||
second.connect(first.ticket().await.unwrap()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while first.known_peers().is_empty() || second.known_peers().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let vector = vec![0.5; 4];
|
||||
let profile = "sim1:integration";
|
||||
let stats = second_routing
|
||||
.sync_local_signatures(
|
||||
profile.to_string(),
|
||||
vec![routing_signature(&vector).unwrap()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stats.records, 12);
|
||||
assert!(stats.local_replica);
|
||||
assert_eq!(stats.remote_nodes, 1);
|
||||
|
||||
let peers = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
first_routing.find_peers(profile, &vector, 16),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
peers.first().map(|ticket| ticket.endpoint_id()),
|
||||
Some(second.endpoint_id())
|
||||
);
|
||||
let mut outbound = first
|
||||
.open_stream_to(peers.first().unwrap(), TEST_DIRECT_ALPN)
|
||||
.await
|
||||
.unwrap();
|
||||
let inbound = tokio::time::timeout(Duration::from_secs(5), second_direct_acceptor.accept())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(inbound.peer_id, first.endpoint_id());
|
||||
outbound.send.finish().unwrap();
|
||||
drop(inbound);
|
||||
drop(outbound);
|
||||
|
||||
first_serve.abort();
|
||||
second_serve.abort();
|
||||
first.shutdown().await.unwrap();
|
||||
second.shutdown().await.unwrap();
|
||||
first_events.abort();
|
||||
second_events.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user