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::models::{
20 self as core_models, ConfidenceBandSummary, MonthlyRollupRequest, NumericRange, PsiRange,
21};
22use locus_core_rs::domain::contracts::EmbeddingProvider;
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,
32 MemoryScope, 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::*;
43use crate::orchestration::{build_state, parse_cors_allowed_origins, shutdown_signal};
44#[cfg(test)]
45use crate::orchestration::build_in_memory_state;
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::Never,
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
653 .include_calibration
654 .unwrap_or(purge_session);
655 let include_checkpoints = request
656 .include_checkpoints
657 .unwrap_or(purge_session);
658
659 let result = evict_service(&state)
660 .execute(&MemoryEvictRequest {
661 mode,
662 scope: MemoryScope {
663 tenant_id: Some(tenant),
664 session_ids: Some(vec![scoped_session]),
665 tiers: normalize_request_tiers(request.tiers.as_deref()),
666 from_utc: None,
667 to_utc: None,
668 },
669 filter: build_semantic_memory_filter(
670 request.semantic_tags,
671 request.tags_contains,
672 request.link_rel,
673 request.link_target,
674 request.links_to_ref,
675 request.tag_prefix,
676 request.has_semantic_links,
677 ),
678 sync_keys: request.sync_keys,
679 node_ids: request.node_ids,
680 dry_run: request.dry_run.unwrap_or(false),
681 force: request.force.unwrap_or(false),
682 max_nodes: request.max_nodes.unwrap_or(5000),
683 include_calibration,
684 include_checkpoints,
685 })
686 .await
687 .map_err(internal_error)?;
688
689 Ok(Json(to_evict_dto(result)))
690}
691
692fn resolve_evict_mode(request: &EvictHttpRequest) -> Result<MemoryEvictMode, (StatusCode, Json<ErrorResponse>)> {
693 if request.purge_session.unwrap_or(false) {
694 return Ok(MemoryEvictMode::PurgeSession);
695 }
696
697 if let Some(mode) = request.mode.as_deref() {
698 return match mode.trim().to_ascii_lowercase().as_str() {
699 "by_sync_keys" | "sync_keys" => Ok(MemoryEvictMode::BySyncKeys),
700 "by_node_ids" | "node_ids" => Ok(MemoryEvictMode::ByNodeIds),
701 "by_filter" | "filter" => Ok(MemoryEvictMode::ByFilter),
702 "purge_session" | "purge" => Ok(MemoryEvictMode::PurgeSession),
703 _ => Err(bad_request(format!("unsupported evict mode: {mode}"))),
704 };
705 }
706
707 if request.sync_keys.as_ref().is_some_and(|keys| !keys.is_empty()) {
708 return Ok(MemoryEvictMode::BySyncKeys);
709 }
710 if request.node_ids.as_ref().is_some_and(|ids| !ids.is_empty()) {
711 return Ok(MemoryEvictMode::ByNodeIds);
712 }
713 if request.semantic_tags.is_some()
714 || request.tags_contains.is_some()
715 || request.link_rel.is_some()
716 || request.link_target.is_some()
717 || request.links_to_ref.is_some()
718 || request.tag_prefix.is_some()
719 || request.has_semantic_links.is_some()
720 {
721 return Ok(MemoryEvictMode::ByFilter);
722 }
723
724 Err(bad_request(
725 "evict mode could not be inferred; provide syncKeys, nodeIds, filter fields, or purgeSession=true",
726 ))
727}
728
729fn to_evict_dto(result: MemoryEvictResult) -> EvictHttpResultDto {
730 EvictHttpResultDto {
731 dry_run: result.dry_run,
732 deleted: result.deleted,
733 blocked: result.blocked,
734 not_found: result.not_found,
735 skipped: result.skipped,
736 would_delete: result.would_delete,
737 calibrations_deleted: result.calibrations_deleted,
738 checkpoints_deleted: result.checkpoints_deleted,
739 records: result
740 .records
741 .into_iter()
742 .map(|record| {
743 json!({
744 "nodeId": record.node_id,
745 "syncKey": record.sync_key,
746 "status": record.status,
747 "reason": record.reason,
748 "inboundReferences": record.inbound_references,
749 })
750 })
751 .collect(),
752 }
753}
754
755async fn get_moods_handler(
756 State(state): State<Arc<AppState>>,
757 Query(query): Query<GetMoodsQuery>,
758) -> ApiResult<MoodCatalogResultDto> {
759 let result = state.mood_catalog.get(
760 query.target_mood.as_deref(),
761 query.blend.unwrap_or(1.0),
762 query.current_stability,
763 query.current_friction,
764 query.current_logic,
765 query.current_autonomy,
766 );
767
768 Ok(Json(to_mood_catalog_dto(result)))
769}
770
771async fn create_monthly_rollup_handler(
772 State(state): State<Arc<AppState>>,
773 headers: HeaderMap,
774 Json(request): Json<CreateMonthlyRollupHttpRequest>,
775) -> ApiResult<MonthlyRollupResultDto> {
776 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
777
778 let rollup_request = MonthlyRollupRequest {
779 session_id: scope_session_id(&tenant, &request.session_id),
780 start_utc: request.start_date_utc,
781 end_utc: request.end_date_utc,
782 source_session_id: request
783 .source_session_id
784 .map(|session_id| scope_session_id(&tenant, &session_id)),
785 parent_node_id: request.parent_node_id,
786 persist: request.persist.unwrap_or(true),
787 limit: request.limit.unwrap_or(5000),
788 };
789
790 let result = state.monthly_rollup.create_async(rollup_request).await;
791 Ok(Json(to_monthly_rollup_dto(result)))
792}
793
794async fn batch_rekey_handler(
795 State(state): State<Arc<AppState>>,
796 headers: HeaderMap,
797 Json(request): Json<BatchRekeyHttpRequest>,
798) -> ApiResult<BatchRekeyResultDto> {
799 if request.node_ids.is_empty() {
800 return Err(bad_request("nodeIds must contain at least one value"));
801 }
802
803 if request.target_session_id.trim().is_empty() {
804 return Err(bad_request("targetSessionId cannot be empty"));
805 }
806
807 let target_tenant = resolve_http_tenant(request.target_tenant_id.as_deref(), &headers);
808 let scoped_target_session = scope_session_id(&target_tenant, request.target_session_id.trim());
809
810 let result = state
811 .rekey_scope
812 .rekey_async(
813 request.node_ids,
814 &target_tenant,
815 &scoped_target_session,
816 request.dry_run.unwrap_or(true),
817 request.allow_merge.unwrap_or(false),
818 )
819 .await
820 .map_err(internal_error)?;
821
822 Ok(Json(to_batch_rekey_dto(result)))
823}
824
825async fn preview_embedding_migration_handler(
826 State(state): State<Arc<AppState>>,
827 headers: HeaderMap,
828 Json(request): Json<EmbeddingMigrationPreviewHttpRequest>,
829) -> ApiResult<EmbeddingMigrationPreviewResultDto> {
830 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
831 let sample_limit = request.sample_limit.unwrap_or(20).clamp(1, 200);
832 let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
833 let (scope, filter, sync_keys) = scoped_memory_filter(request.filter, &tenant);
834
835 let find_service = find_service(&state);
836 let find_result = find_service
837 .execute(&MemoryFindRequest {
838 scope,
839 filter,
840 page: MemoryPage {
841 limit: max_nodes,
842 cursor: None,
843 },
844 ..Default::default()
845 })
846 .await
847 .map_err(internal_error)?;
848
849 let mut nodes = find_result.nodes;
850 if let Some(sync_keys) = sync_keys {
851 nodes.retain(|node| sync_keys.iter().any(|key| key == &node.sync_key));
852 }
853
854 let total_candidates = nodes.len();
855 let sample = nodes
856 .into_iter()
857 .take(sample_limit)
858 .map(|sample| EmbeddingMigrationSampleDto {
859 sync_key: sample.sync_key,
860 session_id: display_session_id(&sample.session_id),
861 tier: sample.tier,
862 has_embedding: sample
863 .embedding
864 .as_ref()
865 .is_some_and(|values| !values.is_empty()),
866 embedding_model: sample.embedding_model,
867 embedding_dimensions: sample.embedding_dimensions,
868 embedded_at: sample.embedded_at,
869 updated_at: sample.updated_at,
870 context_summary: sample.context_summary,
871 })
872 .collect::<Vec<_>>();
873
874 Ok(Json(EmbeddingMigrationPreviewResultDto {
875 total_candidates,
876 sample,
877 provider_available: state.embedding_provider.is_some(),
878 provider_model: state
879 .embedding_provider
880 .as_ref()
881 .map(|provider| provider.model_name().to_string()),
882 }))
883}
884
885async fn run_embedding_migration_handler(
886 State(state): State<Arc<AppState>>,
887 headers: HeaderMap,
888 Json(request): Json<EmbeddingMigrationRunHttpRequest>,
889) -> ApiResult<EmbeddingMigrationRunResultDto> {
890 let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
891 let mode = match request
892 .mode
893 .unwrap_or(EmbeddingMigrationModeHttp::MissingOnly)
894 {
895 EmbeddingMigrationModeHttp::MissingOnly => MemoryTransformOperation::EmbedBackfill,
896 EmbeddingMigrationModeHttp::ReindexAll => MemoryTransformOperation::ReindexEmbeddings,
897 EmbeddingMigrationModeHttp::Tags => MemoryTransformOperation::EmbedTagBackfill,
898 EmbeddingMigrationModeHttp::Both => MemoryTransformOperation::EmbedBackfill,
899 };
900 let dry_run = request.dry_run.unwrap_or(true);
901 let batch_size = request.batch_size.unwrap_or(100).clamp(1, 500);
902 let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
903 let (scope, filter, _sync_keys) = scoped_memory_filter(request.filter.clone(), &tenant);
904
905 let transform_service = transform_service(&state);
906
907 let mut result = transform_service
908 .execute(&MemoryTransformRequest {
909 scope: scope.clone(),
910 filter: filter.clone(),
911 operation: mode,
912 dry_run,
913 batch_size,
914 max_nodes,
915 provider_id: state
916 .embedding_provider
917 .as_ref()
918 .map(|_| "gateway-embedding".to_string()),
919 model: state
920 .embedding_provider
921 .as_ref()
922 .map(|provider| provider.model_name().to_string()),
923 })
924 .await
925 .map_err(internal_error)?;
926
927 if request.mode == Some(EmbeddingMigrationModeHttp::Both) && !dry_run {
928 let tag_result = transform_service
929 .execute(&MemoryTransformRequest {
930 scope,
931 filter,
932 operation: MemoryTransformOperation::EmbedTagBackfill,
933 dry_run: false,
934 batch_size,
935 max_nodes,
936 provider_id: state
937 .embedding_provider
938 .as_ref()
939 .map(|_| "gateway-embedding".to_string()),
940 model: state
941 .embedding_provider
942 .as_ref()
943 .map(|provider| provider.model_name().to_string()),
944 })
945 .await
946 .map_err(internal_error)?;
947 result.updated += tag_result.updated;
948 result.failed += tag_result.failed;
949 result.skipped += tag_result.skipped;
950 result.failures.extend(tag_result.failures);
951 }
952
953 Ok(Json(to_embedding_migration_run_dto(
954 result,
955 mode,
956 dry_run,
957 state
958 .embedding_provider
959 .as_ref()
960 .map(|provider| provider.model_name().to_string()),
961 )))
962}
963
964fn recall_service(state: &AppState) -> MemoryRecallService {
965 MemoryRecallService::new(state.node_store.clone())
966 .with_semantic_index(state.semantic_index.clone())
967}
968
969fn find_service(state: &AppState) -> MemoryFindService {
970 MemoryFindService::new(state.node_store.clone())
971 .with_semantic_index(state.semantic_index.clone())
972}
973
974fn evict_service(state: &AppState) -> MemoryEvictService {
975 MemoryEvictService::new(state.node_store.clone())
976 .with_semantic_index(state.semantic_index.clone())
977}
978
979fn graph_service(state: &AppState) -> MemoryGraphService {
980 MemoryGraphService::new(state.node_store.clone())
981 .with_semantic_index(state.semantic_index.clone())
982}
983
984fn transform_service(state: &AppState) -> MemoryTransformService {
985 let providers = build_gateway_provider_registry(state.embedding_provider.clone());
986 MemoryTransformService::new(state.node_store.clone(), providers)
987 .with_semantic_index(state.semantic_index.clone())
988}
989
990fn build_semantic_memory_filter(
991 semantic_tags: Option<Vec<String>>,
992 tags_contains: Option<Vec<String>>,
993 link_rel: Option<String>,
994 link_target: Option<String>,
995 links_to_ref: Option<String>,
996 tag_prefix: Option<String>,
997 has_semantic_links: Option<bool>,
998) -> MemoryFilter {
999 MemoryFilter {
1000 indexed_tags: semantic_tags,
1001 tags_contains,
1002 link_rel,
1003 link_target,
1004 links_to_ref,
1005 tag_prefix,
1006 has_semantic_links,
1007 ..Default::default()
1008 }
1009}
1010
1011fn bad_request(message: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
1012 (
1013 StatusCode::BAD_REQUEST,
1014 Json(ErrorResponse {
1015 error: message.into(),
1016 }),
1017 )
1018}
1019
1020fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
1021 (
1022 StatusCode::INTERNAL_SERVER_ERROR,
1023 Json(ErrorResponse {
1024 error: error.to_string(),
1025 }),
1026 )
1027}
1028
1029fn to_avec_dto(value: core_models::AvecState) -> AvecStateDto {
1030 AvecStateDto {
1031 stability: value.stability,
1032 friction: value.friction,
1033 logic: value.logic,
1034 autonomy: value.autonomy,
1035 psi: value.psi(),
1036 }
1037}
1038
1039fn to_node_dto(value: &core_models::SttpNode) -> SttpNodeDto {
1040 SttpNodeDto {
1041 raw: value.raw.clone(),
1042 session_id: display_session_id(&value.session_id),
1043 tier: value.tier.clone(),
1044 timestamp: value.timestamp,
1045 compression_depth: value.compression_depth,
1046 parent_node_id: value.parent_node_id.clone(),
1047 semantic_tags: value.semantic_tags.clone(),
1048 semantic_links: value.semantic_links.clone(),
1049 user_avec: to_avec_dto(value.user_avec),
1050 model_avec: to_avec_dto(value.model_avec),
1051 compression_avec: value.compression_avec.map(to_avec_dto),
1052 rho: value.rho,
1053 kappa: value.kappa,
1054 psi: value.psi,
1055 sync_key: value.sync_key.clone(),
1056 synthetic_id: graph_node_id(value),
1057 }
1058}
1059
1060fn to_mood_catalog_dto(result: core_models::MoodCatalogResult) -> MoodCatalogResultDto {
1061 MoodCatalogResultDto {
1062 presets: result
1063 .presets
1064 .into_iter()
1065 .map(|preset| MoodPresetDto {
1066 name: preset.name,
1067 description: preset.description,
1068 avec: to_avec_dto(preset.avec),
1069 })
1070 .collect(),
1071 apply_guide: result.apply_guide,
1072 swap_preview: result.swap_preview.map(|preview| MoodSwapPreviewDto {
1073 target_mood: preview.target_mood,
1074 blend: preview.blend,
1075 current: to_avec_dto(preview.current),
1076 target: to_avec_dto(preview.target),
1077 blended: to_avec_dto(preview.blended),
1078 }),
1079 }
1080}
1081
1082fn to_monthly_rollup_dto(result: core_models::MonthlyRollupResult) -> MonthlyRollupResultDto {
1083 MonthlyRollupResultDto {
1084 success: result.success,
1085 node_id: result.node_id,
1086 raw_node: result.raw_node,
1087 error: result.error,
1088 source_nodes: result.source_nodes,
1089 parent_reference: result.parent_reference,
1090 user_average: to_avec_dto(result.user_average),
1091 model_average: to_avec_dto(result.model_average),
1092 compression_average: to_avec_dto(result.compression_average),
1093 rho_range: to_numeric_range_dto(result.rho_range),
1094 kappa_range: to_numeric_range_dto(result.kappa_range),
1095 psi_range: to_numeric_range_dto(result.psi_range),
1096 rho_bands: to_confidence_bands_dto(result.rho_bands),
1097 kappa_bands: to_confidence_bands_dto(result.kappa_bands),
1098 }
1099}
1100
1101fn to_batch_rekey_dto(result: core_models::BatchRekeyResult) -> BatchRekeyResultDto {
1102 let updated_scopes = result.scopes.iter().filter(|scope| scope.applied).count();
1103 let conflict_scopes = result.scopes.iter().filter(|scope| scope.conflict).count();
1104
1105 BatchRekeyResultDto {
1106 dry_run: result.dry_run,
1107 requested_node_ids: result.requested_node_ids,
1108 resolved_node_ids: result.resolved_node_ids,
1109 missing_node_ids: result.missing_node_ids,
1110 scopes: result
1111 .scopes
1112 .into_iter()
1113 .map(|scope| ScopeRekeyResultDto {
1114 source_tenant_id: scope.source_tenant_id,
1115 source_session_id: display_session_id(&scope.source_session_id),
1116 target_tenant_id: scope.target_tenant_id,
1117 target_session_id: display_session_id(&scope.target_session_id),
1118 temporal_nodes: scope.temporal_nodes,
1119 calibrations: scope.calibrations,
1120 target_temporal_nodes: scope.target_temporal_nodes,
1121 target_calibrations: scope.target_calibrations,
1122 applied: scope.applied,
1123 conflict: scope.conflict,
1124 message: scope.message,
1125 })
1126 .collect(),
1127 temporal_nodes_updated: result.temporal_nodes_updated,
1128 calibrations_updated: result.calibrations_updated,
1129 updated_scopes,
1130 conflict_scopes,
1131 }
1132}
1133
1134fn scoped_memory_filter(
1135 request_filter: Option<EmbeddingMigrationFilterHttp>,
1136 tenant: &str,
1137) -> (MemoryScope, MemoryFilter, Option<Vec<String>>) {
1138 let filter = request_filter.unwrap_or(EmbeddingMigrationFilterHttp {
1139 session_id: None,
1140 from_utc: None,
1141 to_utc: None,
1142 tiers: None,
1143 has_embedding: None,
1144 embedding_model: None,
1145 sync_keys: None,
1146 });
1147
1148 (
1149 MemoryScope {
1150 tenant_id: None,
1151 session_ids: filter
1152 .session_id
1153 .map(|session_id| vec![scope_session_id(tenant, &session_id)]),
1154 tiers: filter.tiers,
1155 from_utc: filter.from_utc,
1156 to_utc: filter.to_utc,
1157 },
1158 MemoryFilter {
1159 has_embedding: filter.has_embedding,
1160 embedding_model: filter.embedding_model,
1161 ..Default::default()
1162 },
1163 filter.sync_keys,
1164 )
1165}
1166
1167fn build_gateway_provider_registry(
1168 provider: Option<Arc<dyn EmbeddingProvider>>,
1169) -> Arc<InMemoryAiProviderRegistry> {
1170 let mut registry = InMemoryAiProviderRegistry::new();
1171 if let Some(provider) = provider {
1172 registry.register(SttpEmbeddingProviderAdapter::new("gateway-embedding", provider));
1173 }
1174 Arc::new(registry)
1175}
1176
1177fn to_embedding_migration_run_dto(
1178 result: locus_sdk::domain::memory::MemoryTransformResult,
1179 mode: MemoryTransformOperation,
1180 dry_run: bool,
1181 provider_model: Option<String>,
1182) -> EmbeddingMigrationRunResultDto {
1183 EmbeddingMigrationRunResultDto {
1184 scanned: result.scanned,
1185 selected: result.selected,
1186 updated: result.updated,
1187 skipped: result.skipped,
1188 failed: result.failed,
1189 duplicate: result.duplicate,
1190 started_at: result.started_at,
1191 completed_at: result.completed_at,
1192 provider_model,
1193 dry_run,
1194 mode: match mode {
1195 MemoryTransformOperation::EmbedBackfill => "missing_only".to_string(),
1196 MemoryTransformOperation::ReindexEmbeddings => "reindex_all".to_string(),
1197 MemoryTransformOperation::EmbedTagBackfill => "tags".to_string(),
1198 MemoryTransformOperation::ReindexTagEmbeddings => "reindex_tags".to_string(),
1199 },
1200 failure_reasons: result.failures,
1201 }
1202}
1203
1204fn to_numeric_range_dto(value: NumericRange) -> NumericRangeDto {
1205 NumericRangeDto {
1206 min: value.min,
1207 max: value.max,
1208 average: value.average,
1209 }
1210}
1211
1212fn to_confidence_bands_dto(value: ConfidenceBandSummary) -> ConfidenceBandSummaryDto {
1213 ConfidenceBandSummaryDto {
1214 low: value.low,
1215 medium: value.medium,
1216 high: value.high,
1217 }
1218}
1219
1220#[derive(Clone)]
1221struct GrpcGatewayService {
1222 state: Arc<AppState>,
1223}
1224
1225impl GrpcGatewayService {
1226 fn new(state: Arc<AppState>) -> Self {
1227 Self { state }
1228 }
1229}
1230
1231#[tonic::async_trait]
1232impl proto::sttp_gateway_service_server::SttpGatewayService for GrpcGatewayService {
1233 async fn calibrate_session(
1234 &self,
1235 request: Request<proto::CalibrateSessionRequest>,
1236 ) -> Result<Response<proto::CalibrateSessionReply>, Status> {
1237 let tenant = resolve_grpc_tenant(request.metadata());
1238 let request = request.into_inner();
1239 let trigger = if request.trigger.trim().is_empty() {
1240 "manual"
1241 } else {
1242 &request.trigger
1243 };
1244 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1245
1246 let result = self
1247 .state
1248 .calibration
1249 .calibrate_async(
1250 &scoped_session_id,
1251 request.stability,
1252 request.friction,
1253 request.logic,
1254 request.autonomy,
1255 trigger,
1256 )
1257 .await
1258 .map_err(|err| Status::internal(err.to_string()))?;
1259
1260 let reply = proto::CalibrateSessionReply {
1261 previous_avec: Some(to_grpc_avec(result.previous_avec)),
1262 delta: result.delta,
1263 drift_classification: format!("{:?}", result.drift_classification),
1264 trigger: result.trigger,
1265 trigger_history: result.trigger_history,
1266 is_first_calibration: result.is_first_calibration,
1267 };
1268
1269 Ok(Response::new(reply))
1270 }
1271
1272 async fn store_context(
1273 &self,
1274 request: Request<proto::StoreContextRequest>,
1275 ) -> Result<Response<proto::StoreContextReply>, Status> {
1276 let tenant = resolve_grpc_tenant(request.metadata());
1277 let request = request.into_inner();
1278 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1279
1280 let result = self
1281 .state
1282 .store_context
1283 .store_async(&request.node, &scoped_session_id)
1284 .await;
1285
1286 let reply = proto::StoreContextReply {
1287 node_id: result.node_id,
1288 psi: result.psi,
1289 valid: result.valid,
1290 validation_error: result.validation_error,
1291 };
1292
1293 Ok(Response::new(reply))
1294 }
1295
1296 async fn get_context(
1297 &self,
1298 request: Request<proto::GetContextRequest>,
1299 ) -> Result<Response<proto::GetContextReply>, Status> {
1300 let tenant = resolve_grpc_tenant(request.metadata());
1301 let request = request.into_inner();
1302 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1303
1304 let query_embedding = resolve_query_embedding(
1305 self.state.embedding_provider.as_ref(),
1306 request.query_text.as_deref(),
1307 if request.query_embedding.is_empty() {
1308 None
1309 } else {
1310 Some(request.query_embedding.as_slice())
1311 },
1312 )
1313 .await;
1314
1315 let limit = if request.limit <= 0 {
1316 5
1317 } else {
1318 request.limit as usize
1319 };
1320 let tiers = normalize_request_tiers(Some(&request.tiers));
1321 let recall_service = MemoryRecallService::new(self.state.node_store.clone());
1322 let recall_result = recall_service
1323 .execute(&MemoryRecallRequest {
1324 scope: MemoryScope {
1325 tenant_id: None,
1326 session_ids: Some(vec![scoped_session_id]),
1327 tiers,
1328 from_utc: timestamp_from_proto_optional(request.from_utc)?,
1329 to_utc: timestamp_from_proto_optional(request.to_utc)?,
1330 },
1331 page: MemoryPage {
1332 limit,
1333 cursor: None,
1334 },
1335 scoring: MemoryScoring {
1336 alpha: request
1337 .alpha
1338 .unwrap_or(DEFAULT_HYBRID_ALPHA)
1339 .clamp(0.0, 1.0),
1340 beta: request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1341 fallback_policy: FallbackPolicy::Never,
1342 ..Default::default()
1343 },
1344 current_avec: Some(core_models::AvecState {
1345 stability: request.stability,
1346 friction: request.friction,
1347 logic: request.logic,
1348 autonomy: request.autonomy,
1349 }),
1350 query_text: request.query_text,
1351 query_embedding,
1352 ..Default::default()
1353 })
1354 .await
1355 .map_err(|err| Status::internal(err.to_string()))?;
1356
1357 let nodes = recall_result
1358 .nodes
1359 .iter()
1360 .cloned()
1361 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1362 .collect::<Vec<_>>();
1363
1364 let reply = proto::GetContextReply {
1365 nodes: nodes.iter().map(to_grpc_node).collect(),
1366 retrieved: clamp_usize_to_i32(nodes.len()),
1367 psi_range: Some(to_grpc_psi_range(recall_result.psi_range)),
1368 };
1369
1370 Ok(Response::new(reply))
1371 }
1372
1373 async fn get_embedding_context(
1374 &self,
1375 request: Request<proto::GetEmbeddingContextRequest>,
1376 ) -> Result<Response<proto::GetContextReply>, Status> {
1377 let tenant = resolve_grpc_tenant(request.metadata());
1378 let request = request.into_inner();
1379 let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1380
1381 let rag_embedding = resolve_query_embedding(
1382 self.state.embedding_provider.as_ref(),
1383 request.rag_query_text.as_deref(),
1384 if request.rag_embedding.is_empty() {
1385 None
1386 } else {
1387 Some(request.rag_embedding.as_slice())
1388 },
1389 )
1390 .await;
1391
1392 let avec_embedding = resolve_query_embedding(
1393 self.state.embedding_provider.as_ref(),
1394 request.avec_query_text.as_deref(),
1395 if request.avec_embedding.is_empty() {
1396 None
1397 } else {
1398 Some(request.avec_embedding.as_slice())
1399 },
1400 )
1401 .await;
1402
1403 let fused_embedding = fuse_weighted_embeddings(
1404 rag_embedding.as_deref(),
1405 avec_embedding.as_deref(),
1406 request.rag_weight.unwrap_or(0.7),
1407 request.avec_weight.unwrap_or(0.3),
1408 )
1409 .map_err(|(_, payload)| Status::invalid_argument(payload.0.error))?;
1410
1411 if fused_embedding.is_empty() {
1412 return Err(Status::invalid_argument(
1413 "Provide rag_embedding/rag_query_text and/or avec_embedding/avec_query_text",
1414 ));
1415 }
1416
1417 let limit = if request.limit <= 0 {
1418 5
1419 } else {
1420 request.limit as usize
1421 };
1422 let tiers = normalize_request_tiers(Some(&request.tiers));
1423
1424 let result = self
1425 .state
1426 .context_query
1427 .get_context_hybrid_scoped_filtered_async(
1428 Some(&scoped_session_id),
1429 request.stability,
1430 request.friction,
1431 request.logic,
1432 request.autonomy,
1433 timestamp_from_proto_optional(request.from_utc)?,
1434 timestamp_from_proto_optional(request.to_utc)?,
1435 tiers.as_deref(),
1436 Some(fused_embedding.as_slice()),
1437 request
1438 .alpha
1439 .unwrap_or(DEFAULT_HYBRID_ALPHA)
1440 .clamp(0.0, 1.0),
1441 request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1442 limit,
1443 )
1444 .await;
1445
1446 let nodes = result
1447 .nodes
1448 .iter()
1449 .cloned()
1450 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1451 .collect::<Vec<_>>();
1452
1453 let reply = proto::GetContextReply {
1454 nodes: nodes.iter().map(to_grpc_node).collect(),
1455 retrieved: clamp_usize_to_i32(nodes.len()),
1456 psi_range: Some(to_grpc_psi_range(result.psi_range)),
1457 };
1458
1459 Ok(Response::new(reply))
1460 }
1461
1462 async fn list_nodes(
1463 &self,
1464 request: Request<proto::ListNodesRequest>,
1465 ) -> Result<Response<proto::ListNodesReply>, Status> {
1466 let tenant = resolve_grpc_tenant(request.metadata());
1467 let request = request.into_inner();
1468 let requested_limit = if request.limit <= 0 {
1469 50
1470 } else {
1471 request.limit as usize
1472 }
1473 .clamp(1, TENANT_SCAN_LIMIT);
1474 let scoped_session_filter = request
1475 .session_id
1476 .as_deref()
1477 .map(|session_id| scope_session_id(&tenant, session_id));
1478 let backend_limit = if scoped_session_filter.is_some() {
1479 requested_limit
1480 } else {
1481 TENANT_SCAN_LIMIT
1482 };
1483
1484 let find_service = MemoryFindService::new(self.state.node_store.clone());
1485 let result = find_service
1486 .execute(&MemoryFindRequest {
1487 scope: MemoryScope {
1488 tenant_id: None,
1489 session_ids: scoped_session_filter.map(|session| vec![session]),
1490 tiers: None,
1491 from_utc: None,
1492 to_utc: None,
1493 },
1494 page: MemoryPage {
1495 limit: backend_limit,
1496 cursor: None,
1497 },
1498 ..Default::default()
1499 })
1500 .await
1501 .map_err(|err| Status::internal(err.to_string()))?;
1502
1503 let nodes = result
1504 .nodes
1505 .into_iter()
1506 .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1507 .take(requested_limit)
1508 .collect::<Vec<_>>();
1509
1510 let reply = proto::ListNodesReply {
1511 nodes: nodes.iter().map(to_grpc_node).collect(),
1512 retrieved: clamp_usize_to_i32(nodes.len()),
1513 };
1514
1515 Ok(Response::new(reply))
1516 }
1517
1518 async fn get_moods(
1519 &self,
1520 request: Request<proto::GetMoodsRequest>,
1521 ) -> Result<Response<proto::GetMoodsReply>, Status> {
1522 let request = request.into_inner();
1523 let result = self.state.mood_catalog.get(
1524 request.target_mood.as_deref(),
1525 request.blend,
1526 request.current_stability,
1527 request.current_friction,
1528 request.current_logic,
1529 request.current_autonomy,
1530 );
1531
1532 let reply = proto::GetMoodsReply {
1533 presets: result
1534 .presets
1535 .into_iter()
1536 .map(|preset| proto::MoodPreset {
1537 name: preset.name,
1538 description: preset.description,
1539 avec: Some(to_grpc_avec(preset.avec)),
1540 })
1541 .collect(),
1542 apply_guide: result.apply_guide,
1543 swap_preview: result.swap_preview.map(|preview| proto::MoodSwapPreview {
1544 target_mood: preview.target_mood,
1545 blend: preview.blend,
1546 current: Some(to_grpc_avec(preview.current)),
1547 target: Some(to_grpc_avec(preview.target)),
1548 blended: Some(to_grpc_avec(preview.blended)),
1549 }),
1550 };
1551
1552 Ok(Response::new(reply))
1553 }
1554
1555 async fn batch_rekey(
1556 &self,
1557 request: Request<proto::BatchRekeyRequest>,
1558 ) -> Result<Response<proto::BatchRekeyReply>, Status> {
1559 let metadata_tenant = resolve_grpc_tenant(request.metadata());
1560 let request = request.into_inner();
1561
1562 if request.node_ids.is_empty() {
1563 return Err(Status::invalid_argument(
1564 "node_ids must contain at least one value",
1565 ));
1566 }
1567
1568 if request.target_session_id.trim().is_empty() {
1569 return Err(Status::invalid_argument(
1570 "target_session_id cannot be empty",
1571 ));
1572 }
1573
1574 let target_tenant = request
1575 .target_tenant_id
1576 .as_deref()
1577 .and_then(normalize_tenant_value)
1578 .unwrap_or(metadata_tenant);
1579 let scoped_target_session =
1580 scope_session_id(&target_tenant, request.target_session_id.trim());
1581
1582 let result = self
1583 .state
1584 .rekey_scope
1585 .rekey_async(
1586 request.node_ids,
1587 &target_tenant,
1588 &scoped_target_session,
1589 request.dry_run.unwrap_or(true),
1590 request.allow_merge.unwrap_or(false),
1591 )
1592 .await
1593 .map_err(|err| Status::internal(err.to_string()))?;
1594
1595 Ok(Response::new(to_grpc_batch_rekey_reply(result)))
1596 }
1597
1598 async fn create_monthly_rollup(
1599 &self,
1600 request: Request<proto::CreateMonthlyRollupRequest>,
1601 ) -> Result<Response<proto::CreateMonthlyRollupReply>, Status> {
1602 let tenant = resolve_grpc_tenant(request.metadata());
1603 let request = request.into_inner();
1604
1605 let start_utc = timestamp_from_proto(request.start_utc)?;
1606 let end_utc = timestamp_from_proto(request.end_utc)?;
1607
1608 let monthly_request = MonthlyRollupRequest {
1609 session_id: scope_session_id(&tenant, &request.session_id),
1610 start_utc,
1611 end_utc,
1612 source_session_id: request
1613 .source_session_id
1614 .map(|session_id| scope_session_id(&tenant, &session_id)),
1615 parent_node_id: request.parent_node_id,
1616 persist: request.persist,
1617 limit: if request.limit <= 0 {
1618 5000
1619 } else {
1620 request.limit as usize
1621 },
1622 };
1623
1624 let result = self
1625 .state
1626 .monthly_rollup
1627 .create_async(monthly_request)
1628 .await;
1629
1630 let reply = proto::CreateMonthlyRollupReply {
1631 success: result.success,
1632 node_id: result.node_id,
1633 raw_node: result.raw_node,
1634 error: result.error,
1635 source_nodes: clamp_usize_to_i32(result.source_nodes),
1636 parent_reference: result.parent_reference,
1637 user_average: Some(to_grpc_avec(result.user_average)),
1638 model_average: Some(to_grpc_avec(result.model_average)),
1639 compression_average: Some(to_grpc_avec(result.compression_average)),
1640 rho_range: Some(to_grpc_numeric_range(result.rho_range)),
1641 kappa_range: Some(to_grpc_numeric_range(result.kappa_range)),
1642 psi_range: Some(to_grpc_numeric_range(result.psi_range)),
1643 rho_bands: Some(to_grpc_confidence_bands(result.rho_bands)),
1644 kappa_bands: Some(to_grpc_confidence_bands(result.kappa_bands)),
1645 };
1646
1647 Ok(Response::new(reply))
1648 }
1649}
1650
1651fn to_grpc_avec(value: core_models::AvecState) -> proto::AvecState {
1652 proto::AvecState {
1653 stability: value.stability,
1654 friction: value.friction,
1655 logic: value.logic,
1656 autonomy: value.autonomy,
1657 psi: value.psi(),
1658 }
1659}
1660
1661fn to_grpc_node(value: &core_models::SttpNode) -> proto::SttpNode {
1662 proto::SttpNode {
1663 raw: value.raw.clone(),
1664 session_id: display_session_id(&value.session_id),
1665 tier: value.tier.clone(),
1666 timestamp: Some(timestamp_to_proto(value.timestamp)),
1667 compression_depth: value.compression_depth,
1668 parent_node_id: value.parent_node_id.clone(),
1669 user_avec: Some(to_grpc_avec(value.user_avec)),
1670 model_avec: Some(to_grpc_avec(value.model_avec)),
1671 compression_avec: value.compression_avec.map(to_grpc_avec),
1672 rho: value.rho,
1673 kappa: value.kappa,
1674 psi: value.psi,
1675 semantic_tags: value.semantic_tags.clone().unwrap_or_default(),
1676 semantic_links: value
1677 .semantic_links
1678 .clone()
1679 .unwrap_or_default()
1680 .into_iter()
1681 .map(|link| proto::SemanticLink {
1682 rel: link.rel,
1683 target: link.target,
1684 confidence: link.confidence,
1685 })
1686 .collect(),
1687 }
1688}
1689
1690fn to_grpc_psi_range(value: PsiRange) -> proto::PsiRange {
1691 proto::PsiRange {
1692 min: value.min,
1693 max: value.max,
1694 average: value.average,
1695 }
1696}
1697
1698fn to_grpc_numeric_range(value: NumericRange) -> proto::NumericRange {
1699 proto::NumericRange {
1700 min: value.min,
1701 max: value.max,
1702 average: value.average,
1703 }
1704}
1705
1706fn to_grpc_confidence_bands(value: ConfidenceBandSummary) -> proto::ConfidenceBandSummary {
1707 proto::ConfidenceBandSummary {
1708 low: clamp_usize_to_i32(value.low),
1709 medium: clamp_usize_to_i32(value.medium),
1710 high: clamp_usize_to_i32(value.high),
1711 }
1712}
1713
1714fn to_grpc_scope_rekey_result(value: core_models::ScopeRekeyResult) -> proto::ScopeRekeyResult {
1715 proto::ScopeRekeyResult {
1716 source_tenant_id: value.source_tenant_id,
1717 source_session_id: display_session_id(&value.source_session_id),
1718 target_tenant_id: value.target_tenant_id,
1719 target_session_id: display_session_id(&value.target_session_id),
1720 temporal_nodes: clamp_usize_to_i32(value.temporal_nodes),
1721 calibrations: clamp_usize_to_i32(value.calibrations),
1722 target_temporal_nodes: clamp_usize_to_i32(value.target_temporal_nodes),
1723 target_calibrations: clamp_usize_to_i32(value.target_calibrations),
1724 applied: value.applied,
1725 conflict: value.conflict,
1726 message: value.message,
1727 }
1728}
1729
1730fn to_grpc_batch_rekey_reply(value: core_models::BatchRekeyResult) -> proto::BatchRekeyReply {
1731 let updated_scopes = value.scopes.iter().filter(|scope| scope.applied).count();
1732 let conflict_scopes = value.scopes.iter().filter(|scope| scope.conflict).count();
1733
1734 proto::BatchRekeyReply {
1735 dry_run: value.dry_run,
1736 requested_node_ids: clamp_usize_to_i32(value.requested_node_ids),
1737 resolved_node_ids: clamp_usize_to_i32(value.resolved_node_ids),
1738 missing_node_ids: value.missing_node_ids,
1739 scopes: value
1740 .scopes
1741 .into_iter()
1742 .map(to_grpc_scope_rekey_result)
1743 .collect(),
1744 temporal_nodes_updated: clamp_usize_to_i32(value.temporal_nodes_updated),
1745 calibrations_updated: clamp_usize_to_i32(value.calibrations_updated),
1746 updated_scopes: clamp_usize_to_i32(updated_scopes),
1747 conflict_scopes: clamp_usize_to_i32(conflict_scopes),
1748 }
1749}
1750
1751fn timestamp_to_proto(value: DateTime<Utc>) -> prost_types::Timestamp {
1752 prost_types::Timestamp {
1753 seconds: value.timestamp(),
1754 nanos: value.timestamp_subsec_nanos() as i32,
1755 }
1756}
1757
1758fn timestamp_from_proto(value: Option<prost_types::Timestamp>) -> Result<DateTime<Utc>, Status> {
1759 let value = value.ok_or_else(|| Status::invalid_argument("missing timestamp"))?;
1760 DateTime::<Utc>::from_timestamp(value.seconds, value.nanos as u32)
1761 .ok_or_else(|| Status::invalid_argument("invalid timestamp"))
1762}
1763
1764fn timestamp_from_proto_optional(
1765 value: Option<prost_types::Timestamp>,
1766) -> Result<Option<DateTime<Utc>>, Status> {
1767 value
1768 .map(|timestamp| timestamp_from_proto(Some(timestamp)))
1769 .transpose()
1770}
1771
1772fn normalize_request_tiers(tiers: Option<&[String]>) -> Option<Vec<String>> {
1773 let normalized = tiers
1774 .unwrap_or(&[])
1775 .iter()
1776 .map(|tier| tier.trim().to_ascii_lowercase())
1777 .filter(|tier| !tier.is_empty())
1778 .collect::<Vec<_>>();
1779
1780 if normalized.is_empty() {
1781 None
1782 } else {
1783 Some(normalized)
1784 }
1785}
1786
1787fn clamp_usize_to_i32(value: usize) -> i32 {
1788 if value > i32::MAX as usize {
1789 i32::MAX
1790 } else {
1791 value as i32
1792 }
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797 use super::*;
1798 use crate::gateway::proto::sttp_gateway_service_server::SttpGatewayService;
1799 use crate::gateway_args::{EmbeddingsProviderKind, GatewayBackend};
1800 use crate::providers::parse_avec_state_from_text;
1801 use crate::tenant::session_belongs_to_tenant;
1802 use locus_core_rs::storage::{
1803 SurrealDbEndpointsSettings, SurrealDbRuntimeOptions, SurrealDbSettings,
1804 };
1805
1806 fn sample_node(session_id: &str) -> String {
1807 format!(
1808 r#"
1809⊕⟨ {{ 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 }} }} ⟩
1810⦿⟨ {{ 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 }} }} ⟩
1811◈⟨ {{ test(.99): "gateway parser check" }} ⟩
1812⍉⟨ {{ 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 }} }} ⟩
1813"#
1814 )
1815 }
1816
1817 fn surreal_test_args() -> GatewayArgs {
1818 GatewayArgs {
1819 http_port: 8080,
1820 grpc_port: 8081,
1821 backend: GatewayBackend::Surreal,
1822 root_dir_name: ".locus-gateway".to_string(),
1823 remote: true,
1824 cors_enabled: true,
1825 cors_allowed_origins: "*".to_string(),
1826 surreal_embedded_endpoint: None,
1827 surreal_remote_endpoint: Some("ws://127.0.0.1:8000/rpc".to_string()),
1828 surreal_namespace: "entasis".to_string(),
1829 surreal_database: "locus_gateway".to_string(),
1830 surreal_user: "root".to_string(),
1831 surreal_password: "root".to_string(),
1832 embeddings_enabled: false,
1833 embeddings_provider: EmbeddingsProviderKind::Ollama,
1834 embeddings_endpoint: "http://127.0.0.1:11434/api/embeddings".to_string(),
1835 embeddings_model: "sttp-encoder".to_string(),
1836 embeddings_repo: "sentence-transformers/all-MiniLM-L6-v2".to_string(),
1837 avec_scoring_enabled: false,
1838 avec_scoring_endpoint: "http://127.0.0.1:11434/api/chat".to_string(),
1839 avec_scoring_model: "qwen2.5:0.5b".to_string(),
1840 }
1841 }
1842
1843 #[test]
1844 fn parse_avec_state_accepts_plain_json() {
1845 let value = parse_avec_state_from_text(
1846 r#"{"stability":0.8,"friction":0.2,"logic":0.9,"autonomy":0.7}"#,
1847 )
1848 .expect("plain JSON should parse");
1849
1850 assert!((value.stability - 0.8).abs() < f32::EPSILON);
1851 assert!((value.friction - 0.2).abs() < f32::EPSILON);
1852 assert!((value.logic - 0.9).abs() < f32::EPSILON);
1853 assert!((value.autonomy - 0.7).abs() < f32::EPSILON);
1854 }
1855
1856 #[test]
1857 fn parse_avec_state_extracts_embedded_json() {
1858 let value = parse_avec_state_from_text(
1859 "Here is the answer: {\"stability\":1.1,\"friction\":-0.1,\"logic\":0.5,\"autonomy\":0.6}",
1860 )
1861 .expect("embedded JSON should parse");
1862
1863 assert!((value.stability - 1.0).abs() < f32::EPSILON);
1864 assert!((value.friction - 0.0).abs() < f32::EPSILON);
1865 assert!((value.logic - 0.5).abs() < f32::EPSILON);
1866 assert!((value.autonomy - 0.6).abs() < f32::EPSILON);
1867 }
1868
1869 #[tokio::test]
1870 async fn score_avec_returns_bad_request_when_disabled() {
1871 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1872
1873 let err = score_avec_handler(
1874 State(state),
1875 HeaderMap::new(),
1876 Json(ScoreAvecHttpRequest {
1877 text: "hello world".to_string(),
1878 tenant_id: None,
1879 }),
1880 )
1881 .await
1882 .expect_err("scoring should fail when disabled");
1883
1884 assert_eq!(err.0, StatusCode::BAD_REQUEST);
1885 }
1886
1887 #[test]
1888 fn surreal_runtime_options_are_derived_from_gateway_args() {
1889 let args = surreal_test_args();
1890 let mut settings = SurrealDbSettings::default();
1891 settings.endpoints = SurrealDbEndpointsSettings {
1892 embedded: args.surreal_embedded_endpoint.clone(),
1893 remote: args.surreal_remote_endpoint.clone(),
1894 };
1895 settings.namespace = args.surreal_namespace.clone();
1896 settings.database = args.surreal_database.clone();
1897
1898 let runtime_args = vec!["--remote".to_string()];
1899 let runtime = SurrealDbRuntimeOptions::from_args(
1900 &runtime_args,
1901 &settings,
1902 Some(args.root_dir_name.as_str()),
1903 )
1904 .expect("runtime options should be computed");
1905
1906 assert!(runtime.use_remote);
1907 assert_eq!(runtime.endpoint, "ws://127.0.0.1:8000/rpc");
1908 assert_eq!(runtime.namespace, "entasis");
1909 assert_eq!(runtime.database, "locus_gateway");
1910 }
1911
1912 #[test]
1913 fn tenant_scoping_is_applied_only_for_non_default_tenant() {
1914 let scoped = scope_session_id("acme", "session-1");
1915 assert_eq!(scoped, "tenant:acme::session:session-1");
1916 assert!(session_belongs_to_tenant(&scoped, "acme"));
1917 assert!(!session_belongs_to_tenant(&scoped, "default"));
1918 assert_eq!(display_session_id(&scoped), "session-1");
1919
1920 let legacy = scope_session_id("default", "session-1");
1921 assert_eq!(legacy, "session-1");
1922 assert!(session_belongs_to_tenant(&legacy, "default"));
1923 }
1924
1925 #[tokio::test]
1926 async fn http_calibrate_defaults_trigger_to_manual() {
1927 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1928
1929 let request = CalibrateSessionHttpRequest {
1930 session_id: "http-calibrate-session".to_string(),
1931 tenant_id: None,
1932 stability: 0.8,
1933 friction: 0.2,
1934 logic: 0.8,
1935 autonomy: 0.7,
1936 trigger: None,
1937 };
1938
1939 let Json(reply) = calibrate_handler(State(state), HeaderMap::new(), Json(request))
1940 .await
1941 .expect("calibrate should succeed");
1942
1943 assert_eq!(reply.trigger, "manual");
1944 assert!(reply.is_first_calibration);
1945 }
1946
1947 #[tokio::test]
1948 async fn http_store_then_get_context_roundtrip() {
1949 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1950 let session_id = "http-store-session";
1951
1952 let Json(store_reply) = store_context_handler(
1953 State(state.clone()),
1954 HeaderMap::new(),
1955 Json(StoreContextHttpRequest {
1956 node: sample_node(session_id),
1957 session_id: session_id.to_string(),
1958 tenant_id: None,
1959 }),
1960 )
1961 .await
1962 .expect("store should succeed");
1963
1964 assert!(store_reply.valid);
1965 assert!(!store_reply.node_id.is_empty());
1966
1967 let Json(context_reply) = get_context_handler(
1968 State(state),
1969 HeaderMap::new(),
1970 Json(GetContextHttpRequest {
1971 session_id: session_id.to_string(),
1972 tenant_id: None,
1973 stability: 0.85,
1974 friction: 0.25,
1975 logic: 0.80,
1976 autonomy: 0.70,
1977 limit: Some(5),
1978 from_utc: None,
1979 to_utc: None,
1980 tiers: None,
1981 query_text: Some("gateway parser check".to_string()),
1982 query_embedding: None,
1983 alpha: None,
1984 beta: None,
1985 gamma: None,
1986 semantic_tags: None,
1987 tags_contains: None,
1988 link_rel: None,
1989 link_target: None,
1990 links_to_ref: None,
1991 tag_prefix: None,
1992 has_semantic_links: None,
1993 }),
1994 )
1995 .await
1996 .expect("get_context should succeed");
1997
1998 assert!(context_reply.retrieved >= 1);
1999 assert!(
2000 context_reply
2001 .nodes
2002 .iter()
2003 .any(|node| node.session_id == session_id)
2004 );
2005 }
2006
2007 #[tokio::test]
2008 async fn http_embedding_context_roundtrip_with_direct_embeddings() {
2009 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2010 let session_id = "http-embedding-context-session";
2011
2012 let Json(store_reply) = store_context_handler(
2013 State(state.clone()),
2014 HeaderMap::new(),
2015 Json(StoreContextHttpRequest {
2016 node: sample_node(session_id),
2017 session_id: session_id.to_string(),
2018 tenant_id: None,
2019 }),
2020 )
2021 .await
2022 .expect("store should succeed");
2023
2024 assert!(store_reply.valid);
2025
2026 let Json(context_reply) = get_embedding_context_handler(
2027 State(state),
2028 HeaderMap::new(),
2029 Json(GetEmbeddingContextHttpRequest {
2030 session_id: session_id.to_string(),
2031 tenant_id: None,
2032 stability: 0.85,
2033 friction: 0.25,
2034 logic: 0.80,
2035 autonomy: 0.70,
2036 limit: Some(5),
2037 from_utc: None,
2038 to_utc: None,
2039 tiers: None,
2040 rag_query_text: None,
2041 rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2042 avec_query_text: None,
2043 avec_embedding: Some(vec![0.2, 0.2, 0.2]),
2044 rag_weight: Some(0.7),
2045 avec_weight: Some(0.3),
2046 alpha: Some(0.65),
2047 beta: Some(0.35),
2048 tag_weight: None,
2049 semantic_tags: None,
2050 link_rel: None,
2051 link_target: None,
2052 }),
2053 )
2054 .await
2055 .expect("embedding context should succeed");
2056
2057 assert!(context_reply.retrieved >= 1);
2058 }
2059
2060 #[tokio::test]
2061 async fn http_embedding_context_rejects_mismatched_embedding_dimensions() {
2062 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2063
2064 let err = get_embedding_context_handler(
2065 State(state),
2066 HeaderMap::new(),
2067 Json(GetEmbeddingContextHttpRequest {
2068 session_id: "session".to_string(),
2069 tenant_id: None,
2070 stability: 0.8,
2071 friction: 0.2,
2072 logic: 0.8,
2073 autonomy: 0.7,
2074 limit: Some(5),
2075 from_utc: None,
2076 to_utc: None,
2077 tiers: None,
2078 rag_query_text: None,
2079 rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2080 avec_query_text: None,
2081 avec_embedding: Some(vec![0.3, 0.2]),
2082 rag_weight: Some(0.7),
2083 avec_weight: Some(0.3),
2084 alpha: None,
2085 beta: None,
2086 tag_weight: None,
2087 semantic_tags: None,
2088 link_rel: None,
2089 link_target: None,
2090 }),
2091 )
2092 .await
2093 .expect_err("mismatched dimensions should fail");
2094
2095 assert_eq!(err.0, StatusCode::BAD_REQUEST);
2096 }
2097
2098 #[tokio::test]
2099 async fn http_evict_dry_run_then_delete_roundtrip() {
2100 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2101 let session_id = "http-evict-session";
2102
2103 let _ = store_context_handler(
2104 State(state.clone()),
2105 HeaderMap::new(),
2106 Json(StoreContextHttpRequest {
2107 node: sample_node(session_id),
2108 session_id: session_id.to_string(),
2109 tenant_id: None,
2110 }),
2111 )
2112 .await
2113 .expect("store should succeed");
2114
2115 let nodes_before = list_nodes_handler(
2116 State(state.clone()),
2117 HeaderMap::new(),
2118 Query(ListNodesQuery {
2119 tenant_id: None,
2120 session_id: Some(session_id.to_string()),
2121 limit: Some(10),
2122 semantic_tags: None,
2123 tags_contains: None,
2124 link_rel: None,
2125 link_target: None,
2126 links_to_ref: None,
2127 tag_prefix: None,
2128 has_semantic_links: None,
2129 }),
2130 )
2131 .await
2132 .expect("list should succeed");
2133 assert!(nodes_before.retrieved >= 1);
2134 let sync_key = nodes_before.nodes[0].sync_key.clone();
2135
2136 let Json(preview) = evict_handler(
2137 State(state.clone()),
2138 HeaderMap::new(),
2139 Json(EvictHttpRequest {
2140 tenant_id: None,
2141 session_id: session_id.to_string(),
2142 mode: None,
2143 sync_keys: Some(vec![sync_key.clone()]),
2144 node_ids: None,
2145 semantic_tags: None,
2146 tags_contains: None,
2147 link_rel: None,
2148 link_target: None,
2149 links_to_ref: None,
2150 tag_prefix: None,
2151 has_semantic_links: None,
2152 purge_session: None,
2153 dry_run: Some(true),
2154 force: Some(true),
2155 max_nodes: None,
2156 include_calibration: None,
2157 include_checkpoints: None,
2158 tiers: None,
2159 }),
2160 )
2161 .await
2162 .expect("dry-run evict should succeed");
2163
2164 assert_eq!(preview.deleted, 1);
2165 assert_eq!(preview.would_delete, vec![sync_key.clone()]);
2166
2167 let nodes_after_preview = list_nodes_handler(
2168 State(state.clone()),
2169 HeaderMap::new(),
2170 Query(ListNodesQuery {
2171 tenant_id: None,
2172 session_id: Some(session_id.to_string()),
2173 limit: Some(10),
2174 semantic_tags: None,
2175 tags_contains: None,
2176 link_rel: None,
2177 link_target: None,
2178 links_to_ref: None,
2179 tag_prefix: None,
2180 has_semantic_links: None,
2181 }),
2182 )
2183 .await
2184 .expect("list should succeed");
2185 assert_eq!(nodes_after_preview.retrieved, nodes_before.retrieved);
2186
2187 let Json(applied) = evict_handler(
2188 State(state.clone()),
2189 HeaderMap::new(),
2190 Json(EvictHttpRequest {
2191 tenant_id: None,
2192 session_id: session_id.to_string(),
2193 mode: None,
2194 sync_keys: Some(vec![sync_key]),
2195 node_ids: None,
2196 semantic_tags: None,
2197 tags_contains: None,
2198 link_rel: None,
2199 link_target: None,
2200 links_to_ref: None,
2201 tag_prefix: None,
2202 has_semantic_links: None,
2203 purge_session: None,
2204 dry_run: Some(false),
2205 force: Some(true),
2206 max_nodes: None,
2207 include_calibration: None,
2208 include_checkpoints: None,
2209 tiers: None,
2210 }),
2211 )
2212 .await
2213 .expect("evict should succeed");
2214 assert_eq!(applied.deleted, 1);
2215
2216 let nodes_after = list_nodes_handler(
2217 State(state),
2218 HeaderMap::new(),
2219 Query(ListNodesQuery {
2220 tenant_id: None,
2221 session_id: Some(session_id.to_string()),
2222 limit: Some(10),
2223 semantic_tags: None,
2224 tags_contains: None,
2225 link_rel: None,
2226 link_target: None,
2227 links_to_ref: None,
2228 tag_prefix: None,
2229 has_semantic_links: None,
2230 }),
2231 )
2232 .await
2233 .expect("list should succeed");
2234 assert_eq!(nodes_after.retrieved, 0);
2235 }
2236
2237 #[tokio::test]
2238 async fn grpc_service_roundtrip_for_calibrate_store_and_list() {
2239 let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2240 let service = GrpcGatewayService::new(state);
2241 let session_id = "grpc-store-session";
2242
2243 let calibrate_reply = service
2244 .calibrate_session(Request::new(proto::CalibrateSessionRequest {
2245 session_id: session_id.to_string(),
2246 stability: 0.8,
2247 friction: 0.2,
2248 logic: 0.8,
2249 autonomy: 0.7,
2250 trigger: String::new(),
2251 }))
2252 .await
2253 .expect("gRPC calibrate should succeed")
2254 .into_inner();
2255
2256 assert_eq!(calibrate_reply.trigger, "manual");
2257
2258 let store_reply = service
2259 .store_context(Request::new(proto::StoreContextRequest {
2260 node: sample_node(session_id),
2261 session_id: session_id.to_string(),
2262 }))
2263 .await
2264 .expect("gRPC store should succeed")
2265 .into_inner();
2266
2267 assert!(store_reply.valid);
2268
2269 let rekey_reply = service
2270 .batch_rekey(Request::new(proto::BatchRekeyRequest {
2271 node_ids: vec![store_reply.node_id.clone()],
2272 target_session_id: session_id.to_string(),
2273 target_tenant_id: Some("default".to_string()),
2274 dry_run: Some(true),
2275 allow_merge: Some(false),
2276 }))
2277 .await
2278 .expect("gRPC batch_rekey should succeed")
2279 .into_inner();
2280
2281 assert_eq!(rekey_reply.requested_node_ids, 1);
2282 assert_eq!(rekey_reply.resolved_node_ids, 1);
2283 assert!(rekey_reply.scopes.iter().any(|scope| !scope.applied));
2284
2285 let list_reply = service
2286 .list_nodes(Request::new(proto::ListNodesRequest {
2287 limit: 50,
2288 session_id: Some(session_id.to_string()),
2289 }))
2290 .await
2291 .expect("gRPC list_nodes should succeed")
2292 .into_inner();
2293
2294 assert!(list_reply.retrieved >= 1);
2295 assert!(
2296 list_reply
2297 .nodes
2298 .iter()
2299 .any(|node| node.session_id == session_id)
2300 );
2301
2302 let context_reply = service
2303 .get_context(Request::new(proto::GetContextRequest {
2304 session_id: session_id.to_string(),
2305 stability: 0.8,
2306 friction: 0.2,
2307 logic: 0.8,
2308 autonomy: 0.7,
2309 limit: 5,
2310 query_text: None,
2311 query_embedding: vec![0.1, 0.2, 0.3],
2312 alpha: Some(0.7),
2313 beta: Some(0.3),
2314 from_utc: None,
2315 to_utc: None,
2316 tiers: Vec::new(),
2317 }))
2318 .await
2319 .expect("gRPC get_context should succeed")
2320 .into_inner();
2321
2322 assert!(context_reply.retrieved >= 1);
2323
2324 let embedding_context_reply = service
2325 .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2326 session_id: session_id.to_string(),
2327 stability: 0.8,
2328 friction: 0.2,
2329 logic: 0.8,
2330 autonomy: 0.7,
2331 limit: 5,
2332 rag_query_text: None,
2333 rag_embedding: vec![0.1, 0.2, 0.3],
2334 avec_query_text: None,
2335 avec_embedding: vec![0.2, 0.2, 0.2],
2336 rag_weight: Some(0.7),
2337 avec_weight: Some(0.3),
2338 alpha: Some(0.7),
2339 beta: Some(0.3),
2340 from_utc: None,
2341 to_utc: None,
2342 tiers: Vec::new(),
2343 }))
2344 .await
2345 .expect("gRPC get_embedding_context should succeed")
2346 .into_inner();
2347
2348 assert!(embedding_context_reply.retrieved >= 1);
2349
2350 let err = service
2351 .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2352 session_id: session_id.to_string(),
2353 stability: 0.8,
2354 friction: 0.2,
2355 logic: 0.8,
2356 autonomy: 0.7,
2357 limit: 5,
2358 rag_query_text: None,
2359 rag_embedding: vec![0.1, 0.2, 0.3],
2360 avec_query_text: None,
2361 avec_embedding: vec![0.2, 0.2],
2362 rag_weight: Some(0.7),
2363 avec_weight: Some(0.3),
2364 alpha: None,
2365 beta: None,
2366 from_utc: None,
2367 to_utc: None,
2368 tiers: Vec::new(),
2369 }))
2370 .await
2371 .expect_err("gRPC get_embedding_context should reject invalid dimensions");
2372
2373 assert_eq!(err.code(), Status::invalid_argument("x").code());
2374 }
2375}