1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use anyhow::{Result, anyhow};
5use axum::extract::{Query, State};
6use axum::http::{HeaderMap, Method, StatusCode};
7use axum::routing::{get, post};
8use axum::{Json, Router};
9use chrono::{DateTime, Utc};
10use clap::Parser;
11use serde_json::{Value, json};
12use tokio::net::TcpListener;
13use tonic::transport::Server;
14use tonic::{Request, Response, Status};
15use tower_http::cors::{Any, CorsLayer};
16use tracing::{error, info};
17use tracing_subscriber::EnvFilter;
18
19use locus_core_rs::domain::contracts::EmbeddingProvider;
20use locus_core_rs::domain::models::{
21 self as core_models, ConfidenceBandSummary, MonthlyRollupRequest, NumericRange, PsiRange,
22};
23use locus_sdk::application::memory_evict::MemoryEvictService;
24use locus_sdk::application::memory_find::MemoryFindService;
25use locus_sdk::application::memory_graph::{MemoryGraphService, graph_node_id};
26use locus_sdk::application::memory_recall::MemoryRecallService;
27use locus_sdk::application::memory_transform::MemoryTransformService;
28use locus_sdk::domain::evict::{MemoryEvictMode, MemoryEvictRequest, MemoryEvictResult};
29use locus_sdk::domain::graph::MemoryGraphRequest;
30use locus_sdk::domain::memory::{
31 FallbackPolicy, MemoryFilter, MemoryFindRequest, MemoryPage, MemoryRecallRequest, MemoryScope,
32 MemoryScoring, MemoryTransformOperation, MemoryTransformRequest,
33};
34use locus_sdk::infrastructure::registry::InMemoryAiProviderRegistry;
35use locus_sdk::infrastructure::sttp_native::embedding_provider_adapter::SttpEmbeddingProviderAdapter;
36
37use crate::app_state::AppState;
38use crate::constants::{
39 DEFAULT_HYBRID_ALPHA, DEFAULT_HYBRID_BETA, FILE_DESCRIPTOR_SET, TENANT_SCAN_LIMIT,
40};
41use crate::gateway_args::GatewayArgs;
42use crate::http_models::*;
43#[cfg(test)]
44use crate::orchestration::build_in_memory_state;
45use crate::orchestration::{build_state, parse_cors_allowed_origins, shutdown_signal};
46use crate::providers::resolve_query_embedding;
47use crate::tenant::{
48 display_session_id, normalize_node_for_tenant, normalize_tenant_value, resolve_grpc_tenant,
49 resolve_http_tenant, scope_session_id,
50};
51
52pub mod proto {
53 tonic::include_proto!("sttp.v1");
54}
55
56pub(crate) async fn run() -> Result<()> {
57 tracing_subscriber::fmt()
58 .with_env_filter(EnvFilter::from_default_env())
59 .init();
60
61 let args = GatewayArgs::parse();
62
63 if args.http_port == args.grpc_port {
64 return Err(anyhow!(
65 "--http-port and --grpc-port must be different values"
66 ));
67 }
68
69 let state = Arc::new(build_state(&args).await?);
70
71 let base_router = Router::new()
72 .route("/health", get(health_handler))
73 .route("/api/v1/calibrate", post(calibrate_handler))
74 .route("/api/v1/store", post(store_context_handler))
75 .route("/api/store", post(store_context_handler))
76 .route("/store", post(store_context_handler))
77 .route("/api/v1/avec/score", post(score_avec_handler))
78 .route("/api/avec/score", post(score_avec_handler))
79 .route("/avec/score", post(score_avec_handler))
80 .route("/api/v1/session/rename", post(rename_session_handler))
81 .route("/api/session/rename", post(rename_session_handler))
82 .route("/session/rename", post(rename_session_handler))
83 .route("/api/v1/context", post(get_context_handler))
84 .route(
85 "/api/v1/context/embeddings",
86 post(get_embedding_context_handler),
87 )
88 .route(
89 "/api/context/embeddings",
90 post(get_embedding_context_handler),
91 )
92 .route("/context/embeddings", post(get_embedding_context_handler))
93 .route("/api/v1/nodes", get(list_nodes_handler))
94 .route("/api/nodes", get(list_nodes_handler))
95 .route("/nodes", get(list_nodes_handler))
96 .route("/api/v1/graph", get(graph_handler))
97 .route("/api/graph", get(graph_handler))
98 .route("/graph", get(graph_handler))
99 .route("/api/v1/evict", post(evict_handler))
100 .route("/api/evict", post(evict_handler))
101 .route("/evict", post(evict_handler))
102 .route("/api/v1/moods", get(get_moods_handler))
103 .route("/api/v1/rekey", post(batch_rekey_handler))
104 .route(
105 "/api/v1/rollups/monthly",
106 post(create_monthly_rollup_handler),
107 )
108 .route(
109 "/api/v1/embeddings/migration/preview",
110 post(preview_embedding_migration_handler),
111 )
112 .route(
113 "/api/v1/embeddings/migration/run",
114 post(run_embedding_migration_handler),
115 )
116 .with_state(state.clone());
117
118 let http_router = if args.cors_enabled {
119 let allowed_origins = parse_cors_allowed_origins(&args.cors_allowed_origins)?;
120 let cors_base = CorsLayer::new()
121 .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::OPTIONS])
122 .allow_headers(Any);
123
124 let cors = match allowed_origins {
125 CorsAllowedOrigins::Any => cors_base.allow_origin(Any),
126 CorsAllowedOrigins::Explicit(origins) => cors_base.allow_origin(origins),
127 };
128
129 base_router.layer(cors)
130 } else {
131 base_router
132 };
133
134 let grpc_service = GrpcGatewayService::new(state);
135
136 let grpc_addr = SocketAddr::from(([0, 0, 0, 0], args.grpc_port));
137 let http_listener = TcpListener::bind(("0.0.0.0", args.http_port)).await?;
138
139 let reflection_service = tonic_reflection::server::Builder::configure()
140 .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
141 .build_v1()?;
142
143 info!(
144 http_port = args.http_port,
145 grpc_port = args.grpc_port,
146 cors_enabled = args.cors_enabled,
147 cors_allowed_origins = %args.cors_allowed_origins,
148 "Starting locus-gateway"
149 );
150
151 let http_server =
152 axum::serve(http_listener, http_router).with_graceful_shutdown(shutdown_signal());
153 let grpc_server = Server::builder()
154 .add_service(reflection_service)
155 .add_service(
156 proto::sttp_gateway_service_server::SttpGatewayServiceServer::new(grpc_service),
157 )
158 .serve_with_shutdown(grpc_addr, shutdown_signal());
159
160 let (http_result, grpc_result) = tokio::join!(http_server, grpc_server);
161 if let Err(err) = http_result {
162 error!(error = %err, "HTTP server exited with error");
163 return Err(err.into());
164 }
165 if let Err(err) = grpc_result {
166 error!(error = %err, "gRPC server exited with error");
167 return Err(err.into());
168 }
169
170 Ok(())
171}
172
173async fn health_handler() -> Json<Value> {
174 Json(json!({ "status": "ok", "transport": "http+grpc" }))
175}
176
177async fn calibrate_handler(
178 State(state): State<Arc<AppState>>,
179 headers: HeaderMap,
180 Json(request): Json<CalibrateSessionHttpRequest>,
181) -> ApiResult<CalibrationResultDto> {
182 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
183 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
184
185 let trigger = request
186 .trigger
187 .as_deref()
188 .filter(|v| !v.trim().is_empty())
189 .unwrap_or("manual");
190
191 let result = state
192 .calibration
193 .calibrate_async(
194 &scoped_session_id,
195 request.stability,
196 request.friction,
197 request.logic,
198 request.autonomy,
199 trigger,
200 )
201 .await
202 .map_err(internal_error)?;
203
204 Ok(Json(CalibrationResultDto {
205 previous_avec: to_avec_dto(result.previous_avec),
206 delta: result.delta,
207 drift_classification: format!("{:?}", result.drift_classification),
208 trigger: result.trigger,
209 trigger_history: result.trigger_history,
210 is_first_calibration: result.is_first_calibration,
211 }))
212}
213
214async fn store_context_handler(
215 State(state): State<Arc<AppState>>,
216 headers: HeaderMap,
217 Json(request): Json<StoreContextHttpRequest>,
218) -> ApiResult<StoreResultDto> {
219 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
220 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
221
222 let result = state
223 .store_context
224 .store_async(&request.node, &scoped_session_id)
225 .await;
226
227 Ok(Json(StoreResultDto {
228 node_id: result.node_id,
229 psi: result.psi,
230 valid: result.valid,
231 validation_error: result.validation_error,
232 duplicate_skipped: false,
233 upsert_status: if result.valid {
234 "created".to_string()
235 } else {
236 "skipped".to_string()
237 },
238 }))
239}
240
241async fn score_avec_handler(
242 State(state): State<Arc<AppState>>,
243 headers: HeaderMap,
244 Json(request): Json<ScoreAvecHttpRequest>,
245) -> ApiResult<ScoreAvecResultDto> {
246 let _tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
247
248 if request.text.trim().is_empty() {
249 return Err(bad_request("text cannot be empty"));
250 }
251
252 let scorer = state.avec_scorer.as_ref().ok_or_else(|| {
253 bad_request("AVEC scoring is disabled; enable LOCUS_GATEWAY_AVEC_SCORING_ENABLED")
254 })?;
255
256 let avec = scorer
257 .score_async(request.text.trim())
258 .await
259 .map_err(internal_error)?;
260
261 Ok(Json(ScoreAvecResultDto {
262 provider: scorer.provider_name().to_string(),
263 model: scorer.model_name().to_string(),
264 avec: to_avec_dto(avec),
265 }))
266}
267
268async fn rename_session_handler(
269 State(state): State<Arc<AppState>>,
270 headers: HeaderMap,
271 Json(request): Json<RenameSessionHttpRequest>,
272) -> ApiResult<RenameSessionResultDto> {
273 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
274 let source_session_id = request.source_session_id.trim();
275 let target_session_id = request.target_session_id.trim();
276
277 if source_session_id.is_empty() || target_session_id.is_empty() {
278 return Err(bad_request(
279 "sourceSessionId and targetSessionId are required",
280 ));
281 }
282
283 if source_session_id == target_session_id {
284 return Ok(Json(RenameSessionResultDto {
285 source_session_id: source_session_id.to_string(),
286 target_session_id: target_session_id.to_string(),
287 moved_nodes: 0,
288 moved_calibrations: 0,
289 scopes_applied: 0,
290 }));
291 }
292
293 let scoped_source_session_id = scope_session_id(&tenant, source_session_id);
294 let scoped_target_session_id = scope_session_id(&tenant, target_session_id);
295
296 let source_nodes = state
297 .node_store
298 .query_nodes_async(core_models::NodeQuery {
299 limit: 10_000,
300 session_id: Some(scoped_source_session_id.clone()),
301 from_utc: None,
302 to_utc: None,
303 tiers: None,
304 })
305 .await
306 .map_err(internal_error)?;
307
308 if source_nodes.is_empty() {
309 return Err(bad_request(format!(
310 "source session not found: {source_session_id}"
311 )));
312 }
313
314 let mut anchor_node_ids = Vec::with_capacity(source_nodes.len());
315 for node in source_nodes {
316 let upsert = state
317 .node_store
318 .upsert_node_async(node)
319 .await
320 .map_err(internal_error)?;
321 anchor_node_ids.push(upsert.node_id);
322 }
323 anchor_node_ids.sort();
324 anchor_node_ids.dedup();
325
326 let rekey_result = state
327 .rekey_scope
328 .rekey_async(
329 anchor_node_ids,
330 &tenant,
331 &scoped_target_session_id,
332 false,
333 request.allow_merge.unwrap_or(false),
334 )
335 .await
336 .map_err(internal_error)?;
337
338 if let Some(conflict) = rekey_result.scopes.iter().find(|scope| scope.conflict) {
339 return Err(bad_request(
340 conflict
341 .message
342 .clone()
343 .unwrap_or_else(|| "target session already exists".to_string()),
344 ));
345 }
346
347 let scopes_applied = rekey_result
348 .scopes
349 .iter()
350 .filter(|scope| scope.applied)
351 .count();
352
353 Ok(Json(RenameSessionResultDto {
354 source_session_id: source_session_id.to_string(),
355 target_session_id: target_session_id.to_string(),
356 moved_nodes: rekey_result.temporal_nodes_updated,
357 moved_calibrations: rekey_result.calibrations_updated,
358 scopes_applied,
359 }))
360}
361
362async fn get_context_handler(
363 State(state): State<Arc<AppState>>,
364 headers: HeaderMap,
365 Json(request): Json<GetContextHttpRequest>,
366) -> ApiResult<RetrieveResultDto> {
367 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
368 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
369
370 let limit = request.limit.unwrap_or(5);
371 let tiers = normalize_request_tiers(request.tiers.as_deref());
372 let query_embedding = resolve_query_embedding(
373 state.embedding_provider.as_ref(),
374 request.query_text.as_deref(),
375 request.query_embedding.as_deref(),
376 )
377 .await;
378 let recall_service = recall_service(&state);
379 let memory_filter = build_semantic_memory_filter(
380 request.semantic_tags,
381 request.tags_contains,
382 request.link_rel,
383 request.link_target,
384 request.links_to_ref,
385 request.tag_prefix,
386 request.has_semantic_links,
387 );
388 let recall_result = recall_service
389 .execute(&MemoryRecallRequest {
390 scope: MemoryScope {
391 tenant_id: None,
392 session_ids: Some(vec![scoped_session_id]),
393 tiers,
394 from_utc: request.from_utc,
395 to_utc: request.to_utc,
396 },
397 page: MemoryPage {
398 limit,
399 cursor: None,
400 },
401 scoring: MemoryScoring {
402 alpha: request
403 .alpha
404 .unwrap_or(DEFAULT_HYBRID_ALPHA)
405 .clamp(0.0, 1.0),
406 beta: request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
407 gamma: request.gamma.unwrap_or(0.0).clamp(0.0, 1.0),
408 fallback_policy: FallbackPolicy::OnEmpty,
409 ..Default::default()
410 },
411 filter: memory_filter,
412 current_avec: Some(core_models::AvecState {
413 stability: request.stability,
414 friction: request.friction,
415 logic: request.logic,
416 autonomy: request.autonomy,
417 }),
418 query_text: request.query_text,
419 query_embedding,
420 ..Default::default()
421 })
422 .await
423 .map_err(internal_error)?;
424
425 Ok(Json(RetrieveResultDto {
426 nodes: recall_result.nodes.iter().map(to_node_dto).collect(),
427 retrieved: recall_result.retrieved,
428 psi_range: PsiRangeDto {
429 min: recall_result.psi_range.min,
430 max: recall_result.psi_range.max,
431 average: recall_result.psi_range.average,
432 },
433 }))
434}
435
436async fn get_embedding_context_handler(
437 State(state): State<Arc<AppState>>,
438 headers: HeaderMap,
439 Json(request): Json<GetEmbeddingContextHttpRequest>,
440) -> ApiResult<RetrieveResultDto> {
441 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
442 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
443 let limit = request.limit.unwrap_or(5);
444 let tiers = normalize_request_tiers(request.tiers.as_deref());
445
446 let rag_embedding = resolve_query_embedding(
447 state.embedding_provider.as_ref(),
448 request.rag_query_text.as_deref(),
449 request.rag_embedding.as_deref(),
450 )
451 .await;
452
453 let avec_embedding = resolve_query_embedding(
454 state.embedding_provider.as_ref(),
455 request.avec_query_text.as_deref(),
456 request.avec_embedding.as_deref(),
457 )
458 .await;
459
460 let fused_embedding = fuse_weighted_embeddings(
461 rag_embedding.as_deref(),
462 avec_embedding.as_deref(),
463 request.rag_weight.unwrap_or(0.7),
464 request.avec_weight.unwrap_or(0.3),
465 )?;
466
467 if fused_embedding.is_empty() {
468 return Err(bad_request(
469 "Provide ragEmbedding/ragQueryText and/or avecEmbedding/avecQueryText",
470 ));
471 }
472
473 let result = state
474 .context_query
475 .get_context_hybrid_scoped_filtered_async(
476 Some(&scoped_session_id),
477 request.stability,
478 request.friction,
479 request.logic,
480 request.autonomy,
481 request.from_utc,
482 request.to_utc,
483 tiers.as_deref(),
484 Some(fused_embedding.as_slice()),
485 request
486 .alpha
487 .unwrap_or(DEFAULT_HYBRID_ALPHA)
488 .clamp(0.0, 1.0),
489 request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
490 limit,
491 )
492 .await;
493
494 Ok(Json(RetrieveResultDto {
495 nodes: result.nodes.iter().map(to_node_dto).collect(),
496 retrieved: result.retrieved,
497 psi_range: PsiRangeDto {
498 min: result.psi_range.min,
499 max: result.psi_range.max,
500 average: result.psi_range.average,
501 },
502 }))
503}
504
505fn fuse_weighted_embeddings(
506 rag_embedding: Option<&[f32]>,
507 avec_embedding: Option<&[f32]>,
508 rag_weight: f32,
509 avec_weight: f32,
510) -> Result<Vec<f32>, (StatusCode, Json<ErrorResponse>)> {
511 let rag_weight = rag_weight.clamp(0.0, 1.0);
512 let avec_weight = avec_weight.clamp(0.0, 1.0);
513
514 match (rag_embedding, avec_embedding) {
515 (Some(rag), Some(avec)) => {
516 if rag.len() != avec.len() {
517 return Err(bad_request(
518 "rag embedding and avec embedding must have the same dimensions",
519 ));
520 }
521
522 let sum = rag_weight + avec_weight;
523 let denom = if sum > f32::EPSILON { sum } else { 1.0 };
524 let fused = rag
525 .iter()
526 .zip(avec.iter())
527 .map(|(r, a)| ((r * rag_weight) + (a * avec_weight)) / denom)
528 .collect::<Vec<_>>();
529 Ok(fused)
530 }
531 (Some(rag), None) => Ok(rag.to_vec()),
532 (None, Some(avec)) => Ok(avec.to_vec()),
533 (None, None) => Ok(Vec::new()),
534 }
535}
536
537async fn list_nodes_handler(
538 State(state): State<Arc<AppState>>,
539 headers: HeaderMap,
540 Query(query): Query<ListNodesQuery>,
541) -> ApiResult<ListNodesResultDto> {
542 let tenant = resolve_http_tenant(query.tenant_id.as_deref(), &headers);
543 let requested_limit = query.limit.unwrap_or(50).clamp(1, TENANT_SCAN_LIMIT);
544 let scoped_session_filter = query
545 .session_id
546 .as_deref()
547 .map(|session_id| scope_session_id(&tenant, session_id));
548 let backend_limit = if scoped_session_filter.is_some() {
549 requested_limit
550 } else {
551 TENANT_SCAN_LIMIT
552 };
553
554 let find_service = find_service(&state);
555 let result = find_service
556 .execute(&MemoryFindRequest {
557 scope: MemoryScope {
558 tenant_id: None,
559 session_ids: scoped_session_filter.map(|session| vec![session]),
560 tiers: None,
561 from_utc: None,
562 to_utc: None,
563 },
564 filter: build_semantic_memory_filter(
565 query.semantic_tags,
566 query.tags_contains,
567 query.link_rel,
568 query.link_target,
569 query.links_to_ref,
570 query.tag_prefix,
571 query.has_semantic_links,
572 ),
573 page: MemoryPage {
574 limit: backend_limit,
575 cursor: None,
576 },
577 ..Default::default()
578 })
579 .await
580 .map_err(internal_error)?;
581
582 let nodes = result
583 .nodes
584 .into_iter()
585 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
586 .take(requested_limit)
587 .collect::<Vec<_>>();
588
589 Ok(Json(ListNodesResultDto {
590 nodes: nodes.iter().map(to_node_dto).collect(),
591 retrieved: nodes.len(),
592 }))
593}
594
595async fn graph_handler(
596 State(state): State<Arc<AppState>>,
597 headers: HeaderMap,
598 Query(query): Query<GraphQuery>,
599) -> ApiResult<GraphResponse> {
600 let tenant = resolve_http_tenant(query.tenant_id.as_deref(), &headers);
601 let scoped_session_filter = query
602 .session_id
603 .as_deref()
604 .map(|session_id| scope_session_id(&tenant, session_id));
605
606 let graph_result = graph_service(&state)
607 .execute(&MemoryGraphRequest {
608 scope: MemoryScope {
609 tenant_id: Some(tenant.clone()),
610 session_ids: scoped_session_filter.map(|session| vec![session]),
611 tiers: None,
612 from_utc: None,
613 to_utc: None,
614 },
615 filter: build_semantic_memory_filter(
616 query.semantic_tags,
617 None,
618 query.link_rel,
619 query.link_target,
620 query.links_to_ref,
621 query.tag_prefix,
622 query.has_semantic_links,
623 ),
624 include_lineage: true,
625 include_semantic: true,
626 include_session_topology: true,
627 rel: query.rel,
628 target_prefix: query.target_prefix,
629 limit: query.limit.unwrap_or(1000),
630 })
631 .await
632 .map_err(internal_error)?;
633
634 Ok(Json(GraphResponse {
635 sessions: graph_result.sessions,
636 nodes: graph_result.nodes,
637 edges: graph_result.edges,
638 retrieved: graph_result.retrieved,
639 }))
640}
641
642async fn evict_handler(
643 State(state): State<Arc<AppState>>,
644 headers: HeaderMap,
645 Json(request): Json<EvictHttpRequest>,
646) -> ApiResult<EvictHttpResultDto> {
647 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
648 let scoped_session = scope_session_id(&tenant, &request.session_id);
649 let mode = resolve_evict_mode(&request)?;
650
651 let purge_session = matches!(mode, MemoryEvictMode::PurgeSession);
652 let include_calibration = request.include_calibration.unwrap_or(purge_session);
653 let include_checkpoints = request.include_checkpoints.unwrap_or(purge_session);
654
655 let result = evict_service(&state)
656 .execute(&MemoryEvictRequest {
657 mode,
658 scope: MemoryScope {
659 tenant_id: Some(tenant),
660 session_ids: Some(vec![scoped_session]),
661 tiers: normalize_request_tiers(request.tiers.as_deref()),
662 from_utc: None,
663 to_utc: None,
664 },
665 filter: build_semantic_memory_filter(
666 request.semantic_tags,
667 request.tags_contains,
668 request.link_rel,
669 request.link_target,
670 request.links_to_ref,
671 request.tag_prefix,
672 request.has_semantic_links,
673 ),
674 sync_keys: request.sync_keys,
675 node_ids: request.node_ids,
676 dry_run: request.dry_run.unwrap_or(false),
677 force: request.force.unwrap_or(false),
678 max_nodes: request.max_nodes.unwrap_or(5000),
679 include_calibration,
680 include_checkpoints,
681 })
682 .await
683 .map_err(internal_error)?;
684
685 Ok(Json(to_evict_dto(result)))
686}
687
688fn resolve_evict_mode(
689 request: &EvictHttpRequest,
690) -> Result<MemoryEvictMode, (StatusCode, Json<ErrorResponse>)> {
691 if request.purge_session.unwrap_or(false) {
692 return Ok(MemoryEvictMode::PurgeSession);
693 }
694
695 if let Some(mode) = request.mode.as_deref() {
696 return match mode.trim().to_ascii_lowercase().as_str() {
697 "by_sync_keys" | "sync_keys" => Ok(MemoryEvictMode::BySyncKeys),
698 "by_node_ids" | "node_ids" => Ok(MemoryEvictMode::ByNodeIds),
699 "by_filter" | "filter" => Ok(MemoryEvictMode::ByFilter),
700 "purge_session" | "purge" => Ok(MemoryEvictMode::PurgeSession),
701 _ => Err(bad_request(format!("unsupported evict mode: {mode}"))),
702 };
703 }
704
705 if request
706 .sync_keys
707 .as_ref()
708 .is_some_and(|keys| !keys.is_empty())
709 {
710 return Ok(MemoryEvictMode::BySyncKeys);
711 }
712 if request.node_ids.as_ref().is_some_and(|ids| !ids.is_empty()) {
713 return Ok(MemoryEvictMode::ByNodeIds);
714 }
715 if request.semantic_tags.is_some()
716 || request.tags_contains.is_some()
717 || request.link_rel.is_some()
718 || request.link_target.is_some()
719 || request.links_to_ref.is_some()
720 || request.tag_prefix.is_some()
721 || request.has_semantic_links.is_some()
722 {
723 return Ok(MemoryEvictMode::ByFilter);
724 }
725
726 Err(bad_request(
727 "evict mode could not be inferred; provide syncKeys, nodeIds, filter fields, or purgeSession=true",
728 ))
729}
730
731fn to_evict_dto(result: MemoryEvictResult) -> EvictHttpResultDto {
732 EvictHttpResultDto {
733 dry_run: result.dry_run,
734 deleted: result.deleted,
735 blocked: result.blocked,
736 not_found: result.not_found,
737 skipped: result.skipped,
738 would_delete: result.would_delete,
739 calibrations_deleted: result.calibrations_deleted,
740 checkpoints_deleted: result.checkpoints_deleted,
741 records: result
742 .records
743 .into_iter()
744 .map(|record| {
745 json!({
746 "nodeId": record.node_id,
747 "syncKey": record.sync_key,
748 "status": record.status,
749 "reason": record.reason,
750 "inboundReferences": record.inbound_references,
751 })
752 })
753 .collect(),
754 }
755}
756
757async fn get_moods_handler(
758 State(state): State<Arc<AppState>>,
759 Query(query): Query<GetMoodsQuery>,
760) -> ApiResult<MoodCatalogResultDto> {
761 let result = state.mood_catalog.get(
762 query.target_mood.as_deref(),
763 query.blend.unwrap_or(1.0),
764 query.current_stability,
765 query.current_friction,
766 query.current_logic,
767 query.current_autonomy,
768 );
769
770 Ok(Json(to_mood_catalog_dto(result)))
771}
772
773async fn create_monthly_rollup_handler(
774 State(state): State<Arc<AppState>>,
775 headers: HeaderMap,
776 Json(request): Json<CreateMonthlyRollupHttpRequest>,
777) -> ApiResult<MonthlyRollupResultDto> {
778 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
779
780 let rollup_request = MonthlyRollupRequest {
781 session_id: scope_session_id(&tenant, &request.session_id),
782 start_utc: request.start_date_utc,
783 end_utc: request.end_date_utc,
784 source_session_id: request
785 .source_session_id
786 .map(|session_id| scope_session_id(&tenant, &session_id)),
787 parent_node_id: request.parent_node_id,
788 persist: request.persist.unwrap_or(true),
789 limit: request.limit.unwrap_or(5000),
790 };
791
792 let result = state.monthly_rollup.create_async(rollup_request).await;
793 Ok(Json(to_monthly_rollup_dto(result)))
794}
795
796async fn batch_rekey_handler(
797 State(state): State<Arc<AppState>>,
798 headers: HeaderMap,
799 Json(request): Json<BatchRekeyHttpRequest>,
800) -> ApiResult<BatchRekeyResultDto> {
801 if request.node_ids.is_empty() {
802 return Err(bad_request("nodeIds must contain at least one value"));
803 }
804
805 if request.target_session_id.trim().is_empty() {
806 return Err(bad_request("targetSessionId cannot be empty"));
807 }
808
809 let target_tenant = resolve_http_tenant(request.target_tenant_id.as_deref(), &headers);
810 let scoped_target_session = scope_session_id(&target_tenant, request.target_session_id.trim());
811
812 let result = state
813 .rekey_scope
814 .rekey_async(
815 request.node_ids,
816 &target_tenant,
817 &scoped_target_session,
818 request.dry_run.unwrap_or(true),
819 request.allow_merge.unwrap_or(false),
820 )
821 .await
822 .map_err(internal_error)?;
823
824 Ok(Json(to_batch_rekey_dto(result)))
825}
826
827async fn preview_embedding_migration_handler(
828 State(state): State<Arc<AppState>>,
829 headers: HeaderMap,
830 Json(request): Json<EmbeddingMigrationPreviewHttpRequest>,
831) -> ApiResult<EmbeddingMigrationPreviewResultDto> {
832 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
833 let sample_limit = request.sample_limit.unwrap_or(20).clamp(1, 200);
834 let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
835 let (scope, filter, sync_keys) = scoped_memory_filter(request.filter, &tenant);
836
837 let find_service = find_service(&state);
838 let find_result = find_service
839 .execute(&MemoryFindRequest {
840 scope,
841 filter,
842 page: MemoryPage {
843 limit: max_nodes,
844 cursor: None,
845 },
846 ..Default::default()
847 })
848 .await
849 .map_err(internal_error)?;
850
851 let mut nodes = find_result.nodes;
852 if let Some(sync_keys) = sync_keys {
853 nodes.retain(|node| sync_keys.iter().any(|key| key == &node.sync_key));
854 }
855
856 let total_candidates = nodes.len();
857 let sample = nodes
858 .into_iter()
859 .take(sample_limit)
860 .map(|sample| EmbeddingMigrationSampleDto {
861 sync_key: sample.sync_key,
862 session_id: display_session_id(&sample.session_id),
863 tier: sample.tier,
864 has_embedding: sample
865 .embedding
866 .as_ref()
867 .is_some_and(|values| !values.is_empty()),
868 embedding_model: sample.embedding_model,
869 embedding_dimensions: sample.embedding_dimensions,
870 embedded_at: sample.embedded_at,
871 updated_at: sample.updated_at,
872 context_summary: sample.context_summary,
873 })
874 .collect::<Vec<_>>();
875
876 Ok(Json(EmbeddingMigrationPreviewResultDto {
877 total_candidates,
878 sample,
879 provider_available: state.embedding_provider.is_some(),
880 provider_model: state
881 .embedding_provider
882 .as_ref()
883 .map(|provider| provider.model_name().to_string()),
884 }))
885}
886
887async fn run_embedding_migration_handler(
888 State(state): State<Arc<AppState>>,
889 headers: HeaderMap,
890 Json(request): Json<EmbeddingMigrationRunHttpRequest>,
891) -> ApiResult<EmbeddingMigrationRunResultDto> {
892 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
893 let mode = match request
894 .mode
895 .unwrap_or(EmbeddingMigrationModeHttp::MissingOnly)
896 {
897 EmbeddingMigrationModeHttp::MissingOnly => MemoryTransformOperation::EmbedBackfill,
898 EmbeddingMigrationModeHttp::ReindexAll => MemoryTransformOperation::ReindexEmbeddings,
899 EmbeddingMigrationModeHttp::Tags => MemoryTransformOperation::EmbedTagBackfill,
900 EmbeddingMigrationModeHttp::Both => MemoryTransformOperation::EmbedBackfill,
901 };
902 let dry_run = request.dry_run.unwrap_or(true);
903 let batch_size = request.batch_size.unwrap_or(100).clamp(1, 500);
904 let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
905 let (scope, filter, _sync_keys) = scoped_memory_filter(request.filter.clone(), &tenant);
906
907 let transform_service = transform_service(&state);
908
909 let mut result = transform_service
910 .execute(&MemoryTransformRequest {
911 scope: scope.clone(),
912 filter: filter.clone(),
913 operation: mode,
914 dry_run,
915 batch_size,
916 max_nodes,
917 provider_id: state
918 .embedding_provider
919 .as_ref()
920 .map(|_| "gateway-embedding".to_string()),
921 model: state
922 .embedding_provider
923 .as_ref()
924 .map(|provider| provider.model_name().to_string()),
925 })
926 .await
927 .map_err(internal_error)?;
928
929 if request.mode == Some(EmbeddingMigrationModeHttp::Both) && !dry_run {
930 let tag_result = transform_service
931 .execute(&MemoryTransformRequest {
932 scope,
933 filter,
934 operation: MemoryTransformOperation::EmbedTagBackfill,
935 dry_run: false,
936 batch_size,
937 max_nodes,
938 provider_id: state
939 .embedding_provider
940 .as_ref()
941 .map(|_| "gateway-embedding".to_string()),
942 model: state
943 .embedding_provider
944 .as_ref()
945 .map(|provider| provider.model_name().to_string()),
946 })
947 .await
948 .map_err(internal_error)?;
949 result.updated += tag_result.updated;
950 result.failed += tag_result.failed;
951 result.skipped += tag_result.skipped;
952 result.failures.extend(tag_result.failures);
953 }
954
955 Ok(Json(to_embedding_migration_run_dto(
956 result,
957 mode,
958 dry_run,
959 state
960 .embedding_provider
961 .as_ref()
962 .map(|provider| provider.model_name().to_string()),
963 )))
964}
965
966fn recall_service(state: &AppState) -> MemoryRecallService {
967 MemoryRecallService::new(state.node_store.clone())
968 .with_semantic_index(state.semantic_index.clone())
969}
970
971fn find_service(state: &AppState) -> MemoryFindService {
972 MemoryFindService::new(state.node_store.clone())
973 .with_semantic_index(state.semantic_index.clone())
974}
975
976fn evict_service(state: &AppState) -> MemoryEvictService {
977 MemoryEvictService::new(state.node_store.clone())
978 .with_semantic_index(state.semantic_index.clone())
979}
980
981fn graph_service(state: &AppState) -> MemoryGraphService {
982 MemoryGraphService::new(state.node_store.clone())
983 .with_semantic_index(state.semantic_index.clone())
984}
985
986fn transform_service(state: &AppState) -> MemoryTransformService {
987 let providers = build_gateway_provider_registry(state.embedding_provider.clone());
988 MemoryTransformService::new(state.node_store.clone(), providers)
989 .with_semantic_index(state.semantic_index.clone())
990}
991
992fn build_semantic_memory_filter(
993 semantic_tags: Option<Vec<String>>,
994 tags_contains: Option<Vec<String>>,
995 link_rel: Option<String>,
996 link_target: Option<String>,
997 links_to_ref: Option<String>,
998 tag_prefix: Option<String>,
999 has_semantic_links: Option<bool>,
1000) -> MemoryFilter {
1001 MemoryFilter {
1002 indexed_tags: semantic_tags,
1003 tags_contains,
1004 link_rel,
1005 link_target,
1006 links_to_ref,
1007 tag_prefix,
1008 has_semantic_links,
1009 ..Default::default()
1010 }
1011}
1012
1013fn bad_request(message: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
1014 (
1015 StatusCode::BAD_REQUEST,
1016 Json(ErrorResponse {
1017 error: message.into(),
1018 }),
1019 )
1020}
1021
1022fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
1023 (
1024 StatusCode::INTERNAL_SERVER_ERROR,
1025 Json(ErrorResponse {
1026 error: error.to_string(),
1027 }),
1028 )
1029}
1030
1031fn to_avec_dto(value: core_models::AvecState) -> AvecStateDto {
1032 AvecStateDto {
1033 stability: value.stability,
1034 friction: value.friction,
1035 logic: value.logic,
1036 autonomy: value.autonomy,
1037 psi: value.psi(),
1038 }
1039}
1040
1041fn to_node_dto(value: &core_models::SttpNode) -> SttpNodeDto {
1042 SttpNodeDto {
1043 raw: value.raw.clone(),
1044 session_id: display_session_id(&value.session_id),
1045 tier: value.tier.clone(),
1046 timestamp: value.timestamp,
1047 compression_depth: value.compression_depth,
1048 parent_node_id: value.parent_node_id.clone(),
1049 semantic_tags: value.semantic_tags.clone(),
1050 semantic_links: value.semantic_links.clone(),
1051 user_avec: to_avec_dto(value.user_avec),
1052 model_avec: to_avec_dto(value.model_avec),
1053 compression_avec: value.compression_avec.map(to_avec_dto),
1054 rho: value.rho,
1055 kappa: value.kappa,
1056 psi: value.psi,
1057 sync_key: value.sync_key.clone(),
1058 synthetic_id: graph_node_id(value),
1059 }
1060}
1061
1062fn to_mood_catalog_dto(result: core_models::MoodCatalogResult) -> MoodCatalogResultDto {
1063 MoodCatalogResultDto {
1064 presets: result
1065 .presets
1066 .into_iter()
1067 .map(|preset| MoodPresetDto {
1068 name: preset.name,
1069 description: preset.description,
1070 avec: to_avec_dto(preset.avec),
1071 })
1072 .collect(),
1073 apply_guide: result.apply_guide,
1074 swap_preview: result.swap_preview.map(|preview| MoodSwapPreviewDto {
1075 target_mood: preview.target_mood,
1076 blend: preview.blend,
1077 current: to_avec_dto(preview.current),
1078 target: to_avec_dto(preview.target),
1079 blended: to_avec_dto(preview.blended),
1080 }),
1081 }
1082}
1083
1084fn to_monthly_rollup_dto(result: core_models::MonthlyRollupResult) -> MonthlyRollupResultDto {
1085 MonthlyRollupResultDto {
1086 success: result.success,
1087 node_id: result.node_id,
1088 raw_node: result.raw_node,
1089 error: result.error,
1090 source_nodes: result.source_nodes,
1091 parent_reference: result.parent_reference,
1092 user_average: to_avec_dto(result.user_average),
1093 model_average: to_avec_dto(result.model_average),
1094 compression_average: to_avec_dto(result.compression_average),
1095 rho_range: to_numeric_range_dto(result.rho_range),
1096 kappa_range: to_numeric_range_dto(result.kappa_range),
1097 psi_range: to_numeric_range_dto(result.psi_range),
1098 rho_bands: to_confidence_bands_dto(result.rho_bands),
1099 kappa_bands: to_confidence_bands_dto(result.kappa_bands),
1100 }
1101}
1102
1103fn to_batch_rekey_dto(result: core_models::BatchRekeyResult) -> BatchRekeyResultDto {
1104 let updated_scopes = result.scopes.iter().filter(|scope| scope.applied).count();
1105 let conflict_scopes = result.scopes.iter().filter(|scope| scope.conflict).count();
1106
1107 BatchRekeyResultDto {
1108 dry_run: result.dry_run,
1109 requested_node_ids: result.requested_node_ids,
1110 resolved_node_ids: result.resolved_node_ids,
1111 missing_node_ids: result.missing_node_ids,
1112 scopes: result
1113 .scopes
1114 .into_iter()
1115 .map(|scope| ScopeRekeyResultDto {
1116 source_tenant_id: scope.source_tenant_id,
1117 source_session_id: display_session_id(&scope.source_session_id),
1118 target_tenant_id: scope.target_tenant_id,
1119 target_session_id: display_session_id(&scope.target_session_id),
1120 temporal_nodes: scope.temporal_nodes,
1121 calibrations: scope.calibrations,
1122 target_temporal_nodes: scope.target_temporal_nodes,
1123 target_calibrations: scope.target_calibrations,
1124 applied: scope.applied,
1125 conflict: scope.conflict,
1126 message: scope.message,
1127 })
1128 .collect(),
1129 temporal_nodes_updated: result.temporal_nodes_updated,
1130 calibrations_updated: result.calibrations_updated,
1131 updated_scopes,
1132 conflict_scopes,
1133 }
1134}
1135
1136fn scoped_memory_filter(
1137 request_filter: Option<EmbeddingMigrationFilterHttp>,
1138 tenant: &str,
1139) -> (MemoryScope, MemoryFilter, Option<Vec<String>>) {
1140 let filter = request_filter.unwrap_or(EmbeddingMigrationFilterHttp {
1141 session_id: None,
1142 from_utc: None,
1143 to_utc: None,
1144 tiers: None,
1145 has_embedding: None,
1146 embedding_model: None,
1147 sync_keys: None,
1148 });
1149
1150 (
1151 MemoryScope {
1152 tenant_id: None,
1153 session_ids: filter
1154 .session_id
1155 .map(|session_id| vec![scope_session_id(tenant, &session_id)]),
1156 tiers: filter.tiers,
1157 from_utc: filter.from_utc,
1158 to_utc: filter.to_utc,
1159 },
1160 MemoryFilter {
1161 has_embedding: filter.has_embedding,
1162 embedding_model: filter.embedding_model,
1163 ..Default::default()
1164 },
1165 filter.sync_keys,
1166 )
1167}
1168
1169fn build_gateway_provider_registry(
1170 provider: Option<Arc<dyn EmbeddingProvider>>,
1171) -> Arc<InMemoryAiProviderRegistry> {
1172 let mut registry = InMemoryAiProviderRegistry::new();
1173 if let Some(provider) = provider {
1174 registry.register(SttpEmbeddingProviderAdapter::new(
1175 "gateway-embedding",
1176 provider,
1177 ));
1178 }
1179 Arc::new(registry)
1180}
1181
1182fn to_embedding_migration_run_dto(
1183 result: locus_sdk::domain::memory::MemoryTransformResult,
1184 mode: MemoryTransformOperation,
1185 dry_run: bool,
1186 provider_model: Option<String>,
1187) -> EmbeddingMigrationRunResultDto {
1188 EmbeddingMigrationRunResultDto {
1189 scanned: result.scanned,
1190 selected: result.selected,
1191 updated: result.updated,
1192 skipped: result.skipped,
1193 failed: result.failed,
1194 duplicate: result.duplicate,
1195 started_at: result.started_at,
1196 completed_at: result.completed_at,
1197 provider_model,
1198 dry_run,
1199 mode: match mode {
1200 MemoryTransformOperation::EmbedBackfill => "missing_only".to_string(),
1201 MemoryTransformOperation::ReindexEmbeddings => "reindex_all".to_string(),
1202 MemoryTransformOperation::EmbedTagBackfill => "tags".to_string(),
1203 MemoryTransformOperation::ReindexTagEmbeddings => "reindex_tags".to_string(),
1204 },
1205 failure_reasons: result.failures,
1206 }
1207}
1208
1209fn to_numeric_range_dto(value: NumericRange) -> NumericRangeDto {
1210 NumericRangeDto {
1211 min: value.min,
1212 max: value.max,
1213 average: value.average,
1214 }
1215}
1216
1217fn to_confidence_bands_dto(value: ConfidenceBandSummary) -> ConfidenceBandSummaryDto {
1218 ConfidenceBandSummaryDto {
1219 low: value.low,
1220 medium: value.medium,
1221 high: value.high,
1222 }
1223}
1224
1225#[derive(Clone)]
1226struct GrpcGatewayService {
1227 state: Arc<AppState>,
1228}
1229
1230impl GrpcGatewayService {
1231 fn new(state: Arc<AppState>) -> Self {
1232 Self { state }
1233 }
1234}
1235
1236#[tonic::async_trait]
1237impl proto::sttp_gateway_service_server::SttpGatewayService for GrpcGatewayService {
1238 async fn calibrate_session(
1239 &self,
1240 request: Request<proto::CalibrateSessionRequest>,
1241 ) -> Result<Response<proto::CalibrateSessionReply>, Status> {
1242 let tenant = resolve_grpc_tenant(request.metadata());
1243 let request = request.into_inner();
1244 let trigger = if request.trigger.trim().is_empty() {
1245 "manual"
1246 } else {
1247 &request.trigger
1248 };
1249 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1250
1251 let result = self
1252 .state
1253 .calibration
1254 .calibrate_async(
1255 &scoped_session_id,
1256 request.stability,
1257 request.friction,
1258 request.logic,
1259 request.autonomy,
1260 trigger,
1261 )
1262 .await
1263 .map_err(|err| Status::internal(err.to_string()))?;
1264
1265 let reply = proto::CalibrateSessionReply {
1266 previous_avec: Some(to_grpc_avec(result.previous_avec)),
1267 delta: result.delta,
1268 drift_classification: format!("{:?}", result.drift_classification),
1269 trigger: result.trigger,
1270 trigger_history: result.trigger_history,
1271 is_first_calibration: result.is_first_calibration,
1272 };
1273
1274 Ok(Response::new(reply))
1275 }
1276
1277 async fn store_context(
1278 &self,
1279 request: Request<proto::StoreContextRequest>,
1280 ) -> Result<Response<proto::StoreContextReply>, Status> {
1281 let tenant = resolve_grpc_tenant(request.metadata());
1282 let request = request.into_inner();
1283 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1284
1285 let result = self
1286 .state
1287 .store_context
1288 .store_async(&request.node, &scoped_session_id)
1289 .await;
1290
1291 let reply = proto::StoreContextReply {
1292 node_id: result.node_id,
1293 psi: result.psi,
1294 valid: result.valid,
1295 validation_error: result.validation_error,
1296 };
1297
1298 Ok(Response::new(reply))
1299 }
1300
1301 async fn get_context(
1302 &self,
1303 request: Request<proto::GetContextRequest>,
1304 ) -> Result<Response<proto::GetContextReply>, Status> {
1305 let tenant = resolve_grpc_tenant(request.metadata());
1306 let request = request.into_inner();
1307 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1308
1309 let query_embedding = resolve_query_embedding(
1310 self.state.embedding_provider.as_ref(),
1311 request.query_text.as_deref(),
1312 if request.query_embedding.is_empty() {
1313 None
1314 } else {
1315 Some(request.query_embedding.as_slice())
1316 },
1317 )
1318 .await;
1319
1320 let limit = if request.limit <= 0 {
1321 5
1322 } else {
1323 request.limit as usize
1324 };
1325 let tiers = normalize_request_tiers(Some(&request.tiers));
1326 let recall_service = MemoryRecallService::new(self.state.node_store.clone());
1327 let recall_result = recall_service
1328 .execute(&MemoryRecallRequest {
1329 scope: MemoryScope {
1330 tenant_id: None,
1331 session_ids: Some(vec![scoped_session_id]),
1332 tiers,
1333 from_utc: timestamp_from_proto_optional(request.from_utc)?,
1334 to_utc: timestamp_from_proto_optional(request.to_utc)?,
1335 },
1336 page: MemoryPage {
1337 limit,
1338 cursor: None,
1339 },
1340 scoring: MemoryScoring {
1341 alpha: request
1342 .alpha
1343 .unwrap_or(DEFAULT_HYBRID_ALPHA)
1344 .clamp(0.0, 1.0),
1345 beta: request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1346 fallback_policy: FallbackPolicy::OnEmpty,
1347 ..Default::default()
1348 },
1349 current_avec: Some(core_models::AvecState {
1350 stability: request.stability,
1351 friction: request.friction,
1352 logic: request.logic,
1353 autonomy: request.autonomy,
1354 }),
1355 query_text: request.query_text,
1356 query_embedding,
1357 ..Default::default()
1358 })
1359 .await
1360 .map_err(|err| Status::internal(err.to_string()))?;
1361
1362 let nodes = recall_result
1363 .nodes
1364 .iter()
1365 .cloned()
1366 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1367 .collect::<Vec<_>>();
1368
1369 let reply = proto::GetContextReply {
1370 nodes: nodes.iter().map(to_grpc_node).collect(),
1371 retrieved: clamp_usize_to_i32(nodes.len()),
1372 psi_range: Some(to_grpc_psi_range(recall_result.psi_range)),
1373 };
1374
1375 Ok(Response::new(reply))
1376 }
1377
1378 async fn get_embedding_context(
1379 &self,
1380 request: Request<proto::GetEmbeddingContextRequest>,
1381 ) -> Result<Response<proto::GetContextReply>, Status> {
1382 let tenant = resolve_grpc_tenant(request.metadata());
1383 let request = request.into_inner();
1384 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1385
1386 let rag_embedding = resolve_query_embedding(
1387 self.state.embedding_provider.as_ref(),
1388 request.rag_query_text.as_deref(),
1389 if request.rag_embedding.is_empty() {
1390 None
1391 } else {
1392 Some(request.rag_embedding.as_slice())
1393 },
1394 )
1395 .await;
1396
1397 let avec_embedding = resolve_query_embedding(
1398 self.state.embedding_provider.as_ref(),
1399 request.avec_query_text.as_deref(),
1400 if request.avec_embedding.is_empty() {
1401 None
1402 } else {
1403 Some(request.avec_embedding.as_slice())
1404 },
1405 )
1406 .await;
1407
1408 let fused_embedding = fuse_weighted_embeddings(
1409 rag_embedding.as_deref(),
1410 avec_embedding.as_deref(),
1411 request.rag_weight.unwrap_or(0.7),
1412 request.avec_weight.unwrap_or(0.3),
1413 )
1414 .map_err(|(_, payload)| Status::invalid_argument(payload.0.error))?;
1415
1416 if fused_embedding.is_empty() {
1417 return Err(Status::invalid_argument(
1418 "Provide rag_embedding/rag_query_text and/or avec_embedding/avec_query_text",
1419 ));
1420 }
1421
1422 let limit = if request.limit <= 0 {
1423 5
1424 } else {
1425 request.limit as usize
1426 };
1427 let tiers = normalize_request_tiers(Some(&request.tiers));
1428
1429 let result = self
1430 .state
1431 .context_query
1432 .get_context_hybrid_scoped_filtered_async(
1433 Some(&scoped_session_id),
1434 request.stability,
1435 request.friction,
1436 request.logic,
1437 request.autonomy,
1438 timestamp_from_proto_optional(request.from_utc)?,
1439 timestamp_from_proto_optional(request.to_utc)?,
1440 tiers.as_deref(),
1441 Some(fused_embedding.as_slice()),
1442 request
1443 .alpha
1444 .unwrap_or(DEFAULT_HYBRID_ALPHA)
1445 .clamp(0.0, 1.0),
1446 request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1447 limit,
1448 )
1449 .await;
1450
1451 let nodes = result
1452 .nodes
1453 .iter()
1454 .cloned()
1455 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1456 .collect::<Vec<_>>();
1457
1458 let reply = proto::GetContextReply {
1459 nodes: nodes.iter().map(to_grpc_node).collect(),
1460 retrieved: clamp_usize_to_i32(nodes.len()),
1461 psi_range: Some(to_grpc_psi_range(result.psi_range)),
1462 };
1463
1464 Ok(Response::new(reply))
1465 }
1466
1467 async fn list_nodes(
1468 &self,
1469 request: Request<proto::ListNodesRequest>,
1470 ) -> Result<Response<proto::ListNodesReply>, Status> {
1471 let tenant = resolve_grpc_tenant(request.metadata());
1472 let request = request.into_inner();
1473 let requested_limit = if request.limit <= 0 {
1474 50
1475 } else {
1476 request.limit as usize
1477 }
1478 .clamp(1, TENANT_SCAN_LIMIT);
1479 let scoped_session_filter = request
1480 .session_id
1481 .as_deref()
1482 .map(|session_id| scope_session_id(&tenant, session_id));
1483 let backend_limit = if scoped_session_filter.is_some() {
1484 requested_limit
1485 } else {
1486 TENANT_SCAN_LIMIT
1487 };
1488
1489 let find_service = MemoryFindService::new(self.state.node_store.clone());
1490 let result = find_service
1491 .execute(&MemoryFindRequest {
1492 scope: MemoryScope {
1493 tenant_id: None,
1494 session_ids: scoped_session_filter.map(|session| vec![session]),
1495 tiers: None,
1496 from_utc: None,
1497 to_utc: None,
1498 },
1499 page: MemoryPage {
1500 limit: backend_limit,
1501 cursor: None,
1502 },
1503 ..Default::default()
1504 })
1505 .await
1506 .map_err(|err| Status::internal(err.to_string()))?;
1507
1508 let nodes = result
1509 .nodes
1510 .into_iter()
1511 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1512 .take(requested_limit)
1513 .collect::<Vec<_>>();
1514
1515 let reply = proto::ListNodesReply {
1516 nodes: nodes.iter().map(to_grpc_node).collect(),
1517 retrieved: clamp_usize_to_i32(nodes.len()),
1518 };
1519
1520 Ok(Response::new(reply))
1521 }
1522
1523 async fn get_moods(
1524 &self,
1525 request: Request<proto::GetMoodsRequest>,
1526 ) -> Result<Response<proto::GetMoodsReply>, Status> {
1527 let request = request.into_inner();
1528 let result = self.state.mood_catalog.get(
1529 request.target_mood.as_deref(),
1530 request.blend,
1531 request.current_stability,
1532 request.current_friction,
1533 request.current_logic,
1534 request.current_autonomy,
1535 );
1536
1537 let reply = proto::GetMoodsReply {
1538 presets: result
1539 .presets
1540 .into_iter()
1541 .map(|preset| proto::MoodPreset {
1542 name: preset.name,
1543 description: preset.description,
1544 avec: Some(to_grpc_avec(preset.avec)),
1545 })
1546 .collect(),
1547 apply_guide: result.apply_guide,
1548 swap_preview: result.swap_preview.map(|preview| proto::MoodSwapPreview {
1549 target_mood: preview.target_mood,
1550 blend: preview.blend,
1551 current: Some(to_grpc_avec(preview.current)),
1552 target: Some(to_grpc_avec(preview.target)),
1553 blended: Some(to_grpc_avec(preview.blended)),
1554 }),
1555 };
1556
1557 Ok(Response::new(reply))
1558 }
1559
1560 async fn batch_rekey(
1561 &self,
1562 request: Request<proto::BatchRekeyRequest>,
1563 ) -> Result<Response<proto::BatchRekeyReply>, Status> {
1564 let metadata_tenant = resolve_grpc_tenant(request.metadata());
1565 let request = request.into_inner();
1566
1567 if request.node_ids.is_empty() {
1568 return Err(Status::invalid_argument(
1569 "node_ids must contain at least one value",
1570 ));
1571 }
1572
1573 if request.target_session_id.trim().is_empty() {
1574 return Err(Status::invalid_argument(
1575 "target_session_id cannot be empty",
1576 ));
1577 }
1578
1579 let target_tenant = request
1580 .target_tenant_id
1581 .as_deref()
1582 .and_then(normalize_tenant_value)
1583 .unwrap_or(metadata_tenant);
1584 let scoped_target_session =
1585 scope_session_id(&target_tenant, request.target_session_id.trim());
1586
1587 let result = self
1588 .state
1589 .rekey_scope
1590 .rekey_async(
1591 request.node_ids,
1592 &target_tenant,
1593 &scoped_target_session,
1594 request.dry_run.unwrap_or(true),
1595 request.allow_merge.unwrap_or(false),
1596 )
1597 .await
1598 .map_err(|err| Status::internal(err.to_string()))?;
1599
1600 Ok(Response::new(to_grpc_batch_rekey_reply(result)))
1601 }
1602
1603 async fn create_monthly_rollup(
1604 &self,
1605 request: Request<proto::CreateMonthlyRollupRequest>,
1606 ) -> Result<Response<proto::CreateMonthlyRollupReply>, Status> {
1607 let tenant = resolve_grpc_tenant(request.metadata());
1608 let request = request.into_inner();
1609
1610 let start_utc = timestamp_from_proto(request.start_utc)?;
1611 let end_utc = timestamp_from_proto(request.end_utc)?;
1612
1613 let monthly_request = MonthlyRollupRequest {
1614 session_id: scope_session_id(&tenant, &request.session_id),
1615 start_utc,
1616 end_utc,
1617 source_session_id: request
1618 .source_session_id
1619 .map(|session_id| scope_session_id(&tenant, &session_id)),
1620 parent_node_id: request.parent_node_id,
1621 persist: request.persist,
1622 limit: if request.limit <= 0 {
1623 5000
1624 } else {
1625 request.limit as usize
1626 },
1627 };
1628
1629 let result = self
1630 .state
1631 .monthly_rollup
1632 .create_async(monthly_request)
1633 .await;
1634
1635 let reply = proto::CreateMonthlyRollupReply {
1636 success: result.success,
1637 node_id: result.node_id,
1638 raw_node: result.raw_node,
1639 error: result.error,
1640 source_nodes: clamp_usize_to_i32(result.source_nodes),
1641 parent_reference: result.parent_reference,
1642 user_average: Some(to_grpc_avec(result.user_average)),
1643 model_average: Some(to_grpc_avec(result.model_average)),
1644 compression_average: Some(to_grpc_avec(result.compression_average)),
1645 rho_range: Some(to_grpc_numeric_range(result.rho_range)),
1646 kappa_range: Some(to_grpc_numeric_range(result.kappa_range)),
1647 psi_range: Some(to_grpc_numeric_range(result.psi_range)),
1648 rho_bands: Some(to_grpc_confidence_bands(result.rho_bands)),
1649 kappa_bands: Some(to_grpc_confidence_bands(result.kappa_bands)),
1650 };
1651
1652 Ok(Response::new(reply))
1653 }
1654}
1655
1656fn to_grpc_avec(value: core_models::AvecState) -> proto::AvecState {
1657 proto::AvecState {
1658 stability: value.stability,
1659 friction: value.friction,
1660 logic: value.logic,
1661 autonomy: value.autonomy,
1662 psi: value.psi(),
1663 }
1664}
1665
1666fn to_grpc_node(value: &core_models::SttpNode) -> proto::SttpNode {
1667 proto::SttpNode {
1668 raw: value.raw.clone(),
1669 session_id: display_session_id(&value.session_id),
1670 tier: value.tier.clone(),
1671 timestamp: Some(timestamp_to_proto(value.timestamp)),
1672 compression_depth: value.compression_depth,
1673 parent_node_id: value.parent_node_id.clone(),
1674 user_avec: Some(to_grpc_avec(value.user_avec)),
1675 model_avec: Some(to_grpc_avec(value.model_avec)),
1676 compression_avec: value.compression_avec.map(to_grpc_avec),
1677 rho: value.rho,
1678 kappa: value.kappa,
1679 psi: value.psi,
1680 semantic_tags: value.semantic_tags.clone().unwrap_or_default(),
1681 semantic_links: value
1682 .semantic_links
1683 .clone()
1684 .unwrap_or_default()
1685 .into_iter()
1686 .map(|link| proto::SemanticLink {
1687 rel: link.rel,
1688 target: link.target,
1689 confidence: link.confidence,
1690 })
1691 .collect(),
1692 }
1693}
1694
1695fn to_grpc_psi_range(value: PsiRange) -> proto::PsiRange {
1696 proto::PsiRange {
1697 min: value.min,
1698 max: value.max,
1699 average: value.average,
1700 }
1701}
1702
1703fn to_grpc_numeric_range(value: NumericRange) -> proto::NumericRange {
1704 proto::NumericRange {
1705 min: value.min,
1706 max: value.max,
1707 average: value.average,
1708 }
1709}
1710
1711fn to_grpc_confidence_bands(value: ConfidenceBandSummary) -> proto::ConfidenceBandSummary {
1712 proto::ConfidenceBandSummary {
1713 low: clamp_usize_to_i32(value.low),
1714 medium: clamp_usize_to_i32(value.medium),
1715 high: clamp_usize_to_i32(value.high),
1716 }
1717}
1718
1719fn to_grpc_scope_rekey_result(value: core_models::ScopeRekeyResult) -> proto::ScopeRekeyResult {
1720 proto::ScopeRekeyResult {
1721 source_tenant_id: value.source_tenant_id,
1722 source_session_id: display_session_id(&value.source_session_id),
1723 target_tenant_id: value.target_tenant_id,
1724 target_session_id: display_session_id(&value.target_session_id),
1725 temporal_nodes: clamp_usize_to_i32(value.temporal_nodes),
1726 calibrations: clamp_usize_to_i32(value.calibrations),
1727 target_temporal_nodes: clamp_usize_to_i32(value.target_temporal_nodes),
1728 target_calibrations: clamp_usize_to_i32(value.target_calibrations),
1729 applied: value.applied,
1730 conflict: value.conflict,
1731 message: value.message,
1732 }
1733}
1734
1735fn to_grpc_batch_rekey_reply(value: core_models::BatchRekeyResult) -> proto::BatchRekeyReply {
1736 let updated_scopes = value.scopes.iter().filter(|scope| scope.applied).count();
1737 let conflict_scopes = value.scopes.iter().filter(|scope| scope.conflict).count();
1738
1739 proto::BatchRekeyReply {
1740 dry_run: value.dry_run,
1741 requested_node_ids: clamp_usize_to_i32(value.requested_node_ids),
1742 resolved_node_ids: clamp_usize_to_i32(value.resolved_node_ids),
1743 missing_node_ids: value.missing_node_ids,
1744 scopes: value
1745 .scopes
1746 .into_iter()
1747 .map(to_grpc_scope_rekey_result)
1748 .collect(),
1749 temporal_nodes_updated: clamp_usize_to_i32(value.temporal_nodes_updated),
1750 calibrations_updated: clamp_usize_to_i32(value.calibrations_updated),
1751 updated_scopes: clamp_usize_to_i32(updated_scopes),
1752 conflict_scopes: clamp_usize_to_i32(conflict_scopes),
1753 }
1754}
1755
1756fn timestamp_to_proto(value: DateTime<Utc>) -> prost_types::Timestamp {
1757 prost_types::Timestamp {
1758 seconds: value.timestamp(),
1759 nanos: value.timestamp_subsec_nanos() as i32,
1760 }
1761}
1762
1763fn timestamp_from_proto(value: Option<prost_types::Timestamp>) -> Result<DateTime<Utc>, Status> {
1764 let value = value.ok_or_else(|| Status::invalid_argument("missing timestamp"))?;
1765 DateTime::<Utc>::from_timestamp(value.seconds, value.nanos as u32)
1766 .ok_or_else(|| Status::invalid_argument("invalid timestamp"))
1767}
1768
1769fn timestamp_from_proto_optional(
1770 value: Option<prost_types::Timestamp>,
1771) -> Result<Option<DateTime<Utc>>, Status> {
1772 value
1773 .map(|timestamp| timestamp_from_proto(Some(timestamp)))
1774 .transpose()
1775}
1776
1777fn normalize_request_tiers(tiers: Option<&[String]>) -> Option<Vec<String>> {
1778 let normalized = tiers
1779 .unwrap_or(&[])
1780 .iter()
1781 .map(|tier| tier.trim().to_ascii_lowercase())
1782 .filter(|tier| !tier.is_empty())
1783 .collect::<Vec<_>>();
1784
1785 if normalized.is_empty() {
1786 None
1787 } else {
1788 Some(normalized)
1789 }
1790}
1791
1792fn clamp_usize_to_i32(value: usize) -> i32 {
1793 if value > i32::MAX as usize {
1794 i32::MAX
1795 } else {
1796 value as i32
1797 }
1798}
1799
1800#[cfg(test)]
1801mod tests {
1802 use super::*;
1803 use crate::gateway::proto::sttp_gateway_service_server::SttpGatewayService;
1804 use crate::gateway_args::{EmbeddingsProviderKind, GatewayBackend};
1805 use crate::providers::parse_avec_state_from_text;
1806 use crate::tenant::session_belongs_to_tenant;
1807 use locus_core_rs::storage::{
1808 SurrealDbEndpointsSettings, SurrealDbRuntimeOptions, SurrealDbSettings,
1809 };
1810
1811 fn sample_node(session_id: &str) -> String {
1812 format!(
1813 r#"
1814⊕⟨ {{ trigger: manual, response_format: temporal_node, origin_session: "{session_id}", compression_depth: 1, parent_node: null, prime: {{ attractor_config: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70 }}, context_summary: "gateway test", relevant_tier: raw, retrieval_budget: 3 }} }} ⟩
1815⦿⟨ {{ timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "{session_id}", user_avec: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70, psi: 2.60 }}, model_avec: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70, psi: 2.60 }} }} ⟩
1816◈⟨ {{ test(.99): "gateway parser check" }} ⟩
1817⍉⟨ {{ rho: 0.96, kappa: 0.94, psi: 2.60, compression_avec: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70, psi: 2.60 }} }} ⟩
1818"#
1819 )
1820 }
1821
1822 fn surreal_test_args() -> GatewayArgs {
1823 GatewayArgs {
1824 http_port: 8080,
1825 grpc_port: 8081,
1826 backend: GatewayBackend::Surreal,
1827 root_dir_name: ".locus-gateway".to_string(),
1828 remote: true,
1829 cors_enabled: true,
1830 cors_allowed_origins: "*".to_string(),
1831 surreal_embedded_endpoint: None,
1832 surreal_remote_endpoint: Some("ws://127.0.0.1:8000/rpc".to_string()),
1833 surreal_namespace: "entasis".to_string(),
1834 surreal_database: "locus_gateway".to_string(),
1835 surreal_user: "root".to_string(),
1836 surreal_password: "root".to_string(),
1837 sqlite_path: "data/locus-gateway.sqlite".to_string(),
1838 embeddings_enabled: false,
1839 embeddings_provider: EmbeddingsProviderKind::Ollama,
1840 embeddings_endpoint: "http://127.0.0.1:11434/api/embeddings".to_string(),
1841 embeddings_model: "sttp-encoder".to_string(),
1842 embeddings_repo: "sentence-transformers/all-MiniLM-L6-v2".to_string(),
1843 avec_scoring_enabled: false,
1844 avec_scoring_endpoint: "http://127.0.0.1:11434/api/chat".to_string(),
1845 avec_scoring_model: "qwen2.5:0.5b".to_string(),
1846 }
1847 }
1848
1849 #[test]
1850 fn parse_avec_state_accepts_plain_json() {
1851 let value = parse_avec_state_from_text(
1852 r#"{"stability":0.8,"friction":0.2,"logic":0.9,"autonomy":0.7}"#,
1853 )
1854 .expect("plain JSON should parse");
1855
1856 assert!((value.stability - 0.8).abs() < f32::EPSILON);
1857 assert!((value.friction - 0.2).abs() < f32::EPSILON);
1858 assert!((value.logic - 0.9).abs() < f32::EPSILON);
1859 assert!((value.autonomy - 0.7).abs() < f32::EPSILON);
1860 }
1861
1862 #[test]
1863 fn parse_avec_state_extracts_embedded_json() {
1864 let value = parse_avec_state_from_text(
1865 "Here is the answer: {\"stability\":1.1,\"friction\":-0.1,\"logic\":0.5,\"autonomy\":0.6}",
1866 )
1867 .expect("embedded JSON should parse");
1868
1869 assert!((value.stability - 1.0).abs() < f32::EPSILON);
1870 assert!((value.friction - 0.0).abs() < f32::EPSILON);
1871 assert!((value.logic - 0.5).abs() < f32::EPSILON);
1872 assert!((value.autonomy - 0.6).abs() < f32::EPSILON);
1873 }
1874
1875 #[tokio::test]
1876 async fn score_avec_returns_bad_request_when_disabled() {
1877 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1878
1879 let err = score_avec_handler(
1880 State(state),
1881 HeaderMap::new(),
1882 Json(ScoreAvecHttpRequest {
1883 text: "hello world".to_string(),
1884 tenant_id: None,
1885 }),
1886 )
1887 .await
1888 .expect_err("scoring should fail when disabled");
1889
1890 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1891 }
1892
1893 #[test]
1894 fn surreal_runtime_options_are_derived_from_gateway_args() {
1895 let args = surreal_test_args();
1896 let mut settings = SurrealDbSettings::default();
1897 settings.endpoints = SurrealDbEndpointsSettings {
1898 embedded: args.surreal_embedded_endpoint.clone(),
1899 remote: args.surreal_remote_endpoint.clone(),
1900 };
1901 settings.namespace = args.surreal_namespace.clone();
1902 settings.database = args.surreal_database.clone();
1903
1904 let runtime_args = vec!["--remote".to_string()];
1905 let runtime = SurrealDbRuntimeOptions::from_args(
1906 &runtime_args,
1907 &settings,
1908 Some(args.root_dir_name.as_str()),
1909 )
1910 .expect("runtime options should be computed");
1911
1912 assert!(runtime.use_remote);
1913 assert_eq!(runtime.endpoint, "ws://127.0.0.1:8000/rpc");
1914 assert_eq!(runtime.namespace, "entasis");
1915 assert_eq!(runtime.database, "locus_gateway");
1916 }
1917
1918 #[test]
1919 fn tenant_scoping_is_applied_only_for_non_default_tenant() {
1920 let scoped = scope_session_id("acme", "session-1");
1921 assert_eq!(scoped, "tenant:acme::session:session-1");
1922 assert!(session_belongs_to_tenant(&scoped, "acme"));
1923 assert!(!session_belongs_to_tenant(&scoped, "default"));
1924 assert_eq!(display_session_id(&scoped), "session-1");
1925
1926 let legacy = scope_session_id("default", "session-1");
1927 assert_eq!(legacy, "session-1");
1928 assert!(session_belongs_to_tenant(&legacy, "default"));
1929 }
1930
1931 #[tokio::test]
1932 async fn http_calibrate_defaults_trigger_to_manual() {
1933 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1934
1935 let request = CalibrateSessionHttpRequest {
1936 session_id: "http-calibrate-session".to_string(),
1937 tenant_id: None,
1938 stability: 0.8,
1939 friction: 0.2,
1940 logic: 0.8,
1941 autonomy: 0.7,
1942 trigger: None,
1943 };
1944
1945 let Json(reply) = calibrate_handler(State(state), HeaderMap::new(), Json(request))
1946 .await
1947 .expect("calibrate should succeed");
1948
1949 assert_eq!(reply.trigger, "manual");
1950 assert!(reply.is_first_calibration);
1951 }
1952
1953 #[tokio::test]
1954 async fn http_store_then_get_context_roundtrip() {
1955 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1956 let session_id = "http-store-session";
1957
1958 let Json(store_reply) = store_context_handler(
1959 State(state.clone()),
1960 HeaderMap::new(),
1961 Json(StoreContextHttpRequest {
1962 node: sample_node(session_id),
1963 session_id: session_id.to_string(),
1964 tenant_id: None,
1965 }),
1966 )
1967 .await
1968 .expect("store should succeed");
1969
1970 assert!(store_reply.valid);
1971 assert!(!store_reply.node_id.is_empty());
1972
1973 let Json(context_reply) = get_context_handler(
1974 State(state),
1975 HeaderMap::new(),
1976 Json(GetContextHttpRequest {
1977 session_id: session_id.to_string(),
1978 tenant_id: None,
1979 stability: 0.85,
1980 friction: 0.25,
1981 logic: 0.80,
1982 autonomy: 0.70,
1983 limit: Some(5),
1984 from_utc: None,
1985 to_utc: None,
1986 tiers: None,
1987 query_text: Some("gateway parser check".to_string()),
1988 query_embedding: None,
1989 alpha: None,
1990 beta: None,
1991 gamma: None,
1992 semantic_tags: None,
1993 tags_contains: None,
1994 link_rel: None,
1995 link_target: None,
1996 links_to_ref: None,
1997 tag_prefix: None,
1998 has_semantic_links: None,
1999 }),
2000 )
2001 .await
2002 .expect("get_context should succeed");
2003
2004 assert!(context_reply.retrieved >= 1);
2005 assert!(
2006 context_reply
2007 .nodes
2008 .iter()
2009 .any(|node| node.session_id == session_id)
2010 );
2011 }
2012
2013 #[tokio::test]
2014 async fn http_embedding_context_roundtrip_with_direct_embeddings() {
2015 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2016 let session_id = "http-embedding-context-session";
2017
2018 let Json(store_reply) = store_context_handler(
2019 State(state.clone()),
2020 HeaderMap::new(),
2021 Json(StoreContextHttpRequest {
2022 node: sample_node(session_id),
2023 session_id: session_id.to_string(),
2024 tenant_id: None,
2025 }),
2026 )
2027 .await
2028 .expect("store should succeed");
2029
2030 assert!(store_reply.valid);
2031
2032 let Json(context_reply) = get_embedding_context_handler(
2033 State(state),
2034 HeaderMap::new(),
2035 Json(GetEmbeddingContextHttpRequest {
2036 session_id: session_id.to_string(),
2037 tenant_id: None,
2038 stability: 0.85,
2039 friction: 0.25,
2040 logic: 0.80,
2041 autonomy: 0.70,
2042 limit: Some(5),
2043 from_utc: None,
2044 to_utc: None,
2045 tiers: None,
2046 rag_query_text: None,
2047 rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2048 avec_query_text: None,
2049 avec_embedding: Some(vec![0.2, 0.2, 0.2]),
2050 rag_weight: Some(0.7),
2051 avec_weight: Some(0.3),
2052 alpha: Some(0.65),
2053 beta: Some(0.35),
2054 tag_weight: None,
2055 semantic_tags: None,
2056 link_rel: None,
2057 link_target: None,
2058 }),
2059 )
2060 .await
2061 .expect("embedding context should succeed");
2062
2063 assert!(context_reply.retrieved >= 1);
2064 }
2065
2066 #[tokio::test]
2067 async fn http_embedding_context_rejects_mismatched_embedding_dimensions() {
2068 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2069
2070 let err = get_embedding_context_handler(
2071 State(state),
2072 HeaderMap::new(),
2073 Json(GetEmbeddingContextHttpRequest {
2074 session_id: "session".to_string(),
2075 tenant_id: None,
2076 stability: 0.8,
2077 friction: 0.2,
2078 logic: 0.8,
2079 autonomy: 0.7,
2080 limit: Some(5),
2081 from_utc: None,
2082 to_utc: None,
2083 tiers: None,
2084 rag_query_text: None,
2085 rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2086 avec_query_text: None,
2087 avec_embedding: Some(vec![0.3, 0.2]),
2088 rag_weight: Some(0.7),
2089 avec_weight: Some(0.3),
2090 alpha: None,
2091 beta: None,
2092 tag_weight: None,
2093 semantic_tags: None,
2094 link_rel: None,
2095 link_target: None,
2096 }),
2097 )
2098 .await
2099 .expect_err("mismatched dimensions should fail");
2100
2101 assert_eq!(err.0, StatusCode::BAD_REQUEST);
2102 }
2103
2104 #[tokio::test]
2105 async fn http_evict_dry_run_then_delete_roundtrip() {
2106 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2107 let session_id = "http-evict-session";
2108
2109 let _ = store_context_handler(
2110 State(state.clone()),
2111 HeaderMap::new(),
2112 Json(StoreContextHttpRequest {
2113 node: sample_node(session_id),
2114 session_id: session_id.to_string(),
2115 tenant_id: None,
2116 }),
2117 )
2118 .await
2119 .expect("store should succeed");
2120
2121 let nodes_before = list_nodes_handler(
2122 State(state.clone()),
2123 HeaderMap::new(),
2124 Query(ListNodesQuery {
2125 tenant_id: None,
2126 session_id: Some(session_id.to_string()),
2127 limit: Some(10),
2128 semantic_tags: None,
2129 tags_contains: None,
2130 link_rel: None,
2131 link_target: None,
2132 links_to_ref: None,
2133 tag_prefix: None,
2134 has_semantic_links: None,
2135 }),
2136 )
2137 .await
2138 .expect("list should succeed");
2139 assert!(nodes_before.retrieved >= 1);
2140 let sync_key = nodes_before.nodes[0].sync_key.clone();
2141
2142 let Json(preview) = evict_handler(
2143 State(state.clone()),
2144 HeaderMap::new(),
2145 Json(EvictHttpRequest {
2146 tenant_id: None,
2147 session_id: session_id.to_string(),
2148 mode: None,
2149 sync_keys: Some(vec![sync_key.clone()]),
2150 node_ids: None,
2151 semantic_tags: None,
2152 tags_contains: None,
2153 link_rel: None,
2154 link_target: None,
2155 links_to_ref: None,
2156 tag_prefix: None,
2157 has_semantic_links: None,
2158 purge_session: None,
2159 dry_run: Some(true),
2160 force: Some(true),
2161 max_nodes: None,
2162 include_calibration: None,
2163 include_checkpoints: None,
2164 tiers: None,
2165 }),
2166 )
2167 .await
2168 .expect("dry-run evict should succeed");
2169
2170 assert_eq!(preview.deleted, 1);
2171 assert_eq!(preview.would_delete, vec![sync_key.clone()]);
2172
2173 let nodes_after_preview = list_nodes_handler(
2174 State(state.clone()),
2175 HeaderMap::new(),
2176 Query(ListNodesQuery {
2177 tenant_id: None,
2178 session_id: Some(session_id.to_string()),
2179 limit: Some(10),
2180 semantic_tags: None,
2181 tags_contains: None,
2182 link_rel: None,
2183 link_target: None,
2184 links_to_ref: None,
2185 tag_prefix: None,
2186 has_semantic_links: None,
2187 }),
2188 )
2189 .await
2190 .expect("list should succeed");
2191 assert_eq!(nodes_after_preview.retrieved, nodes_before.retrieved);
2192
2193 let Json(applied) = evict_handler(
2194 State(state.clone()),
2195 HeaderMap::new(),
2196 Json(EvictHttpRequest {
2197 tenant_id: None,
2198 session_id: session_id.to_string(),
2199 mode: None,
2200 sync_keys: Some(vec![sync_key]),
2201 node_ids: None,
2202 semantic_tags: None,
2203 tags_contains: None,
2204 link_rel: None,
2205 link_target: None,
2206 links_to_ref: None,
2207 tag_prefix: None,
2208 has_semantic_links: None,
2209 purge_session: None,
2210 dry_run: Some(false),
2211 force: Some(true),
2212 max_nodes: None,
2213 include_calibration: None,
2214 include_checkpoints: None,
2215 tiers: None,
2216 }),
2217 )
2218 .await
2219 .expect("evict should succeed");
2220 assert_eq!(applied.deleted, 1);
2221
2222 let nodes_after = list_nodes_handler(
2223 State(state),
2224 HeaderMap::new(),
2225 Query(ListNodesQuery {
2226 tenant_id: None,
2227 session_id: Some(session_id.to_string()),
2228 limit: Some(10),
2229 semantic_tags: None,
2230 tags_contains: None,
2231 link_rel: None,
2232 link_target: None,
2233 links_to_ref: None,
2234 tag_prefix: None,
2235 has_semantic_links: None,
2236 }),
2237 )
2238 .await
2239 .expect("list should succeed");
2240 assert_eq!(nodes_after.retrieved, 0);
2241 }
2242
2243 #[tokio::test]
2244 async fn grpc_service_roundtrip_for_calibrate_store_and_list() {
2245 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2246 let service = GrpcGatewayService::new(state);
2247 let session_id = "grpc-store-session";
2248
2249 let calibrate_reply = service
2250 .calibrate_session(Request::new(proto::CalibrateSessionRequest {
2251 session_id: session_id.to_string(),
2252 stability: 0.8,
2253 friction: 0.2,
2254 logic: 0.8,
2255 autonomy: 0.7,
2256 trigger: String::new(),
2257 }))
2258 .await
2259 .expect("gRPC calibrate should succeed")
2260 .into_inner();
2261
2262 assert_eq!(calibrate_reply.trigger, "manual");
2263
2264 let store_reply = service
2265 .store_context(Request::new(proto::StoreContextRequest {
2266 node: sample_node(session_id),
2267 session_id: session_id.to_string(),
2268 }))
2269 .await
2270 .expect("gRPC store should succeed")
2271 .into_inner();
2272
2273 assert!(store_reply.valid);
2274
2275 let rekey_reply = service
2276 .batch_rekey(Request::new(proto::BatchRekeyRequest {
2277 node_ids: vec![store_reply.node_id.clone()],
2278 target_session_id: session_id.to_string(),
2279 target_tenant_id: Some("default".to_string()),
2280 dry_run: Some(true),
2281 allow_merge: Some(false),
2282 }))
2283 .await
2284 .expect("gRPC batch_rekey should succeed")
2285 .into_inner();
2286
2287 assert_eq!(rekey_reply.requested_node_ids, 1);
2288 assert_eq!(rekey_reply.resolved_node_ids, 1);
2289 assert!(rekey_reply.scopes.iter().any(|scope| !scope.applied));
2290
2291 let list_reply = service
2292 .list_nodes(Request::new(proto::ListNodesRequest {
2293 limit: 50,
2294 session_id: Some(session_id.to_string()),
2295 }))
2296 .await
2297 .expect("gRPC list_nodes should succeed")
2298 .into_inner();
2299
2300 assert!(list_reply.retrieved >= 1);
2301 assert!(
2302 list_reply
2303 .nodes
2304 .iter()
2305 .any(|node| node.session_id == session_id)
2306 );
2307
2308 let context_reply = service
2309 .get_context(Request::new(proto::GetContextRequest {
2310 session_id: session_id.to_string(),
2311 stability: 0.8,
2312 friction: 0.2,
2313 logic: 0.8,
2314 autonomy: 0.7,
2315 limit: 5,
2316 query_text: None,
2317 query_embedding: vec![0.1, 0.2, 0.3],
2318 alpha: Some(0.7),
2319 beta: Some(0.3),
2320 from_utc: None,
2321 to_utc: None,
2322 tiers: Vec::new(),
2323 }))
2324 .await
2325 .expect("gRPC get_context should succeed")
2326 .into_inner();
2327
2328 assert!(context_reply.retrieved >= 1);
2329
2330 let embedding_context_reply = service
2331 .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2332 session_id: session_id.to_string(),
2333 stability: 0.8,
2334 friction: 0.2,
2335 logic: 0.8,
2336 autonomy: 0.7,
2337 limit: 5,
2338 rag_query_text: None,
2339 rag_embedding: vec![0.1, 0.2, 0.3],
2340 avec_query_text: None,
2341 avec_embedding: vec![0.2, 0.2, 0.2],
2342 rag_weight: Some(0.7),
2343 avec_weight: Some(0.3),
2344 alpha: Some(0.7),
2345 beta: Some(0.3),
2346 from_utc: None,
2347 to_utc: None,
2348 tiers: Vec::new(),
2349 }))
2350 .await
2351 .expect("gRPC get_embedding_context should succeed")
2352 .into_inner();
2353
2354 assert!(embedding_context_reply.retrieved >= 1);
2355
2356 let err = service
2357 .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2358 session_id: session_id.to_string(),
2359 stability: 0.8,
2360 friction: 0.2,
2361 logic: 0.8,
2362 autonomy: 0.7,
2363 limit: 5,
2364 rag_query_text: None,
2365 rag_embedding: vec![0.1, 0.2, 0.3],
2366 avec_query_text: None,
2367 avec_embedding: vec![0.2, 0.2],
2368 rag_weight: Some(0.7),
2369 avec_weight: Some(0.3),
2370 alpha: None,
2371 beta: None,
2372 from_utc: None,
2373 to_utc: None,
2374 tiers: Vec::new(),
2375 }))
2376 .await
2377 .expect_err("gRPC get_embedding_context should reject invalid dimensions");
2378
2379 assert_eq!(err.code(), Status::invalid_argument("x").code());
2380 }
2381}