Add shared music similarity protocol
CI / check (push) Successful in 1m55s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-10 00:21:39 +01:00
parent 10fa97a915
commit 6ceb52c5d2
8 changed files with 616 additions and 2 deletions
+8
View File
@@ -151,6 +151,14 @@ stream capability and defines shared wire models for Furumi catalog and device
sync protocols. Audio transfer, rich catalog exchange, and synchronization can
therefore use dedicated ALPNs while sharing identity and connectivity.
Music similarity is another shared extension protocol. `music-dht` owns its
versioned ALPN, bounded request/response models, validation, and byte-stream
framing. Applications own model selection, user consent, audio preprocessing,
embedding generation and storage, nearest-neighbor search, peer selection, and
result presentation. Compatible clients can therefore use different local
implementations while exchanging vectors only when their exact profile
fingerprints match.
Capability discovery is the narrow exception to schema isolation. The bounded,
self-versioned `furumi/capabilities/1` stream still validates the federation
transport version and network id, but remains reachable across application
Generated
+1 -1
View File
@@ -1967,7 +1967,7 @@ dependencies = [
[[package]]
name = "music-dht"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "music-dht"
version = "0.3.0"
version = "0.3.1"
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
readme = "README.md"
documentation = "https://docs.rs/music-dht"
+17
View File
@@ -74,6 +74,23 @@ network.
federation feature: every instance publishes its library index and can search
the libraries of all other instances on the same network.
## Similarity protocol
`music_dht::similarity` is the shared, model-neutral wire contract for finding
tracks by a compatible embedding. It provides the versioned ALPN, bounded
request/response types, validation, and JSON byte-stream framing. It does not
generate embeddings or prescribe a vector database, model, preprocessing
pipeline, peer-routing policy, or user-consent UI; those remain client-owned.
Requests carry an L2-normalized vector and an exact model/profile fingerprint.
A peer must reject fingerprints it cannot search. This lets native, web, and
future Furumi clients implement local inference differently while remaining
wire-compatible when they choose the same embedding contract.
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.
## Trusted-device sync and listening history
`music_dht::device_sync` is the canonical wire contract shared by Furumi
+7
View File
@@ -31,6 +31,8 @@ pub const RENDEZVOUS_ID: &str = "rendezvous";
pub const MUSIC_DHT_ID: &str = "music_dht";
/// Stable protocol identifier for rich catalog streams.
pub const CATALOG_ID: &str = "catalog";
/// Stable protocol identifier for music-similarity streams.
pub const SIMILARITY_ID: &str = "similarity";
/// Stable protocol identifier for personal-device synchronization.
pub const DEVICE_SYNC_ID: &str = "device_sync";
/// Stable protocol identifier for Jam playback control.
@@ -67,6 +69,10 @@ impl CapabilityManifest {
CATALOG_ID.to_string(),
crate::catalog::CATALOG_PROTOCOL_VERSION,
);
protocols.insert(
SIMILARITY_ID.to_string(),
crate::similarity::SIMILARITY_PROTOCOL_VERSION,
);
protocols.insert(
DEVICE_SYNC_ID.to_string(),
crate::device_sync::DEVICE_SYNC_PROTOCOL_VERSION,
@@ -205,6 +211,7 @@ mod tests {
RENDEZVOUS_ID,
MUSIC_DHT_ID,
CATALOG_ID,
SIMILARITY_ID,
DEVICE_SYNC_ID,
JAM_ID,
] {
+3
View File
@@ -18,6 +18,8 @@
//! * Track records may also carry a compact `b3:<hex>` content id and are then
//! published under a content key, so applications can find another peer with
//! the exact same audio bytes.
//! * [`similarity`] defines the bounded, model-neutral stream contract used by
//! clients that independently generate compatible music embeddings.
//! * 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
@@ -91,6 +93,7 @@ mod record;
mod request;
mod routing;
mod service;
pub mod similarity;
pub use config::{
DEFAULT_EXPIRE_INTERVAL, DEFAULT_LOOKUP_TIMEOUT, DEFAULT_REPUBLISH_INTERVAL,
+498
View File
@@ -0,0 +1,498 @@
//! Shared wire contract for federated music-similarity queries.
//!
//! This module deliberately does not define how embeddings are generated,
//! stored, or searched. Applications provide that policy and implementation;
//! Frid only supplies compatible, bounded messages over an authenticated byte
//! stream.
use federation_net::ByteStream;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt};
use crate::error::{MusicDhtError, Result};
/// ALPN of the Furumi similarity-search stream protocol.
pub const SIMILARITY_ALPN: &[u8] = b"furumi-fd/similarity/1";
/// Current similarity-search wire version.
pub const SIMILARITY_PROTOCOL_VERSION: u16 = 1;
/// Maximum serialized request size.
pub const MAX_SIMILARITY_REQUEST_BYTES: usize = 96 * 1024;
/// Maximum serialized response size.
pub const MAX_SIMILARITY_RESPONSE_BYTES: usize = 256 * 1024;
/// Maximum embedding dimensions accepted from a peer.
pub const MAX_SIMILARITY_DIMENSIONS: usize = 4096;
/// Maximum results in one peer response.
pub const MAX_SIMILARITY_RESULTS: usize = 50;
/// Maximum UTF-8 bytes in a model/profile compatibility fingerprint.
pub const MAX_SIMILARITY_PROFILE_BYTES: usize = 128;
/// Maximum main or featured artists attached to one hit.
pub const MAX_SIMILARITY_ARTISTS: usize = 32;
/// Maximum UTF-8 bytes in one user-visible metadata field.
pub const MAX_SIMILARITY_TEXT_BYTES: usize = 1024;
/// Size of the compact SimHash used to suppress near-duplicate results.
pub const SIMILARITY_SIGNATURE_BYTES: usize = 16;
/// Normalized embedding query sent to a peer.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SimilarityRequest {
/// Wire protocol version.
pub version: u16,
/// Exact model-artifact and preprocessing fingerprint.
pub profile_id: String,
/// Number of values in `vector`.
pub dimensions: usize,
/// L2-normalized query embedding.
pub vector: Vec<f32>,
/// Maximum desired result count.
pub limit: usize,
}
impl SimilarityRequest {
/// Builds and validates a request for the current wire version.
pub fn new(profile_id: impl Into<String>, vector: Vec<f32>, limit: usize) -> Result<Self> {
let request = Self {
version: SIMILARITY_PROTOCOL_VERSION,
profile_id: profile_id.into(),
dimensions: vector.len(),
vector,
limit,
};
request.validate()?;
Ok(request)
}
/// Validates compatibility, input bounds, and vector normalization.
pub fn validate(&self) -> Result<()> {
if self.version != SIMILARITY_PROTOCOL_VERSION {
return Err(protocol_error(format!(
"unsupported similarity protocol {}",
self.version
)));
}
if self.profile_id.is_empty() || self.profile_id.len() > MAX_SIMILARITY_PROFILE_BYTES {
return Err(protocol_error("invalid similarity profile id"));
}
if self.dimensions == 0
|| self.dimensions > MAX_SIMILARITY_DIMENSIONS
|| self.vector.len() != self.dimensions
{
return Err(protocol_error("invalid similarity vector dimensions"));
}
if !(1..=MAX_SIMILARITY_RESULTS).contains(&self.limit) {
return Err(protocol_error("invalid similarity result limit"));
}
if !self.vector.iter().all(|value| value.is_finite()) {
return Err(protocol_error(
"similarity vector contains a non-finite value",
));
}
let norm = self
.vector
.iter()
.map(|value| value * value)
.sum::<f32>()
.sqrt();
if !norm.is_finite() || (norm - 1.0).abs() > 0.05 {
return Err(protocol_error("similarity vector is not L2-normalized"));
}
Ok(())
}
}
/// One track returned by a similarity provider.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SimilarityHit {
/// Cosine similarity in the inclusive `[-1, 1]` range.
pub score: f32,
/// Peer-local DHT track item identifier.
pub item_id: String,
/// Display track title.
pub title: String,
/// Main artist names.
pub artist_names: Vec<String>,
/// Featured artist names.
pub featured_artist_names: Vec<String>,
/// Release year when known.
pub year: Option<i32>,
/// Rounded duration in seconds when known.
pub duration_seconds: Option<i64>,
/// Stable audio content identifier when known.
pub content_id: Option<String>,
/// Release title when known.
pub release_title: Option<String>,
/// Track number when known.
pub track_number: Option<i32>,
/// Disc number when known.
pub disc_number: Option<i32>,
/// Compact, model-neutral SimHash of the normalized embedding.
///
/// Older peers may omit it. It is a ranking hint, not a track identity.
pub embedding_signature: Option<[u8; SIMILARITY_SIGNATURE_BYTES]>,
}
impl SimilarityHit {
/// Validates one untrusted result and its metadata bounds.
pub fn validate(&self) -> Result<()> {
if !self.score.is_finite() || !(-1.001..=1.001).contains(&self.score) {
return Err(protocol_error("invalid similarity score"));
}
if self.item_id.is_empty() || self.item_id.len() > 128 {
return Err(protocol_error("invalid similarity item id"));
}
validate_text(&self.title, false)?;
validate_names(&self.artist_names)?;
validate_names(&self.featured_artist_names)?;
if let Some(content_id) = &self.content_id {
validate_text(content_id, true)?;
}
if let Some(release_title) = &self.release_title {
validate_text(release_title, true)?;
}
Ok(())
}
}
/// Bounded response returned by a similarity provider.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SimilarityResponse {
/// Whether the query was accepted.
pub ok: bool,
/// Human-readable refusal reason when `ok` is false.
pub error: Option<String>,
/// Ranked track matches.
pub hits: Vec<SimilarityHit>,
}
impl SimilarityResponse {
/// Creates a successful, validated response.
pub fn success(hits: Vec<SimilarityHit>) -> Result<Self> {
let response = Self {
ok: true,
error: None,
hits,
};
response.validate()?;
Ok(response)
}
/// Creates a bounded refusal response.
pub fn refused(error: impl Into<String>) -> Result<Self> {
let response = Self {
ok: false,
error: Some(error.into()),
hits: Vec::new(),
};
response.validate()?;
Ok(response)
}
/// Validates response consistency and every untrusted result.
pub fn validate(&self) -> Result<()> {
if self.hits.len() > MAX_SIMILARITY_RESULTS {
return Err(protocol_error("too many similarity results"));
}
if self.ok {
if self.error.is_some() {
return Err(protocol_error(
"successful similarity response has an error",
));
}
} else {
let error = self
.error
.as_deref()
.ok_or_else(|| protocol_error("similarity refusal has no reason"))?;
validate_text(error, false)?;
if !self.hits.is_empty() {
return Err(protocol_error("similarity refusal contains results"));
}
}
for hit in &self.hits {
hit.validate()?;
}
Ok(())
}
}
/// Writes one validated request without closing the send stream.
pub async fn write_request(stream: &mut ByteStream, request: &SimilarityRequest) -> Result<()> {
request.validate()?;
write_json(stream, request, MAX_SIMILARITY_REQUEST_BYTES).await
}
/// Reads and validates one request after the sender closes its stream.
pub async fn read_request(stream: &mut ByteStream) -> Result<SimilarityRequest> {
read_request_from(&mut stream.recv).await
}
/// Reads and validates one request from an arbitrary async reader.
pub async fn read_request_from<R: AsyncRead + Unpin>(reader: &mut R) -> Result<SimilarityRequest> {
let request: SimilarityRequest = read_json(reader, MAX_SIMILARITY_REQUEST_BYTES).await?;
request.validate()?;
Ok(request)
}
/// Writes one validated response without closing the send stream.
pub async fn write_response(stream: &mut ByteStream, response: &SimilarityResponse) -> Result<()> {
response.validate()?;
write_json(stream, response, MAX_SIMILARITY_RESPONSE_BYTES).await
}
/// Reads and validates one response after the sender closes its stream.
pub async fn read_response(stream: &mut ByteStream) -> Result<SimilarityResponse> {
read_response_from(&mut stream.recv).await
}
/// Reads and validates one response from an arbitrary async reader.
pub async fn read_response_from<R: AsyncRead + Unpin>(
reader: &mut R,
) -> Result<SimilarityResponse> {
let response: SimilarityResponse = read_json(reader, MAX_SIMILARITY_RESPONSE_BYTES).await?;
response.validate()?;
Ok(response)
}
/// Performs one request/response exchange on an already authenticated stream.
pub async fn exchange(
stream: &mut ByteStream,
request: &SimilarityRequest,
) -> Result<SimilarityResponse> {
write_request(stream, request).await?;
stream.send.finish().map_err(network_error)?;
read_response(stream).await
}
/// Produces the stable compact signature carried with a similarity hit.
///
/// Hamming distance between signatures approximates angular distance between
/// normalized embeddings without returning every result vector to the caller.
pub fn embedding_signature(vector: &[f32]) -> Result<[u8; SIMILARITY_SIGNATURE_BYTES]> {
if vector.is_empty()
|| vector.len() > MAX_SIMILARITY_DIMENSIONS
|| !vector.iter().all(|value| value.is_finite())
{
return Err(protocol_error("invalid similarity vector for signature"));
}
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 vector is not L2-normalized"));
}
let mut projections = [0.0f32; SIMILARITY_SIGNATURE_BYTES * 8];
for (dimension, value) in vector.iter().copied().enumerate() {
let mut hasher = blake3::Hasher::new();
hasher.update(b"frid-similarity-simhash-v1");
hasher.update(&(dimension as u64).to_le_bytes());
let random_signs = hasher.finalize();
for (bit, projection) in projections.iter_mut().enumerate() {
let positive = random_signs.as_bytes()[bit / 8] & (1 << (bit % 8)) != 0;
*projection += if positive { value } else { -value };
}
}
let mut signature = [0u8; SIMILARITY_SIGNATURE_BYTES];
for (bit, projection) in projections.into_iter().enumerate() {
if projection >= 0.0 {
signature[bit / 8] |= 1 << (bit % 8);
}
}
Ok(signature)
}
/// Returns the bit distance between two compact embedding signatures.
pub fn signature_distance(
left: &[u8; SIMILARITY_SIGNATURE_BYTES],
right: &[u8; SIMILARITY_SIGNATURE_BYTES],
) -> u32 {
left.iter()
.zip(right)
.map(|(left, right)| (left ^ right).count_ones())
.sum()
}
async fn write_json<T: Serialize>(stream: &mut ByteStream, value: &T, max: usize) -> Result<()> {
let payload = serde_json::to_vec(value).map_err(protocol_error)?;
if payload.len() > max {
return Err(protocol_error("similarity message is too large"));
}
stream.send.write_all(&payload).await.map_err(network_error)
}
async fn read_json<R, T>(reader: &mut R, max: usize) -> Result<T>
where
R: AsyncRead + Unpin,
T: for<'de> Deserialize<'de>,
{
let mut payload = Vec::new();
reader
.take(max as u64 + 1)
.read_to_end(&mut payload)
.await
.map_err(network_error)?;
if payload.len() > max {
return Err(protocol_error("similarity message is too large"));
}
if payload.is_empty() {
return Err(protocol_error("similarity message is empty"));
}
serde_json::from_slice(&payload).map_err(protocol_error)
}
fn validate_names(names: &[String]) -> Result<()> {
if names.len() > MAX_SIMILARITY_ARTISTS {
return Err(protocol_error("too many artists in similarity result"));
}
for name in names {
validate_text(name, false)?;
}
Ok(())
}
fn validate_text(value: &str, allow_empty: bool) -> Result<()> {
if (!allow_empty && value.trim().is_empty()) || value.len() > MAX_SIMILARITY_TEXT_BYTES {
return Err(protocol_error("invalid similarity metadata bounds"));
}
Ok(())
}
fn protocol_error(error: impl std::fmt::Display) -> MusicDhtError {
MusicDhtError::Protocol(error.to_string())
}
fn network_error(error: impl std::fmt::Display) -> MusicDhtError {
MusicDhtError::Network(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> SimilarityRequest {
SimilarityRequest::new("sim1:test", vec![0.5; 4], 10).unwrap()
}
fn hit() -> SimilarityHit {
SimilarityHit {
score: 0.75,
item_id: "00aa".to_string(),
title: "Track".to_string(),
artist_names: vec!["Artist".to_string()],
..SimilarityHit::default()
}
}
#[test]
fn request_accepts_normalized_bounded_vector() {
request().validate().unwrap();
}
#[test]
fn request_rejects_incompatible_and_oversized_inputs() {
let mut invalid = request();
invalid.version += 1;
assert!(invalid.validate().is_err());
let mut invalid = request();
invalid.vector.push(0.0);
assert!(invalid.validate().is_err());
let mut invalid = request();
invalid.profile_id = "x".repeat(MAX_SIMILARITY_PROFILE_BYTES + 1);
assert!(invalid.validate().is_err());
}
#[test]
fn request_accepts_limits_and_rejects_the_first_value_beyond_them() {
let component = 1.0 / (MAX_SIMILARITY_DIMENSIONS as f32).sqrt();
let at_dimension_limit = SimilarityRequest::new(
"x".repeat(MAX_SIMILARITY_PROFILE_BYTES),
vec![component; MAX_SIMILARITY_DIMENSIONS],
MAX_SIMILARITY_RESULTS,
)
.unwrap();
at_dimension_limit.validate().unwrap();
let over_dimensions = vec![component; MAX_SIMILARITY_DIMENSIONS + 1];
assert!(SimilarityRequest::new("profile", over_dimensions, 1).is_err());
assert!(SimilarityRequest::new("profile", vec![1.0], MAX_SIMILARITY_RESULTS + 1).is_err());
}
#[test]
fn response_rejects_invalid_hits_and_first_result_over_limit() {
let mut invalid = hit();
invalid.score = 2.0;
assert!(SimilarityResponse::success(vec![invalid]).is_err());
let too_many = vec![hit(); MAX_SIMILARITY_RESULTS + 1];
assert!(SimilarityResponse::success(too_many).is_err());
}
#[test]
fn hit_accepts_metadata_limits_and_rejects_the_first_value_beyond_them() {
let mut at_limit = hit();
at_limit.title = "x".repeat(MAX_SIMILARITY_TEXT_BYTES);
at_limit.artist_names = vec!["Artist".to_string(); MAX_SIMILARITY_ARTISTS];
at_limit.validate().unwrap();
let mut over_text = hit();
over_text.title = "x".repeat(MAX_SIMILARITY_TEXT_BYTES + 1);
assert!(over_text.validate().is_err());
let mut over_artists = hit();
over_artists.artist_names = vec!["Artist".to_string(); MAX_SIMILARITY_ARTISTS + 1];
assert!(over_artists.validate().is_err());
}
#[tokio::test]
async fn request_reader_enforces_the_byte_limit() {
let payload = vec![b'x'; MAX_SIMILARITY_REQUEST_BYTES + 1];
assert!(read_request_from(&mut payload.as_slice()).await.is_err());
}
#[tokio::test]
async fn request_and_response_round_trip_as_json() {
let request = request();
let request_json = serde_json::to_vec(&request).unwrap();
assert_eq!(
read_request_from(&mut request_json.as_slice())
.await
.unwrap(),
request
);
let response = SimilarityResponse::success(vec![hit()]).unwrap();
let response_json = serde_json::to_vec(&response).unwrap();
assert_eq!(
read_response_from(&mut response_json.as_slice())
.await
.unwrap(),
response
);
}
#[test]
fn compact_signatures_are_stable_and_distance_preserving() {
let first = vec![0.5; 4];
let identical = embedding_signature(&first).unwrap();
let different = embedding_signature(&[0.5, -0.5, 0.5, -0.5]).unwrap();
assert_eq!(identical, embedding_signature(&first).unwrap());
assert_eq!(signature_distance(&identical, &identical), 0);
assert!(signature_distance(&identical, &different) > 0);
}
#[test]
fn old_hit_without_signature_remains_compatible() {
let json = r#"{
"score": 0.75,
"item_id": "00aa",
"title": "Track",
"artist_names": ["Artist"]
}"#;
let decoded: SimilarityHit = serde_json::from_str(json).unwrap();
decoded.validate().unwrap();
assert_eq!(decoded.embedding_signature, None);
}
}
+81
View File
@@ -279,3 +279,84 @@ async fn byte_stream_to_item_owner() {
.await
.expect("test timed out");
}
#[tokio::test]
async fn similarity_request_response_uses_the_shared_stream_contract() {
use music_dht::similarity::{
self, SIMILARITY_ALPN, SimilarityHit, SimilarityRequest, SimilarityResponse,
};
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let network = NetworkId::from_name("similarity-stream-test-net");
let config_a = MusicDhtConfig::builder()
.data_dir(dir_a.path())
.network_id(network)
.stream_protocol(SIMILARITY_ALPN)
.build()
.expect("valid config");
let (node_a, _events_a) = MusicDhtService::start(config_a)
.await
.expect("service starts");
let mut acceptor = node_a
.stream_acceptor(SIMILARITY_ALPN)
.expect("similarity acceptor");
let serve_task = tokio::spawn(async move {
let mut stream = acceptor.accept().await.expect("incoming stream");
let request = similarity::read_request(&mut stream)
.await
.expect("valid request");
assert_eq!(request.profile_id, "sim1:test");
let response = SimilarityResponse::success(vec![SimilarityHit {
score: 0.75,
item_id: "00aa".to_string(),
title: "Similar track".to_string(),
artist_names: vec!["Artist".to_string()],
embedding_signature: Some(
similarity::embedding_signature(&[0.5; 4]).expect("valid signature"),
),
..SimilarityHit::default()
}])
.expect("valid response");
similarity::write_response(&mut stream, &response)
.await
.expect("write response");
stream.send.finish().expect("finish response");
let _ = stream.send.stopped().await;
});
let config_b = MusicDhtConfig::builder()
.data_dir(dir_b.path())
.network_id(network)
.stream_protocol(SIMILARITY_ALPN)
.build()
.expect("valid config");
let (node_b, _events_b) = MusicDhtService::start(config_b)
.await
.expect("service starts");
node_b
.connect(node_a.ticket().await.expect("ticket"))
.await
.expect("connect");
let mut stream = node_b
.open_stream(node_a.endpoint_id(), SIMILARITY_ALPN)
.await
.expect("open similarity stream");
let request = SimilarityRequest::new("sim1:test", vec![0.5; 4], 10).expect("valid request");
let response = similarity::exchange(&mut stream, &request)
.await
.expect("similarity exchange");
assert!(response.ok);
assert_eq!(response.hits.len(), 1);
assert_eq!(response.hits[0].title, "Similar track");
assert!(response.hits[0].embedding_signature.is_some());
serve_task.await.expect("serve task");
node_a.shutdown().await.expect("shutdown a");
node_b.shutdown().await.expect("shutdown b");
})
.await
.expect("test timed out");
}