The tasks build the inference path: emsha-backed SHA-256, inferproxy, config, a hand-written HTTP and SSE client, request building, delta assembly, the chat state machine, the thinking cap, the slot gate with retry, the startup self-test and on-device verification. Everything the tasks copy in was checked against a private reference implementation: the gate passes after each task in order, the timing tests pass repeatedly under CPU load, and the reference passes the self-test and all four device checks on straylight. Expected results for the recorded streams were derived by a separate script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
187 lines
5.5 KiB
Rust
187 lines
5.5 KiB
Rust
//! Tests for the small server calls and the cache-loss check. Do not edit.
|
|
|
|
mod support;
|
|
|
|
use loopd::llama::info::{CacheOutcome, SlotInfo, cache_outcome};
|
|
use loopd::llama::{Client, InferError, Timings};
|
|
use support::{FakeServer, Reply};
|
|
|
|
fn client(server: &FakeServer) -> Client {
|
|
Client::new(support::test_config(&server.socket))
|
|
}
|
|
|
|
fn t(cache_n: u64, prompt_n: u64, predicted_n: u64) -> Timings {
|
|
Timings {
|
|
cache_n,
|
|
prompt_n,
|
|
predicted_n,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn props_slots_and_tokenize_read_the_recorded_responses() {
|
|
let server = FakeServer::start();
|
|
server.route("/props", vec![Reply::fixture("props")]);
|
|
server.route("/slots", vec![Reply::fixture("slots")]);
|
|
server.route("/tokenize", vec![Reply::fixture("tokenize")]);
|
|
let c = client(&server);
|
|
|
|
let props = c.props().unwrap();
|
|
assert_eq!(props.n_ctx, 131_072);
|
|
assert_eq!(props.total_slots, 2);
|
|
assert_eq!(
|
|
proto::sha256(props.chat_template.as_bytes())
|
|
.unwrap()
|
|
.to_hex(),
|
|
"f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
|
|
);
|
|
|
|
let want = vec![
|
|
SlotInfo {
|
|
id: 0,
|
|
is_processing: false,
|
|
},
|
|
SlotInfo {
|
|
id: 1,
|
|
is_processing: false,
|
|
},
|
|
];
|
|
assert_eq!(c.slots().unwrap(), want);
|
|
|
|
assert_eq!(c.tokenize("The box is made.").unwrap(), 5);
|
|
|
|
// In router mode the model goes in the query for GET and in the body for POST.
|
|
assert_eq!(
|
|
server.requests_to("/props")[0].target,
|
|
"/props?model=test-model"
|
|
);
|
|
assert_eq!(
|
|
server.requests_to("/slots")[0].target,
|
|
"/slots?model=test-model"
|
|
);
|
|
let sent = server.requests_to("/tokenize")[0].json();
|
|
assert_eq!(
|
|
sent,
|
|
serde_json::json!({"model": "test-model", "content": "The box is made."})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_error_status_keeps_its_body() {
|
|
let server = FakeServer::start();
|
|
server.route("/props", vec![Reply::fixture("unknown_model")]);
|
|
match client(&server).props() {
|
|
Err(InferError::Http { status: 400, body }) => {
|
|
assert!(body.contains("no-such-model"), "{body}")
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_error_body_is_cut_at_4_kib_on_a_character_boundary() {
|
|
let server = FakeServer::start();
|
|
// 3 bytes of padding, then 2-byte characters, so that byte 4096 falls inside a character.
|
|
let long = format!("abc{}", "\u{e9}".repeat(5000));
|
|
server.route("/props", vec![Reply::json(500, &long)]);
|
|
match client(&server).props() {
|
|
Err(InferError::Http { status: 500, body }) => {
|
|
assert!(
|
|
body.len() <= 4096 && body.len() >= 4094,
|
|
"{} bytes",
|
|
body.len()
|
|
);
|
|
assert!(body.starts_with("abc\u{e9}"));
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_bodies_are_protocol_errors() {
|
|
let server = FakeServer::start();
|
|
server.route("/props", vec![Reply::json(200, r#"{"chat_template": 5}"#)]);
|
|
server.route("/slots", vec![Reply::json(200, "not json")]);
|
|
server.route("/tokenize", vec![Reply::json(200, r#"{"tokens": "many"}"#)]);
|
|
let c = client(&server);
|
|
assert!(matches!(c.props(), Err(InferError::Protocol(_))));
|
|
assert!(matches!(c.slots(), Err(InferError::Protocol(_))));
|
|
assert!(matches!(c.tokenize("x"), Err(InferError::Protocol(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn a_dead_server_is_a_connect_error() {
|
|
let server = FakeServer::start();
|
|
let mut cfg = support::test_config(&server.socket);
|
|
cfg.infer.socket = std::env::temp_dir().join("loopd-no-such-socket.sock");
|
|
assert!(matches!(
|
|
Client::new(cfg).props(),
|
|
Err(InferError::Connect(_))
|
|
));
|
|
}
|
|
|
|
/// Numbers from docs/inference-contract.md.
|
|
#[test]
|
|
fn cache_outcome_matches_what_was_measured() {
|
|
// (b) a normal turn: 539 processed + 53 generated, then 591 reused.
|
|
assert_eq!(
|
|
cache_outcome(&t(0, 539, 53), &t(591, 29, 61)),
|
|
CacheOutcome::Hit
|
|
);
|
|
// (k) a natural end to thinking: 64 + 104 = 168 left, 167 reused.
|
|
assert_eq!(
|
|
cache_outcome(&t(0, 64, 104), &t(167, 22, 2)),
|
|
CacheOutcome::Hit
|
|
);
|
|
// (k) a forced end to thinking: 89 + 717 = 806 left, only 85 reused.
|
|
assert_eq!(
|
|
cache_outcome(&t(0, 89, 717), &t(85, 743, 2)),
|
|
CacheOutcome::Loss {
|
|
expected: 806,
|
|
got: 85
|
|
}
|
|
);
|
|
// (h) the tool list changed: everything re-read.
|
|
assert_eq!(
|
|
cache_outcome(&t(685, 27, 55), &t(23, 920, 126)),
|
|
CacheOutcome::Loss {
|
|
expected: 767,
|
|
got: 23
|
|
}
|
|
);
|
|
// The recorded two-turn exchange used by the self-test.
|
|
assert_eq!(
|
|
cache_outcome(&t(15, 29, 2), &t(45, 30, 2)),
|
|
CacheOutcome::Hit
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_outcome_tolerates_64_tokens_and_no_more() {
|
|
let previous = t(1000, 100, 50); // 1150 left in the slot
|
|
assert_eq!(cache_outcome(&previous, &t(1150, 5, 5)), CacheOutcome::Hit);
|
|
assert_eq!(
|
|
cache_outcome(&previous, &t(1086, 5, 5)),
|
|
CacheOutcome::Hit,
|
|
"64 short"
|
|
);
|
|
assert_eq!(
|
|
cache_outcome(&previous, &t(1085, 5, 5)),
|
|
CacheOutcome::Loss {
|
|
expected: 1150,
|
|
got: 1085
|
|
},
|
|
"65 short"
|
|
);
|
|
assert_eq!(
|
|
cache_outcome(&t(0, 0, 0), &t(0, 10, 1)),
|
|
CacheOutcome::Hit,
|
|
"a first request"
|
|
);
|
|
assert_eq!(
|
|
cache_outcome(&previous, &t(5000, 5, 5)),
|
|
CacheOutcome::Hit,
|
|
"more than expected"
|
|
);
|
|
}
|