Add member directory and aggregates, cut citation economics from spec (Draft 0.4)

This commit is contained in:
George Coles
2026-09-15 03:56:11 -04:00
parent 82a95fb907
commit fb45cfa8cf
13 changed files with 890 additions and 79 deletions
+275
View File
@@ -0,0 +1,275 @@
mod common;
use std::fs;
use std::path::Path;
use common::{ask, client, collection, config_for, spawn_relay};
use frxd::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Member, save_members};
use frxd::index::LocalIndex;
use frxd::message::EXPOSURE_FULL;
use frxd::node::{self, Node, NodeHandle, current_period};
use serde_json::Value;
fn corpus_dir(root: &Path, name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let dir = root.join(format!("{name}-docs"));
fs::create_dir_all(&dir).unwrap();
for (file, text) in files {
fs::write(dir.join(file), text).unwrap();
}
dir
}
async fn start_node(
root: &Path,
name: &str,
relay_url: &str,
exposure: &str,
files: &[(&str, &str)],
) -> NodeHandle {
let docs = corpus_dir(root, name, files);
let config = config_for(&root.join(name), name, relay_url);
{
let index = LocalIndex::open(&config.index_dir()).unwrap();
index
.add_collection(&collection("docs", &docs, true, exposure))
.unwrap();
}
Node::start(config).await.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn aggregates_count_sent_and_passed_per_member() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node(
root.path(),
"bob",
&relay_url,
EXPOSURE_FULL,
&[("doc.txt", "aggregate rust document")],
)
.await;
let alice = start_node(
root.path(),
"alice",
&relay_url,
EXPOSURE_FULL,
&[("mine.txt", "alice rust note")],
)
.await;
let (_status, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
assert_eq!(
value
.get("responses")
.and_then(Value::as_array)
.unwrap()
.len(),
1
);
let period = current_period();
let aggregate = node::control_aggregate_request(
&format!("http://{}", _bob.addr),
&alice.pubkey,
&period,
Some(700),
)
.await
.unwrap();
assert_eq!(aggregate.get("sent").and_then(Value::as_u64), Some(1));
assert_eq!(aggregate.get("passed").and_then(Value::as_u64), Some(1));
assert!(aggregate.get("cited").is_none());
assert_eq!(
aggregate.get("period").and_then(Value::as_str),
Some(period.as_str())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn aggregate_rollup_for_year_sums_months() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob = start_node(
root.path(),
"bob",
&relay_url,
EXPOSURE_FULL,
&[("doc.txt", "rollup rust document")],
)
.await;
let alice = start_node(
root.path(),
"alice",
&relay_url,
EXPOSURE_FULL,
&[("mine.txt", "alice rust note")],
)
.await;
let (_status, _raw, _value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
let year = current_period()[0..4].to_string();
let aggregate = node::control_aggregate_request(
&format!("http://{}", bob.addr),
&alice.pubkey,
&year,
Some(700),
)
.await
.unwrap();
assert_eq!(aggregate.get("sent").and_then(Value::as_u64), Some(1));
assert_eq!(
aggregate.get("period").and_then(Value::as_str),
Some(year.as_str())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn aggregate_floor_rejects_finer_than_month() {
let root = tempfile::tempdir().unwrap();
let alice = start_node(
root.path(),
"alice",
&spawn_relay().await,
EXPOSURE_FULL,
&[("mine.txt", "alice rust note")],
)
.await;
let http = client();
let response = http
.get(format!(
"http://{}/v1/local/aggregates?period=2026-09-15",
alice.addr
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let local = http
.get(format!("http://{}/v1/local/aggregates", alice.addr))
.send()
.await
.unwrap();
assert!(local.status().is_success());
let config = alice.node.config.clone();
let direct = alice
.node
.request_aggregate(&config.node.relays[0], "2026-09-15", Some(50))
.await;
assert!(direct.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn enrichment_members_cannot_send_content() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob_full = start_node(
root.path(),
"bob",
&relay_url,
EXPOSURE_FULL,
&[("doc.txt", "enrichment test rust content")],
)
.await;
let carol_meta = start_node(
root.path(),
"carol",
&relay_url,
"metadata",
&[("doc.txt", "enrichment test rust metadata")],
)
.await;
let alice = start_node(
root.path(),
"alice",
&relay_url,
EXPOSURE_FULL,
&[("mine.txt", "alice local")],
)
.await;
save_members(
&alice.node.config.members_path(),
&[
Member {
name: "bob".to_string(),
pubkey: bob_full.pubkey.clone(),
class: CLASS_ENRICHMENT.to_string(),
},
Member {
name: "carol".to_string(),
pubkey: carol_meta.pubkey.clone(),
class: CLASS_ENRICHMENT.to_string(),
},
],
)
.unwrap();
let (_status, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
let responses = value.get("responses").and_then(Value::as_array).unwrap();
assert_eq!(
responses.len(),
1,
"full-exposure enrichment reply must be dropped"
);
assert_eq!(
responses[0].get("member").and_then(Value::as_str),
Some(carol_meta.pubkey.as_str())
);
assert!(
responses[0]
.get("results")
.and_then(Value::as_array)
.unwrap()[0]
.get("content")
.unwrap()
.is_null()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn source_members_may_send_content() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob = start_node(
root.path(),
"bob",
&relay_url,
EXPOSURE_FULL,
&[("doc.txt", "source test rust content")],
)
.await;
let alice = start_node(
root.path(),
"alice",
&relay_url,
EXPOSURE_FULL,
&[("mine.txt", "alice local")],
)
.await;
save_members(
&alice.node.config.members_path(),
&[Member {
name: "bob".to_string(),
pubkey: bob.pubkey.clone(),
class: CLASS_SOURCE.to_string(),
}],
)
.unwrap();
let (_status, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
let responses = value.get("responses").and_then(Value::as_array).unwrap();
assert_eq!(responses.len(), 1);
assert!(
responses[0]
.get("results")
.and_then(Value::as_array)
.unwrap()[0]
.get("content")
.unwrap()
.is_string()
);
}
+19
View File
@@ -125,6 +125,25 @@ fn cli_init_add_search_status() {
assert_eq!(mode, 0o600, "key file must not be world readable");
}
let stdout = run_ok(&[
"--config".to_string(),
config_arg.clone(),
"member".to_string(),
"add".to_string(),
"carol".to_string(),
"ab".repeat(32),
"--class".to_string(),
"enrichment".to_string(),
]);
assert!(stdout.contains("carol"));
let stdout = run_ok(&[
"--config".to_string(),
config_arg.clone(),
"member".to_string(),
"list".to_string(),
]);
assert!(stdout.contains("carol [enrichment]"));
let stdout = run_ok(&add_args(&config, &docs, "docs", true));
assert!(stdout.contains("indexed 1 file(s)"));
-1
View File
@@ -15,7 +15,6 @@ pub fn config_for(dir: &Path, name: &str, relay_url: &str) -> Config {
name: name.to_string(),
listen: "127.0.0.1:0".to_string(),
relays: vec![relay_url.to_string()],
trusted_keys: Vec::new(),
responder: true,
},
query: QuerySection {
+29 -18
View File
@@ -116,7 +116,7 @@ fn envelope_field_set_is_fixed() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ordering_travels_scores_dont() {
async fn scores_never_travel_and_selection_is_local() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
@@ -126,8 +126,8 @@ async fn ordering_travels_scores_dont() {
true,
EXPOSURE_FULL,
&[
("strong.txt", "rust rust rust rust search engine"),
("weak.txt", "rust notes"),
("alpha.txt", "rust search engine document"),
("beta.txt", "rust notes"),
],
)
.await;
@@ -135,20 +135,23 @@ async fn ordering_travels_scores_dont() {
let http = client();
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
assert_eq!(responses.len(), 1);
let results = responses[0]
.pointer("/body/results")
.and_then(Value::as_array)
.unwrap();
let body = responses[0].pointer("/body").unwrap();
let results = body.pointer("/results").and_then(Value::as_array).unwrap();
assert_eq!(results.len(), 2);
let first_url = results[0].get("url").and_then(Value::as_str).unwrap();
assert!(
first_url.contains("strong"),
"best match should lead the ordering: {first_url}"
);
let urls: BTreeSet<&str> = results
.iter()
.map(|result| result.get("url").and_then(Value::as_str).unwrap())
.collect();
assert!(urls.iter().any(|url| url.contains("alpha")));
assert!(urls.iter().any(|url| url.contains("beta")));
for result in results {
assert!(!keys(result).contains("score"));
assert!(!keys(result).contains("rank"));
}
assert_eq!(
body.pointer("/truncated").and_then(Value::as_bool),
Some(false)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -350,15 +353,23 @@ async fn entities_are_optional_hints() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn trusted_keys_allowlist_filters_senders() {
async fn member_directory_filters_senders() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let alice = Keypair::generate();
let untrusted = Keypair::generate();
let docs = corpus_dir(root.path(), "bob", &[("doc.txt", "rust document")]);
let mut config = config_for(&root.path().join("bob"), "bob", &relay_url);
config.node.trusted_keys = vec![alice.public_hex()];
let config = config_for(&root.path().join("bob"), "bob", &relay_url);
frxd::config::save_members(
&config.members_path(),
&[frxd::config::Member {
name: "alice".to_string(),
pubkey: alice.public_hex(),
class: frxd::config::CLASS_SOURCE.to_string(),
}],
)
.unwrap();
{
let index = LocalIndex::open(&config.index_dir()).unwrap();
index
@@ -718,7 +729,7 @@ async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
let broadcast = Envelope::new(
&sender,
TYPE_AGGREGATE,
json!({"period": "2026-03", "sent": 1, "passed": 0, "cited": 0}),
json!({"period": "2026-03", "sent": 1, "passed": 0}),
);
assert_eq!(
publish(&http, &relay_url, &broadcast).await.status(),
@@ -729,7 +740,7 @@ async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
let unicast_envelope = Envelope::new(
&sender,
TYPE_AGGREGATE,
json!({"period": "2026-03", "sent": 1, "passed": 0, "cited": 0}),
json!({"period": "2026-03", "sent": 1, "passed": 0}),
);
assert_eq!(
unicast(&http, &relay_url, &bob.pubkey, &unicast_envelope)
@@ -874,7 +885,7 @@ fn canonical_signing_bytes_are_stable() {
assert_eq!(first, second);
assert_eq!(
String::from_utf8(first).unwrap(),
"FRX/0.3\nquery\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n1700000000\n00112233445566778899aabbccddeeff\n{\"budget\":{\"max_results\":5},\"entities\":[],\"qid\":\"q1\",\"text\":\"rust\"}"
"FRX/0.4\nquery\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n1700000000\n00112233445566778899aabbccddeeff\n{\"budget\":{\"max_results\":5},\"entities\":[],\"qid\":\"q1\",\"text\":\"rust\"}"
);
}
+65 -3
View File
@@ -57,6 +57,70 @@ fn no_bounty_winner_selection_or_slashing() {
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn no_in_protocol_citation_accounting_or_settlement() {
let relay_url = spawn_relay().await;
let http = client();
let key = Keypair::generate();
for msg_type in ["receipt", "settlement", "citation", "invoice"] {
let envelope = Envelope::new(&key, msg_type, serde_json::json!({}));
assert_eq!(
publish(&http, &relay_url, &envelope).await.status(),
reqwest::StatusCode::BAD_REQUEST,
"relay accepted {msg_type} on the broadcast channel"
);
assert_eq!(
unicast(&http, &relay_url, &key.public_hex(), &envelope)
.await
.status(),
reqwest::StatusCode::BAD_REQUEST,
"relay accepted unicast {msg_type}"
);
}
let response = serde_json::to_value(build_response("q1", Vec::new(), 0, 5)).unwrap();
assert_absent_fields(
&response,
&[
"receipt",
"settlement",
"citation",
"cited",
"price",
"payment",
"paid",
],
);
let aggregate = serde_json::to_value(AggregateBody {
period: "2026-03".to_string(),
sent: 0,
passed: 0,
})
.unwrap();
assert_absent_fields(
&aggregate,
&[
"receipt",
"settlement",
"citation",
"cited",
"price",
"payment",
"paid",
],
);
for route in ["/v1/receipt", "/v1/settlement", "/v1/invoice"] {
let response = http
.get(format!("{relay_url}{route}"))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn no_protocol_query_dedup() {
let relay_url = spawn_relay().await;
@@ -190,7 +254,6 @@ async fn no_aggregate_appeals() {
period: "2026-03".to_string(),
sent: 1,
passed: 1,
cited: 0,
})
.unwrap(),
);
@@ -205,10 +268,9 @@ async fn no_aggregate_appeals() {
period: "2026-03".to_string(),
sent: 1,
passed: 1,
cited: 0,
})
.unwrap();
assert_exact_keys(&body, &["period", "sent", "passed", "cited"]);
assert_exact_keys(&body, &["period", "sent", "passed"]);
assert_absent_fields(&body, &["appeal", "dispute", "complaint", "sanction"]);
for route in ["/v1/appeal", "/v1/dispute"] {