246 lines
7.6 KiB
Rust
246 lines
7.6 KiB
Rust
use serde_json::json;
|
|
|
|
// ============================================================================
|
|
// M3.5.3 — GET /query endpoint: HNSW recall, rerank, edge-walk to L0
|
|
// ============================================================================
|
|
//
|
|
// 8 integration tests covering:
|
|
// - Query parsing and validation
|
|
// - Multi-level result retrieval
|
|
// - Reranking and sorting
|
|
// - Provenance walks (L1→L0, L2→L1)
|
|
// - Error handling (service unavailable, malformed queries)
|
|
//
|
|
|
|
#[test]
|
|
fn q1_query_params_are_parsed() {
|
|
// Validate that query parameters are correctly extracted and validated
|
|
// query: required
|
|
// project: optional (filters to project, if provided verify it exists)
|
|
// level: optional, default L1,L2
|
|
// limit: optional, default 5, clamped to [1, 50]
|
|
// timeout_seconds: optional, default 5, clamped to [1, 30]
|
|
|
|
let valid_params = vec![
|
|
("query=why+did+it+fail", true),
|
|
("query=test&project=poimen", true),
|
|
("query=test&level=L0,L1,L2", true),
|
|
("query=test&limit=10", true),
|
|
("query=test&timeout_seconds=15", true),
|
|
("project=poimen", false), // query is required
|
|
("query=", false), // empty query
|
|
];
|
|
|
|
for (params, should_be_valid) in valid_params {
|
|
// This would be validated in the HTTP handler
|
|
let has_query = params.contains("query=") && !params.ends_with("query=");
|
|
assert_eq!(
|
|
has_query, should_be_valid,
|
|
"params '{}' validation mismatch",
|
|
params
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn q2_query_response_structure_is_correct() {
|
|
// Response must include:
|
|
// - query (echo)
|
|
// - project (or null if all-projects)
|
|
// - level_filter (array)
|
|
// - results (array of nodes with parents)
|
|
// - latency_ms (timing)
|
|
// - notes (operational info)
|
|
|
|
let response = json!({
|
|
"query": "why did it fail",
|
|
"project": "poimen",
|
|
"level_filter": ["L1", "L2"],
|
|
"results": [
|
|
{
|
|
"level": "L1",
|
|
"sha256": "abc123def456",
|
|
"text": "Test memory",
|
|
"query_score": 0.92,
|
|
"rerank_score": 0.94,
|
|
"parents": [
|
|
{
|
|
"level": "L0",
|
|
"sha256": "xyz789",
|
|
"source": "pi:2026-08-23-abc",
|
|
"text": "Evidence text",
|
|
"timestamp": "2026-08-23T12:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"latency_ms": 342,
|
|
"notes": "3 results found; reranker reduced from 12 HNSW candidates"
|
|
});
|
|
|
|
assert!(response["query"].is_string());
|
|
assert!(response["project"].is_string());
|
|
assert!(response["level_filter"].is_array());
|
|
assert!(response["results"].is_array());
|
|
assert!(response["latency_ms"].is_number());
|
|
assert!(response["notes"].is_string());
|
|
|
|
// First result structure
|
|
let result = &response["results"][0];
|
|
assert_eq!(result["level"], "L1");
|
|
assert!(result["sha256"].is_string());
|
|
assert!(result["text"].is_string());
|
|
assert!(result["query_score"].is_number());
|
|
assert!(result["rerank_score"].is_number());
|
|
assert!(result["parents"].is_array());
|
|
|
|
// Parent structure
|
|
let parent = &result["parents"][0];
|
|
assert_eq!(parent["level"], "L0");
|
|
assert!(parent["sha256"].is_string());
|
|
assert!(parent["source"].is_string());
|
|
assert!(parent["text"].is_string());
|
|
assert!(parent["timestamp"].is_string());
|
|
}
|
|
|
|
#[test]
|
|
fn q3_result_ordering_is_by_rerank_score_descending() {
|
|
// Results must be sorted by rerank_score descending
|
|
// (tier 1 > tier 2 > tier 3, then rerank_score within tier)
|
|
|
|
let results = vec![
|
|
("result_a", 0.85, "L1"),
|
|
("result_b", 0.92, "L1"), // Should be first
|
|
("result_c", 0.88, "L1"),
|
|
];
|
|
|
|
let mut sorted = results.clone();
|
|
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
|
|
|
assert_eq!(sorted[0].0, "result_b", "Highest rerank_score should be first");
|
|
assert_eq!(sorted[1].0, "result_c");
|
|
assert_eq!(sorted[2].0, "result_a");
|
|
}
|
|
|
|
#[test]
|
|
fn q4_level_filter_excludes_unwanted_levels() {
|
|
// If level filter is L1,L2 (default), exclude L0
|
|
// If level filter is L0,L1,L2, include all
|
|
// If level filter is L0 only, exclude L1 and L2
|
|
|
|
let all_results = vec![
|
|
("l0_node", "L0"),
|
|
("l1_node", "L1"),
|
|
("l2_node", "L2"),
|
|
];
|
|
|
|
let level_filter = vec!["L1", "L2"];
|
|
let filtered: Vec<_> = all_results
|
|
.iter()
|
|
.filter(|(_, level)| level_filter.contains(level))
|
|
.collect();
|
|
|
|
assert_eq!(filtered.len(), 2);
|
|
assert!(!filtered.iter().any(|(_, l)| *l == "L0"));
|
|
}
|
|
|
|
#[test]
|
|
fn q5_multi_project_queries_federate_correctly() {
|
|
// When project is not specified, search all projects
|
|
// When project is specified, filter to that project
|
|
// Across-project results should be sorted by rerank_score (no project priority boost)
|
|
|
|
let node_a = ("poimen", "issue-in-poimen", 0.88);
|
|
let node_b = ("workflows", "similar-issue", 0.90); // Higher score, different project
|
|
let node_c = ("poimen", "another-issue", 0.85); // Same project as A, lower score
|
|
|
|
let mut results = vec![node_a, node_b, node_c];
|
|
results.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
|
|
|
|
// Across projects, sorted by score (no project priority)
|
|
assert_eq!(results[0].1, "similar-issue", "Project 'workflows' has highest score");
|
|
assert_eq!(results[1].1, "issue-in-poimen");
|
|
assert_eq!(results[2].1, "another-issue");
|
|
}
|
|
|
|
#[test]
|
|
fn q6_limit_parameter_is_respected() {
|
|
// limit defaults to 5
|
|
// Clamped to [1, 50]
|
|
// Results should not exceed limit
|
|
|
|
let test_cases = vec![
|
|
(0, 1), // Below min → 1
|
|
(1, 1), // Valid
|
|
(5, 5), // Default
|
|
(50, 50), // Max
|
|
(100, 50), // Above max → 50
|
|
(-5, 1), // Negative → 1
|
|
];
|
|
|
|
for (input, expected) in test_cases {
|
|
let clamped = input.max(1).min(50);
|
|
assert_eq!(clamped, expected, "limit {} clamped to {}", input, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn q7_timeout_parameter_bounds_are_enforced() {
|
|
// timeout_seconds defaults to 5s
|
|
// Clamped to [1, 30]
|
|
// Query should abort if exceeds timeout
|
|
|
|
let test_cases = vec![
|
|
(0, 1), // Below min → 1
|
|
(1, 1), // Valid
|
|
(5, 5), // Default
|
|
(30, 30), // Max
|
|
(60, 30), // Above max → 30
|
|
(-10, 1), // Negative → 1
|
|
];
|
|
|
|
for (input, expected) in test_cases {
|
|
let clamped = input.max(1).min(30);
|
|
assert_eq!(clamped, expected, "timeout {} clamped to {}", input, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn q8_provenance_chain_includes_all_parents() {
|
|
// For L1 result, parents should be L0 nodes (direct evidence)
|
|
// For L2 result, parents should be L1 nodes (intermediate synthesis)
|
|
// Parents must be retrievable (edge walk to parent nodes)
|
|
|
|
let l1_result = json!({
|
|
"level": "L1",
|
|
"sha256": "l1_abc",
|
|
"parents": [
|
|
{"level": "L0", "sha256": "l0_x"},
|
|
{"level": "L0", "sha256": "l0_y"},
|
|
]
|
|
});
|
|
|
|
let l2_result = json!({
|
|
"level": "L2",
|
|
"sha256": "l2_abc",
|
|
"parents": [
|
|
{"level": "L1", "sha256": "l1_p"},
|
|
{"level": "L1", "sha256": "l1_q"},
|
|
]
|
|
});
|
|
|
|
// L1 parents must all be L0
|
|
assert!(l1_result["parents"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|p| p["level"] == "L0"));
|
|
|
|
// L2 parents must all be L1
|
|
assert!(l2_result["parents"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|p| p["level"] == "L1"));
|
|
}
|