use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{Duration, Instant};
use nescio::prelude::*;
use nescio::server::{handle, NescioServer};
const DAY: i64 = 86_400;
fn schema_and_sources() -> (Schema, Vec<Source>) {
let mut slots = BTreeMap::new();
slots.insert(
"price".to_string(),
Domain::Continuous {
lo: 0.0,
hi: 1_000_000.0,
n_bins: 200,
},
);
slots.insert("wants_to_sell".to_string(), Domain::boolean());
let sources = vec![
Source {
name: "broker".into(),
reliability: 0.9,
half_life_days: Some(90.0),
axiomatic: false,
},
Source {
name: "registry_a".into(),
reliability: 1.0,
half_life_days: None,
axiomatic: true,
},
Source {
name: "registry_b".into(),
reliability: 1.0,
half_life_days: None,
axiomatic: true,
},
];
(
Schema {
slots,
couplings: vec![],
},
sources,
)
}
fn test_db() -> Db {
let (schema, sources) = schema_and_sources();
Db::in_memory(schema, sources).unwrap()
}
fn watch(name: &str, entity: &str, slot: &str, max_bits: f64) -> Watch {
Watch {
name: name.into(),
entity: entity.into(),
slot: slot.into(),
max_entropy_bits: Some(max_bits),
min_knowledge: None,
}
}
fn ingest_price(db: &mut Db, source: &str, lo: f64, hi: f64, at: i64) {
db.ingest(EvidenceRecord {
entity: "villa_1".into(),
claim: Claim::Interval {
slot: "price".into(),
lo,
hi,
},
source: source.into(),
observed_at: at,
})
.unwrap();
}
#[test]
fn horizon_is_predicted_to_the_day() {
let mut db = test_db();
ingest_price(&mut db, "broker", 400_000.0, 500_000.0, 0);
let at = DAY;
let e_now = Query::new(&db, at)
.bound("villa_1", "price", 0.95)
.unwrap()
.entropy_bits;
let w = watch("price_fresh", "villa_1", "price", e_now + 1.0);
let st = evaluate_watch(&db, &w, at, DEFAULT_HORIZON_DAYS);
assert!(!st.triggered);
let h = st.horizon.expect("a decaying source must yield a horizon");
assert!(h > at);
assert!(!evaluate_watch(&db, &w, h - DAY, DEFAULT_HORIZON_DAYS).triggered);
assert!(evaluate_watch(&db, &w, h, DEFAULT_HORIZON_DAYS).triggered);
assert_eq!(evaluate_watch(&db, &w, h, DEFAULT_HORIZON_DAYS).horizon, Some(h));
}
#[test]
fn min_knowledge_is_threshold_sugar() {
let mut db = test_db();
ingest_price(&mut db, "broker", 400_000.0, 500_000.0, 0);
let max = db.domain("price").unwrap().max_entropy_bits();
let w = Watch {
name: "k".into(),
entity: "villa_1".into(),
slot: "price".into(),
max_entropy_bits: None,
min_knowledge: Some(0.4),
};
let st = evaluate_watch(&db, &w, DAY, DEFAULT_HORIZON_DAYS);
let threshold = st.threshold_bits.unwrap();
assert!((threshold - 0.6 * max).abs() < 1e-9);
let e = st.entropy_bits.unwrap();
assert!((st.knowledge.unwrap() - (1.0 - e / max)).abs() < 1e-9);
}
#[test]
fn axiomatic_evidence_has_no_horizon() {
let mut db = test_db();
ingest_price(&mut db, "registry_a", 400_000.0, 500_000.0, 0);
let e_now = Query::new(&db, DAY)
.bound("villa_1", "price", 0.95)
.unwrap()
.entropy_bits;
let w = watch("pinned", "villa_1", "price", e_now + 0.5);
let st = evaluate_watch(&db, &w, DAY, DEFAULT_HORIZON_DAYS);
assert!(!st.triggered);
assert!(st.horizon.is_none(), "an axiom without decay never lets go");
}
#[test]
fn axiom_conflict_fires_the_watch() {
let mut db = test_db();
ingest_price(&mut db, "registry_a", 0.0, 100_000.0, 0);
ingest_price(&mut db, "registry_b", 900_000.0, 1_000_000.0, 0);
let w = watch("conflicted", "villa_1", "price", 100.0);
let st = evaluate_watch(&db, &w, DAY, DEFAULT_HORIZON_DAYS);
assert!(st.triggered);
assert!(st.error.unwrap().contains("axiom"));
}
#[test]
fn watches_validate_persist_and_remove() {
let dir = std::env::temp_dir().join(format!("nescio-watch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (schema, sources) = schema_and_sources();
let mut db = Db::init(&dir, schema, sources).unwrap();
db.add_watch(watch("w1", "villa_1", "price", 5.0)).unwrap();
assert!(db.add_watch(watch("w1", "x", "price", 4.0)).is_err());
assert!(db.add_watch(watch("w2", "x", "colour", 4.0)).is_err());
assert!(db
.add_watch(Watch {
name: "w3".into(),
entity: "x".into(),
slot: "price".into(),
max_entropy_bits: None,
min_knowledge: None,
})
.is_err());
assert!(db
.add_watch(Watch {
name: "w4".into(),
entity: "x".into(),
slot: "price".into(),
max_entropy_bits: Some(3.0),
min_knowledge: Some(0.5),
})
.is_err());
assert!(db
.add_watch(Watch {
name: "w5".into(),
entity: "x".into(),
slot: "price".into(),
max_entropy_bits: None,
min_knowledge: Some(1.5),
})
.is_err());
drop(db);
let mut db = Db::open(&dir).unwrap();
assert_eq!(db.watches.len(), 1);
assert_eq!(db.watches[0].name, "w1");
db.remove_watch("w1").unwrap();
assert!(db.remove_watch("w1").is_err());
drop(db);
let db = Db::open(&dir).unwrap();
assert!(db.watches.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_slot_takes_its_watches() {
let mut db = test_db();
db.add_watch(watch("wp", "villa_1", "price", 5.0)).unwrap();
db.add_watch(watch("ws", "villa_1", "wants_to_sell", 0.5))
.unwrap();
let r = db.remove_slot("price").unwrap();
assert_eq!(r.watches_removed, 1);
assert_eq!(db.watches.len(), 1);
assert_eq!(db.watches[0].slot, "wants_to_sell");
}
#[test]
fn watch_routes_over_http() {
let mut db = test_db();
ingest_price(&mut db, "broker", 400_000.0, 500_000.0, 0);
let (s, body) = handle(
&mut db,
"POST",
"/watches",
"",
r#"{"name":"w1","entity":"villa_1","slot":"price","max_entropy_bits":5.0}"#,
);
assert_eq!(s, 200, "{body}");
assert!(body.contains("\"state\"") && body.contains("\"threshold_bits\""));
let (s, _) = handle(
&mut db,
"POST",
"/watches",
"",
r#"{"name":"w2","entity":"e","slot":"price","max_entropy_bits":5.0,"min_knowledge":0.4}"#,
);
assert_eq!(s, 400);
let (s, body) = handle(&mut db, "GET", "/watches", "", "");
assert_eq!(s, 200);
assert!(body.contains("\"w1\"") && body.contains("\"horizon\""));
let (s, body) = handle(&mut db, "GET", "/watches/check", "at=2036-01-01", "");
assert_eq!(s, 200, "{body}");
assert!(body.contains("\"triggered\":[{"), "{body}");
let (s, _) = handle(&mut db, "POST", "/watches/remove", "", r#"{"name":"w1"}"#);
assert_eq!(s, 200);
let (s, _) = handle(&mut db, "POST", "/watches/remove", "", r#"{"name":"w1"}"#);
assert_eq!(s, 400);
}
fn raw_request(port: u16, method: &str, path: &str, body: &str) -> (u16, String) {
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\
Content-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
stream.write_all(req.as_bytes()).unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let status: u16 = raw
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.expect("status line");
let payload = raw
.split_once("\r\n\r\n")
.map(|(_, b)| b.to_string())
.unwrap_or_default();
(status, payload)
}
fn read_until(stream: &mut TcpStream, acc: &mut String, needle: &str, secs: u64) {
let deadline = Instant::now() + Duration::from_secs(secs);
let mut buf = [0u8; 4096];
while !acc.contains(needle) {
assert!(
Instant::now() < deadline,
"SSE stream never delivered {needle:?}; got so far: {acc}"
);
match stream.read(&mut buf) {
Ok(0) => panic!("SSE stream closed early; got so far: {acc}"),
Ok(n) => acc.push_str(&String::from_utf8_lossy(&buf[..n])),
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => panic!("SSE read error: {e}"),
}
}
}
#[test]
fn sse_stream_delivers_snapshot_and_transitions() {
let server = NescioServer::bind("127.0.0.1:0").unwrap();
let port = server.port();
std::thread::spawn(move || server.run(test_db()).unwrap());
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
stream
.set_read_timeout(Some(Duration::from_millis(200)))
.unwrap();
stream
.write_all(
b"GET /watches/events HTTP/1.1\r\nHost: localhost\r\nAccept: text/event-stream\r\n\r\n",
)
.unwrap();
let mut acc = String::new();
read_until(&mut stream, &mut acc, "event: snapshot", 5);
assert!(acc.contains("text/event-stream"));
let (s, body) = raw_request(
port,
"POST",
"/watches",
r#"{"name":"stale","entity":"nobody","slot":"price","max_entropy_bits":1.0}"#,
);
assert_eq!(s, 200, "{body}");
read_until(&mut stream, &mut acc, "event: triggered", 20);
assert!(acc.contains("\"stale\""));
}