diff --git a/apps/elf-api/src/routes.rs b/apps/elf-api/src/routes.rs index 0439dc81..85b3363c 100644 --- a/apps/elf-api/src/routes.rs +++ b/apps/elf-api/src/routes.rs @@ -1,5 +1,25 @@ //! HTTP route builders and request handlers. +#[path = "routes/admin_notes.rs"] mod admin_notes; +#[path = "routes/admin_ops.rs"] mod admin_ops; +#[path = "routes/consolidation.rs"] mod consolidation; +#[path = "routes/core_memory.rs"] mod core_memory; +#[path = "routes/docs.rs"] mod docs; +#[path = "routes/dreaming.rs"] mod dreaming; +#[path = "routes/events.rs"] mod events; +#[path = "routes/graph.rs"] mod graph; +#[path = "routes/health.rs"] mod health; +#[path = "routes/ingestion_profiles.rs"] mod ingestion_profiles; +#[path = "routes/knowledge.rs"] mod knowledge; +#[path = "routes/notes.rs"] mod notes; +#[path = "routes/recall.rs"] mod recall; +#[path = "routes/search.rs"] mod search; +#[path = "routes/sharing.rs"] mod sharing; +#[path = "routes/support.rs"] mod support; +#[path = "routes/trace.rs"] mod trace; +#[path = "routes/types.rs"] mod types; +#[path = "routes/work_journal.rs"] mod work_journal; + use axum::{ Json, Router, body::{self, Body}, @@ -18,11 +38,36 @@ use axum::{ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use utoipa::{OpenApi, ToSchema}; +use utoipa::OpenApi; use utoipa_scalar::{Scalar, Servable}; use uuid::Uuid; use crate::state::AppState; +use admin_notes::{ + __path_admin_note_correction_apply, __path_admin_note_history_get, + __path_admin_note_provenance_get, admin_note_correction_apply, admin_note_history_get, + admin_note_provenance_get, +}; +use admin_ops::{__path_rebuild_qdrant, rebuild_qdrant}; +use consolidation::{ + __path_consolidation_proposal_get, __path_consolidation_proposal_review, + __path_consolidation_proposals_list, __path_consolidation_run_create, + __path_consolidation_run_get, __path_consolidation_runs_list, consolidation_proposal_get, + consolidation_proposal_review, consolidation_proposals_list, consolidation_run_create, + consolidation_run_get, consolidation_runs_list, +}; +use core_memory::{ + __path_admin_core_block_attach, __path_admin_core_block_detach, __path_admin_core_block_upsert, + __path_core_blocks_get, __path_entity_memory_get, admin_core_block_attach, + admin_core_block_detach, admin_core_block_upsert, core_blocks_get, entity_memory_get, +}; +use docs::{ + __path_admin_docs_excerpts_get, __path_admin_docs_get, __path_admin_docs_search_l0, + __path_docs_delete, __path_docs_excerpts_get, __path_docs_get, __path_docs_put, + __path_docs_search_l0, admin_docs_excerpts_get, admin_docs_get, admin_docs_search_l0, + docs_delete, docs_excerpts_get, docs_get, docs_put, docs_search_l0, +}; +use dreaming::{__path_dreaming_review_queue, dreaming_review_queue}; use elf_config::{SecurityAuthKey, SecurityAuthRole}; use elf_domain::{ consolidation::{ @@ -76,6 +121,81 @@ use elf_service::{ WorkJournalEntryFamily, WorkJournalEntryGetRequest, WorkJournalEntryResponse, WorkJournalSessionReadbackRequest, WorkJournalSessionReadbackResponse, search::TraceBundleMode, }; +use events::{__path_events_ingest, events_ingest}; +use graph::{ + __path_admin_graph_predicate_alias_add, __path_admin_graph_predicate_aliases_list, + __path_admin_graph_predicate_patch, __path_admin_graph_predicates_list, __path_graph_query, + admin_graph_predicate_alias_add, admin_graph_predicate_aliases_list, + admin_graph_predicate_patch, admin_graph_predicates_list, graph_query, graph_report, +}; +use health::{__path_health, health}; +use ingestion_profiles::{ + __path_admin_ingestion_profile_create, __path_admin_ingestion_profile_default_get, + __path_admin_ingestion_profile_default_set, __path_admin_ingestion_profile_get, + __path_admin_ingestion_profile_versions_list, __path_admin_ingestion_profiles_list, + admin_ingestion_profile_create, admin_ingestion_profile_default_get, + admin_ingestion_profile_default_set, admin_ingestion_profile_get, + admin_ingestion_profile_versions_list, admin_ingestion_profiles_list, +}; +use knowledge::{ + __path_knowledge_page_get, __path_knowledge_page_lint, __path_knowledge_page_rebuild, + __path_knowledge_pages_list, __path_knowledge_pages_search, + __path_knowledge_pages_watch_rebuild, knowledge_page_get, knowledge_page_lint, + knowledge_page_rebuild, knowledge_pages_list, knowledge_pages_search, + knowledge_pages_watch_rebuild, +}; +use notes::{ + __path_notes_delete, __path_notes_get, __path_notes_ingest, __path_notes_list, + __path_notes_patch, __path_notes_publish, __path_notes_unpublish, notes_delete, notes_get, + notes_ingest, notes_list, notes_patch, notes_publish, notes_unpublish, +}; +use recall::{__path_recall_debug_panel, admin_recall_debug_panel, recall_debug_panel}; +use search::{ + __path_searches_create, __path_searches_get, __path_searches_notes, __path_searches_raw, + __path_searches_timeline, searches_create, searches_get, searches_notes, searches_raw, + searches_timeline, +}; +use sharing::{ + __path_space_grant_revoke, __path_space_grant_upsert, __path_space_grants_list, + space_grant_revoke, space_grant_upsert, space_grants_list, +}; +use support::{ + ApiError, EntityMemoryQuery, RequestContext, SearchMode, admin_auth_middleware, + api_auth_middleware, effective_token_id, empty_json_object, format_scope, format_space, + json_error, parse_optional_rfc3339, parse_space, require_admin_for_org_shared_writes, + required_read_profile, +}; +#[cfg(test)] +use support::{ + apply_auth_key_context, inject_request_id_into_json_body, parse_request_id_from_headers, + resolve_auth_key, sanitize_trusted_token_header, +}; +use trace::{ + __path_trace_bundle_get, __path_trace_get, __path_trace_item_get, __path_trace_recent_list, + __path_trace_trajectory_get, trace_bundle_get, trace_get, trace_item_get, trace_recent_list, + trace_trajectory_get, +}; +use types::{ + AdminGraphPredicateAliasAddBody, AdminGraphPredicatePatchBody, AdminGraphPredicatesListQuery, + AdminIngestionProfileCreateBody, AdminIngestionProfileDefaultResponseV2, + AdminIngestionProfileDefaultSetBody, AdminIngestionProfileGetQuery, AdminNoteCorrectionBody, + ConsolidationProposalReviewBody, ConsolidationProposalsListQuery, ConsolidationRunCreateBody, + ConsolidationRunsListQuery, CoreBlockAttachBody, CoreBlockUpsertBody, DocsExcerptsGetBody, + DocsPutBody, DocsSearchL0Body, DreamingReviewQueueQuery, ErrorBody, EventsIngestRequest, + GraphQueryBody, GraphReportBody, KnowledgePageRebuildBody, KnowledgePageWatchRebuildBody, + KnowledgePagesListQuery, KnowledgePagesSearchBody, NotePatchRequest, NotesIngestRequest, + NotesListQuery, PublishResponseV2, RecallDebugPanelBody, SearchCreateRequest, + SearchCreateResponseV2, SearchDetailsBody, SearchDetailsResponseV2, SearchIndexResponseV2, + SearchSessionGetQuery, SearchTimelineQuery, SearchTimelineResponseV2, ShareScopeBody, + SpaceGrantItemV2, SpaceGrantUpsertBody, SpaceGrantUpsertResponseV2, SpaceGrantsListResponseV2, + TraceBundleGetQuery, TraceRecentListQuery, WorkJournalEntryCreateBody, + WorkJournalSessionReadbackBody, +}; +use work_journal::{ + __path_work_journal_entry_create, __path_work_journal_entry_get, + __path_work_journal_session_readback, work_journal_entry_create, work_journal_entry_get, + work_journal_session_readback, +}; /// JSON OpenAPI contract route. pub const OPENAPI_JSON_PATH: &str = "/openapi.json"; @@ -204,543 +324,6 @@ const VIEWER_HTML: &str = include_str!("../static/viewer.html"); )] pub struct ApiDoc; -#[derive(Clone, Debug)] -struct RequestContext { - tenant_id: String, - project_id: String, - agent_id: String, -} -impl RequestContext { - fn from_headers(headers: &HeaderMap) -> Result { - let tenant_id = required_header(headers, HEADER_TENANT_ID)?; - let project_id = required_header(headers, HEADER_PROJECT_ID)?; - let agent_id = required_header(headers, HEADER_AGENT_ID)?; - - Ok(Self { tenant_id, project_id, agent_id }) - } -} - -#[derive(Clone, Debug, Deserialize)] -struct NotesIngestRequest { - scope: String, - notes: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct EventsIngestRequest { - scope: Option, - dry_run: Option, - ingestion_profile: Option, - messages: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct DocsPutBody { - scope: String, - doc_type: Option, - title: Option, - #[serde(default)] - source_ref: Value, - - write_policy: Option, - content: String, -} - -#[derive(Clone, Debug, Deserialize)] -struct WorkJournalEntryCreateBody { - entry_id: Option, - scope: String, - session_id: String, - family: WorkJournalEntryFamily, - title: Option, - body: String, - source_refs: Vec, - write_policy: Option, - #[serde(default)] - explicit_next_steps: Vec, - #[serde(default)] - inferred_next_steps: Vec, - #[serde(default)] - rejected_options: Vec, - #[serde(default = "empty_json_object")] - promotion_boundary: Value, -} - -#[derive(Clone, Debug, Deserialize)] -struct WorkJournalSessionReadbackBody { - session_id: String, - #[serde(default)] - families: Vec, - limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct CoreBlockUpsertBody { - block_id: Option, - scope: String, - key: String, - title: String, - content: String, - #[serde(default)] - source_ref: Value, - reason: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct CoreBlockAttachBody { - target_agent_id: String, - read_profile: String, - reason: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct DocsSearchL0Body { - query: String, - scope: Option, - status: Option, - doc_type: Option, - sparse_mode: Option, - domain: Option, - repo: Option, - agent_id: Option, - thread_id: Option, - updated_after: Option, - updated_before: Option, - ts_gte: Option, - ts_lte: Option, - top_k: Option, - candidate_k: Option, - explain: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct DocsExcerptsGetBody { - doc_id: Uuid, - level: String, - chunk_id: Option, - quote: Option, - position: Option, - explain: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct GraphQueryBody { - subject: GraphQueryEntityRef, - predicate: Option, - scopes: Option>, - as_of: Option, - limit: Option, - explain: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct GraphReportBody { - subject: GraphQueryEntityRef, - predicate: Option, - scopes: Option>, - as_of: Option, - limit: Option, - explain: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct SearchCreateRequest { - mode: SearchMode, - query: String, - top_k: Option, - candidate_k: Option, - - filter: Option, - payload_level: Option, - ranking: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct SearchIndexResponseV2 { - mode: SearchMode, - trace_id: Uuid, - search_id: Uuid, - #[serde(with = "elf_service::time_serde")] - expires_at: OffsetDateTime, - items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - trajectory_summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - query_plan: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct SearchCreateResponseV2 { - mode: SearchMode, - trace_id: Uuid, - search_id: Uuid, - #[serde(with = "elf_service::time_serde")] - expires_at: OffsetDateTime, - items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - trajectory_summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - query_plan: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct SearchSessionGetQuery { - payload_level: Option, - top_k: Option, - touch: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct SearchTimelineQuery { - payload_level: Option, - group_by: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct SearchTimelineResponseV2 { - search_id: Uuid, - #[serde(with = "elf_service::time_serde")] - expires_at: OffsetDateTime, - groups: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct SearchDetailsBody { - note_ids: Vec, - payload_level: Option, - record_hits: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdminIngestionProfileCreateBody { - profile_id: String, - version: Option, - profile: Value, - created_by: String, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdminIngestionProfileGetQuery { - version: Option, -} - -#[derive(Clone, Debug, Deserialize, ToSchema)] -struct AdminIngestionProfileDefaultSetBody { - profile_id: String, - version: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationRunCreateBody { - job_kind: String, - input_refs: Vec, - #[serde(default = "empty_json_object")] - source_snapshot: Value, - lineage: ConsolidationLineage, - #[serde(default)] - proposals: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationRunsListQuery { - limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationProposalsListQuery { - run_id: Option, - review_state: Option, - limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationProposalReviewBody { - action: ConsolidationReviewAction, - review_comment: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct DreamingReviewQueueQuery { - run_id: Option, - review_state: Option, - limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct KnowledgePageRebuildBody { - page_kind: KnowledgePageKind, - page_key: String, - title: Option, - #[serde(default)] - doc_ids: Vec, - #[serde(default)] - doc_chunk_ids: Vec, - #[serde(default)] - note_ids: Vec, - #[serde(default)] - event_ids: Vec, - #[serde(default)] - relation_ids: Vec, - #[serde(default)] - proposal_ids: Vec, - #[serde(default = "empty_json_object")] - provider_metadata: Value, -} - -#[derive(Clone, Debug, Deserialize)] -struct KnowledgePageChangedSourceBody { - source_kind: KnowledgeSourceKind, - source_id: Uuid, -} - -#[derive(Clone, Debug, Deserialize)] -struct KnowledgePageWatchRebuildBody { - changed_sources: Vec, - page_kind: Option, - limit: Option, - generate_memory_candidates: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct KnowledgePagesListQuery { - page_kind: Option, - limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct KnowledgePagesSearchBody { - query: String, - page_kind: Option, - limit: Option, -} - -#[derive(Clone, Debug, Serialize, ToSchema)] -struct AdminIngestionProfileDefaultResponseV2 { - profile_id: String, - version: Option, - updated_at: String, -} - -#[derive(Clone, Debug, Serialize)] -struct SearchDetailsResponseV2 { - search_id: Uuid, - #[serde(with = "elf_service::time_serde")] - expires_at: OffsetDateTime, - results: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct NotesListQuery { - scope: Option, - status: Option, - r#type: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct NotePatchRequest { - text: Option, - importance: Option, - confidence: Option, - ttl_days: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdminNoteCorrectionBody { - action: MemoryCorrectionAction, - reason: String, - source_ref: Value, - restore_version_id: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdminGraphPredicatesListQuery { - scope: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdminGraphPredicatePatchBody { - status: Option, - cardinality: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdminGraphPredicateAliasAddBody { - alias: String, -} - -#[derive(Clone, Debug, Deserialize)] -struct TraceRecentListQuery { - limit: Option, - cursor_created_at: Option, - cursor_trace_id: Option, - agent_id: Option, - read_profile: Option, - created_after: Option, - created_before: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct TraceBundleGetQuery { - mode: Option, - stage_items_limit: Option, - candidates_limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct RecallDebugPanelBody { - trace_id: Option, - query: Option, - docs_query: Option, - knowledge_query: Option, - graph_subject: Option, - graph_predicate: Option, - include_dreaming: Option, - limit: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct ShareScopeBody { - space: String, -} - -#[derive(Clone, Debug, Deserialize)] -struct SpaceGrantUpsertBody { - grantee_kind: GranteeKind, - grantee_agent_id: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct PublishResponseV2 { - note_id: Uuid, - space: String, -} - -#[derive(Clone, Debug, Serialize)] -struct SpaceGrantUpsertResponseV2 { - space: String, - grantee_kind: GranteeKind, - grantee_agent_id: Option, - granted: bool, -} - -#[derive(Clone, Debug, Serialize)] -struct SpaceGrantItemV2 { - space: String, - grantee_kind: GranteeKind, - grantee_agent_id: Option, - granted_by_agent_id: String, - granted_at: OffsetDateTime, -} - -#[derive(Clone, Debug, Serialize)] -struct SpaceGrantsListResponseV2 { - grants: Vec, -} - -#[derive(Debug, Serialize, ToSchema)] -struct ErrorBody { - error_code: String, - message: String, - fields: Option>, -} - -#[derive(Debug)] -struct ApiError { - status: StatusCode, - error_code: String, - message: String, - fields: Option>, -} -impl ApiError { - fn new( - status: StatusCode, - error_code: impl Into, - message: impl Into, - fields: Option>, - ) -> Self { - Self { status, error_code: error_code.into(), message: message.into(), fields } - } -} - -impl From for ApiError { - fn from(err: Error) -> Self { - match err { - Error::NonEnglishInput { field } => json_error( - StatusCode::UNPROCESSABLE_ENTITY, - "NON_ENGLISH_INPUT", - "Non-English input detected; upstream must canonicalize to English before calling ELF.", - Some(vec![field]), - ), - Error::InvalidRequest { message } => - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", message, None), - Error::ScopeDenied { message } => - json_error(StatusCode::FORBIDDEN, "SCOPE_DENIED", message, None), - Error::NotFound { message } => - json_error(StatusCode::NOT_FOUND, "NOT_FOUND", message, None), - Error::Conflict { message } => - json_error(StatusCode::CONFLICT, "CONFLICT", message, None), - Error::Provider { message } => { - let sanitized = sanitize_log_text(message.as_str()); - - tracing::error!(error = %sanitized, "Provider error."); - - json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - "Internal error.".to_string(), - None, - ) - }, - Error::Storage { message } => { - let sanitized = sanitize_log_text(message.as_str()); - - tracing::error!(error = %sanitized, "Storage error."); - - json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - "Internal error.".to_string(), - None, - ) - }, - Error::Qdrant { message } => { - let sanitized = sanitize_log_text(message.as_str()); - - tracing::error!(error = %sanitized, "Qdrant error."); - - json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - "Internal error.".to_string(), - None, - ) - }, - } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let body = - ErrorBody { error_code: self.error_code, message: self.message, fields: self.fields }; - - (self.status, Json(body)).into_response() - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum SearchMode { - QuickFind, - PlannedSearch, -} - -#[derive(Clone, Debug, Deserialize)] -struct EntityMemoryQuery { - entity_id: Option, - entity_surface: Option, -} - /// Builds the authenticated public API router. pub fn router(state: AppState) -> Router { let auth_state = state.clone(); @@ -892,308 +475,6 @@ where .merge(Scalar::with_url(SCALAR_DOCS_PATH, ::openapi())) } -fn empty_json_object() -> Value { - Value::Object(Map::new()) -} - -fn json_error( - status: StatusCode, - code: &str, - message: impl Into, - fields: Option>, -) -> ApiError { - ApiError::new(status, code, message, fields) -} - -fn sanitize_log_text(text: &str) -> String { - let mut parts = Vec::new(); - let mut redact_next = false; - - for raw in text.split_whitespace() { - let mut word = raw.to_string(); - - if redact_next { - word = "[REDACTED]".to_string(); - redact_next = false; - } - if raw.eq_ignore_ascii_case("bearer") { - redact_next = true; - } - - let lowered = raw.to_ascii_lowercase(); - - for key in ["api_key", "apikey", "password", "secret", "token"] { - if lowered.contains(key) && (lowered.contains('=') || lowered.contains(':')) { - let sep = if raw.contains('=') { '=' } else { ':' }; - let prefix = match raw.split(sep).next() { - Some(prefix) => prefix, - None => raw, - }; - - word = format!("{prefix}{sep}[REDACTED]"); - - break; - } - } - - parts.push(word); - } - - let mut out = parts.join(" "); - - if out.chars().count() > MAX_ERROR_LOG_CHARS { - out = out.chars().take(MAX_ERROR_LOG_CHARS).collect(); - - out.push_str("..."); - } - - out -} - -fn required_header(headers: &HeaderMap, name: &'static str) -> Result { - let raw = headers.get(name).ok_or_else(|| { - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{name} header is required."), - Some(vec![format!("$.headers.{name}")]), - ) - })?; - let value = raw.to_str().map_err(|_| { - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{name} header must be a valid string."), - Some(vec![format!("$.headers.{name}")]), - ) - })?; - let trimmed = value.trim(); - - if trimmed.is_empty() { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{name} header must be non-empty."), - Some(vec![format!("$.headers.{name}")]), - )); - } - if trimmed.chars().count() > MAX_CONTEXT_HEADER_CHARS { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{name} header is too long."), - Some(vec![format!("$.headers.{name}")]), - )); - } - if !english_gate::is_english_identifier(trimmed) { - return Err(json_error( - StatusCode::UNPROCESSABLE_ENTITY, - "NON_ENGLISH_INPUT", - "Non-English input detected; upstream must canonicalize to English before calling ELF." - .to_string(), - Some(vec![format!("$.headers.{name}")]), - )); - } - - Ok(trimmed.to_string()) -} - -fn required_read_profile(headers: &HeaderMap) -> Result { - required_header(headers, HEADER_READ_PROFILE) -} - -fn parse_space(scope: &str) -> Result { - match scope { - "team_shared" | "project_shared" => Ok(ShareScope::ProjectShared), - "org_shared" => Ok(ShareScope::OrgShared), - _ => Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid space.".to_string(), - Some(vec!["$.space".to_string()]), - )), - } -} - -fn format_space(scope: ShareScope) -> &'static str { - match scope { - ShareScope::ProjectShared => "team_shared", - ShareScope::OrgShared => "org_shared", - } -} - -fn format_scope(scope: &str) -> Result<&'static str, ApiError> { - match scope { - "project_shared" => Ok("team_shared"), - "org_shared" => Ok("org_shared"), - "agent_private" => Ok("agent_private"), - _ => Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid space.".to_string(), - Some(vec!["$.space".to_string()]), - )), - } -} - -fn parse_request_id_from_headers(headers: &HeaderMap) -> Result { - if let Some(raw) = headers.get(HEADER_REQUEST_ID) { - let raw = raw.to_str().map_err(|_| { - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{HEADER_REQUEST_ID} header must be a valid string."), - Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")]), - ) - })?; - let trimmed = raw.trim(); - - if trimmed.is_empty() { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{HEADER_REQUEST_ID} header must be non-empty."), - Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")]), - )); - } - - Uuid::parse_str(trimmed).map_err(|_| { - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{HEADER_REQUEST_ID} header must be a valid UUID."), - Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")]), - ) - }) - } else { - Ok(Uuid::new_v4()) - } -} - -fn inject_request_id_into_json_body(body: &[u8], request_id: &Uuid) -> Option> { - let mut response_body: Value = serde_json::from_slice(body).ok()?; - let object = response_body.as_object_mut()?; - - object.insert("request_id".to_string(), Value::String(request_id.to_string())); - - serde_json::to_vec(&response_body).ok() -} - -fn trusted_token_id(headers: &HeaderMap) -> Option { - let raw = headers.get(HEADER_TRUSTED_TOKEN_ID)?; - let value = raw.to_str().ok()?.trim(); - - if value.is_empty() { None } else { Some(value.to_string()) } -} - -fn sanitize_trusted_token_header(headers: &mut HeaderMap) { - headers.remove(HEADER_TRUSTED_TOKEN_ID); -} - -fn effective_token_id(auth_mode: &str, headers: &HeaderMap) -> Option { - match auth_mode.trim() { - "static_keys" => trusted_token_id(headers), - _ => None, - } -} - -fn bearer_token(headers: &HeaderMap) -> Option { - let raw = headers.get(HEADER_AUTHORIZATION)?; - let value = raw.to_str().ok()?.trim(); - let token = value.strip_prefix("Bearer ")?; - let token = token.trim(); - - if token.is_empty() { None } else { Some(token.to_string()) } -} - -fn resolve_auth_key<'a>( - headers: &HeaderMap, - auth_keys: &'a [SecurityAuthKey], -) -> Result<&'a SecurityAuthKey, ApiError> { - let token = bearer_token(headers).ok_or_else(|| { - json_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "Authentication required.", None) - })?; - - auth_keys.iter().find(|key| key.token == token).ok_or_else(|| { - json_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "Authentication required.", None) - }) -} - -fn set_context_header( - headers: &mut HeaderMap, - name: &'static str, - value: &str, -) -> Result<(), ApiError> { - let header_value = value.parse().map_err(|_| { - json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - format!("Invalid configured auth context for {name}."), - None, - ) - })?; - - headers.insert(name, header_value); - - Ok(()) -} - -fn apply_auth_key_context(headers: &mut HeaderMap, key: &SecurityAuthKey) -> Result<(), ApiError> { - let agent_id = key.agent_id.as_deref().ok_or_else(|| { - json_error(StatusCode::FORBIDDEN, "FORBIDDEN", "Token is not scoped to an agent_id.", None) - })?; - - set_context_header(headers, HEADER_TENANT_ID, key.tenant_id.as_str())?; - set_context_header(headers, HEADER_PROJECT_ID, key.project_id.as_str())?; - set_context_header(headers, HEADER_AGENT_ID, agent_id)?; - set_context_header(headers, HEADER_READ_PROFILE, key.read_profile.as_str())?; - set_context_header(headers, HEADER_TRUSTED_TOKEN_ID, key.token_id.as_str())?; - - Ok(()) -} - -fn require_admin_for_org_shared_writes( - auth_mode: &str, - role: Option, -) -> Result<(), ApiError> { - if auth_mode.trim() != "static_keys" { - return Ok(()); - } - if matches!(role, Some(SecurityAuthRole::Admin | SecurityAuthRole::SuperAdmin)) { - return Ok(()); - } - - Err(json_error(StatusCode::FORBIDDEN, "FORBIDDEN", "Admin token required.", None)) -} - -fn parse_optional_rfc3339( - raw: Option<&String>, - path: &str, -) -> Result, ApiError> { - let Some(raw) = raw else { - return Ok(None); - }; - let raw = raw.trim(); - - if raw.is_empty() { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{path} must be non-empty."), - Some(vec![path.to_string()]), - )); - } - - OffsetDateTime::parse(raw, &Rfc3339).map(Some).map_err(|_| { - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - format!("{path} must be an RFC3339 datetime string."), - Some(vec![path.to_string()]), - ) - }) -} - async fn openapi_json() -> Response { let mut response = Json(::openapi()).into_response(); @@ -1215,3462 +496,6 @@ async fn admin_viewer() -> Response { response } -async fn with_request_id(response: Response, request_id: Uuid) -> Response { - let (mut parts, body) = response.into_parts(); - - parts.headers.insert( - HEADER_REQUEST_ID, - request_id.to_string().parse().expect("request_id is valid uuid string"), - ); - - let is_json_response = parts - .headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|content_type| content_type.starts_with("application/json")) - .unwrap_or(false); - - if !is_json_response { - return Response::from_parts(parts, body); - } - - let body_bytes = match body::to_bytes(body, usize::MAX).await { - Ok(bytes) => bytes, - Err(_) => return Response::from_parts(parts, Body::empty()), - }; - - if let Some(response_body) = inject_request_id_into_json_body(&body_bytes, &request_id) { - parts.headers.remove(CONTENT_LENGTH); - - Response::from_parts(parts, Body::from(response_body)) - } else { - Response::from_parts(parts, Body::from(body_bytes)) - } -} - -async fn api_auth_middleware( - State(state): State, - req: Request, - next: Next, -) -> Response { - let security = &state.service.cfg.security; - let request_id = match parse_request_id_from_headers(req.headers()) { - Ok(request_id) => request_id, - Err(err) => return with_request_id(err.into_response(), Uuid::new_v4()).await, - }; - let mut req = req; - - sanitize_trusted_token_header(req.headers_mut()); - - let response = match security.auth_mode.trim() { - "off" => next.run(req).await, - "static_keys" => { - let key = match resolve_auth_key(req.headers(), &security.auth_keys) { - Ok(key) => key, - Err(err) => return with_request_id(err.into_response(), request_id).await, - }; - - req.extensions_mut().insert(key.role); - - if let Err(err) = apply_auth_key_context(req.headers_mut(), key) { - return with_request_id(err.into_response(), request_id).await; - } - - next.run(req).await - }, - _ => json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - "Invalid security.auth_mode configuration.", - None, - ) - .into_response(), - }; - - with_request_id(response, request_id).await -} - -async fn admin_auth_middleware( - State(state): State, - req: Request, - next: Next, -) -> Response { - let security = &state.service.cfg.security; - let request_id = match parse_request_id_from_headers(req.headers()) { - Ok(request_id) => request_id, - Err(err) => return with_request_id(err.into_response(), Uuid::new_v4()).await, - }; - let mut req = req; - - sanitize_trusted_token_header(req.headers_mut()); - - let response = match security.auth_mode.trim() { - "off" => next.run(req).await, - "static_keys" => { - let key = match resolve_auth_key(req.headers(), &security.auth_keys) { - Ok(key) => key, - Err(err) => return with_request_id(err.into_response(), request_id).await, - }; - - req.extensions_mut().insert(key.role); - - if !matches!(key.role, SecurityAuthRole::Admin | SecurityAuthRole::SuperAdmin) { - return with_request_id( - json_error(StatusCode::FORBIDDEN, "FORBIDDEN", "Admin token required.", None) - .into_response(), - request_id, - ) - .await; - } - - if let Err(err) = apply_auth_key_context(req.headers_mut(), key) { - return with_request_id(err.into_response(), request_id).await; - } - - next.run(req).await - }, - _ => json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - "Invalid security.auth_mode configuration.", - None, - ) - .into_response(), - }; - - with_request_id(response, request_id).await -} - -#[utoipa::path( - get, - path = "/health", - tag = "health", - responses((status = 200, description = "API process is healthy.")) -)] -async fn health() -> StatusCode { - StatusCode::OK -} - -#[utoipa::path( - post, - path = "/v2/notes/ingest", - tag = "notes", - request_body = Value, - responses( - (status = 200, description = "Notes were processed.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_ingest( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.trim() == "org_shared" { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - if payload.notes.len() > MAX_NOTES_PER_INGEST { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Notes list is too large.", - Some(vec!["$.notes".to_string()]), - )); - } - - let response = state - .service - .add_note(AddNoteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: payload.scope, - notes: payload.notes, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/events/ingest", - tag = "events", - request_body = Value, - responses( - (status = 200, description = "Event messages were processed.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn events_ingest( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.as_deref().map(str::trim) == Some("org_shared") { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - if payload.messages.len() > MAX_MESSAGES_PER_EVENT { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Messages list is too large.", - Some(vec!["$.messages".to_string()]), - )); - } - - for (idx, msg) in payload.messages.iter().enumerate() { - if msg.content.chars().count() > MAX_MESSAGE_CHARS { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Message content is too long.", - Some(vec![format!("$.messages[{idx}].content")]), - )); - } - } - - let response = state - .service - .add_event(AddEventRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: payload.scope, - dry_run: payload.dry_run, - ingestion_profile: payload.ingestion_profile, - messages: payload.messages, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/docs", - tag = "docs", - request_body = Value, - responses( - (status = 200, description = "Document was stored.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn docs_put( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.trim() == "org_shared" { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .docs_put(DocsPutRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: payload.scope, - doc_type: payload.doc_type.map(|doc_type| doc_type.as_str().to_string()), - title: payload.title, - source_ref: payload.source_ref, - write_policy: payload.write_policy, - content: payload.content, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/work-journal/entries", - tag = "work_journal", - request_body = Value, - responses( - (status = 200, description = "Work Journal entry was stored.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn work_journal_entry_create( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.trim() == "org_shared" { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .work_journal_entry_create(WorkJournalEntryCreateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - entry_id: payload.entry_id, - scope: payload.scope, - session_id: payload.session_id, - family: payload.family, - title: payload.title, - body: payload.body, - source_refs: payload.source_refs, - write_policy: payload.write_policy, - explicit_next_steps: payload.explicit_next_steps, - inferred_next_steps: payload.inferred_next_steps, - rejected_options: payload.rejected_options, - promotion_boundary: payload.promotion_boundary, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/work-journal/entries/{entry_id}", - tag = "work_journal", - responses( - (status = 200, description = "Work Journal entry metadata.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Work Journal entry not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn work_journal_entry_get( - State(state): State, - headers: HeaderMap, - Path(entry_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let response = state - .service - .work_journal_entry_get(WorkJournalEntryGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - entry_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/work-journal/readback", - tag = "work_journal", - request_body = Value, - responses( - (status = 200, description = "Work Journal session readback.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn work_journal_session_readback( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .work_journal_session_readback(WorkJournalSessionReadbackRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - session_id: payload.session_id, - families: payload.families, - limit: payload.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/core-blocks", - tag = "core_blocks", - responses( - (status = 200, description = "Attached core memory blocks.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn core_blocks_get( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let response = state - .service - .core_blocks_get(CoreBlocksGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/entity-memory", - tag = "graph", - params( - ("entity_id" = Option, Query, description = "Graph entity id. Exactly one of entity_id or entity_surface is required."), - ("entity_surface" = Option, Query, description = "Canonical or alias entity surface. Exactly one of entity_id or entity_surface is required."), - ), - responses( - (status = 200, description = "Entity-scoped memory authority view.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Entity was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn entity_memory_get( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - ApiError::new( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .entity_memory_view(EntityMemoryViewRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - entity_id: query.entity_id, - entity_surface: query.entity_surface, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/core-blocks", - tag = "core_blocks", - request_body = Value, - responses( - (status = 200, description = "Core block was stored.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 409, description = "Core block conflict.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_core_block_upsert( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.trim() == "org_shared" { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .core_block_upsert(CoreBlockUpsertRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - block_id: payload.block_id, - scope: payload.scope, - key: payload.key, - title: payload.title, - content: payload.content, - source_ref: payload.source_ref, - reason: payload.reason, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/core-blocks/{block_id}/attachments", - tag = "core_blocks", - params(("block_id" = Uuid, Path, description = "Core block ID.")), - request_body = Value, - responses( - (status = 200, description = "Core block was attached.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Core block was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_core_block_attach( - State(state): State, - headers: HeaderMap, - Path(block_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .core_block_attach(CoreBlockAttachRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - block_id, - target_agent_id: payload.target_agent_id, - read_profile: payload.read_profile, - reason: payload.reason, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - delete, - path = "/v2/admin/core-blocks/attachments/{attachment_id}", - tag = "core_blocks", - params(("attachment_id" = Uuid, Path, description = "Core block attachment ID.")), - responses( - (status = 200, description = "Core block attachment was detached.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_core_block_detach( - State(state): State, - headers: HeaderMap, - Path(attachment_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .core_block_detach(CoreBlockDetachRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - attachment_id, - reason: None, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/docs/{doc_id}", - tag = "docs", - params(("doc_id" = Uuid, Path, description = "Document ID.")), - responses( - (status = 200, description = "Document was fetched.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Document was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn docs_get( - State(state): State, - headers: HeaderMap, - Path(doc_id): Path, -) -> Result, ApiError> { - docs_get_inner(state, headers, doc_id).await -} - -#[utoipa::path( - get, - path = "/v2/admin/docs/{doc_id}", - tag = "admin", - params(("doc_id" = Uuid, Path, description = "Document ID.")), - responses( - (status = 200, description = "Document was fetched through the admin mirror.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Document was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_docs_get( - State(state): State, - headers: HeaderMap, - Path(doc_id): Path, -) -> Result, ApiError> { - docs_get_inner(state, headers, doc_id).await -} - -async fn docs_get_inner( - state: AppState, - headers: HeaderMap, - doc_id: Uuid, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let response = state - .service - .docs_get(DocsGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - doc_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - delete, - path = "/v2/docs/{doc_id}", - tag = "docs", - params(("doc_id" = Uuid, Path, description = "Document ID.")), - responses( - (status = 200, description = "Document was deleted.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Document was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn docs_delete( - State(state): State, - headers: HeaderMap, - Path(doc_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .docs_delete(DocsDeleteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - doc_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/docs/search/l0", - tag = "docs", - request_body = Value, - responses( - (status = 200, description = "L0 document search results.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn docs_search_l0( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - docs_search_l0_inner(state, headers, payload).await -} - -#[utoipa::path( - post, - path = "/v2/admin/docs/search/l0", - tag = "admin", - request_body = Value, - responses( - (status = 200, description = "L0 document search results through the admin mirror.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_docs_search_l0( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - docs_search_l0_inner(state, headers, payload).await -} - -async fn docs_search_l0_inner( - state: AppState, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(mut payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let status = payload.status.as_deref().map(str::trim).filter(|status| !status.is_empty()); - - if let Some(status) = status { - let status = status.to_lowercase(); - - if !DOC_STATUSES.contains(&status.as_str()) { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "status must be one of: active|deleted.", - Some(vec!["$.status".to_string()]), - )); - } - - payload.status = Some(status); - } - - let updated_after = parse_optional_rfc3339(payload.updated_after.as_ref(), "$.updated_after")?; - let updated_before = - parse_optional_rfc3339(payload.updated_before.as_ref(), "$.updated_before")?; - let ts_gte = parse_optional_rfc3339(payload.ts_gte.as_ref(), "$.ts_gte")?; - let ts_lte = parse_optional_rfc3339(payload.ts_lte.as_ref(), "$.ts_lte")?; - - if let (Some(ts_gte), Some(ts_lte)) = (ts_gte, ts_lte) - && ts_gte >= ts_lte - { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "ts_gte must be earlier than ts_lte.", - Some(vec!["$.ts_gte".to_string(), "$.ts_lte".to_string()]), - )); - } - if let (Some(updated_after), Some(updated_before)) = (updated_after, updated_before) - && updated_after >= updated_before - { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "updated_after must be earlier than updated_before.", - Some(vec!["$.updated_after".to_string(), "$.updated_before".to_string()]), - )); - } - - if payload.query.chars().count() > MAX_QUERY_CHARS { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Query is too long.", - Some(vec!["$.query".to_string()]), - )); - } - - let response = state - .service - .docs_search_l0(DocsSearchL0Request { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - caller_agent_id: ctx.agent_id, - read_profile, - query: payload.query, - scope: payload.scope, - status: payload.status, - doc_type: payload.doc_type.map(|doc_type| doc_type.as_str().to_string()), - sparse_mode: payload.sparse_mode, - domain: payload.domain, - repo: payload.repo, - agent_id: payload.agent_id, - thread_id: payload.thread_id, - updated_after: payload.updated_after, - updated_before: payload.updated_before, - ts_gte: payload.ts_gte, - ts_lte: payload.ts_lte, - top_k: payload.top_k, - candidate_k: payload.candidate_k, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/docs/excerpts", - tag = "docs", - request_body = Value, - responses( - (status = 200, description = "Document excerpt result.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn docs_excerpts_get( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - docs_excerpts_get_inner(state, headers, payload).await -} - -#[utoipa::path( - post, - path = "/v2/admin/docs/excerpts", - tag = "admin", - request_body = Value, - responses( - (status = 200, description = "Document excerpt result through the admin mirror.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_docs_excerpts_get( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - docs_excerpts_get_inner(state, headers, payload).await -} - -async fn docs_excerpts_get_inner( - state: AppState, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .docs_excerpts_get(DocsExcerptsGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - doc_id: payload.doc_id, - level: payload.level, - chunk_id: payload.chunk_id, - quote: payload.quote, - position: payload.position, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/graph/query", - tag = "graph", - request_body = Value, - responses( - (status = 200, description = "Graph facts matching the query.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn graph_query( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let as_of = parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; - let response = state - .service - .graph_query(GraphQueryRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - subject: payload.subject, - predicate: payload.predicate, - scopes: payload.scopes, - as_of, - limit: payload.limit, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/graph/report", - tag = "graph", - request_body = Value, - responses( - (status = 200, description = "Source-backed graph topic-map report.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn graph_report( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let as_of = parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; - let response = state - .service - .graph_report(GraphReportRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - subject: payload.subject, - predicate: payload.predicate, - scopes: payload.scopes, - as_of, - limit: payload.limit, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/searches", - tag = "search", - request_body = Value, - responses( - (status = 200, description = "Search session was created.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn searches_create( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - - if payload.query.chars().count() > MAX_QUERY_CHARS { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Query is too long.", - Some(vec!["$.query".to_string()]), - )); - } - if payload.top_k.unwrap_or(state.service.cfg.memory.top_k) > MAX_TOP_K { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "top_k is too large.", - Some(vec!["$.top_k".to_string()]), - )); - } - if payload.candidate_k.unwrap_or(state.service.cfg.memory.candidate_k) > MAX_CANDIDATE_K { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "candidate_k is too large.", - Some(vec!["$.candidate_k".to_string()]), - )); - } - if payload.ranking.is_some() { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Ranking overrides are only supported on admin endpoints.".to_string(), - None, - )); - } - - let mode = payload.mode; - let token_id = effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); - let build_request = || SearchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id: token_id.clone(), - read_profile, - query: payload.query.clone(), - top_k: payload.top_k, - candidate_k: payload.candidate_k, - filter: payload.filter.clone(), - payload_level: payload.payload_level.unwrap_or_default(), - record_hits: Some(false), - ranking: None, - }; - let response = match mode { - SearchMode::QuickFind => { - let response = state.service.search_quick(build_request()).await?; - - SearchCreateResponseV2 { - mode, - trace_id: response.trace_id, - search_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan: None, - } - }, - SearchMode::PlannedSearch => { - let response = state.service.search_planned(build_request()).await?; - - SearchCreateResponseV2 { - mode, - trace_id: response.trace_id, - search_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan: Some(response.query_plan), - } - }, - }; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/searches/{search_id}", - tag = "search", - params( - ("search_id" = Uuid, Path, description = "Search session ID."), - ("payload_level" = Option, Query, description = "Optional payload level."), - ("top_k" = Option, Query, description = "Optional result limit override."), - ("touch" = Option, Query, description = "Whether to extend the session TTL."), - ), - responses( - (status = 200, description = "Search session index view.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Search session was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn searches_get( - State(state): State, - headers: HeaderMap, - Path(search_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .search_session_get(SearchSessionGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - search_session_id: search_id, - payload_level: query.payload_level.unwrap_or_default(), - top_k: query.top_k, - touch: query.touch, - }) - .await?; - let mode = if response.query_plan.is_some() { - SearchMode::PlannedSearch - } else { - SearchMode::QuickFind - }; - - Ok(Json(SearchIndexResponseV2 { - mode, - trace_id: response.trace_id, - search_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan: response.query_plan, - })) -} - -#[utoipa::path( - get, - path = "/v2/searches/{search_id}/timeline", - tag = "search", - params( - ("search_id" = Uuid, Path, description = "Search session ID."), - ("payload_level" = Option, Query, description = "Optional payload level."), - ("group_by" = Option, Query, description = "Timeline grouping mode."), - ), - responses( - (status = 200, description = "Search session timeline.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Search session was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn searches_timeline( - State(state): State, - headers: HeaderMap, - Path(search_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .search_timeline(SearchTimelineRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - search_session_id: search_id, - payload_level: query.payload_level.unwrap_or_default(), - group_by: query.group_by, - }) - .await?; - - Ok(Json(SearchTimelineResponseV2 { - search_id: response.search_session_id, - expires_at: response.expires_at, - groups: response.groups, - })) -} - -#[utoipa::path( - post, - path = "/v2/searches/{search_id}/notes", - tag = "search", - params(("search_id" = Uuid, Path, description = "Search session ID.")), - request_body = Value, - responses( - (status = 200, description = "Hydrated search note details.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Search session was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn searches_notes( - State(state): State, - headers: HeaderMap, - Path(search_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - - if payload.note_ids.len() > MAX_NOTE_IDS_PER_DETAILS { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "note_ids list is too large.", - Some(vec!["$.note_ids".to_string()]), - )); - } - - let response = state - .service - .search_details(SearchDetailsRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - search_session_id: search_id, - payload_level: payload.payload_level.unwrap_or_default(), - note_ids: payload.note_ids, - record_hits: payload.record_hits, - }) - .await?; - - Ok(Json(SearchDetailsResponseV2 { - search_id: response.search_session_id, - expires_at: response.expires_at, - results: response.results, - })) -} - -#[utoipa::path( - get, - path = "/v2/notes", - tag = "notes", - params( - ("scope" = Option, Query, description = "Optional note scope filter."), - ("status" = Option, Query, description = "Optional note status filter."), - ("type" = Option, Query, description = "Optional note type filter."), - ), - responses( - (status = 200, description = "Notes visible to the caller.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .list(ListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: Some(ctx.agent_id), - scope: query.scope, - status: query.status, - r#type: query.r#type, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/notes/{note_id}", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Note details.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_get( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .get_note(NoteFetchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - patch, - path = "/v2/notes/{note_id}", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Note was updated.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_patch( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .update(UpdateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - text: payload.text, - importance: payload.importance, - confidence: payload.confidence, - ttl_days: payload.ttl_days, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - delete, - path = "/v2/notes/{note_id}", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Note was deleted.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_delete( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .delete(DeleteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/notes/{note_id}/publish", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Note was published to a shared space.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_publish( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let scope = parse_space(payload.space.as_str())?; - let role = role.map(|Extension(role)| role); - - if matches!(scope, ShareScope::OrgShared) { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .publish_note(PublishNoteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - scope, - }) - .await?; - - Ok(Json(PublishResponseV2 { - note_id: response.note_id, - space: format_scope(response.scope.as_str())?.to_string(), - })) -} - -#[utoipa::path( - post, - path = "/v2/notes/{note_id}/unpublish", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Note was returned to private scope.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn notes_unpublish( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let scope = parse_space(payload.space.as_str())?; - let role = role.map(|Extension(role)| role); - - if matches!(scope, ShareScope::OrgShared) { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .unpublish_note(UnpublishNoteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - }) - .await?; - - Ok(Json(PublishResponseV2 { - note_id: response.note_id, - space: format_scope(response.scope.as_str())?.to_string(), - })) -} - -#[utoipa::path( - get, - path = "/v2/spaces/{space}/grants", - tag = "notes", - params(("space" = String, Path, description = "Shared space name.")), - responses( - (status = 200, description = "Space grants.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn space_grants_list( - State(state): State, - headers: HeaderMap, - Path(space): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let scope = parse_space(space.as_str())?; - let response = state - .service - .space_grants_list(SpaceGrantsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope, - }) - .await?; - - Ok(Json(SpaceGrantsListResponseV2 { - grants: response - .grants - .into_iter() - .map(|item| SpaceGrantItemV2 { - space: format_space(item.scope).to_string(), - grantee_kind: item.grantee_kind, - grantee_agent_id: item.grantee_agent_id, - granted_by_agent_id: item.granted_by_agent_id, - granted_at: item.granted_at, - }) - .collect(), - })) -} - -#[utoipa::path( - post, - path = "/v2/spaces/{space}/grants", - tag = "notes", - params(("space" = String, Path, description = "Shared space name.")), - request_body = Value, - responses( - (status = 200, description = "Space grant was upserted.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn space_grant_upsert( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(space): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let scope = parse_space(space.as_str())?; - let role = role.map(|Extension(role)| role); - - if matches!(scope, ShareScope::OrgShared) { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .space_grant_upsert(SpaceGrantUpsertRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope, - grantee_kind: payload.grantee_kind, - grantee_agent_id: payload.grantee_agent_id, - }) - .await?; - - Ok(Json(SpaceGrantUpsertResponseV2 { - space: format_scope(response.scope.as_str())?.to_string(), - grantee_kind: response.grantee_kind, - grantee_agent_id: response.grantee_agent_id, - granted: response.granted, - })) -} - -#[utoipa::path( - post, - path = "/v2/spaces/{space}/grants/revoke", - tag = "notes", - params(("space" = String, Path, description = "Shared space name.")), - request_body = Value, - responses( - (status = 200, description = "Space grant was revoked.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn space_grant_revoke( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(space): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let scope = parse_space(space.as_str())?; - let role = role.map(|Extension(role)| role); - - if matches!(scope, ShareScope::OrgShared) { - require_admin_for_org_shared_writes(state.service.cfg.security.auth_mode.as_str(), role)?; - } - - let response = state - .service - .space_grant_revoke(SpaceGrantRevokeRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope, - grantee_kind: payload.grantee_kind, - grantee_agent_id: payload.grantee_agent_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/graph/predicates", - tag = "graph", - params(("scope" = Option, Query, description = "Predicate scope filter.")), - responses( - (status = 200, description = "Graph predicates.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_graph_predicates_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .admin_graph_predicates_list(AdminGraphPredicatesListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: query.scope, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - patch, - path = "/v2/admin/graph/predicates/{predicate_id}", - tag = "graph", - params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), - request_body = Value, - responses( - (status = 200, description = "Graph predicate was updated.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Predicate was not found.", body = ErrorBody), - (status = 409, description = "Predicate update conflicted.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_graph_predicate_patch( - State(state): State, - headers: HeaderMap, - Path(predicate_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let token_id = effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); - let response = state - .service - .admin_graph_predicate_patch(AdminGraphPredicatePatchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id, - predicate_id, - status: payload.status, - cardinality: payload.cardinality, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/graph/predicates/{predicate_id}/aliases", - tag = "graph", - params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), - request_body = Value, - responses( - (status = 200, description = "Graph predicate alias was added.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Predicate was not found.", body = ErrorBody), - (status = 409, description = "Predicate update conflicted.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_graph_predicate_alias_add( - State(state): State, - headers: HeaderMap, - Path(predicate_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let token_id = effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); - let response = state - .service - .admin_graph_predicate_alias_add(AdminGraphPredicateAliasAddRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id, - predicate_id, - alias: payload.alias, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/graph/predicates/{predicate_id}/aliases", - tag = "graph", - params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), - responses( - (status = 200, description = "Graph predicate aliases.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Predicate was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_graph_predicate_aliases_list( - State(state): State, - headers: HeaderMap, - Path(predicate_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_graph_predicate_aliases_list(AdminGraphPredicateAliasesListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - predicate_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/notes/{note_id}/provenance", - tag = "admin", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Note provenance bundle.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_note_provenance_get( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .note_provenance_get(NoteProvenanceGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/notes/{note_id}/history", - tag = "admin", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Memory history timeline.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_note_history_get( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .memory_history_get(MemoryHistoryGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/notes/{note_id}/corrections", - tag = "admin", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Memory correction was applied.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_note_correction_apply( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .memory_correction_apply(MemoryCorrectionRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - actor_agent_id: ctx.agent_id, - note_id, - action: payload.action, - reason: payload.reason, - source_ref: payload.source_ref, - restore_version_id: payload.restore_version_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/consolidation/runs", - tag = "consolidation", - request_body = Value, - responses( - (status = 200, description = "Consolidation run was created.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn consolidation_run_create( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .consolidation_run_create(ConsolidationRunCreateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - job_kind: payload.job_kind, - input_refs: payload.input_refs, - source_snapshot: payload.source_snapshot, - lineage: payload.lineage, - proposals: payload.proposals, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/runs", - tag = "consolidation", - params(("limit" = Option, Query, description = "Maximum runs to return.")), - responses( - (status = 200, description = "Consolidation runs.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn consolidation_runs_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .consolidation_runs_list(ConsolidationRunsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/runs/{run_id}", - tag = "consolidation", - params(("run_id" = Uuid, Path, description = "Consolidation run ID.")), - responses( - (status = 200, description = "Consolidation run.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Consolidation run was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn consolidation_run_get( - State(state): State, - headers: HeaderMap, - Path(run_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .consolidation_run_get(ConsolidationRunGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - run_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/proposals", - tag = "consolidation", - params( - ("run_id" = Option, Query, description = "Optional run filter."), - ("review_state" = Option, Query, description = "Optional review-state filter."), - ("limit" = Option, Query, description = "Maximum proposals to return."), - ), - responses( - (status = 200, description = "Consolidation proposals.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn consolidation_proposals_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .consolidation_proposals_list(ConsolidationProposalsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - run_id: query.run_id, - review_state: query.review_state, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/proposals/{proposal_id}", - tag = "consolidation", - params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), - responses( - (status = 200, description = "Consolidation proposal.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Consolidation proposal was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn consolidation_proposal_get( - State(state): State, - headers: HeaderMap, - Path(proposal_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .consolidation_proposal_get(ConsolidationProposalGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - proposal_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/consolidation/proposals/{proposal_id}/review", - tag = "consolidation", - params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), - request_body = Value, - responses( - (status = 200, description = "Consolidation proposal review action was applied.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Consolidation proposal was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn consolidation_proposal_review( - State(state): State, - headers: HeaderMap, - Path(proposal_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .consolidation_proposal_review(ConsolidationProposalReviewRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - reviewer_agent_id: ctx.agent_id, - proposal_id, - review_action: payload.action, - review_comment: payload.review_comment, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/dreaming/review-queue", - tag = "dreaming", - params( - ("run_id" = Option, Query, description = "Optional consolidation run filter."), - ("review_state" = Option, Query, description = "Optional review-state filter."), - ("limit" = Option, Query, description = "Maximum queue items to return."), - ), - responses( - (status = 200, description = "Dreaming review queue items.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn dreaming_review_queue( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .dreaming_review_queue(DreamingReviewQueueRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - run_id: query.run_id, - review_state: query.review_state, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/recall-debug/panel", - tag = "recall", - request_body = Value, - responses( - (status = 200, description = "Agent-facing cross-layer recall/debug panel.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn recall_debug_panel( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - recall_debug_panel_inner(state, headers, payload, false).await -} - -async fn admin_recall_debug_panel( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - recall_debug_panel_inner(state, headers, payload, true).await -} - -async fn recall_debug_panel_inner( - state: AppState, - headers: HeaderMap, - payload: Result, JsonRejection>, - allow_project_trace_debug: bool, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .recall_debug_panel(RecallDebugPanelRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - trace_id: payload.trace_id, - query: payload.query, - docs_query: payload.docs_query, - knowledge_query: payload.knowledge_query, - graph_subject: payload.graph_subject, - graph_predicate: payload.graph_predicate, - include_dreaming: payload.include_dreaming, - limit: payload.limit, - allow_project_trace_debug, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/rebuild", - tag = "knowledge", - request_body = Value, - responses( - (status = 200, description = "Knowledge page was rebuilt.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn knowledge_page_rebuild( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .knowledge_page_rebuild(KnowledgePageRebuildRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - page_kind: payload.page_kind, - page_key: payload.page_key, - title: payload.title, - doc_ids: payload.doc_ids, - doc_chunk_ids: payload.doc_chunk_ids, - note_ids: payload.note_ids, - event_ids: payload.event_ids, - relation_ids: payload.relation_ids, - proposal_ids: payload.proposal_ids, - provider_metadata: payload.provider_metadata, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/rebuild-changed-sources", - tag = "knowledge", - request_body = Value, - responses( - (status = 200, description = "Affected knowledge pages were rebuilt.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn knowledge_pages_watch_rebuild( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let changed_sources = payload - .changed_sources - .into_iter() - .map(|source| KnowledgePageChangedSource { - source_kind: source.source_kind, - source_id: source.source_id, - }) - .collect(); - let response = state - .service - .knowledge_pages_watch_rebuild(KnowledgePageWatchRebuildRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - changed_sources, - page_kind: payload.page_kind, - limit: payload.limit, - generate_memory_candidates: payload.generate_memory_candidates.unwrap_or(true), - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/knowledge/pages", - tag = "knowledge", - params( - ("page_kind" = Option, Query, description = "Optional page-kind filter."), - ("limit" = Option, Query, description = "Maximum pages to return."), - ), - responses( - (status = 200, description = "Knowledge pages.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn knowledge_pages_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .knowledge_pages_list(KnowledgePagesListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - page_kind: query.page_kind, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/search", - tag = "knowledge", - request_body = Value, - responses( - (status = 200, description = "Knowledge page section search results.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn knowledge_pages_search( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .knowledge_pages_search(KnowledgePageSearchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - query: payload.query, - page_kind: payload.page_kind, - limit: payload.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/knowledge/pages/{page_id}", - tag = "knowledge", - params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), - responses( - (status = 200, description = "Knowledge page.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Knowledge page was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn knowledge_page_get( - State(state): State, - headers: HeaderMap, - Path(page_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .knowledge_page_get(KnowledgePageGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - page_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/{page_id}/lint", - tag = "knowledge", - params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), - responses( - (status = 200, description = "Knowledge page lint findings.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Knowledge page was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn knowledge_page_lint( - State(state): State, - headers: HeaderMap, - Path(page_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .knowledge_page_lint(KnowledgePageLintRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - page_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles", - tag = "admin", - responses( - (status = 200, description = "Ingestion profile versions.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_ingestion_profiles_list( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_ingestion_profiles_list(AdminIngestionProfileListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/events/ingestion-profiles", - tag = "admin", - request_body = Value, - responses( - (status = 200, description = "Ingestion profile version was created.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_ingestion_profile_create( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .admin_ingestion_profile_create(AdminIngestionProfileCreateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id: payload.profile_id, - version: payload.version, - profile: payload.profile, - created_by: payload.created_by, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles/{profile_id}", - tag = "admin", - params( - ("profile_id" = String, Path, description = "Ingestion profile ID."), - ("version" = Option, Query, description = "Optional profile version."), - ), - responses( - (status = 200, description = "Ingestion profile version.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Profile was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_ingestion_profile_get( - State(state): State, - headers: HeaderMap, - Path(profile_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .admin_ingestion_profile_get(AdminIngestionProfileGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id, - version: query.version, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles/{profile_id}/versions", - tag = "admin", - params(("profile_id" = String, Path, description = "Ingestion profile ID.")), - responses( - (status = 200, description = "Versions for one ingestion profile.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_ingestion_profile_versions_list( - State(state): State, - headers: HeaderMap, - Path(profile_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_ingestion_profile_versions_list(AdminIngestionProfileVersionsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles/default", - tag = "admin", - responses( - ( - status = 200, - description = "Default add_event ingestion profile pointer.", - body = AdminIngestionProfileDefaultResponseV2, - ), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_ingestion_profile_default_get( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_ingestion_profile_default_get(AdminIngestionProfileDefaultGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - put, - path = "/v2/admin/events/ingestion-profiles/default", - tag = "admin", - request_body = AdminIngestionProfileDefaultSetBody, - responses( - ( - status = 200, - description = "Default add_event ingestion profile pointer was updated.", - body = AdminIngestionProfileDefaultResponseV2, - ), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Profile was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn admin_ingestion_profile_default_set( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - let response = state - .service - .admin_ingestion_profile_default_set(AdminIngestionProfileDefaultSetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id: payload.profile_id, - version: payload.version, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/qdrant/rebuild", - tag = "admin", - responses( - (status = 200, description = "Qdrant rebuild report.", body = Value), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn rebuild_qdrant(State(state): State) -> Result, ApiError> { - let response = state.service.rebuild_qdrant().await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/searches/raw", - tag = "search", - request_body = Value, - responses( - (status = 200, description = "Raw admin search response.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn searches_raw( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) - })?; - - if payload.query.chars().count() > MAX_QUERY_CHARS { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Query is too long.", - Some(vec!["$.query".to_string()]), - )); - } - if payload.top_k.unwrap_or(state.service.cfg.memory.top_k) > MAX_TOP_K { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "top_k is too large.", - Some(vec!["$.top_k".to_string()]), - )); - } - if payload.candidate_k.unwrap_or(state.service.cfg.memory.candidate_k) > MAX_CANDIDATE_K { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "candidate_k is too large.", - Some(vec!["$.candidate_k".to_string()]), - )); - } - - let request = SearchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id: effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers), - read_profile, - query: payload.query, - filter: payload.filter, - payload_level: payload.payload_level.unwrap_or_default(), - top_k: payload.top_k, - candidate_k: payload.candidate_k, - record_hits: Some(false), - ranking: payload.ranking, - }; - let response = match payload.mode { - SearchMode::QuickFind => state.service.search_raw_quick(request).await?, - SearchMode::PlannedSearch => { - let response = state.service.search_raw_planned(request).await?; - - SearchResponse { - trace_id: response.trace_id, - items: response.items, - trajectory_summary: response.trajectory_summary, - } - }, - }; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/traces/{trace_id}", - tag = "admin", - params(("trace_id" = Uuid, Path, description = "Search trace ID.")), - responses( - (status = 200, description = "Search trace bundle without full stage internals.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn trace_get( - State(state): State, - headers: HeaderMap, - Path(trace_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .trace_get(TraceGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - trace_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/traces/recent", - tag = "admin", - params( - ("limit" = Option, Query, description = "Page size."), - ("cursor_created_at" = Option, Query, description = "Created-at page cursor."), - ("cursor_trace_id" = Option, Query, description = "Trace ID page cursor."), - ("agent_id" = Option, Query, description = "Optional trace creator filter."), - ("read_profile" = Option, Query, description = "Optional read profile filter."), - ("created_after" = Option, Query, description = "Strict lower created_at bound."), - ("created_before" = Option, Query, description = "Strict upper created_at bound."), - ), - responses( - (status = 200, description = "Recent search traces.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn trace_recent_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let cursor_created_at = - parse_optional_rfc3339(query.cursor_created_at.as_ref(), "$.cursor_created_at")?; - let cursor_trace_id = query.cursor_trace_id; - let created_after = parse_optional_rfc3339(query.created_after.as_ref(), "$.created_after")?; - let created_before = parse_optional_rfc3339(query.created_before.as_ref(), "$.created_before")?; - - if cursor_created_at.is_some() != cursor_trace_id.is_some() { - return Err(json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "cursor_created_at and cursor_trace_id must be both set or both omitted.".to_string(), - Some(vec!["$.cursor_created_at".to_string(), "$.cursor_trace_id".to_string()]), - )); - } - - let response = state - .service - .trace_recent_list(TraceRecentListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - limit: query.limit, - cursor_created_at, - cursor_trace_id, - agent_id_filter: query.agent_id, - read_profile: query.read_profile, - created_after, - created_before, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/trajectories/{trace_id}", - tag = "admin", - params(("trace_id" = Uuid, Path, description = "Search trace ID.")), - responses( - (status = 200, description = "Search trace retrieval trajectory.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn trace_trajectory_get( - State(state): State, - headers: HeaderMap, - Path(trace_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .trace_trajectory_get(TraceTrajectoryGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - trace_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/trace-items/{item_id}", - tag = "admin", - params(("item_id" = Uuid, Path, description = "Trace item/result handle ID.")), - responses( - (status = 200, description = "Search trace item explain payload.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace item was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn trace_item_get( - State(state): State, - headers: HeaderMap, - Path(item_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .search_explain(SearchExplainRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - result_handle: item_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/traces/{trace_id}/bundle", - tag = "admin", - params( - ("trace_id" = Uuid, Path, description = "Search trace ID."), - ("mode" = Option, Query, description = "bounded or full."), - ("stage_items_limit" = Option, Query, description = "Maximum stage items."), - ("candidates_limit" = Option, Query, description = "Maximum candidate snapshot items."), - ), - responses( - (status = 200, description = "Search trace bundle.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -async fn trace_bundle_get( - State(state): State, - headers: HeaderMap, - Path(trace_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .trace_bundle_get(TraceBundleGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - trace_id, - mode: query.mode.unwrap_or_default(), - stage_items_limit: query.stage_items_limit, - candidates_limit: query.candidates_limit, - }) - .await?; - - Ok(Json(response)) -} - #[cfg(test)] -mod tests { - use axum::http::HeaderMap; - use uuid::Uuid; - - use crate::routes::{ - self, ADMIN_VIEWER_PATH, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, - HEADER_READ_PROFILE, HEADER_REQUEST_ID, HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, - }; - use elf_config::{SecurityAuthKey, SecurityAuthRole}; - - #[test] - fn require_admin_for_org_shared_writes_denies_user_in_static_keys_mode() { - let err = routes::require_admin_for_org_shared_writes( - "static_keys", - Some(SecurityAuthRole::User), - ) - .expect_err("Expected forbidden error for non-admin role."); - - assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); - } - - #[test] - fn require_admin_for_org_shared_writes_allows_admin_in_static_keys_mode() { - routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::Admin)) - .expect("Expected admin role to be allowed."); - } - - #[test] - fn require_admin_for_org_shared_writes_allows_superadmin_in_static_keys_mode() { - routes::require_admin_for_org_shared_writes( - "static_keys", - Some(SecurityAuthRole::SuperAdmin), - ) - .expect("Expected superadmin role to be allowed."); - } - - #[test] - fn require_admin_for_org_shared_writes_allows_non_static_keys_auth_mode() { - routes::require_admin_for_org_shared_writes("off", None) - .expect("Expected auth_mode != static_keys."); - } - - #[test] - fn admin_viewer_uses_admin_operator_routes_without_raw_memory_bypasses() { - let html = routes::VIEWER_HTML; - - assert_eq!(ADMIN_VIEWER_PATH, "/viewer"); - assert!(html.contains("/v2/admin/searches")); - assert!(html.contains("/v2/admin/docs/search/l0")); - assert!(html.contains("/v2/admin/docs/excerpts")); - assert!(html.contains("/v2/admin/docs/${encodeURIComponent(item.doc_id)}")); - assert!(html.contains("/v2/admin/dreaming/review-queue")); - assert!(html.contains( - "/v2/admin/consolidation/proposals/${encodeURIComponent(proposalId)}/review" - )); - assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/history")); - assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/corrections")); - assert!(html.contains("/v2/admin/recall-debug/panel")); - assert!(html.contains("/v2/admin/traces/recent")); - assert!(html.contains("/v2/admin/traces/${encodeURIComponent(traceId)}/bundle")); - assert!(html.contains("/v2/admin/notes/")); - assert!(html.contains("/v2/admin/knowledge/pages/search")); - assert!(html.contains("mode: \"full\"")); - assert!(html.contains("candidates_limit: 200")); - assert!(html.contains("Replay Candidates")); - assert!(html.contains("Selected Final Results")); - assert!(html.contains("Providers And Ranking")); - assert!(html.contains("Relation Context")); - assert!(html.contains("Knowledge Page Snippets")); - assert!(html.contains("Derived page: source documents")); - assert!(html.contains("Source Library")); - assert!(html.contains("Memory Inbox")); - assert!(html.contains("Memory History")); - assert!(html.contains("Recall Debug")); - assert!(html.contains("Apply Ledger Correction")); - assert!(html.contains("Apply / Supersede")); - assert!(html.contains("directTraceId")); - assert!(html.contains("trace_id")); - assert!(html.contains("loadInitialTrace")); - assert!(!html.contains("method: \"PATCH\"")); - assert!(!html.contains("method: \"PUT\"")); - assert!(!html.contains("method: \"DELETE\"")); - assert!(!html.contains("/v2/notes/ingest")); - assert!(!html.contains("/v2/events/ingest")); - assert!(!html.contains("/publish")); - } - - #[test] - fn resolve_auth_key_requires_bearer_header() { - let headers = HeaderMap::new(); - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let err = - routes::resolve_auth_key(&headers, &keys).expect_err("Expected unauthorized error."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); - } - - #[test] - fn resolve_auth_key_rejects_unknown_token() { - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "Bearer wrong".parse().expect("invalid header")); - - let err = routes::resolve_auth_key(&headers, &keys) - .expect_err("Expected unauthorized error for bad key."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); - } - - #[test] - fn resolve_auth_key_rejects_non_bearer_authorization() { - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "Token secret".parse().expect("invalid header")); - - let err = routes::resolve_auth_key(&headers, &keys) - .expect_err("Expected unauthorized error for non-bearer authorization."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); - } - - #[test] - fn resolve_auth_key_rejects_lowercase_bearer_prefix() { - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "bearer secret".parse().expect("invalid header")); - - let err = routes::resolve_auth_key(&headers, &keys) - .expect_err("Expected unauthorized error for lowercase bearer prefix."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); - } - - #[test] - fn apply_auth_key_context_overrides_headers() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "Bearer old".parse().expect("invalid header")); - headers.insert(HEADER_TENANT_ID, "bad-tenant".parse().expect("invalid header")); - headers.insert(HEADER_PROJECT_ID, "bad-project".parse().expect("invalid header")); - headers.insert(HEADER_AGENT_ID, "bad-agent".parse().expect("invalid header")); - headers.insert(HEADER_READ_PROFILE, "private_only".parse().expect("invalid header")); - headers.insert(HEADER_TRUSTED_TOKEN_ID, "old-id".parse().expect("invalid header")); - - let key = SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "all_scopes".to_string(), - role: SecurityAuthRole::Admin, - }; - - routes::apply_auth_key_context(&mut headers, &key).expect("Expected context injection."); - - assert_eq!( - headers.get(HEADER_TENANT_ID).and_then(|v| v.to_str().ok()).expect("missing tenant"), - "t" - ); - assert_eq!( - headers.get(HEADER_PROJECT_ID).and_then(|v| v.to_str().ok()).expect("missing project"), - "p" - ); - assert_eq!( - headers.get(HEADER_AGENT_ID).and_then(|v| v.to_str().ok()).expect("missing agent"), - "a" - ); - assert_eq!( - headers - .get(HEADER_READ_PROFILE) - .and_then(|v| v.to_str().ok()) - .expect("missing read profile"), - "all_scopes" - ); - assert_eq!( - headers - .get(HEADER_TRUSTED_TOKEN_ID) - .and_then(|v| v.to_str().ok()) - .expect("missing trusted token_id"), - "k1" - ); - } - - #[test] - fn apply_auth_key_context_requires_agent_scope() { - let mut headers = HeaderMap::new(); - let key = SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: None, - read_profile: "all_scopes".to_string(), - role: SecurityAuthRole::User, - }; - let err = routes::apply_auth_key_context(&mut headers, &key) - .expect_err("Expected forbidden error for missing agent_id."); - - assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); - } - - #[test] - fn effective_token_id_ignores_header_when_auth_mode_off() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); - - assert_eq!(routes::effective_token_id("off", &headers), None); - } - - #[test] - fn effective_token_id_uses_header_when_auth_mode_static_keys() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_TRUSTED_TOKEN_ID, "k1".parse().expect("invalid header")); - - assert_eq!(routes::effective_token_id("static_keys", &headers), Some("k1".to_string())); - } - - #[test] - fn sanitize_trusted_token_header_removes_header() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); - - routes::sanitize_trusted_token_header(&mut headers); - - assert!(headers.get(HEADER_TRUSTED_TOKEN_ID).is_none()); - } - - #[test] - fn parse_request_id_from_headers_generates_when_missing() { - let headers = HeaderMap::new(); - let request_id = routes::parse_request_id_from_headers(&headers) - .expect("Expected a generated request ID when header is missing."); - - assert_ne!(request_id.to_string(), Uuid::nil().to_string()); - } - - #[test] - fn parse_request_id_from_headers_rejects_invalid() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_REQUEST_ID, "not-a-uuid".parse().expect("invalid request_id")); - - let err = routes::parse_request_id_from_headers(&headers) - .expect_err("Expected invalid request_id to be rejected."); - - assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); - assert_eq!(err.error_code, "INVALID_REQUEST"); - assert_eq!(err.fields, Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")])); - } - - #[test] - fn inject_request_id_into_json_body_adds_request_id_to_object() { - let request_id = - Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); - let body = serde_json::json!({"note_id":"abc","status":"ok"}).to_string(); - let response_body = routes::inject_request_id_into_json_body(body.as_bytes(), &request_id) - .expect("Expected request_id field to be injected."); - let response_value = serde_json::from_slice::(&response_body) - .expect("Expected valid JSON"); - - assert_eq!(response_value["request_id"], request_id.to_string()); - } - - #[test] - fn inject_request_id_into_json_body_skips_non_object() { - let request_id = - Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); - let body = serde_json::json!(["a", "b", "c"]).to_string(); - - assert!(routes::inject_request_id_into_json_body(body.as_bytes(), &request_id).is_none()); - } -} +#[path = "routes/tests.rs"] +mod tests; diff --git a/apps/elf-api/src/routes/admin_notes.rs b/apps/elf-api/src/routes/admin_notes.rs new file mode 100644 index 00000000..c06d0630 --- /dev/null +++ b/apps/elf-api/src/routes/admin_notes.rs @@ -0,0 +1,119 @@ +use crate::routes::{ + self, AdminNoteCorrectionBody, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, + MemoryCorrectionRequest, MemoryCorrectionResponse, MemoryHistoryGetRequest, + MemoryHistoryResponse, NoteProvenanceBundleResponse, NoteProvenanceGetRequest, Path, + RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/notes/{note_id}/provenance", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note provenance bundle.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_note_provenance_get( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .note_provenance_get(NoteProvenanceGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/notes/{note_id}/history", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Memory history timeline.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_note_history_get( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .memory_history_get(MemoryHistoryGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/notes/{note_id}/corrections", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Memory correction was applied.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_note_correction_apply( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .memory_correction_apply(MemoryCorrectionRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + actor_agent_id: ctx.agent_id, + note_id, + action: payload.action, + reason: payload.reason, + source_ref: payload.source_ref, + restore_version_id: payload.restore_version_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/admin_ops.rs b/apps/elf-api/src/routes/admin_ops.rs new file mode 100644 index 00000000..ec3d5272 --- /dev/null +++ b/apps/elf-api/src/routes/admin_ops.rs @@ -0,0 +1,20 @@ +use crate::routes::{ApiError, AppState, ErrorBody, Json, RebuildReport, State}; + +#[utoipa::path( + post, + path = "/v2/admin/qdrant/rebuild", + tag = "admin", + responses( + (status = 200, description = "Qdrant rebuild report.", body = Value), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn rebuild_qdrant( + State(state): State, +) -> Result, ApiError> { + let response = state.service.rebuild_qdrant().await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/consolidation.rs b/apps/elf-api/src/routes/consolidation.rs new file mode 100644 index 00000000..3b4a054d --- /dev/null +++ b/apps/elf-api/src/routes/consolidation.rs @@ -0,0 +1,255 @@ +use crate::routes::{ + self, ApiError, AppState, ConsolidationProposalGetRequest, ConsolidationProposalResponse, + ConsolidationProposalReviewBody, ConsolidationProposalReviewRequest, + ConsolidationProposalsListQuery, ConsolidationProposalsListRequest, + ConsolidationProposalsListResponse, ConsolidationRunCreateBody, ConsolidationRunCreateRequest, + ConsolidationRunCreateResponse, ConsolidationRunGetRequest, ConsolidationRunResponse, + ConsolidationRunsListQuery, ConsolidationRunsListRequest, ConsolidationRunsListResponse, + ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, RequestContext, State, + StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/admin/consolidation/runs", + tag = "consolidation", + request_body = Value, + responses( + (status = 200, description = "Consolidation run was created.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn consolidation_run_create( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .consolidation_run_create(ConsolidationRunCreateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + job_kind: payload.job_kind, + input_refs: payload.input_refs, + source_snapshot: payload.source_snapshot, + lineage: payload.lineage, + proposals: payload.proposals, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/runs", + tag = "consolidation", + params(("limit" = Option, Query, description = "Maximum runs to return.")), + responses( + (status = 200, description = "Consolidation runs.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn consolidation_runs_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .consolidation_runs_list(ConsolidationRunsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/runs/{run_id}", + tag = "consolidation", + params(("run_id" = Uuid, Path, description = "Consolidation run ID.")), + responses( + (status = 200, description = "Consolidation run.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Consolidation run was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn consolidation_run_get( + State(state): State, + headers: HeaderMap, + Path(run_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .consolidation_run_get(ConsolidationRunGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + run_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/proposals", + tag = "consolidation", + params( + ("run_id" = Option, Query, description = "Optional run filter."), + ("review_state" = Option, Query, description = "Optional review-state filter."), + ("limit" = Option, Query, description = "Maximum proposals to return."), + ), + responses( + (status = 200, description = "Consolidation proposals.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn consolidation_proposals_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .consolidation_proposals_list(ConsolidationProposalsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + run_id: query.run_id, + review_state: query.review_state, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/proposals/{proposal_id}", + tag = "consolidation", + params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), + responses( + (status = 200, description = "Consolidation proposal.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Consolidation proposal was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn consolidation_proposal_get( + State(state): State, + headers: HeaderMap, + Path(proposal_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .consolidation_proposal_get(ConsolidationProposalGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + proposal_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/consolidation/proposals/{proposal_id}/review", + tag = "consolidation", + params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), + request_body = Value, + responses( + (status = 200, description = "Consolidation proposal review action was applied.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Consolidation proposal was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn consolidation_proposal_review( + State(state): State, + headers: HeaderMap, + Path(proposal_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .consolidation_proposal_review(ConsolidationProposalReviewRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + reviewer_agent_id: ctx.agent_id, + proposal_id, + review_action: payload.action, + review_comment: payload.review_comment, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/core_memory.rs b/apps/elf-api/src/routes/core_memory.rs new file mode 100644 index 00000000..a863aee7 --- /dev/null +++ b/apps/elf-api/src/routes/core_memory.rs @@ -0,0 +1,229 @@ +use crate::routes::{ + self, ApiError, AppState, CoreBlockAttachBody, CoreBlockAttachRequest, CoreBlockAttachResponse, + CoreBlockDetachRequest, CoreBlockDetachResponse, CoreBlockUpsertBody, CoreBlockUpsertRequest, + CoreBlockUpsertResponse, CoreBlocksGetRequest, CoreBlocksResponse, EntityMemoryQuery, + EntityMemoryViewRequest, EntityMemoryViewResponse, ErrorBody, Extension, HeaderMap, Json, + JsonRejection, Path, Query, QueryRejection, RequestContext, SecurityAuthRole, State, + StatusCode, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/core-blocks", + tag = "core_blocks", + responses( + (status = 200, description = "Attached core memory blocks.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn core_blocks_get( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let response = state + .service + .core_blocks_get(CoreBlocksGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/entity-memory", + tag = "graph", + params( + ("entity_id" = Option, Query, description = "Graph entity id. Exactly one of entity_id or entity_surface is required."), + ("entity_surface" = Option, Query, description = "Canonical or alias entity surface. Exactly one of entity_id or entity_surface is required."), + ), + responses( + (status = 200, description = "Entity-scoped memory authority view.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Entity was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn entity_memory_get( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + ApiError::new( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .entity_memory_view(EntityMemoryViewRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + entity_id: query.entity_id, + entity_surface: query.entity_surface, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/core-blocks", + tag = "core_blocks", + request_body = Value, + responses( + (status = 200, description = "Core block was stored.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 409, description = "Core block conflict.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_core_block_upsert( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.trim() == "org_shared" { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .core_block_upsert(CoreBlockUpsertRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + block_id: payload.block_id, + scope: payload.scope, + key: payload.key, + title: payload.title, + content: payload.content, + source_ref: payload.source_ref, + reason: payload.reason, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/core-blocks/{block_id}/attachments", + tag = "core_blocks", + params(("block_id" = Uuid, Path, description = "Core block ID.")), + request_body = Value, + responses( + (status = 200, description = "Core block was attached.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Core block was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_core_block_attach( + State(state): State, + headers: HeaderMap, + Path(block_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .core_block_attach(CoreBlockAttachRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + block_id, + target_agent_id: payload.target_agent_id, + read_profile: payload.read_profile, + reason: payload.reason, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/v2/admin/core-blocks/attachments/{attachment_id}", + tag = "core_blocks", + params(("attachment_id" = Uuid, Path, description = "Core block attachment ID.")), + responses( + (status = 200, description = "Core block attachment was detached.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_core_block_detach( + State(state): State, + headers: HeaderMap, + Path(attachment_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .core_block_detach(CoreBlockDetachRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + attachment_id, + reason: None, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/docs.rs b/apps/elf-api/src/routes/docs.rs new file mode 100644 index 00000000..3e336289 --- /dev/null +++ b/apps/elf-api/src/routes/docs.rs @@ -0,0 +1,388 @@ +use crate::routes::{ + self, ApiError, AppState, DOC_STATUSES, DocsDeleteRequest, DocsDeleteResponse, + DocsExcerptResponse, DocsExcerptsGetBody, DocsExcerptsGetRequest, DocsGetRequest, + DocsGetResponse, DocsPutBody, DocsPutRequest, DocsPutResponse, DocsSearchL0Body, + DocsSearchL0Request, DocsSearchL0Response, ErrorBody, Extension, HeaderMap, Json, + JsonRejection, MAX_QUERY_CHARS, Path, RequestContext, SecurityAuthRole, State, StatusCode, + Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/docs", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "Document was stored.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn docs_put( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.trim() == "org_shared" { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .docs_put(DocsPutRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: payload.scope, + doc_type: payload.doc_type.map(|doc_type| doc_type.as_str().to_string()), + title: payload.title, + source_ref: payload.source_ref, + write_policy: payload.write_policy, + content: payload.content, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/docs/{doc_id}", + tag = "docs", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was fetched.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn docs_get( + State(state): State, + headers: HeaderMap, + Path(doc_id): Path, +) -> Result, ApiError> { + docs_get_inner(state, headers, doc_id).await +} + +#[utoipa::path( + get, + path = "/v2/admin/docs/{doc_id}", + tag = "admin", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was fetched through the admin mirror.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_docs_get( + State(state): State, + headers: HeaderMap, + Path(doc_id): Path, +) -> Result, ApiError> { + docs_get_inner(state, headers, doc_id).await +} + +pub(super) async fn docs_get_inner( + state: AppState, + headers: HeaderMap, + doc_id: Uuid, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let response = state + .service + .docs_get(DocsGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + doc_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/v2/docs/{doc_id}", + tag = "docs", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was deleted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn docs_delete( + State(state): State, + headers: HeaderMap, + Path(doc_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .docs_delete(DocsDeleteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + doc_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/docs/search/l0", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "L0 document search results.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn docs_search_l0( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_search_l0_inner(state, headers, payload).await +} + +#[utoipa::path( + post, + path = "/v2/admin/docs/search/l0", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "L0 document search results through the admin mirror.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_docs_search_l0( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_search_l0_inner(state, headers, payload).await +} + +pub(super) async fn docs_search_l0_inner( + state: AppState, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(mut payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let status = payload.status.as_deref().map(str::trim).filter(|status| !status.is_empty()); + + if let Some(status) = status { + let status = status.to_lowercase(); + + if !DOC_STATUSES.contains(&status.as_str()) { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "status must be one of: active|deleted.", + Some(vec!["$.status".to_string()]), + )); + } + + payload.status = Some(status); + } + + let updated_after = + routes::parse_optional_rfc3339(payload.updated_after.as_ref(), "$.updated_after")?; + let updated_before = + routes::parse_optional_rfc3339(payload.updated_before.as_ref(), "$.updated_before")?; + let ts_gte = routes::parse_optional_rfc3339(payload.ts_gte.as_ref(), "$.ts_gte")?; + let ts_lte = routes::parse_optional_rfc3339(payload.ts_lte.as_ref(), "$.ts_lte")?; + + if let (Some(ts_gte), Some(ts_lte)) = (ts_gte, ts_lte) + && ts_gte >= ts_lte + { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "ts_gte must be earlier than ts_lte.", + Some(vec!["$.ts_gte".to_string(), "$.ts_lte".to_string()]), + )); + } + if let (Some(updated_after), Some(updated_before)) = (updated_after, updated_before) + && updated_after >= updated_before + { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "updated_after must be earlier than updated_before.", + Some(vec!["$.updated_after".to_string(), "$.updated_before".to_string()]), + )); + } + + if payload.query.chars().count() > MAX_QUERY_CHARS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Query is too long.", + Some(vec!["$.query".to_string()]), + )); + } + + let response = state + .service + .docs_search_l0(DocsSearchL0Request { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + caller_agent_id: ctx.agent_id, + read_profile, + query: payload.query, + scope: payload.scope, + status: payload.status, + doc_type: payload.doc_type.map(|doc_type| doc_type.as_str().to_string()), + sparse_mode: payload.sparse_mode, + domain: payload.domain, + repo: payload.repo, + agent_id: payload.agent_id, + thread_id: payload.thread_id, + updated_after: payload.updated_after, + updated_before: payload.updated_before, + ts_gte: payload.ts_gte, + ts_lte: payload.ts_lte, + top_k: payload.top_k, + candidate_k: payload.candidate_k, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/docs/excerpts", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "Document excerpt result.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn docs_excerpts_get( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_excerpts_get_inner(state, headers, payload).await +} + +#[utoipa::path( + post, + path = "/v2/admin/docs/excerpts", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "Document excerpt result through the admin mirror.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_docs_excerpts_get( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_excerpts_get_inner(state, headers, payload).await +} + +pub(super) async fn docs_excerpts_get_inner( + state: AppState, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .docs_excerpts_get(DocsExcerptsGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + doc_id: payload.doc_id, + level: payload.level, + chunk_id: payload.chunk_id, + quote: payload.quote, + position: payload.position, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/dreaming.rs b/apps/elf-api/src/routes/dreaming.rs new file mode 100644 index 00000000..81087050 --- /dev/null +++ b/apps/elf-api/src/routes/dreaming.rs @@ -0,0 +1,52 @@ +use crate::routes::{ + self, ApiError, AppState, DreamingReviewQueueQuery, DreamingReviewQueueRequest, + DreamingReviewQueueResponse, ErrorBody, HeaderMap, Json, Query, QueryRejection, RequestContext, + State, StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/admin/dreaming/review-queue", + tag = "dreaming", + params( + ("run_id" = Option, Query, description = "Optional consolidation run filter."), + ("review_state" = Option, Query, description = "Optional review-state filter."), + ("limit" = Option, Query, description = "Maximum queue items to return."), + ), + responses( + (status = 200, description = "Dreaming review queue items.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn dreaming_review_queue( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .dreaming_review_queue(DreamingReviewQueueRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + run_id: query.run_id, + review_state: query.review_state, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/events.rs b/apps/elf-api/src/routes/events.rs new file mode 100644 index 00000000..257d5519 --- /dev/null +++ b/apps/elf-api/src/routes/events.rs @@ -0,0 +1,80 @@ +use crate::routes::{ + self, AddEventRequest, AddEventResponse, ApiError, AppState, ErrorBody, EventsIngestRequest, + Extension, HeaderMap, Json, JsonRejection, MAX_MESSAGE_CHARS, MAX_MESSAGES_PER_EVENT, + RequestContext, SecurityAuthRole, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/events/ingest", + tag = "events", + request_body = Value, + responses( + (status = 200, description = "Event messages were processed.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn events_ingest( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.as_deref().map(str::trim) == Some("org_shared") { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + if payload.messages.len() > MAX_MESSAGES_PER_EVENT { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Messages list is too large.", + Some(vec!["$.messages".to_string()]), + )); + } + + for (idx, msg) in payload.messages.iter().enumerate() { + if msg.content.chars().count() > MAX_MESSAGE_CHARS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Message content is too long.", + Some(vec![format!("$.messages[{idx}].content")]), + )); + } + } + + let response = state + .service + .add_event(AddEventRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: payload.scope, + dry_run: payload.dry_run, + ingestion_profile: payload.ingestion_profile, + messages: payload.messages, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/graph.rs b/apps/elf-api/src/routes/graph.rs new file mode 100644 index 00000000..9dcf3004 --- /dev/null +++ b/apps/elf-api/src/routes/graph.rs @@ -0,0 +1,288 @@ +use crate::routes::{ + self, AdminGraphPredicateAliasAddBody, AdminGraphPredicateAliasAddRequest, + AdminGraphPredicateAliasesListRequest, AdminGraphPredicateAliasesResponse, + AdminGraphPredicatePatchBody, AdminGraphPredicatePatchRequest, AdminGraphPredicateResponse, + AdminGraphPredicatesListQuery, AdminGraphPredicatesListRequest, + AdminGraphPredicatesListResponse, ApiError, AppState, ErrorBody, GraphQueryBody, + GraphQueryRequest, GraphQueryResponse, GraphReportBody, GraphReportRequest, + GraphReportResponse, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, + RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/graph/query", + tag = "graph", + request_body = Value, + responses( + (status = 200, description = "Graph facts matching the query.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn graph_query( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let as_of = routes::parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; + let response = state + .service + .graph_query(GraphQueryRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + subject: payload.subject, + predicate: payload.predicate, + scopes: payload.scopes, + as_of, + limit: payload.limit, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/graph/report", + tag = "graph", + request_body = Value, + responses( + (status = 200, description = "Source-backed graph topic-map report.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn graph_report( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let as_of = routes::parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; + let response = state + .service + .graph_report(GraphReportRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + subject: payload.subject, + predicate: payload.predicate, + scopes: payload.scopes, + as_of, + limit: payload.limit, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/graph/predicates", + tag = "graph", + params(("scope" = Option, Query, description = "Predicate scope filter.")), + responses( + (status = 200, description = "Graph predicates.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_graph_predicates_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .admin_graph_predicates_list(AdminGraphPredicatesListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: query.scope, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + patch, + path = "/v2/admin/graph/predicates/{predicate_id}", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + request_body = Value, + responses( + (status = 200, description = "Graph predicate was updated.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 409, description = "Predicate update conflicted.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_graph_predicate_patch( + State(state): State, + headers: HeaderMap, + Path(predicate_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let token_id = + routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); + let response = state + .service + .admin_graph_predicate_patch(AdminGraphPredicatePatchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id, + predicate_id, + status: payload.status, + cardinality: payload.cardinality, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/graph/predicates/{predicate_id}/aliases", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + request_body = Value, + responses( + (status = 200, description = "Graph predicate alias was added.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 409, description = "Predicate update conflicted.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_graph_predicate_alias_add( + State(state): State, + headers: HeaderMap, + Path(predicate_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let token_id = + routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); + let response = state + .service + .admin_graph_predicate_alias_add(AdminGraphPredicateAliasAddRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id, + predicate_id, + alias: payload.alias, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/graph/predicates/{predicate_id}/aliases", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + responses( + (status = 200, description = "Graph predicate aliases.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_graph_predicate_aliases_list( + State(state): State, + headers: HeaderMap, + Path(predicate_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_graph_predicate_aliases_list(AdminGraphPredicateAliasesListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + predicate_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/health.rs b/apps/elf-api/src/routes/health.rs new file mode 100644 index 00000000..fe3ec899 --- /dev/null +++ b/apps/elf-api/src/routes/health.rs @@ -0,0 +1,11 @@ +use crate::routes::StatusCode; + +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses((status = 200, description = "API process is healthy.")) +)] +pub(super) async fn health() -> StatusCode { + StatusCode::OK +} diff --git a/apps/elf-api/src/routes/ingestion_profiles.rs b/apps/elf-api/src/routes/ingestion_profiles.rs new file mode 100644 index 00000000..8b5371ce --- /dev/null +++ b/apps/elf-api/src/routes/ingestion_profiles.rs @@ -0,0 +1,240 @@ +use crate::routes::{ + self, AdminIngestionProfileCreateBody, AdminIngestionProfileCreateRequest, + AdminIngestionProfileDefaultGetRequest, AdminIngestionProfileDefaultResponse, + AdminIngestionProfileDefaultResponseV2, AdminIngestionProfileDefaultSetBody, + AdminIngestionProfileDefaultSetRequest, AdminIngestionProfileGetQuery, + AdminIngestionProfileGetRequest, AdminIngestionProfileListRequest, + AdminIngestionProfileResponse, AdminIngestionProfileVersionsListRequest, + AdminIngestionProfileVersionsListResponse, AdminIngestionProfilesListResponse, ApiError, + AppState, ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, + RequestContext, State, StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles", + tag = "admin", + responses( + (status = 200, description = "Ingestion profile versions.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_ingestion_profiles_list( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_ingestion_profiles_list(AdminIngestionProfileListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/events/ingestion-profiles", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "Ingestion profile version was created.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_ingestion_profile_create( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .admin_ingestion_profile_create(AdminIngestionProfileCreateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id: payload.profile_id, + version: payload.version, + profile: payload.profile, + created_by: payload.created_by, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/{profile_id}", + tag = "admin", + params( + ("profile_id" = String, Path, description = "Ingestion profile ID."), + ("version" = Option, Query, description = "Optional profile version."), + ), + responses( + (status = 200, description = "Ingestion profile version.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Profile was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_ingestion_profile_get( + State(state): State, + headers: HeaderMap, + Path(profile_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .admin_ingestion_profile_get(AdminIngestionProfileGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id, + version: query.version, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/{profile_id}/versions", + tag = "admin", + params(("profile_id" = String, Path, description = "Ingestion profile ID.")), + responses( + (status = 200, description = "Versions for one ingestion profile.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_ingestion_profile_versions_list( + State(state): State, + headers: HeaderMap, + Path(profile_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_ingestion_profile_versions_list(AdminIngestionProfileVersionsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/default", + tag = "admin", + responses( + ( + status = 200, + description = "Default add_event ingestion profile pointer.", + body = AdminIngestionProfileDefaultResponseV2, + ), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_ingestion_profile_default_get( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_ingestion_profile_default_get(AdminIngestionProfileDefaultGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + put, + path = "/v2/admin/events/ingestion-profiles/default", + tag = "admin", + request_body = AdminIngestionProfileDefaultSetBody, + responses( + ( + status = 200, + description = "Default add_event ingestion profile pointer was updated.", + body = AdminIngestionProfileDefaultResponseV2, + ), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Profile was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn admin_ingestion_profile_default_set( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .admin_ingestion_profile_default_set(AdminIngestionProfileDefaultSetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id: payload.profile_id, + version: payload.version, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/knowledge.rs b/apps/elf-api/src/routes/knowledge.rs new file mode 100644 index 00000000..e9ee31a3 --- /dev/null +++ b/apps/elf-api/src/routes/knowledge.rs @@ -0,0 +1,270 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, + KnowledgePageChangedSource, KnowledgePageGetRequest, KnowledgePageLintRequest, + KnowledgePageLintResponse, KnowledgePageRebuildBody, KnowledgePageRebuildRequest, + KnowledgePageRebuildResponse, KnowledgePageResponse, KnowledgePageSearchRequest, + KnowledgePageSearchResponse, KnowledgePageWatchRebuildBody, KnowledgePageWatchRebuildRequest, + KnowledgePageWatchRebuildResponse, KnowledgePagesListQuery, KnowledgePagesListRequest, + KnowledgePagesListResponse, KnowledgePagesSearchBody, Path, Query, QueryRejection, + RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/rebuild", + tag = "knowledge", + request_body = Value, + responses( + (status = 200, description = "Knowledge page was rebuilt.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn knowledge_page_rebuild( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .knowledge_page_rebuild(KnowledgePageRebuildRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + page_kind: payload.page_kind, + page_key: payload.page_key, + title: payload.title, + doc_ids: payload.doc_ids, + doc_chunk_ids: payload.doc_chunk_ids, + note_ids: payload.note_ids, + event_ids: payload.event_ids, + relation_ids: payload.relation_ids, + proposal_ids: payload.proposal_ids, + provider_metadata: payload.provider_metadata, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/rebuild-changed-sources", + tag = "knowledge", + request_body = Value, + responses( + (status = 200, description = "Affected knowledge pages were rebuilt.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn knowledge_pages_watch_rebuild( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let changed_sources = payload + .changed_sources + .into_iter() + .map(|source| KnowledgePageChangedSource { + source_kind: source.source_kind, + source_id: source.source_id, + }) + .collect(); + let response = state + .service + .knowledge_pages_watch_rebuild(KnowledgePageWatchRebuildRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + changed_sources, + page_kind: payload.page_kind, + limit: payload.limit, + generate_memory_candidates: payload.generate_memory_candidates.unwrap_or(true), + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/knowledge/pages", + tag = "knowledge", + params( + ("page_kind" = Option, Query, description = "Optional page-kind filter."), + ("limit" = Option, Query, description = "Maximum pages to return."), + ), + responses( + (status = 200, description = "Knowledge pages.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn knowledge_pages_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .knowledge_pages_list(KnowledgePagesListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + page_kind: query.page_kind, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/search", + tag = "knowledge", + request_body = Value, + responses( + (status = 200, description = "Knowledge page section search results.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn knowledge_pages_search( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .knowledge_pages_search(KnowledgePageSearchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + query: payload.query, + page_kind: payload.page_kind, + limit: payload.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/knowledge/pages/{page_id}", + tag = "knowledge", + params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), + responses( + (status = 200, description = "Knowledge page.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Knowledge page was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn knowledge_page_get( + State(state): State, + headers: HeaderMap, + Path(page_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .knowledge_page_get(KnowledgePageGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + page_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/{page_id}/lint", + tag = "knowledge", + params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), + responses( + (status = 200, description = "Knowledge page lint findings.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Knowledge page was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn knowledge_page_lint( + State(state): State, + headers: HeaderMap, + Path(page_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .knowledge_page_lint(KnowledgePageLintRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + page_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/notes.rs b/apps/elf-api/src/routes/notes.rs new file mode 100644 index 00000000..d22cbb89 --- /dev/null +++ b/apps/elf-api/src/routes/notes.rs @@ -0,0 +1,353 @@ +use crate::routes::{ + self, AddNoteRequest, AddNoteResponse, ApiError, AppState, DeleteRequest, DeleteResponse, + ErrorBody, Extension, HeaderMap, Json, JsonRejection, ListRequest, ListResponse, + MAX_NOTES_PER_INGEST, NoteFetchRequest, NoteFetchResponse, NotePatchRequest, + NotesIngestRequest, NotesListQuery, Path, PublishNoteRequest, PublishResponseV2, Query, + QueryRejection, RequestContext, SecurityAuthRole, ShareScope, ShareScopeBody, State, + StatusCode, UnpublishNoteRequest, UpdateRequest, UpdateResponse, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/notes/ingest", + tag = "notes", + request_body = Value, + responses( + (status = 200, description = "Notes were processed.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_ingest( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.trim() == "org_shared" { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + if payload.notes.len() > MAX_NOTES_PER_INGEST { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Notes list is too large.", + Some(vec!["$.notes".to_string()]), + )); + } + + let response = state + .service + .add_note(AddNoteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: payload.scope, + notes: payload.notes, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/notes", + tag = "notes", + params( + ("scope" = Option, Query, description = "Optional note scope filter."), + ("status" = Option, Query, description = "Optional note status filter."), + ("type" = Option, Query, description = "Optional note type filter."), + ), + responses( + (status = 200, description = "Notes visible to the caller.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .list(ListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: Some(ctx.agent_id), + scope: query.scope, + status: query.status, + r#type: query.r#type, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note details.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_get( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .get_note(NoteFetchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + patch, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was updated.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_patch( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .update(UpdateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + text: payload.text, + importance: payload.importance, + confidence: payload.confidence, + ttl_days: payload.ttl_days, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note was deleted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_delete( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .delete(DeleteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/notes/{note_id}/publish", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was published to a shared space.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_publish( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let scope = routes::parse_space(payload.space.as_str())?; + let role = role.map(|Extension(role)| role); + + if matches!(scope, ShareScope::OrgShared) { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .publish_note(PublishNoteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + scope, + }) + .await?; + + Ok(Json(PublishResponseV2 { + note_id: response.note_id, + space: routes::format_scope(response.scope.as_str())?.to_string(), + })) +} + +#[utoipa::path( + post, + path = "/v2/notes/{note_id}/unpublish", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was returned to private scope.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn notes_unpublish( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let scope = routes::parse_space(payload.space.as_str())?; + let role = role.map(|Extension(role)| role); + + if matches!(scope, ShareScope::OrgShared) { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .unpublish_note(UnpublishNoteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + }) + .await?; + + Ok(Json(PublishResponseV2 { + note_id: response.note_id, + space: routes::format_scope(response.scope.as_str())?.to_string(), + })) +} diff --git a/apps/elf-api/src/routes/recall.rs b/apps/elf-api/src/routes/recall.rs new file mode 100644 index 00000000..540fc5f3 --- /dev/null +++ b/apps/elf-api/src/routes/recall.rs @@ -0,0 +1,73 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, RecallDebugPanelBody, + RecallDebugPanelRequest, RecallDebugPanelResponse, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/recall-debug/panel", + tag = "recall", + request_body = Value, + responses( + (status = 200, description = "Agent-facing cross-layer recall/debug panel.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn recall_debug_panel( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + recall_debug_panel_inner(state, headers, payload, false).await +} + +pub(super) async fn admin_recall_debug_panel( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + recall_debug_panel_inner(state, headers, payload, true).await +} + +pub(super) async fn recall_debug_panel_inner( + state: AppState, + headers: HeaderMap, + payload: Result, JsonRejection>, + allow_project_trace_debug: bool, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .recall_debug_panel(RecallDebugPanelRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + trace_id: payload.trace_id, + query: payload.query, + docs_query: payload.docs_query, + knowledge_query: payload.knowledge_query, + graph_subject: payload.graph_subject, + graph_predicate: payload.graph_predicate, + include_dreaming: payload.include_dreaming, + limit: payload.limit, + allow_project_trace_debug, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/search.rs b/apps/elf-api/src/routes/search.rs new file mode 100644 index 00000000..4e6ccb2a --- /dev/null +++ b/apps/elf-api/src/routes/search.rs @@ -0,0 +1,393 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, MAX_CANDIDATE_K, + MAX_NOTE_IDS_PER_DETAILS, MAX_QUERY_CHARS, MAX_TOP_K, Path, Query, QueryRejection, + RequestContext, SearchCreateRequest, SearchCreateResponseV2, SearchDetailsBody, + SearchDetailsRequest, SearchDetailsResponseV2, SearchIndexResponseV2, SearchMode, + SearchRequest, SearchResponse, SearchSessionGetQuery, SearchSessionGetRequest, + SearchTimelineQuery, SearchTimelineRequest, SearchTimelineResponseV2, State, StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/searches", + tag = "search", + request_body = Value, + responses( + (status = 200, description = "Search session was created.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn searches_create( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + + if payload.query.chars().count() > MAX_QUERY_CHARS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Query is too long.", + Some(vec!["$.query".to_string()]), + )); + } + if payload.top_k.unwrap_or(state.service.cfg.memory.top_k) > MAX_TOP_K { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "top_k is too large.", + Some(vec!["$.top_k".to_string()]), + )); + } + if payload.candidate_k.unwrap_or(state.service.cfg.memory.candidate_k) > MAX_CANDIDATE_K { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "candidate_k is too large.", + Some(vec!["$.candidate_k".to_string()]), + )); + } + if payload.ranking.is_some() { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Ranking overrides are only supported on admin endpoints.".to_string(), + None, + )); + } + + let mode = payload.mode; + let token_id = + routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); + let build_request = || SearchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id: token_id.clone(), + read_profile, + query: payload.query.clone(), + top_k: payload.top_k, + candidate_k: payload.candidate_k, + filter: payload.filter.clone(), + payload_level: payload.payload_level.unwrap_or_default(), + record_hits: Some(false), + ranking: None, + }; + let response = match mode { + SearchMode::QuickFind => { + let response = state.service.search_quick(build_request()).await?; + + SearchCreateResponseV2 { + mode, + trace_id: response.trace_id, + search_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan: None, + } + }, + SearchMode::PlannedSearch => { + let response = state.service.search_planned(build_request()).await?; + + SearchCreateResponseV2 { + mode, + trace_id: response.trace_id, + search_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan: Some(response.query_plan), + } + }, + }; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/searches/{search_id}", + tag = "search", + params( + ("search_id" = Uuid, Path, description = "Search session ID."), + ("payload_level" = Option, Query, description = "Optional payload level."), + ("top_k" = Option, Query, description = "Optional result limit override."), + ("touch" = Option, Query, description = "Whether to extend the session TTL."), + ), + responses( + (status = 200, description = "Search session index view.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn searches_get( + State(state): State, + headers: HeaderMap, + Path(search_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .search_session_get(SearchSessionGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + search_session_id: search_id, + payload_level: query.payload_level.unwrap_or_default(), + top_k: query.top_k, + touch: query.touch, + }) + .await?; + let mode = if response.query_plan.is_some() { + SearchMode::PlannedSearch + } else { + SearchMode::QuickFind + }; + + Ok(Json(SearchIndexResponseV2 { + mode, + trace_id: response.trace_id, + search_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan: response.query_plan, + })) +} + +#[utoipa::path( + get, + path = "/v2/searches/{search_id}/timeline", + tag = "search", + params( + ("search_id" = Uuid, Path, description = "Search session ID."), + ("payload_level" = Option, Query, description = "Optional payload level."), + ("group_by" = Option, Query, description = "Timeline grouping mode."), + ), + responses( + (status = 200, description = "Search session timeline.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn searches_timeline( + State(state): State, + headers: HeaderMap, + Path(search_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .search_timeline(SearchTimelineRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + search_session_id: search_id, + payload_level: query.payload_level.unwrap_or_default(), + group_by: query.group_by, + }) + .await?; + + Ok(Json(SearchTimelineResponseV2 { + search_id: response.search_session_id, + expires_at: response.expires_at, + groups: response.groups, + })) +} + +#[utoipa::path( + post, + path = "/v2/searches/{search_id}/notes", + tag = "search", + params(("search_id" = Uuid, Path, description = "Search session ID.")), + request_body = Value, + responses( + (status = 200, description = "Hydrated search note details.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn searches_notes( + State(state): State, + headers: HeaderMap, + Path(search_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + + if payload.note_ids.len() > MAX_NOTE_IDS_PER_DETAILS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "note_ids list is too large.", + Some(vec!["$.note_ids".to_string()]), + )); + } + + let response = state + .service + .search_details(SearchDetailsRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + search_session_id: search_id, + payload_level: payload.payload_level.unwrap_or_default(), + note_ids: payload.note_ids, + record_hits: payload.record_hits, + }) + .await?; + + Ok(Json(SearchDetailsResponseV2 { + search_id: response.search_session_id, + expires_at: response.expires_at, + results: response.results, + })) +} + +#[utoipa::path( + post, + path = "/v2/admin/searches/raw", + tag = "search", + request_body = Value, + responses( + (status = 200, description = "Raw admin search response.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn searches_raw( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + + if payload.query.chars().count() > MAX_QUERY_CHARS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Query is too long.", + Some(vec!["$.query".to_string()]), + )); + } + if payload.top_k.unwrap_or(state.service.cfg.memory.top_k) > MAX_TOP_K { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "top_k is too large.", + Some(vec!["$.top_k".to_string()]), + )); + } + if payload.candidate_k.unwrap_or(state.service.cfg.memory.candidate_k) > MAX_CANDIDATE_K { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "candidate_k is too large.", + Some(vec!["$.candidate_k".to_string()]), + )); + } + + let request = SearchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id: routes::effective_token_id( + state.service.cfg.security.auth_mode.as_str(), + &headers, + ), + read_profile, + query: payload.query, + filter: payload.filter, + payload_level: payload.payload_level.unwrap_or_default(), + top_k: payload.top_k, + candidate_k: payload.candidate_k, + record_hits: Some(false), + ranking: payload.ranking, + }; + let response = match payload.mode { + SearchMode::QuickFind => state.service.search_raw_quick(request).await?, + SearchMode::PlannedSearch => { + let response = state.service.search_raw_planned(request).await?; + + SearchResponse { + trace_id: response.trace_id, + items: response.items, + trajectory_summary: response.trajectory_summary, + } + }, + }; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/sharing.rs b/apps/elf-api/src/routes/sharing.rs new file mode 100644 index 00000000..69b443de --- /dev/null +++ b/apps/elf-api/src/routes/sharing.rs @@ -0,0 +1,171 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, + RequestContext, SecurityAuthRole, ShareScope, SpaceGrantItemV2, SpaceGrantRevokeRequest, + SpaceGrantRevokeResponse, SpaceGrantUpsertBody, SpaceGrantUpsertRequest, + SpaceGrantUpsertResponseV2, SpaceGrantsListRequest, SpaceGrantsListResponseV2, State, + StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/spaces/{space}/grants", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + responses( + (status = 200, description = "Space grants.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn space_grants_list( + State(state): State, + headers: HeaderMap, + Path(space): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let scope = routes::parse_space(space.as_str())?; + let response = state + .service + .space_grants_list(SpaceGrantsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope, + }) + .await?; + + Ok(Json(SpaceGrantsListResponseV2 { + grants: response + .grants + .into_iter() + .map(|item| SpaceGrantItemV2 { + space: routes::format_space(item.scope).to_string(), + grantee_kind: item.grantee_kind, + grantee_agent_id: item.grantee_agent_id, + granted_by_agent_id: item.granted_by_agent_id, + granted_at: item.granted_at, + }) + .collect(), + })) +} + +#[utoipa::path( + post, + path = "/v2/spaces/{space}/grants", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + request_body = Value, + responses( + (status = 200, description = "Space grant was upserted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn space_grant_upsert( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(space): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let scope = routes::parse_space(space.as_str())?; + let role = role.map(|Extension(role)| role); + + if matches!(scope, ShareScope::OrgShared) { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .space_grant_upsert(SpaceGrantUpsertRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope, + grantee_kind: payload.grantee_kind, + grantee_agent_id: payload.grantee_agent_id, + }) + .await?; + + Ok(Json(SpaceGrantUpsertResponseV2 { + space: routes::format_scope(response.scope.as_str())?.to_string(), + grantee_kind: response.grantee_kind, + grantee_agent_id: response.grantee_agent_id, + granted: response.granted, + })) +} + +#[utoipa::path( + post, + path = "/v2/spaces/{space}/grants/revoke", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + request_body = Value, + responses( + (status = 200, description = "Space grant was revoked.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn space_grant_revoke( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(space): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let scope = routes::parse_space(space.as_str())?; + let role = role.map(|Extension(role)| role); + + if matches!(scope, ShareScope::OrgShared) { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .space_grant_revoke(SpaceGrantRevokeRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope, + grantee_kind: payload.grantee_kind, + grantee_agent_id: payload.grantee_agent_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/support.rs b/apps/elf-api/src/routes/support.rs new file mode 100644 index 00000000..04188e1a --- /dev/null +++ b/apps/elf-api/src/routes/support.rs @@ -0,0 +1,24 @@ +#[path = "support/auth.rs"] mod auth; +#[path = "support/errors.rs"] mod errors; +#[path = "support/headers.rs"] mod headers; +#[path = "support/request_id.rs"] mod request_id; +#[path = "support/scope.rs"] mod scope; +#[path = "support/support_types.rs"] mod support_types; +#[path = "support/time.rs"] mod time; + +pub(super) use self::{ + auth::{ + admin_auth_middleware, api_auth_middleware, effective_token_id, + require_admin_for_org_shared_writes, + }, + errors::{ApiError, json_error}, + headers::{RequestContext, required_read_profile}, + scope::{format_scope, format_space, parse_space}, + support_types::{EntityMemoryQuery, SearchMode, empty_json_object}, + time::parse_optional_rfc3339, +}; +#[cfg(test)] +pub(super) use self::{ + auth::{apply_auth_key_context, resolve_auth_key, sanitize_trusted_token_header}, + request_id::{inject_request_id_into_json_body, parse_request_id_from_headers}, +}; diff --git a/apps/elf-api/src/routes/support/auth.rs b/apps/elf-api/src/routes/support/auth.rs new file mode 100644 index 00000000..f11c4208 --- /dev/null +++ b/apps/elf-api/src/routes/support/auth.rs @@ -0,0 +1,214 @@ +use crate::routes::{ + AppState, Body, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, HEADER_READ_PROFILE, + HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, HeaderMap, IntoResponse, Next, Request, Response, + SecurityAuthKey, SecurityAuthRole, State, StatusCode, Uuid, + support::{ + errors::{self, ApiError}, + request_id, + }, +}; + +pub(in super::super) fn trusted_token_id(headers: &HeaderMap) -> Option { + let raw = headers.get(HEADER_TRUSTED_TOKEN_ID)?; + let value = raw.to_str().ok()?.trim(); + + if value.is_empty() { None } else { Some(value.to_string()) } +} + +pub(in super::super) fn sanitize_trusted_token_header(headers: &mut HeaderMap) { + headers.remove(HEADER_TRUSTED_TOKEN_ID); +} + +pub(in super::super) fn effective_token_id(auth_mode: &str, headers: &HeaderMap) -> Option { + match auth_mode.trim() { + "static_keys" => trusted_token_id(headers), + _ => None, + } +} + +pub(in super::super) fn bearer_token(headers: &HeaderMap) -> Option { + let raw = headers.get(HEADER_AUTHORIZATION)?; + let value = raw.to_str().ok()?.trim(); + let token = value.strip_prefix("Bearer ")?; + let token = token.trim(); + + if token.is_empty() { None } else { Some(token.to_string()) } +} + +pub(in super::super) fn resolve_auth_key<'a>( + headers: &HeaderMap, + auth_keys: &'a [SecurityAuthKey], +) -> Result<&'a SecurityAuthKey, ApiError> { + let token = bearer_token(headers).ok_or_else(|| { + errors::json_error( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "Authentication required.", + None, + ) + })?; + + auth_keys.iter().find(|key| key.token == token).ok_or_else(|| { + errors::json_error( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "Authentication required.", + None, + ) + }) +} + +pub(in super::super) fn set_context_header( + headers: &mut HeaderMap, + name: &'static str, + value: &str, +) -> Result<(), ApiError> { + let header_value = value.parse().map_err(|_| { + errors::json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + format!("Invalid configured auth context for {name}."), + None, + ) + })?; + + headers.insert(name, header_value); + + Ok(()) +} + +pub(in super::super) fn apply_auth_key_context( + headers: &mut HeaderMap, + key: &SecurityAuthKey, +) -> Result<(), ApiError> { + let agent_id = key.agent_id.as_deref().ok_or_else(|| { + errors::json_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Token is not scoped to an agent_id.", + None, + ) + })?; + + set_context_header(headers, HEADER_TENANT_ID, key.tenant_id.as_str())?; + set_context_header(headers, HEADER_PROJECT_ID, key.project_id.as_str())?; + set_context_header(headers, HEADER_AGENT_ID, agent_id)?; + set_context_header(headers, HEADER_READ_PROFILE, key.read_profile.as_str())?; + set_context_header(headers, HEADER_TRUSTED_TOKEN_ID, key.token_id.as_str())?; + + Ok(()) +} + +pub(in super::super) fn require_admin_for_org_shared_writes( + auth_mode: &str, + role: Option, +) -> Result<(), ApiError> { + if auth_mode.trim() != "static_keys" { + return Ok(()); + } + if matches!(role, Some(SecurityAuthRole::Admin | SecurityAuthRole::SuperAdmin)) { + return Ok(()); + } + + Err(errors::json_error(StatusCode::FORBIDDEN, "FORBIDDEN", "Admin token required.", None)) +} + +pub(in super::super) async fn api_auth_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + let security = &state.service.cfg.security; + let request_id = match request_id::parse_request_id_from_headers(req.headers()) { + Ok(request_id) => request_id, + Err(err) => return request_id::with_request_id(err.into_response(), Uuid::new_v4()).await, + }; + let mut req = req; + + sanitize_trusted_token_header(req.headers_mut()); + + let response = match security.auth_mode.trim() { + "off" => next.run(req).await, + "static_keys" => { + let key = match resolve_auth_key(req.headers(), &security.auth_keys) { + Ok(key) => key, + Err(err) => + return request_id::with_request_id(err.into_response(), request_id).await, + }; + + req.extensions_mut().insert(key.role); + + if let Err(err) = apply_auth_key_context(req.headers_mut(), key) { + return request_id::with_request_id(err.into_response(), request_id).await; + } + + next.run(req).await + }, + _ => errors::json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Invalid security.auth_mode configuration.", + None, + ) + .into_response(), + }; + + request_id::with_request_id(response, request_id).await +} + +pub(in super::super) async fn admin_auth_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + let security = &state.service.cfg.security; + let request_id = match request_id::parse_request_id_from_headers(req.headers()) { + Ok(request_id) => request_id, + Err(err) => return request_id::with_request_id(err.into_response(), Uuid::new_v4()).await, + }; + let mut req = req; + + sanitize_trusted_token_header(req.headers_mut()); + + let response = match security.auth_mode.trim() { + "off" => next.run(req).await, + "static_keys" => { + let key = match resolve_auth_key(req.headers(), &security.auth_keys) { + Ok(key) => key, + Err(err) => + return request_id::with_request_id(err.into_response(), request_id).await, + }; + + req.extensions_mut().insert(key.role); + + if !matches!(key.role, SecurityAuthRole::Admin | SecurityAuthRole::SuperAdmin) { + return request_id::with_request_id( + errors::json_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Admin token required.", + None, + ) + .into_response(), + request_id, + ) + .await; + } + + if let Err(err) = apply_auth_key_context(req.headers_mut(), key) { + return request_id::with_request_id(err.into_response(), request_id).await; + } + + next.run(req).await + }, + _ => errors::json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Invalid security.auth_mode configuration.", + None, + ) + .into_response(), + }; + + request_id::with_request_id(response, request_id).await +} diff --git a/apps/elf-api/src/routes/support/errors.rs b/apps/elf-api/src/routes/support/errors.rs new file mode 100644 index 00000000..3795ab71 --- /dev/null +++ b/apps/elf-api/src/routes/support/errors.rs @@ -0,0 +1,141 @@ +use crate::routes::{ + Error, ErrorBody, IntoResponse, Json, MAX_ERROR_LOG_CHARS, Response, StatusCode, +}; + +#[derive(Debug)] +pub(in super::super) struct ApiError { + pub(in super::super) status: StatusCode, + pub(in super::super) error_code: String, + pub(in super::super) message: String, + pub(in super::super) fields: Option>, +} +impl ApiError { + pub(in super::super) fn new( + status: StatusCode, + error_code: impl Into, + message: impl Into, + fields: Option>, + ) -> Self { + Self { status, error_code: error_code.into(), message: message.into(), fields } + } +} + +impl From for ApiError { + fn from(err: Error) -> Self { + match err { + Error::NonEnglishInput { field } => json_error( + StatusCode::UNPROCESSABLE_ENTITY, + "NON_ENGLISH_INPUT", + "Non-English input detected; upstream must canonicalize to English before calling ELF.", + Some(vec![field]), + ), + Error::InvalidRequest { message } => + json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", message, None), + Error::ScopeDenied { message } => + json_error(StatusCode::FORBIDDEN, "SCOPE_DENIED", message, None), + Error::NotFound { message } => + json_error(StatusCode::NOT_FOUND, "NOT_FOUND", message, None), + Error::Conflict { message } => + json_error(StatusCode::CONFLICT, "CONFLICT", message, None), + Error::Provider { message } => { + let sanitized = sanitize_log_text(message.as_str()); + + tracing::error!(error = %sanitized, "Provider error."); + + json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Internal error.".to_string(), + None, + ) + }, + Error::Storage { message } => { + let sanitized = sanitize_log_text(message.as_str()); + + tracing::error!(error = %sanitized, "Storage error."); + + json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Internal error.".to_string(), + None, + ) + }, + Error::Qdrant { message } => { + let sanitized = sanitize_log_text(message.as_str()); + + tracing::error!(error = %sanitized, "Qdrant error."); + + json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Internal error.".to_string(), + None, + ) + }, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let body = + ErrorBody { error_code: self.error_code, message: self.message, fields: self.fields }; + + (self.status, Json(body)).into_response() + } +} + +pub(in super::super) fn json_error( + status: StatusCode, + code: &str, + message: impl Into, + fields: Option>, +) -> ApiError { + ApiError::new(status, code, message, fields) +} + +pub(in super::super) fn sanitize_log_text(text: &str) -> String { + let mut parts = Vec::new(); + let mut redact_next = false; + + for raw in text.split_whitespace() { + let mut word = raw.to_string(); + + if redact_next { + word = "[REDACTED]".to_string(); + redact_next = false; + } + if raw.eq_ignore_ascii_case("bearer") { + redact_next = true; + } + + let lowered = raw.to_ascii_lowercase(); + + for key in ["api_key", "apikey", "password", "secret", "token"] { + if lowered.contains(key) && (lowered.contains('=') || lowered.contains(':')) { + let sep = if raw.contains('=') { '=' } else { ':' }; + let prefix = match raw.split(sep).next() { + Some(prefix) => prefix, + None => raw, + }; + + word = format!("{prefix}{sep}[REDACTED]"); + + break; + } + } + + parts.push(word); + } + + let mut out = parts.join(" "); + + if out.chars().count() > MAX_ERROR_LOG_CHARS { + out = out.chars().take(MAX_ERROR_LOG_CHARS).collect(); + + out.push_str("..."); + } + + out +} diff --git a/apps/elf-api/src/routes/support/headers.rs b/apps/elf-api/src/routes/support/headers.rs new file mode 100644 index 00000000..f7074c25 --- /dev/null +++ b/apps/elf-api/src/routes/support/headers.rs @@ -0,0 +1,76 @@ +use crate::routes::{ + HEADER_AGENT_ID, HEADER_PROJECT_ID, HEADER_READ_PROFILE, HEADER_TENANT_ID, HeaderMap, + MAX_CONTEXT_HEADER_CHARS, StatusCode, english_gate, + support::errors::{self, ApiError}, +}; + +#[derive(Clone, Debug)] +pub(in super::super) struct RequestContext { + pub(in super::super) tenant_id: String, + pub(in super::super) project_id: String, + pub(in super::super) agent_id: String, +} +impl RequestContext { + pub(in super::super) fn from_headers(headers: &HeaderMap) -> Result { + let tenant_id = required_header(headers, HEADER_TENANT_ID)?; + let project_id = required_header(headers, HEADER_PROJECT_ID)?; + let agent_id = required_header(headers, HEADER_AGENT_ID)?; + + Ok(Self { tenant_id, project_id, agent_id }) + } +} + +pub(in super::super) fn required_header( + headers: &HeaderMap, + name: &'static str, +) -> Result { + let raw = headers.get(name).ok_or_else(|| { + errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{name} header is required."), + Some(vec![format!("$.headers.{name}")]), + ) + })?; + let value = raw.to_str().map_err(|_| { + errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{name} header must be a valid string."), + Some(vec![format!("$.headers.{name}")]), + ) + })?; + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{name} header must be non-empty."), + Some(vec![format!("$.headers.{name}")]), + )); + } + if trimmed.chars().count() > MAX_CONTEXT_HEADER_CHARS { + return Err(errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{name} header is too long."), + Some(vec![format!("$.headers.{name}")]), + )); + } + if !english_gate::is_english_identifier(trimmed) { + return Err(errors::json_error( + StatusCode::UNPROCESSABLE_ENTITY, + "NON_ENGLISH_INPUT", + "Non-English input detected; upstream must canonicalize to English before calling ELF." + .to_string(), + Some(vec![format!("$.headers.{name}")]), + )); + } + + Ok(trimmed.to_string()) +} + +pub(in super::super) fn required_read_profile(headers: &HeaderMap) -> Result { + required_header(headers, HEADER_READ_PROFILE) +} diff --git a/apps/elf-api/src/routes/support/request_id.rs b/apps/elf-api/src/routes/support/request_id.rs new file mode 100644 index 00000000..5ed3a7c3 --- /dev/null +++ b/apps/elf-api/src/routes/support/request_id.rs @@ -0,0 +1,86 @@ +use crate::routes::{ + Body, CONTENT_LENGTH, CONTENT_TYPE, HEADER_REQUEST_ID, HeaderMap, Response, StatusCode, Uuid, + Value, body, + support::errors::{self, ApiError}, +}; + +pub(in super::super) fn parse_request_id_from_headers( + headers: &HeaderMap, +) -> Result { + if let Some(raw) = headers.get(HEADER_REQUEST_ID) { + let raw = raw.to_str().map_err(|_| { + errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{HEADER_REQUEST_ID} header must be a valid string."), + Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")]), + ) + })?; + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{HEADER_REQUEST_ID} header must be non-empty."), + Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")]), + )); + } + + Uuid::parse_str(trimmed).map_err(|_| { + errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{HEADER_REQUEST_ID} header must be a valid UUID."), + Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")]), + ) + }) + } else { + Ok(Uuid::new_v4()) + } +} + +pub(in super::super) fn inject_request_id_into_json_body( + body: &[u8], + request_id: &Uuid, +) -> Option> { + let mut response_body: Value = serde_json::from_slice(body).ok()?; + let object = response_body.as_object_mut()?; + + object.insert("request_id".to_string(), Value::String(request_id.to_string())); + + serde_json::to_vec(&response_body).ok() +} + +pub(in super::super) async fn with_request_id(response: Response, request_id: Uuid) -> Response { + let (mut parts, body) = response.into_parts(); + + parts.headers.insert( + HEADER_REQUEST_ID, + request_id.to_string().parse().expect("request_id is valid uuid string"), + ); + + let is_json_response = parts + .headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|content_type| content_type.starts_with("application/json")) + .unwrap_or(false); + + if !is_json_response { + return Response::from_parts(parts, body); + } + + let body_bytes = match body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes, + Err(_) => return Response::from_parts(parts, Body::empty()), + }; + + if let Some(response_body) = inject_request_id_into_json_body(&body_bytes, &request_id) { + parts.headers.remove(CONTENT_LENGTH); + + Response::from_parts(parts, Body::from(response_body)) + } else { + Response::from_parts(parts, Body::from(body_bytes)) + } +} diff --git a/apps/elf-api/src/routes/support/scope.rs b/apps/elf-api/src/routes/support/scope.rs new file mode 100644 index 00000000..3c5db5ec --- /dev/null +++ b/apps/elf-api/src/routes/support/scope.rs @@ -0,0 +1,38 @@ +use crate::routes::{ + ShareScope, StatusCode, + support::errors::{self, ApiError}, +}; + +pub(in super::super) fn parse_space(scope: &str) -> Result { + match scope { + "team_shared" | "project_shared" => Ok(ShareScope::ProjectShared), + "org_shared" => Ok(ShareScope::OrgShared), + _ => Err(errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid space.".to_string(), + Some(vec!["$.space".to_string()]), + )), + } +} + +pub(in super::super) fn format_space(scope: ShareScope) -> &'static str { + match scope { + ShareScope::ProjectShared => "team_shared", + ShareScope::OrgShared => "org_shared", + } +} + +pub(in super::super) fn format_scope(scope: &str) -> Result<&'static str, ApiError> { + match scope { + "project_shared" => Ok("team_shared"), + "org_shared" => Ok("org_shared"), + "agent_private" => Ok("agent_private"), + _ => Err(errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid space.".to_string(), + Some(vec!["$.space".to_string()]), + )), + } +} diff --git a/apps/elf-api/src/routes/support/support_types.rs b/apps/elf-api/src/routes/support/support_types.rs new file mode 100644 index 00000000..89ee35b9 --- /dev/null +++ b/apps/elf-api/src/routes/support/support_types.rs @@ -0,0 +1,18 @@ +use crate::routes::{Deserialize, Map, Serialize, Uuid, Value}; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(in super::super) enum SearchMode { + QuickFind, + PlannedSearch, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in super::super) struct EntityMemoryQuery { + pub(in super::super) entity_id: Option, + pub(in super::super) entity_surface: Option, +} + +pub(in super::super) fn empty_json_object() -> Value { + Value::Object(Map::new()) +} diff --git a/apps/elf-api/src/routes/support/time.rs b/apps/elf-api/src/routes/support/time.rs new file mode 100644 index 00000000..b6532625 --- /dev/null +++ b/apps/elf-api/src/routes/support/time.rs @@ -0,0 +1,32 @@ +use crate::routes::{ + OffsetDateTime, Rfc3339, StatusCode, + support::errors::{self, ApiError}, +}; + +pub(in super::super) fn parse_optional_rfc3339( + raw: Option<&String>, + path: &str, +) -> Result, ApiError> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + + if raw.is_empty() { + return Err(errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{path} must be non-empty."), + Some(vec![path.to_string()]), + )); + } + + OffsetDateTime::parse(raw, &Rfc3339).map(Some).map_err(|_| { + errors::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + format!("{path} must be an RFC3339 datetime string."), + Some(vec![path.to_string()]), + ) + }) +} diff --git a/apps/elf-api/src/routes/tests.rs b/apps/elf-api/src/routes/tests.rs new file mode 100644 index 00000000..b526c953 --- /dev/null +++ b/apps/elf-api/src/routes/tests.rs @@ -0,0 +1,302 @@ +use axum::http::HeaderMap; +use serde_json::Value; +use uuid::Uuid; + +use crate::routes::{ + self, ADMIN_VIEWER_PATH, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, + HEADER_READ_PROFILE, HEADER_REQUEST_ID, HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, VIEWER_HTML, +}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[test] +fn require_admin_for_org_shared_writes_denies_user_in_static_keys_mode() { + let err = + routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::User)) + .expect_err("Expected forbidden error for non-admin role."); + + assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); +} + +#[test] +fn require_admin_for_org_shared_writes_allows_admin_in_static_keys_mode() { + routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::Admin)) + .expect("Expected admin role to be allowed."); +} + +#[test] +fn require_admin_for_org_shared_writes_allows_superadmin_in_static_keys_mode() { + routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::SuperAdmin)) + .expect("Expected superadmin role to be allowed."); +} + +#[test] +fn require_admin_for_org_shared_writes_allows_non_static_keys_auth_mode() { + routes::require_admin_for_org_shared_writes("off", None) + .expect("Expected auth_mode != static_keys."); +} + +#[test] +fn admin_viewer_uses_admin_operator_routes_without_raw_memory_bypasses() { + let html = VIEWER_HTML; + + assert_eq!(ADMIN_VIEWER_PATH, "/viewer"); + assert!(html.contains("/v2/admin/searches")); + assert!(html.contains("/v2/admin/docs/search/l0")); + assert!(html.contains("/v2/admin/docs/excerpts")); + assert!(html.contains("/v2/admin/docs/${encodeURIComponent(item.doc_id)}")); + assert!(html.contains("/v2/admin/dreaming/review-queue")); + assert!( + html.contains("/v2/admin/consolidation/proposals/${encodeURIComponent(proposalId)}/review") + ); + assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/history")); + assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/corrections")); + assert!(html.contains("/v2/admin/recall-debug/panel")); + assert!(html.contains("/v2/admin/traces/recent")); + assert!(html.contains("/v2/admin/traces/${encodeURIComponent(traceId)}/bundle")); + assert!(html.contains("/v2/admin/notes/")); + assert!(html.contains("/v2/admin/knowledge/pages/search")); + assert!(html.contains("mode: \"full\"")); + assert!(html.contains("candidates_limit: 200")); + assert!(html.contains("Replay Candidates")); + assert!(html.contains("Selected Final Results")); + assert!(html.contains("Providers And Ranking")); + assert!(html.contains("Relation Context")); + assert!(html.contains("Knowledge Page Snippets")); + assert!(html.contains("Derived page: source documents")); + assert!(html.contains("Source Library")); + assert!(html.contains("Memory Inbox")); + assert!(html.contains("Memory History")); + assert!(html.contains("Recall Debug")); + assert!(html.contains("Apply Ledger Correction")); + assert!(html.contains("Apply / Supersede")); + assert!(html.contains("directTraceId")); + assert!(html.contains("trace_id")); + assert!(html.contains("loadInitialTrace")); + assert!(!html.contains("method: \"PATCH\"")); + assert!(!html.contains("method: \"PUT\"")); + assert!(!html.contains("method: \"DELETE\"")); + assert!(!html.contains("/v2/notes/ingest")); + assert!(!html.contains("/v2/events/ingest")); + assert!(!html.contains("/publish")); +} + +#[test] +fn resolve_auth_key_requires_bearer_header() { + let headers = HeaderMap::new(); + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let err = routes::resolve_auth_key(&headers, &keys).expect_err("Expected unauthorized error."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn resolve_auth_key_rejects_unknown_token() { + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Bearer wrong".parse().expect("invalid header")); + + let err = routes::resolve_auth_key(&headers, &keys) + .expect_err("Expected unauthorized error for bad key."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn resolve_auth_key_rejects_non_bearer_authorization() { + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Token secret".parse().expect("invalid header")); + + let err = routes::resolve_auth_key(&headers, &keys) + .expect_err("Expected unauthorized error for non-bearer authorization."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn resolve_auth_key_rejects_lowercase_bearer_prefix() { + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "bearer secret".parse().expect("invalid header")); + + let err = routes::resolve_auth_key(&headers, &keys) + .expect_err("Expected unauthorized error for lowercase bearer prefix."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn apply_auth_key_context_overrides_headers() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Bearer old".parse().expect("invalid header")); + headers.insert(HEADER_TENANT_ID, "bad-tenant".parse().expect("invalid header")); + headers.insert(HEADER_PROJECT_ID, "bad-project".parse().expect("invalid header")); + headers.insert(HEADER_AGENT_ID, "bad-agent".parse().expect("invalid header")); + headers.insert(HEADER_READ_PROFILE, "private_only".parse().expect("invalid header")); + headers.insert(HEADER_TRUSTED_TOKEN_ID, "old-id".parse().expect("invalid header")); + + let key = SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "all_scopes".to_string(), + role: SecurityAuthRole::Admin, + }; + + routes::apply_auth_key_context(&mut headers, &key).expect("Expected context injection."); + + assert_eq!( + headers.get(HEADER_TENANT_ID).and_then(|v| v.to_str().ok()).expect("missing tenant"), + "t" + ); + assert_eq!( + headers.get(HEADER_PROJECT_ID).and_then(|v| v.to_str().ok()).expect("missing project"), + "p" + ); + assert_eq!( + headers.get(HEADER_AGENT_ID).and_then(|v| v.to_str().ok()).expect("missing agent"), + "a" + ); + assert_eq!( + headers + .get(HEADER_READ_PROFILE) + .and_then(|v| v.to_str().ok()) + .expect("missing read profile"), + "all_scopes" + ); + assert_eq!( + headers + .get(HEADER_TRUSTED_TOKEN_ID) + .and_then(|v| v.to_str().ok()) + .expect("missing trusted token_id"), + "k1" + ); +} + +#[test] +fn apply_auth_key_context_requires_agent_scope() { + let mut headers = HeaderMap::new(); + let key = SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: None, + read_profile: "all_scopes".to_string(), + role: SecurityAuthRole::User, + }; + let err = routes::apply_auth_key_context(&mut headers, &key) + .expect_err("Expected forbidden error for missing agent_id."); + + assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); +} + +#[test] +fn effective_token_id_ignores_header_when_auth_mode_off() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); + + assert_eq!(routes::effective_token_id("off", &headers), None); +} + +#[test] +fn effective_token_id_uses_header_when_auth_mode_static_keys() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_TRUSTED_TOKEN_ID, "k1".parse().expect("invalid header")); + + assert_eq!(routes::effective_token_id("static_keys", &headers), Some("k1".to_string())); +} + +#[test] +fn sanitize_trusted_token_header_removes_header() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); + + routes::sanitize_trusted_token_header(&mut headers); + + assert!(headers.get(HEADER_TRUSTED_TOKEN_ID).is_none()); +} + +#[test] +fn parse_request_id_from_headers_generates_when_missing() { + let headers = HeaderMap::new(); + let request_id = routes::parse_request_id_from_headers(&headers) + .expect("Expected a generated request ID when header is missing."); + + assert_ne!(request_id.to_string(), Uuid::nil().to_string()); +} + +#[test] +fn parse_request_id_from_headers_rejects_invalid() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_REQUEST_ID, "not-a-uuid".parse().expect("invalid request_id")); + + let err = routes::parse_request_id_from_headers(&headers) + .expect_err("Expected invalid request_id to be rejected."); + + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(err.error_code, "INVALID_REQUEST"); + assert_eq!(err.fields, Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")])); +} + +#[test] +fn inject_request_id_into_json_body_adds_request_id_to_object() { + let request_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); + let body = serde_json::json!({"note_id":"abc","status":"ok"}).to_string(); + let response_body = routes::inject_request_id_into_json_body(body.as_bytes(), &request_id) + .expect("Expected request_id field to be injected."); + let response_value = + serde_json::from_slice::(&response_body).expect("Expected valid JSON"); + + assert_eq!(response_value["request_id"], request_id.to_string()); +} + +#[test] +fn inject_request_id_into_json_body_skips_non_object() { + let request_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); + let body = serde_json::json!(["a", "b", "c"]).to_string(); + + assert!(routes::inject_request_id_into_json_body(body.as_bytes(), &request_id).is_none()); +} diff --git a/apps/elf-api/src/routes/trace.rs b/apps/elf-api/src/routes/trace.rs new file mode 100644 index 00000000..85b96f14 --- /dev/null +++ b/apps/elf-api/src/routes/trace.rs @@ -0,0 +1,231 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, Path, Query, QueryRejection, + RequestContext, SearchExplainRequest, SearchExplainResponse, SearchTrajectoryResponse, State, + StatusCode, TraceBundleGetQuery, TraceBundleGetRequest, TraceBundleResponse, TraceGetRequest, + TraceGetResponse, TraceRecentListQuery, TraceRecentListRequest, TraceRecentListResponse, + TraceTrajectoryGetRequest, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/traces/{trace_id}", + tag = "admin", + params(("trace_id" = Uuid, Path, description = "Search trace ID.")), + responses( + (status = 200, description = "Search trace bundle without full stage internals.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn trace_get( + State(state): State, + headers: HeaderMap, + Path(trace_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .trace_get(TraceGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + trace_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/traces/recent", + tag = "admin", + params( + ("limit" = Option, Query, description = "Page size."), + ("cursor_created_at" = Option, Query, description = "Created-at page cursor."), + ("cursor_trace_id" = Option, Query, description = "Trace ID page cursor."), + ("agent_id" = Option, Query, description = "Optional trace creator filter."), + ("read_profile" = Option, Query, description = "Optional read profile filter."), + ("created_after" = Option, Query, description = "Strict lower created_at bound."), + ("created_before" = Option, Query, description = "Strict upper created_at bound."), + ), + responses( + (status = 200, description = "Recent search traces.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn trace_recent_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let cursor_created_at = + routes::parse_optional_rfc3339(query.cursor_created_at.as_ref(), "$.cursor_created_at")?; + let cursor_trace_id = query.cursor_trace_id; + let created_after = + routes::parse_optional_rfc3339(query.created_after.as_ref(), "$.created_after")?; + let created_before = + routes::parse_optional_rfc3339(query.created_before.as_ref(), "$.created_before")?; + + if cursor_created_at.is_some() != cursor_trace_id.is_some() { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "cursor_created_at and cursor_trace_id must be both set or both omitted.".to_string(), + Some(vec!["$.cursor_created_at".to_string(), "$.cursor_trace_id".to_string()]), + )); + } + + let response = state + .service + .trace_recent_list(TraceRecentListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + limit: query.limit, + cursor_created_at, + cursor_trace_id, + agent_id_filter: query.agent_id, + read_profile: query.read_profile, + created_after, + created_before, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/trajectories/{trace_id}", + tag = "admin", + params(("trace_id" = Uuid, Path, description = "Search trace ID.")), + responses( + (status = 200, description = "Search trace retrieval trajectory.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn trace_trajectory_get( + State(state): State, + headers: HeaderMap, + Path(trace_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .trace_trajectory_get(TraceTrajectoryGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + trace_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/trace-items/{item_id}", + tag = "admin", + params(("item_id" = Uuid, Path, description = "Trace item/result handle ID.")), + responses( + (status = 200, description = "Search trace item explain payload.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace item was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn trace_item_get( + State(state): State, + headers: HeaderMap, + Path(item_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .search_explain(SearchExplainRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + result_handle: item_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/traces/{trace_id}/bundle", + tag = "admin", + params( + ("trace_id" = Uuid, Path, description = "Search trace ID."), + ("mode" = Option, Query, description = "bounded or full."), + ("stage_items_limit" = Option, Query, description = "Maximum stage items."), + ("candidates_limit" = Option, Query, description = "Maximum candidate snapshot items."), + ), + responses( + (status = 200, description = "Search trace bundle.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn trace_bundle_get( + State(state): State, + headers: HeaderMap, + Path(trace_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .trace_bundle_get(TraceBundleGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + trace_id, + mode: query.mode.unwrap_or_default(), + stage_items_limit: query.stage_items_limit, + candidates_limit: query.candidates_limit, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/types.rs b/apps/elf-api/src/routes/types.rs new file mode 100644 index 00000000..ca9c82a5 --- /dev/null +++ b/apps/elf-api/src/routes/types.rs @@ -0,0 +1,69 @@ +#[path = "types/consolidation.rs"] mod consolidation; +#[path = "types/core_memory.rs"] mod core_memory; +#[path = "types/docs.rs"] mod docs; +#[path = "types/errors.rs"] mod errors; +#[path = "types/events.rs"] mod events; +#[path = "types/graph.rs"] mod graph; +#[path = "types/ingestion_profiles.rs"] mod ingestion_profiles; +#[path = "types/knowledge.rs"] mod knowledge; +#[path = "types/notes.rs"] mod notes; +#[path = "types/recall.rs"] mod recall; +#[path = "types/search.rs"] mod search; +#[path = "types/sharing.rs"] mod sharing; +#[path = "types/trace.rs"] mod trace; +#[path = "types/work_journal.rs"] mod work_journal; + +pub(in crate::routes) use self::{ + consolidation::{ + ConsolidationProposalReviewBody, ConsolidationProposalsListQuery, + ConsolidationRunCreateBody, ConsolidationRunsListQuery, DreamingReviewQueueQuery, + }, + core_memory::{CoreBlockAttachBody, CoreBlockUpsertBody}, + docs::{DocsExcerptsGetBody, DocsPutBody, DocsSearchL0Body}, + errors::ErrorBody, + events::EventsIngestRequest, + graph::{ + AdminGraphPredicateAliasAddBody, AdminGraphPredicatePatchBody, + AdminGraphPredicatesListQuery, GraphQueryBody, GraphReportBody, + }, + ingestion_profiles::{ + AdminIngestionProfileCreateBody, AdminIngestionProfileDefaultResponseV2, + AdminIngestionProfileDefaultSetBody, AdminIngestionProfileGetQuery, + }, + knowledge::{ + KnowledgePageRebuildBody, KnowledgePageWatchRebuildBody, KnowledgePagesListQuery, + KnowledgePagesSearchBody, + }, + notes::{ + AdminNoteCorrectionBody, NotePatchRequest, NotesIngestRequest, NotesListQuery, + PublishResponseV2, + }, + recall::RecallDebugPanelBody, + search::{ + SearchCreateRequest, SearchCreateResponseV2, SearchDetailsBody, SearchDetailsResponseV2, + SearchIndexResponseV2, SearchSessionGetQuery, SearchTimelineQuery, + SearchTimelineResponseV2, + }, + sharing::{ + ShareScopeBody, SpaceGrantItemV2, SpaceGrantUpsertBody, SpaceGrantUpsertResponseV2, + SpaceGrantsListResponseV2, + }, + trace::{TraceBundleGetQuery, TraceRecentListQuery}, + work_journal::{WorkJournalEntryCreateBody, WorkJournalSessionReadbackBody}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::routes::{ + AddNoteInput, ConsolidationInputRef, ConsolidationLineage, ConsolidationProposalInput, + ConsolidationReviewAction, ConsolidationReviewState, DocType, EventMessage, GranteeKind, + GraphQueryEntityRef, GraphQueryPredicateRef, IngestionProfileSelector, KnowledgePageKind, + KnowledgeSourceKind, MemoryCorrectionAction, PayloadLevel, QueryPlan, RankingRequestOverride, + SearchDetailsResult, SearchIndexItem, SearchMode, SearchTimelineGroup, SearchTrajectorySummary, + TextPositionSelector, TextQuoteSelector, TraceBundleMode, WorkJournalEntryFamily, WritePolicy, + empty_json_object, +}; diff --git a/apps/elf-api/src/routes/types/consolidation.rs b/apps/elf-api/src/routes/types/consolidation.rs new file mode 100644 index 00000000..44df73fe --- /dev/null +++ b/apps/elf-api/src/routes/types/consolidation.rs @@ -0,0 +1,41 @@ +use crate::routes::types::{ + ConsolidationInputRef, ConsolidationLineage, ConsolidationProposalInput, + ConsolidationReviewAction, ConsolidationReviewState, Deserialize, Uuid, Value, + empty_json_object, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct ConsolidationRunCreateBody { + pub(in crate::routes) job_kind: String, + pub(in crate::routes) input_refs: Vec, + #[serde(default = "empty_json_object")] + pub(in crate::routes) source_snapshot: Value, + pub(in crate::routes) lineage: ConsolidationLineage, + #[serde(default)] + pub(in crate::routes) proposals: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct ConsolidationRunsListQuery { + pub(in crate::routes) limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct ConsolidationProposalsListQuery { + pub(in crate::routes) run_id: Option, + pub(in crate::routes) review_state: Option, + pub(in crate::routes) limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct ConsolidationProposalReviewBody { + pub(in crate::routes) action: ConsolidationReviewAction, + pub(in crate::routes) review_comment: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct DreamingReviewQueueQuery { + pub(in crate::routes) run_id: Option, + pub(in crate::routes) review_state: Option, + pub(in crate::routes) limit: Option, +} diff --git a/apps/elf-api/src/routes/types/core_memory.rs b/apps/elf-api/src/routes/types/core_memory.rs new file mode 100644 index 00000000..5e86783b --- /dev/null +++ b/apps/elf-api/src/routes/types/core_memory.rs @@ -0,0 +1,20 @@ +use crate::routes::types::{Deserialize, Uuid, Value}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct CoreBlockUpsertBody { + pub(in crate::routes) block_id: Option, + pub(in crate::routes) scope: String, + pub(in crate::routes) key: String, + pub(in crate::routes) title: String, + pub(in crate::routes) content: String, + #[serde(default)] + pub(in crate::routes) source_ref: Value, + pub(in crate::routes) reason: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct CoreBlockAttachBody { + pub(in crate::routes) target_agent_id: String, + pub(in crate::routes) read_profile: String, + pub(in crate::routes) reason: Option, +} diff --git a/apps/elf-api/src/routes/types/docs.rs b/apps/elf-api/src/routes/types/docs.rs new file mode 100644 index 00000000..8a718e22 --- /dev/null +++ b/apps/elf-api/src/routes/types/docs.rs @@ -0,0 +1,45 @@ +use crate::routes::types::{ + Deserialize, DocType, TextPositionSelector, TextQuoteSelector, Uuid, Value, WritePolicy, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct DocsPutBody { + pub(in crate::routes) scope: String, + pub(in crate::routes) doc_type: Option, + pub(in crate::routes) title: Option, + #[serde(default)] + pub(in crate::routes) source_ref: Value, + + pub(in crate::routes) write_policy: Option, + pub(in crate::routes) content: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct DocsSearchL0Body { + pub(in crate::routes) query: String, + pub(in crate::routes) scope: Option, + pub(in crate::routes) status: Option, + pub(in crate::routes) doc_type: Option, + pub(in crate::routes) sparse_mode: Option, + pub(in crate::routes) domain: Option, + pub(in crate::routes) repo: Option, + pub(in crate::routes) agent_id: Option, + pub(in crate::routes) thread_id: Option, + pub(in crate::routes) updated_after: Option, + pub(in crate::routes) updated_before: Option, + pub(in crate::routes) ts_gte: Option, + pub(in crate::routes) ts_lte: Option, + pub(in crate::routes) top_k: Option, + pub(in crate::routes) candidate_k: Option, + pub(in crate::routes) explain: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct DocsExcerptsGetBody { + pub(in crate::routes) doc_id: Uuid, + pub(in crate::routes) level: String, + pub(in crate::routes) chunk_id: Option, + pub(in crate::routes) quote: Option, + pub(in crate::routes) position: Option, + pub(in crate::routes) explain: Option, +} diff --git a/apps/elf-api/src/routes/types/errors.rs b/apps/elf-api/src/routes/types/errors.rs new file mode 100644 index 00000000..b04a62f4 --- /dev/null +++ b/apps/elf-api/src/routes/types/errors.rs @@ -0,0 +1,8 @@ +use crate::routes::types::{Serialize, ToSchema}; + +#[derive(Debug, Serialize, ToSchema)] +pub(in crate::routes) struct ErrorBody { + pub(in crate::routes) error_code: String, + pub(in crate::routes) message: String, + pub(in crate::routes) fields: Option>, +} diff --git a/apps/elf-api/src/routes/types/events.rs b/apps/elf-api/src/routes/types/events.rs new file mode 100644 index 00000000..05646b6e --- /dev/null +++ b/apps/elf-api/src/routes/types/events.rs @@ -0,0 +1,9 @@ +use crate::routes::types::{Deserialize, EventMessage, IngestionProfileSelector}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct EventsIngestRequest { + pub(in crate::routes) scope: Option, + pub(in crate::routes) dry_run: Option, + pub(in crate::routes) ingestion_profile: Option, + pub(in crate::routes) messages: Vec, +} diff --git a/apps/elf-api/src/routes/types/graph.rs b/apps/elf-api/src/routes/types/graph.rs new file mode 100644 index 00000000..6840f73d --- /dev/null +++ b/apps/elf-api/src/routes/types/graph.rs @@ -0,0 +1,37 @@ +use crate::routes::types::{Deserialize, GraphQueryEntityRef, GraphQueryPredicateRef}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct GraphQueryBody { + pub(in crate::routes) subject: GraphQueryEntityRef, + pub(in crate::routes) predicate: Option, + pub(in crate::routes) scopes: Option>, + pub(in crate::routes) as_of: Option, + pub(in crate::routes) limit: Option, + pub(in crate::routes) explain: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct GraphReportBody { + pub(in crate::routes) subject: GraphQueryEntityRef, + pub(in crate::routes) predicate: Option, + pub(in crate::routes) scopes: Option>, + pub(in crate::routes) as_of: Option, + pub(in crate::routes) limit: Option, + pub(in crate::routes) explain: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct AdminGraphPredicatesListQuery { + pub(in crate::routes) scope: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct AdminGraphPredicatePatchBody { + pub(in crate::routes) status: Option, + pub(in crate::routes) cardinality: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct AdminGraphPredicateAliasAddBody { + pub(in crate::routes) alias: String, +} diff --git a/apps/elf-api/src/routes/types/ingestion_profiles.rs b/apps/elf-api/src/routes/types/ingestion_profiles.rs new file mode 100644 index 00000000..5f5a3df0 --- /dev/null +++ b/apps/elf-api/src/routes/types/ingestion_profiles.rs @@ -0,0 +1,27 @@ +use crate::routes::types::{Deserialize, Serialize, ToSchema, Value}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct AdminIngestionProfileCreateBody { + pub(in crate::routes) profile_id: String, + pub(in crate::routes) version: Option, + pub(in crate::routes) profile: Value, + pub(in crate::routes) created_by: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct AdminIngestionProfileGetQuery { + pub(in crate::routes) version: Option, +} + +#[derive(Clone, Debug, Deserialize, ToSchema)] +pub(in crate::routes) struct AdminIngestionProfileDefaultSetBody { + pub(in crate::routes) profile_id: String, + pub(in crate::routes) version: Option, +} + +#[derive(Clone, Debug, Serialize, ToSchema)] +pub(in crate::routes) struct AdminIngestionProfileDefaultResponseV2 { + pub(in crate::routes) profile_id: String, + pub(in crate::routes) version: Option, + pub(in crate::routes) updated_at: String, +} diff --git a/apps/elf-api/src/routes/types/knowledge.rs b/apps/elf-api/src/routes/types/knowledge.rs new file mode 100644 index 00000000..93090314 --- /dev/null +++ b/apps/elf-api/src/routes/types/knowledge.rs @@ -0,0 +1,51 @@ +use crate::routes::types::{ + Deserialize, KnowledgePageKind, KnowledgeSourceKind, Uuid, Value, empty_json_object, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct KnowledgePageRebuildBody { + pub(in crate::routes) page_kind: KnowledgePageKind, + pub(in crate::routes) page_key: String, + pub(in crate::routes) title: Option, + #[serde(default)] + pub(in crate::routes) doc_ids: Vec, + #[serde(default)] + pub(in crate::routes) doc_chunk_ids: Vec, + #[serde(default)] + pub(in crate::routes) note_ids: Vec, + #[serde(default)] + pub(in crate::routes) event_ids: Vec, + #[serde(default)] + pub(in crate::routes) relation_ids: Vec, + #[serde(default)] + pub(in crate::routes) proposal_ids: Vec, + #[serde(default = "empty_json_object")] + pub(in crate::routes) provider_metadata: Value, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct KnowledgePageChangedSourceBody { + pub(in crate::routes) source_kind: KnowledgeSourceKind, + pub(in crate::routes) source_id: Uuid, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct KnowledgePageWatchRebuildBody { + pub(in crate::routes) changed_sources: Vec, + pub(in crate::routes) page_kind: Option, + pub(in crate::routes) limit: Option, + pub(in crate::routes) generate_memory_candidates: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct KnowledgePagesListQuery { + pub(in crate::routes) page_kind: Option, + pub(in crate::routes) limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct KnowledgePagesSearchBody { + pub(in crate::routes) query: String, + pub(in crate::routes) page_kind: Option, + pub(in crate::routes) limit: Option, +} diff --git a/apps/elf-api/src/routes/types/notes.rs b/apps/elf-api/src/routes/types/notes.rs new file mode 100644 index 00000000..c40bf5ac --- /dev/null +++ b/apps/elf-api/src/routes/types/notes.rs @@ -0,0 +1,38 @@ +use crate::routes::types::{ + AddNoteInput, Deserialize, MemoryCorrectionAction, Serialize, Uuid, Value, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct NotesIngestRequest { + pub(in crate::routes) scope: String, + pub(in crate::routes) notes: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct NotesListQuery { + pub(in crate::routes) scope: Option, + pub(in crate::routes) status: Option, + pub(in crate::routes) r#type: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct NotePatchRequest { + pub(in crate::routes) text: Option, + pub(in crate::routes) importance: Option, + pub(in crate::routes) confidence: Option, + pub(in crate::routes) ttl_days: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct AdminNoteCorrectionBody { + pub(in crate::routes) action: MemoryCorrectionAction, + pub(in crate::routes) reason: String, + pub(in crate::routes) source_ref: Value, + pub(in crate::routes) restore_version_id: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct PublishResponseV2 { + pub(in crate::routes) note_id: Uuid, + pub(in crate::routes) space: String, +} diff --git a/apps/elf-api/src/routes/types/recall.rs b/apps/elf-api/src/routes/types/recall.rs new file mode 100644 index 00000000..ec1a16b2 --- /dev/null +++ b/apps/elf-api/src/routes/types/recall.rs @@ -0,0 +1,13 @@ +use crate::routes::types::{Deserialize, GraphQueryEntityRef, GraphQueryPredicateRef, Uuid}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct RecallDebugPanelBody { + pub(in crate::routes) trace_id: Option, + pub(in crate::routes) query: Option, + pub(in crate::routes) docs_query: Option, + pub(in crate::routes) knowledge_query: Option, + pub(in crate::routes) graph_subject: Option, + pub(in crate::routes) graph_predicate: Option, + pub(in crate::routes) include_dreaming: Option, + pub(in crate::routes) limit: Option, +} diff --git a/apps/elf-api/src/routes/types/search.rs b/apps/elf-api/src/routes/types/search.rs new file mode 100644 index 00000000..74302497 --- /dev/null +++ b/apps/elf-api/src/routes/types/search.rs @@ -0,0 +1,81 @@ +use crate::routes::types::{ + Deserialize, OffsetDateTime, PayloadLevel, QueryPlan, RankingRequestOverride, + SearchDetailsResult, SearchIndexItem, SearchMode, SearchTimelineGroup, SearchTrajectorySummary, + Serialize, Uuid, Value, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct SearchCreateRequest { + pub(in crate::routes) mode: SearchMode, + pub(in crate::routes) query: String, + pub(in crate::routes) top_k: Option, + pub(in crate::routes) candidate_k: Option, + + pub(in crate::routes) filter: Option, + pub(in crate::routes) payload_level: Option, + pub(in crate::routes) ranking: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SearchIndexResponseV2 { + pub(in crate::routes) mode: SearchMode, + pub(in crate::routes) trace_id: Uuid, + pub(in crate::routes) search_id: Uuid, + #[serde(with = "elf_service::time_serde")] + pub(in crate::routes) expires_at: OffsetDateTime, + pub(in crate::routes) items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(in crate::routes) trajectory_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(in crate::routes) query_plan: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SearchCreateResponseV2 { + pub(in crate::routes) mode: SearchMode, + pub(in crate::routes) trace_id: Uuid, + pub(in crate::routes) search_id: Uuid, + #[serde(with = "elf_service::time_serde")] + pub(in crate::routes) expires_at: OffsetDateTime, + pub(in crate::routes) items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(in crate::routes) trajectory_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(in crate::routes) query_plan: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct SearchSessionGetQuery { + pub(in crate::routes) payload_level: Option, + pub(in crate::routes) top_k: Option, + pub(in crate::routes) touch: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct SearchTimelineQuery { + pub(in crate::routes) payload_level: Option, + pub(in crate::routes) group_by: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SearchTimelineResponseV2 { + pub(in crate::routes) search_id: Uuid, + #[serde(with = "elf_service::time_serde")] + pub(in crate::routes) expires_at: OffsetDateTime, + pub(in crate::routes) groups: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct SearchDetailsBody { + pub(in crate::routes) note_ids: Vec, + pub(in crate::routes) payload_level: Option, + pub(in crate::routes) record_hits: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SearchDetailsResponseV2 { + pub(in crate::routes) search_id: Uuid, + #[serde(with = "elf_service::time_serde")] + pub(in crate::routes) expires_at: OffsetDateTime, + pub(in crate::routes) results: Vec, +} diff --git a/apps/elf-api/src/routes/types/sharing.rs b/apps/elf-api/src/routes/types/sharing.rs new file mode 100644 index 00000000..61501ed0 --- /dev/null +++ b/apps/elf-api/src/routes/types/sharing.rs @@ -0,0 +1,34 @@ +use crate::routes::types::{Deserialize, GranteeKind, OffsetDateTime, Serialize}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct ShareScopeBody { + pub(in crate::routes) space: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct SpaceGrantUpsertBody { + pub(in crate::routes) grantee_kind: GranteeKind, + pub(in crate::routes) grantee_agent_id: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SpaceGrantUpsertResponseV2 { + pub(in crate::routes) space: String, + pub(in crate::routes) grantee_kind: GranteeKind, + pub(in crate::routes) grantee_agent_id: Option, + pub(in crate::routes) granted: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SpaceGrantItemV2 { + pub(in crate::routes) space: String, + pub(in crate::routes) grantee_kind: GranteeKind, + pub(in crate::routes) grantee_agent_id: Option, + pub(in crate::routes) granted_by_agent_id: String, + pub(in crate::routes) granted_at: OffsetDateTime, +} + +#[derive(Clone, Debug, Serialize)] +pub(in crate::routes) struct SpaceGrantsListResponseV2 { + pub(in crate::routes) grants: Vec, +} diff --git a/apps/elf-api/src/routes/types/trace.rs b/apps/elf-api/src/routes/types/trace.rs new file mode 100644 index 00000000..af3c1f77 --- /dev/null +++ b/apps/elf-api/src/routes/types/trace.rs @@ -0,0 +1,19 @@ +use crate::routes::types::{Deserialize, TraceBundleMode, Uuid}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct TraceRecentListQuery { + pub(in crate::routes) limit: Option, + pub(in crate::routes) cursor_created_at: Option, + pub(in crate::routes) cursor_trace_id: Option, + pub(in crate::routes) agent_id: Option, + pub(in crate::routes) read_profile: Option, + pub(in crate::routes) created_after: Option, + pub(in crate::routes) created_before: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct TraceBundleGetQuery { + pub(in crate::routes) mode: Option, + pub(in crate::routes) stage_items_limit: Option, + pub(in crate::routes) candidates_limit: Option, +} diff --git a/apps/elf-api/src/routes/types/work_journal.rs b/apps/elf-api/src/routes/types/work_journal.rs new file mode 100644 index 00000000..f170c6dd --- /dev/null +++ b/apps/elf-api/src/routes/types/work_journal.rs @@ -0,0 +1,31 @@ +use crate::routes::types::{ + Deserialize, Uuid, Value, WorkJournalEntryFamily, WritePolicy, empty_json_object, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct WorkJournalEntryCreateBody { + pub(in crate::routes) entry_id: Option, + pub(in crate::routes) scope: String, + pub(in crate::routes) session_id: String, + pub(in crate::routes) family: WorkJournalEntryFamily, + pub(in crate::routes) title: Option, + pub(in crate::routes) body: String, + pub(in crate::routes) source_refs: Vec, + pub(in crate::routes) write_policy: Option, + #[serde(default)] + pub(in crate::routes) explicit_next_steps: Vec, + #[serde(default)] + pub(in crate::routes) inferred_next_steps: Vec, + #[serde(default)] + pub(in crate::routes) rejected_options: Vec, + #[serde(default = "empty_json_object")] + pub(in crate::routes) promotion_boundary: Value, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct WorkJournalSessionReadbackBody { + pub(in crate::routes) session_id: String, + #[serde(default)] + pub(in crate::routes) families: Vec, + pub(in crate::routes) limit: Option, +} diff --git a/apps/elf-api/src/routes/work_journal.rs b/apps/elf-api/src/routes/work_journal.rs new file mode 100644 index 00000000..f3a6f904 --- /dev/null +++ b/apps/elf-api/src/routes/work_journal.rs @@ -0,0 +1,152 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, + RequestContext, SecurityAuthRole, State, StatusCode, Uuid, WorkJournalEntryCreateBody, + WorkJournalEntryCreateRequest, WorkJournalEntryCreateResponse, WorkJournalEntryGetRequest, + WorkJournalEntryResponse, WorkJournalSessionReadbackBody, WorkJournalSessionReadbackRequest, + WorkJournalSessionReadbackResponse, +}; + +#[utoipa::path( + post, + path = "/v2/work-journal/entries", + tag = "work_journal", + request_body = Value, + responses( + (status = 200, description = "Work Journal entry was stored.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn work_journal_entry_create( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.trim() == "org_shared" { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .work_journal_entry_create(WorkJournalEntryCreateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + entry_id: payload.entry_id, + scope: payload.scope, + session_id: payload.session_id, + family: payload.family, + title: payload.title, + body: payload.body, + source_refs: payload.source_refs, + write_policy: payload.write_policy, + explicit_next_steps: payload.explicit_next_steps, + inferred_next_steps: payload.inferred_next_steps, + rejected_options: payload.rejected_options, + promotion_boundary: payload.promotion_boundary, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/work-journal/entries/{entry_id}", + tag = "work_journal", + responses( + (status = 200, description = "Work Journal entry metadata.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Work Journal entry not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn work_journal_entry_get( + State(state): State, + headers: HeaderMap, + Path(entry_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let response = state + .service + .work_journal_entry_get(WorkJournalEntryGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + entry_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/work-journal/readback", + tag = "work_journal", + request_body = Value, + responses( + (status = 200, description = "Work Journal session readback.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn work_journal_session_readback( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .work_journal_session_readback(WorkJournalSessionReadbackRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + session_id: payload.session_id, + families: payload.families, + limit: payload.limit, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/tests/http.rs b/apps/elf-api/tests/http.rs index b18ccd05..a268cac4 100644 --- a/apps/elf-api/tests/http.rs +++ b/apps/elf-api/tests/http.rs @@ -2,6 +2,11 @@ //! End-to-end HTTP integration tests for the ELF API app. +#[path = "http/auth_admin.rs"] mod auth_admin; +#[path = "http/contract.rs"] mod contract; +#[path = "http/request_validation.rs"] mod request_validation; +#[path = "http/sharing.rs"] mod sharing; + use std::{collections::HashMap, env}; use axum::{ @@ -14,12 +19,12 @@ use qdrant_client::{ qdrant::{Document, PointStruct, UpsertPointsBuilder, Vector}, }; use serde_json::Map; -use tower::util::ServiceExt as _; +use tower::util::ServiceExt; use tracing::Level; use uuid::Uuid; use elf_api::{ - routes::{self, OPENAPI_JSON_PATH, SCALAR_DOCS_PATH}, + routes::{self, OPENAPI_JSON_PATH}, state::AppState, }; use elf_config::{ @@ -933,1832 +938,3 @@ async fn contract_json() -> serde_json::Value { serde_json::from_slice(&body).expect("Failed to parse OpenAPI response.") } - -#[tokio::test] -async fn openapi_json_route_serves_generated_contract() { - let spec = contract_json().await; - - assert_eq!(spec["info"]["title"], "ELF API"); - assert!(spec.get("request_id").is_none()); - - assert_openapi_method(&spec, "/health", "get"); - assert_openapi_method(&spec, "/v2/notes/ingest", "post"); - assert_openapi_method(&spec, "/v2/events/ingest", "post"); - assert_openapi_method(&spec, "/v2/core-blocks", "get"); - assert_openapi_method(&spec, "/v2/entity-memory", "get"); - assert_openapi_method(&spec, "/v2/docs/search/l0", "post"); - assert_openapi_method(&spec, "/v2/work-journal/entries", "post"); - assert_openapi_method(&spec, "/v2/work-journal/entries/{entry_id}", "get"); - assert_openapi_method(&spec, "/v2/work-journal/readback", "post"); - assert_openapi_method(&spec, "/v2/searches/{search_id}/notes", "post"); - assert_openapi_method(&spec, "/v2/admin/core-blocks", "post"); - assert_openapi_method(&spec, "/v2/admin/core-blocks/{block_id}/attachments", "post"); - assert_openapi_method(&spec, "/v2/admin/core-blocks/attachments/{attachment_id}", "delete"); - assert_openapi_method(&spec, "/v2/admin/docs/{doc_id}", "get"); - assert_openapi_method(&spec, "/v2/admin/docs/search/l0", "post"); - assert_openapi_method(&spec, "/v2/admin/docs/excerpts", "post"); - assert_openapi_method(&spec, "/v2/admin/searches/raw", "post"); - assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "get"); - assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "put"); - assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "post"); - assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "get"); - assert_openapi_method(&spec, "/v2/admin/consolidation/runs/{run_id}", "get"); - assert_openapi_method(&spec, "/v2/admin/consolidation/proposals", "get"); - assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}", "get"); - assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}/review", "post"); - assert_openapi_method(&spec, "/v2/admin/notes/{note_id}/corrections", "post"); - assert_openapi_method(&spec, "/v2/admin/knowledge/pages/rebuild", "post"); - assert_openapi_method(&spec, "/v2/admin/knowledge/pages", "get"); - assert_openapi_method(&spec, "/v2/admin/knowledge/pages/search", "post"); - assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}", "get"); - assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}/lint", "post"); -} - -#[tokio::test] -async fn scalar_docs_route_serves_api_reference_html() { - let app = routes::contract_router::<()>(); - let response = app - .oneshot( - Request::builder() - .uri(SCALAR_DOCS_PATH) - .body(Body::empty()) - .expect("Failed to build Scalar docs request."), - ) - .await - .expect("Failed to call Scalar docs route."); - - assert_eq!(response.status(), StatusCode::OK); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read Scalar docs response body."); - let html = String::from_utf8(body.to_vec()).expect("Scalar docs response was not UTF-8."); - - assert!(html.contains("@scalar/api-reference")); - assert!(html.contains("/v2/admin/events/ingestion-profiles/default")); - assert!(html.contains("/v2/admin/consolidation/proposals")); - assert!(html.contains("/v2/admin/docs/search/l0")); - assert!(html.contains("/v2/admin/knowledge/pages")); - assert!(html.contains("/v2/admin/knowledge/pages/search")); - assert!(html.contains("/v2/work-journal/readback")); -} - -#[tokio::test] -async fn openapi_includes_default_ingestion_profile_get_put_contract() { - let spec = contract_json().await; - let default_path = &spec["paths"]["/v2/admin/events/ingestion-profiles/default"]; - let get_schema_ref = - default_path["get"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] - .as_str() - .expect("Missing default profile GET response schema ref."); - let put_request_schema_ref = default_path["put"]["requestBody"]["content"]["application/json"] - ["schema"]["$ref"] - .as_str() - .expect("Missing default profile PUT request schema ref."); - let put_response_schema_ref = - default_path["put"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] - .as_str() - .expect("Missing default profile PUT response schema ref."); - - assert!(get_schema_ref.ends_with("/AdminIngestionProfileDefaultResponseV2")); - assert!(put_request_schema_ref.ends_with("/AdminIngestionProfileDefaultSetBody")); - assert!(put_response_schema_ref.ends_with("/AdminIngestionProfileDefaultResponseV2")); - - let schemas = &spec["components"]["schemas"]; - let request_schema = &schemas["AdminIngestionProfileDefaultSetBody"]; - let response_schema = &schemas["AdminIngestionProfileDefaultResponseV2"]; - - assert!(request_schema["properties"].get("profile_id").is_some()); - assert!(request_schema["properties"].get("version").is_some()); - assert!( - request_schema["required"] - .as_array() - .expect("Missing request required fields") - .contains(&serde_json::json!("profile_id")) - ); - assert!(response_schema["properties"].get("profile_id").is_some()); - assert!(response_schema["properties"].get("version").is_some()); - assert!(response_schema["properties"].get("updated_at").is_some()); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn sharing_visibility_requires_explicit_project_grant() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - insert_note(&state, note_id, "project_shared", TEST_AGENT_A, "Fact: shared note without grant") - .await; - - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(response.status(), StatusCode::OK); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse list response."); - - assert_eq!(list_json["items"].as_array().expect("Missing items array.").len(), 0); - - let note_response = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(note_response.status(), StatusCode::BAD_REQUEST); - - let body = body::to_bytes(note_response.into_body(), usize::MAX) - .await - .expect("Failed to read get response body."); - let note_json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse get response."); - - assert_eq!(note_json["error_code"], "INVALID_REQUEST"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let admin_app = routes::admin_router(state.clone()); - let private_block_id = create_core_block( - &admin_app, - "agent_private", - "private_operating_context", - "Preference: Keep core context separate from archival search.", - ) - .await; - let note_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "Fact: This archival note must not appear in attached core blocks.", - ) - .await; - - let (status, _) = - attach_core_block(&admin_app, private_block_id, TEST_AGENT_A, "private_only").await; - let before_sessions = search_session_count(&state).await; - let blocks = get_core_blocks(&app, TEST_AGENT_A, "private_only").await; - let after_sessions = search_session_count(&state).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(before_sessions, after_sessions); - assert_eq!(blocks["schema"], "elf.core_memory_blocks/v1"); - assert_eq!(blocks["items"].as_array().expect("items array").len(), 1); - assert_eq!( - blocks["items"][0]["content"], - "Preference: Keep core context separate from archival search." - ); - assert_eq!(blocks["items"][0]["source_ref"]["schema"], "core_block_source/v1"); - assert!(blocks["items"][0]["audit_history"].as_array().expect("audit history").len() >= 2); - assert!(!blocks.to_string().contains("archival note must not appear")); - - let b_private = get_core_blocks(&app, TEST_AGENT_B, "private_only").await; - - assert_eq!(b_private["items"].as_array().expect("items array").len(), 0); - - let shared_block_id = create_core_block( - &admin_app, - "project_shared", - "shared_operating_context", - "Constraint: Shared core context requires explicit project grant and attachment.", - ) - .await; - let (denied_status, _) = - attach_core_block(&admin_app, shared_block_id, TEST_AGENT_B, "private_plus_project").await; - - assert_eq!(denied_status, StatusCode::FORBIDDEN); - - insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - - let (shared_status, _) = - attach_core_block(&admin_app, shared_block_id, TEST_AGENT_B, "private_plus_project").await; - let b_shared = get_core_blocks(&app, TEST_AGENT_B, "private_plus_project").await; - let b_wrong_profile = get_core_blocks(&app, TEST_AGENT_B, "private_only").await; - - assert_eq!(shared_status, StatusCode::OK); - assert_eq!(b_shared["items"].as_array().expect("items array").len(), 1); - assert_eq!(b_shared["items"][0]["scope"], "project_shared"); - assert_eq!(b_wrong_profile["items"].as_array().expect("items array").len(), 0); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn org_shared_note_is_visible_across_projects() { - let Some((test_db, app, state, note_id)) = - org_shared_note_is_visible_across_projects_fixture().await - else { - return; - }; - let list_before_json = list_org_shared_notes_as_reader(&app).await; - - assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 0); - - publish_org_shared_note_as_reader_can_see(&app, note_id).await; - - let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); - let grant_upsert_response = post_with_authorization_and_json_body( - &app, - "/v2/spaces/org_shared/grants", - "Bearer admin-token", - &grant_upsert_payload, - "Failed to build grant upsert request.", - "Failed to call grant upsert.", - ) - .await; - - assert_eq!(grant_upsert_response.status(), StatusCode::OK); - - assert_note_visible_to_project_reader(&app, &state, note_id).await; - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn sharing_project_grant_enables_agent_access_to_shared_note() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "project_shared", - TEST_AGENT_A, - "Fact: shared note with explicit grant.", - ) - .await; - insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(response.status(), StatusCode::OK); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse list response."); - let items = list_json["items"].as_array().expect("Missing items array."); - - assert_eq!(items.len(), 1); - assert_eq!(items[0]["note_id"], note_id.to_string()); - - let note_response = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(note_response.status(), StatusCode::OK); - - let body = body::to_bytes(note_response.into_body(), usize::MAX) - .await - .expect("Failed to read get response body."); - let note_json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse get response."); - - assert_eq!(note_json["note_id"], note_id.to_string()); - assert_eq!(note_json["scope"], "project_shared"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn sharing_publish_creates_scope_and_grant_visibility() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "Fact: private note for publish test.", - ) - .await; - - let initial_grant_count = active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(initial_grant_count, 0); - - let publish_payload = serde_json::json!({"space":"team_shared"}).to_string(); - let publish_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("content-type", "application/json") - .body(Body::from(publish_payload)) - .expect("Failed to build publish request."), - ) - .await - .expect("Failed to call note publish."); - - assert_eq!(publish_response.status(), StatusCode::OK); - - let publish_body = body::to_bytes(publish_response.into_body(), usize::MAX) - .await - .expect("Failed to read publish response body."); - let publish_json: serde_json::Value = - serde_json::from_slice(&publish_body).expect("Failed to parse publish response."); - - assert_eq!(publish_json["note_id"], note_id.to_string()); - assert_eq!(publish_json["space"], "team_shared"); - - let after_grant_count = active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(after_grant_count, 1); - - let list_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(list_response.status(), StatusCode::OK); - - let list_body = body::to_bytes(list_response.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_json: serde_json::Value = - serde_json::from_slice(&list_body).expect("Failed to parse list response."); - let items = list_json["items"].as_array().expect("Missing items array."); - - assert_eq!(items.len(), 1); - assert_eq!(items[0]["note_id"], note_id.to_string()); - - let get_response = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(get_response.status(), StatusCode::OK); - - let get_body = body::to_bytes(get_response.into_body(), usize::MAX) - .await - .expect("Failed to read get response body."); - let get_json: serde_json::Value = - serde_json::from_slice(&get_body).expect("Failed to parse get response."); - - assert_eq!(get_json["note_id"], note_id.to_string()); - assert_eq!(get_json["scope"], "project_shared"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn sharing_revoke_project_grant_removes_visibility() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "project_shared", - TEST_AGENT_A, - "Fact: shared note for revoke test.", - ) - .await; - insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - - let grant_count_before = active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(grant_count_before, 1); - - let list_before = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - let list_before_body = body::to_bytes(list_before.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_before_json: serde_json::Value = - serde_json::from_slice(&list_before_body).expect("Failed to parse list response."); - - assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 1); - - let revoke_payload = serde_json::json!({"grantee_kind":"project"}).to_string(); - let revoke_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/team_shared/grants/revoke") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("content-type", "application/json") - .body(Body::from(revoke_payload)) - .expect("Failed to build revoke request."), - ) - .await - .expect("Failed to call grant revoke."); - - assert_eq!(revoke_response.status(), StatusCode::OK); - - let grant_count_after = active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(grant_count_after, 0); - - let list_after = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(list_after.status(), StatusCode::OK); - - let list_after_body = body::to_bytes(list_after.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_after_json: serde_json::Value = - serde_json::from_slice(&list_after_body).expect("Failed to parse list response."); - - assert_eq!(list_after_json["items"].as_array().expect("Missing items array.").len(), 0); - - let get_after = app - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(get_after.status(), StatusCode::BAD_REQUEST); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn health_ok() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let _ = routes::admin_router(state); - let response = app - .oneshot( - Request::builder() - .uri("/health") - .body(Body::empty()) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call /health."); - - assert_eq!(response.status(), StatusCode::OK); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn rejects_non_english_in_add_note() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "agent_private", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_note."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.notes[0].text"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn rejects_cyrillic_in_add_note() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "agent_private", - "notes": [{ - "type": "fact", - "key": null, - "text": "Привет мир", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_note."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.notes[0].text"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn rejects_non_english_in_add_event() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { return }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "agent_private", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "こんにちは" - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_event."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.messages[0].content"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn rejects_cyrillic_in_add_event() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { return }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "agent_private", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "Это не английский текст." - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_event."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.messages[0].content"); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn rejects_non_english_in_search() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - - for mode in ["quick_find", "planned_search"] { - let payload = serde_json::json!({ - "mode": mode, - "query": "안녕하세요", - "top_k": 5, - "candidate_k": 10, - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/searches") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("X-ELF-Read-Profile", "private_only") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call search."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.query"); - } - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn rejects_cyrillic_in_search() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - - for mode in ["quick_find", "planned_search"] { - let payload = serde_json::json!({ - "mode": mode, - "query": "Привет", - "top_k": 5, - "candidate_k": 10, - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/searches") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("X-ELF-Read-Profile", "private_only") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call search."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.query"); - } - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn searches_notes_payload_level_shapes_source_ref_and_structured() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let source_ref = serde_json::json!({ - "schema": "note_source_ref/v1", - "locator": { - "document_id": Uuid::new_v4().to_string(), - "chunk_id": Uuid::new_v4().to_string(), - "revision": "payload-shaping-contract-test" - }, - "metadata": { - "heavy_field": "This field should be hidden when payload_level is below l2." - } - }); - let structured_summary = "Compact structured summary used for payload-level l1 and l2 shaping."; - let note_text = - "Payload shaping note used in contract tests for search details output shaping."; - let note_id = - create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()).await; - - insert_note_summary_field(&state, note_id, structured_summary).await; - - let search_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/searches") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("X-ELF-Read-Profile", "private_only") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "mode": "quick_find", - "query": "payload shaping", - "top_k": 5, - "candidate_k": 10, - }) - .to_string(), - )) - .expect("Failed to build searches request."), - ) - .await - .expect("Failed to call searches."); - - assert_eq!(search_response.status(), StatusCode::OK); - - let search_body = body::to_bytes(search_response.into_body(), usize::MAX) - .await - .expect("Failed to read searches response body."); - let search_json: serde_json::Value = - serde_json::from_slice(&search_body).expect("Failed to parse searches response."); - let trajectory = &search_json["trajectory_summary"]; - - if !trajectory.is_null() { - assert!(trajectory.is_object()); - assert!(trajectory.get("stages").is_some()); - } - - let search_id = Uuid::parse_str( - search_json["search_id"].as_str().expect("Missing search_id in searches response."), - ) - .expect("Invalid search_id value."); - let notes_l0 = fetch_search_notes_for_payload_level(&app, search_id, note_id, "l0").await; - let notes_l1 = fetch_search_notes_for_payload_level(&app, search_id, note_id, "l1").await; - let notes_l2 = fetch_search_notes_for_payload_level(&app, search_id, note_id, "l2").await; - let search_get_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/v2/searches/{search_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("X-ELF-Read-Profile", "private_only") - .body(Body::empty()) - .expect("Failed to build searches get request."), - ) - .await - .expect("Failed to call searches get."); - - assert_eq!(search_get_response.status(), StatusCode::OK); - - let search_get_body = body::to_bytes(search_get_response.into_body(), usize::MAX) - .await - .expect("Failed to read searches get response body."); - let search_get_json: serde_json::Value = - serde_json::from_slice(&search_get_body).expect("Failed to parse searches get response."); - let search_get_trajectory = &search_get_json["trajectory_summary"]; - - if !search_get_trajectory.is_null() { - assert!(search_get_trajectory.is_object()); - assert!(search_get_trajectory.get("stages").is_some()); - } - - let notes_l0_text = notes_l0["text"].as_str().expect("Missing l0 text."); - let notes_l1_text = notes_l1["text"].as_str().expect("Missing l1 text."); - let notes_l2_text = notes_l2["text"].as_str().expect("Missing l2 text."); - - assert_eq!(notes_l0["source_ref"], serde_json::json!({})); - assert_eq!(notes_l1["source_ref"], serde_json::json!({})); - assert_eq!(notes_l2["source_ref"], source_ref); - assert!(notes_l0["structured"].is_null()); - assert!(notes_l1["structured"].is_object()); - assert!(notes_l2["structured"].is_object()); - assert!(notes_l0_text.len() <= 240); - assert_eq!(notes_l0_text, note_text); - assert_eq!(notes_l1_text, structured_summary); - assert_eq!(notes_l2_text, note_text); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_searches_raw_payload_level_shapes_source_ref() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let admin_app = routes::admin_router(state.clone()); - let source_ref = serde_json::json!({ - "schema": "note_source_ref/v1", - "locator": { - "document_id": Uuid::new_v4().to_string(), - "chunk_id": Uuid::new_v4().to_string(), - "revision": "admin-raw-contract-test" - }, - "metadata": { - "heavy_field": "This field should be hidden when payload_level is below l2." - } - }); - let note_text = - "Admin raw search payload shaping contract note. This long note should be indexed."; - let _note_id = - create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()).await; - let raw_l0 = fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l0").await; - let raw_l1 = fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l1").await; - let raw_l2 = fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l2").await; - - assert_eq!(raw_l0, serde_json::json!({})); - assert_eq!(raw_l1, serde_json::json!({})); - assert_eq!(raw_l2, source_ref); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_requires_bearer_header() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let no_auth = app - .clone() - .oneshot(Request::builder().uri("/health").body(Body::empty()).expect("build request")) - .await - .expect("call /health without auth"); - - assert_eq!(no_auth.status(), StatusCode::UNAUTHORIZED); - - let non_bearer_auth = app - .clone() - .oneshot( - Request::builder() - .uri("/health") - .header("Authorization", "Basic secret") - .body(Body::empty()) - .expect("build non-bearer auth request"), - ) - .await - .expect("call /health with non-bearer auth"); - - assert_eq!(non_bearer_auth.status(), StatusCode::UNAUTHORIZED); - - let bearer_auth = app - .oneshot( - Request::builder() - .uri("/health") - .header("Authorization", "Bearer secret") - .body(Body::empty()) - .expect("build bearer auth request"), - ) - .await - .expect("call /health with bearer auth"); - - assert_eq!(bearer_auth.status(), StatusCode::OK); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -async fn static_keys_admin_required_for_org_shared_writes_fixture() --> Option<(TestDatabase, Router, Uuid)> { - let (test_db, qdrant_url, collection) = test_env().await?; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user-token-id".to_string(), - token: "user-token".to_string(), - tenant_id: TEST_TENANT_ID.to_string(), - project_id: TEST_PROJECT_ID.to_string(), - agent_id: Some("user-agent".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin-token-id".to_string(), - token: "admin-token".to_string(), - tenant_id: TEST_TENANT_ID.to_string(), - project_id: TEST_PROJECT_ID.to_string(), - agent_id: Some("admin-agent".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "agent_private", - "admin-agent", - "Fact: org-shared publish setup note.", - ) - .await; - - Some((test_db, app, note_id)) -} - -async fn static_keys_admin_required_for_org_shared_writes_requests(app: &Router, note_id: Uuid) { - static_keys_admin_required_for_org_shared_writes_ingest_checks(app).await; - static_keys_admin_required_for_org_shared_writes_publish_checks(app, note_id).await; - static_keys_admin_required_for_org_shared_writes_grant_checks(app).await; -} - -async fn static_keys_admin_required_for_org_shared_writes_ingest_checks(app: &Router) { - let notes_payload = serde_json::json!({ - "scope": "org_shared", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }) - .to_string(); - let user_ingest = post_with_authorization_and_json_body( - app, - "/v2/notes/ingest", - "Bearer user-token", - ¬es_payload, - "Failed to build notes ingest request.", - "Failed to call notes ingest.", - ) - .await; - - assert_eq!(user_ingest.status(), StatusCode::FORBIDDEN); - - let admin_ingest = post_with_authorization_and_json_body( - app, - "/v2/notes/ingest", - "Bearer admin-token", - ¬es_payload, - "Failed to build notes ingest request.", - "Failed to call notes ingest (admin).", - ) - .await; - - assert_eq!(admin_ingest.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let admin_ingest_body = body::to_bytes(admin_ingest.into_body(), usize::MAX) - .await - .expect("Failed to read notes ingest response body."); - let admin_ingest_json: serde_json::Value = - serde_json::from_slice(&admin_ingest_body).expect("Failed to parse response."); - - assert_eq!(admin_ingest_json["error_code"], "NON_ENGLISH_INPUT"); -} - -async fn static_keys_admin_required_for_org_shared_writes_publish_checks( - app: &Router, - note_id: Uuid, -) { - let publish_payload = serde_json::json!({ "space": "org_shared" }).to_string(); - let user_publish = post_with_authorization_and_json_body( - app, - &format!("/v2/notes/{note_id}/publish"), - "Bearer user-token", - &publish_payload, - "Failed to build note publish request.", - "Failed to call notes publish.", - ) - .await; - - assert_eq!(user_publish.status(), StatusCode::FORBIDDEN); - - let admin_publish = post_with_authorization_and_json_body( - app, - &format!("/v2/notes/{note_id}/publish"), - "Bearer admin-token", - &publish_payload, - "Failed to build note publish request.", - "Failed to call notes publish (admin).", - ) - .await; - - assert_eq!(admin_publish.status(), StatusCode::OK); -} - -async fn static_keys_admin_required_for_org_shared_writes_grant_checks(app: &Router) { - let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); - let user_grant_upsert = post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants", - "Bearer user-token", - &grant_upsert_payload, - "Failed to build grant upsert request.", - "Failed to call grant upsert.", - ) - .await; - - assert_eq!(user_grant_upsert.status(), StatusCode::FORBIDDEN); - - let admin_grant_upsert = post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants", - "Bearer admin-token", - &grant_upsert_payload, - "Failed to build grant upsert request.", - "Failed to call grant upsert (admin).", - ) - .await; - - assert_eq!(admin_grant_upsert.status(), StatusCode::OK); - - let grant_revoke_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); - let user_grant_revoke = post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants/revoke", - "Bearer user-token", - &grant_revoke_payload, - "Failed to build grant revoke request.", - "Failed to call grant revoke.", - ) - .await; - - assert_eq!(user_grant_revoke.status(), StatusCode::FORBIDDEN); - - let admin_grant_revoke = post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants/revoke", - "Bearer admin-token", - &grant_revoke_payload, - "Failed to build grant revoke request.", - "Failed to call grant revoke (admin).", - ) - .await; - - assert_eq!(admin_grant_revoke.status(), StatusCode::OK); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_admin_required_for_org_shared_writes() { - let Some((test_db, app, note_id)) = - static_keys_admin_required_for_org_shared_writes_fixture().await - else { - return; - }; - - static_keys_admin_required_for_org_shared_writes_requests(&app, note_id).await; - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_ingest_requires_admin() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { return }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "org_shared", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call notes ingest (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call notes ingest (admin)."); - - assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_events_ingest_requires_admin() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { return }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "org_shared", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "こんにちは" - }] - }); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call events ingest (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call events ingest (admin)."); - - assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_publish_requires_admin() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { return }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let note_id = Uuid::new_v4(); - let payload = serde_json::json!({"space":"org_shared"}).to_string(); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.clone())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call note publish (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload)) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call note publish (admin)."); - - assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_grants_require_admin() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { return }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({"grantee_kind":"project","grantee_agent_id":null}).to_string(); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/org_shared/grants") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.clone())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call grant upsert (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/org_shared/grants") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload)) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call grant upsert (admin)."); - - assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_note_provenance_includes_request_id_on_success() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "off".to_string(); - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state.clone()); - let note_id = Uuid::new_v4(); - let request_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "Provenance integration test note.", - ) - .await; - - let response = app - .oneshot( - Request::builder() - .uri(format!("/v2/admin/notes/{note_id}/provenance")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("X-ELF-Request-Id", request_id.to_string()) - .body(Body::empty()) - .expect("Failed to build provenance request."), - ) - .await - .expect("Failed to call admin note provenance."); - - assert_eq!(response.status(), StatusCode::OK); - - let expected_request_id = request_id.to_string(); - - assert_eq!( - response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), - Some(expected_request_id.as_str()) - ); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read provenance response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["schema"], "elf.note_provenance_bundle/v1"); - assert_eq!(json["request_id"], request_id.to_string()); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_note_history_includes_request_id_on_success() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "off".to_string(); - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state.clone()); - let note_id = Uuid::new_v4(); - let request_id = Uuid::new_v4(); - - insert_note(&state, note_id, "agent_private", TEST_AGENT_A, "History integration test note.") - .await; - - let response = app - .oneshot( - Request::builder() - .uri(format!("/v2/admin/notes/{note_id}/history")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("X-ELF-Request-Id", request_id.to_string()) - .body(Body::empty()) - .expect("Failed to build history request."), - ) - .await - .expect("Failed to call admin note history."); - - assert_eq!(response.status(), StatusCode::OK); - - let expected_request_id = request_id.to_string(); - - assert_eq!( - response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), - Some(expected_request_id.as_str()) - ); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read history response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["schema"], "elf.memory_history/v1"); - assert_eq!(json["request_id"], request_id.to_string()); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_note_provenance_rejects_invalid_request_id_header() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "off".to_string(); - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state); - let note_id = Uuid::new_v4(); - let response = app - .oneshot( - Request::builder() - .uri(format!("/v2/admin/notes/{note_id}/provenance")) - .header("X-ELF-Request-Id", "not-a-uuid") - .body(Body::empty()) - .expect("Failed to build provenance request."), - ) - .await - .expect("Failed to call admin note provenance."); - let response_request_id = response - .headers() - .get("X-ELF-Request-Id") - .and_then(|value| value.to_str().ok()) - .expect("Expected request id header in error response."); - let generated_request_id = Uuid::parse_str(response_request_id) - .expect("Expected valid generated request_id in response header."); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read provenance response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "INVALID_REQUEST"); - assert_eq!(json["fields"][0], "$.headers.X-ELF-Request-Id"); - assert_eq!(json["request_id"], serde_json::Value::String(generated_request_id.to_string()),); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn global_graph_predicate_write_requires_super_admin() { - let Some((test_db, qdrant_url, collection)) = test_env().await else { - return; - }; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - SecurityAuthKey { - token_id: "super".to_string(), - token: "super-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::SuperAdmin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state.clone()); - let predicate_id = Uuid::new_v4(); - - sqlx::query( - "\ - INSERT INTO graph_predicates ( - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at - ) - VALUES ($1, '__global__', NULL, NULL, 'global_test', 'global_test', 'multi', 'pending', now(), now())", - ) - .bind(predicate_id) - .execute(&state.service.db.pool) - .await - .expect("Failed to insert global predicate."); - - let payload = serde_json::json!({ "status": "active" }); - let response_admin = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call admin graph predicate patch (admin)."); - - assert_eq!(response_admin.status(), StatusCode::FORBIDDEN); - - let body = body::to_bytes(response_admin.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "SCOPE_DENIED"); - - let response_super = app - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) - .header("Authorization", "Bearer super-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call admin graph predicate patch (super_admin)."); - - assert_eq!(response_super.status(), StatusCode::OK); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} diff --git a/apps/elf-api/tests/http/auth_admin.rs b/apps/elf-api/tests/http/auth_admin.rs new file mode 100644 index 00000000..95a0688e --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin.rs @@ -0,0 +1,801 @@ +use axum::{ + Router, + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::{TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; +use elf_testkit::TestDatabase; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_requires_bearer_header() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let no_auth = app + .clone() + .oneshot(Request::builder().uri("/health").body(Body::empty()).expect("build request")) + .await + .expect("call /health without auth"); + + assert_eq!(no_auth.status(), StatusCode::UNAUTHORIZED); + + let non_bearer_auth = app + .clone() + .oneshot( + Request::builder() + .uri("/health") + .header("Authorization", "Basic secret") + .body(Body::empty()) + .expect("build non-bearer auth request"), + ) + .await + .expect("call /health with non-bearer auth"); + + assert_eq!(non_bearer_auth.status(), StatusCode::UNAUTHORIZED); + + let bearer_auth = app + .oneshot( + Request::builder() + .uri("/health") + .header("Authorization", "Bearer secret") + .body(Body::empty()) + .expect("build bearer auth request"), + ) + .await + .expect("call /health with bearer auth"); + + assert_eq!(bearer_auth.status(), StatusCode::OK); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +async fn static_keys_admin_required_for_org_shared_writes_fixture() +-> Option<(TestDatabase, Router, Uuid)> { + let (test_db, qdrant_url, collection) = crate::test_env().await?; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user-token-id".to_string(), + token: "user-token".to_string(), + tenant_id: TEST_TENANT_ID.to_string(), + project_id: TEST_PROJECT_ID.to_string(), + agent_id: Some("user-agent".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin-token-id".to_string(), + token: "admin-token".to_string(), + tenant_id: TEST_TENANT_ID.to_string(), + project_id: TEST_PROJECT_ID.to_string(), + agent_id: Some("admin-agent".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let note_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "agent_private", + "admin-agent", + "Fact: org-shared publish setup note.", + ) + .await; + + Some((test_db, app, note_id)) +} + +async fn static_keys_admin_required_for_org_shared_writes_requests(app: &Router, note_id: Uuid) { + static_keys_admin_required_for_org_shared_writes_ingest_checks(app).await; + static_keys_admin_required_for_org_shared_writes_publish_checks(app, note_id).await; + static_keys_admin_required_for_org_shared_writes_grant_checks(app).await; +} + +async fn static_keys_admin_required_for_org_shared_writes_ingest_checks(app: &Router) { + let notes_payload = serde_json::json!({ + "scope": "org_shared", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }) + .to_string(); + let user_ingest = crate::post_with_authorization_and_json_body( + app, + "/v2/notes/ingest", + "Bearer user-token", + ¬es_payload, + "Failed to build notes ingest request.", + "Failed to call notes ingest.", + ) + .await; + + assert_eq!(user_ingest.status(), StatusCode::FORBIDDEN); + + let admin_ingest = crate::post_with_authorization_and_json_body( + app, + "/v2/notes/ingest", + "Bearer admin-token", + ¬es_payload, + "Failed to build notes ingest request.", + "Failed to call notes ingest (admin).", + ) + .await; + + assert_eq!(admin_ingest.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let admin_ingest_body = body::to_bytes(admin_ingest.into_body(), usize::MAX) + .await + .expect("Failed to read notes ingest response body."); + let admin_ingest_json: Value = + serde_json::from_slice(&admin_ingest_body).expect("Failed to parse response."); + + assert_eq!(admin_ingest_json["error_code"], "NON_ENGLISH_INPUT"); +} + +async fn static_keys_admin_required_for_org_shared_writes_publish_checks( + app: &Router, + note_id: Uuid, +) { + let publish_payload = serde_json::json!({ "space": "org_shared" }).to_string(); + let user_publish = crate::post_with_authorization_and_json_body( + app, + &format!("/v2/notes/{note_id}/publish"), + "Bearer user-token", + &publish_payload, + "Failed to build note publish request.", + "Failed to call notes publish.", + ) + .await; + + assert_eq!(user_publish.status(), StatusCode::FORBIDDEN); + + let admin_publish = crate::post_with_authorization_and_json_body( + app, + &format!("/v2/notes/{note_id}/publish"), + "Bearer admin-token", + &publish_payload, + "Failed to build note publish request.", + "Failed to call notes publish (admin).", + ) + .await; + + assert_eq!(admin_publish.status(), StatusCode::OK); +} + +async fn static_keys_admin_required_for_org_shared_writes_grant_checks(app: &Router) { + let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); + let user_grant_upsert = crate::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants", + "Bearer user-token", + &grant_upsert_payload, + "Failed to build grant upsert request.", + "Failed to call grant upsert.", + ) + .await; + + assert_eq!(user_grant_upsert.status(), StatusCode::FORBIDDEN); + + let admin_grant_upsert = crate::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants", + "Bearer admin-token", + &grant_upsert_payload, + "Failed to build grant upsert request.", + "Failed to call grant upsert (admin).", + ) + .await; + + assert_eq!(admin_grant_upsert.status(), StatusCode::OK); + + let grant_revoke_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); + let user_grant_revoke = crate::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants/revoke", + "Bearer user-token", + &grant_revoke_payload, + "Failed to build grant revoke request.", + "Failed to call grant revoke.", + ) + .await; + + assert_eq!(user_grant_revoke.status(), StatusCode::FORBIDDEN); + + let admin_grant_revoke = crate::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants/revoke", + "Bearer admin-token", + &grant_revoke_payload, + "Failed to build grant revoke request.", + "Failed to call grant revoke (admin).", + ) + .await; + + assert_eq!(admin_grant_revoke.status(), StatusCode::OK); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_admin_required_for_org_shared_writes() { + let Some((test_db, app, note_id)) = + static_keys_admin_required_for_org_shared_writes_fixture().await + else { + return; + }; + + static_keys_admin_required_for_org_shared_writes_requests(&app, note_id).await; + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_ingest_requires_admin() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "org_shared", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call notes ingest (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call notes ingest (admin)."); + + assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_events_ingest_requires_admin() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "org_shared", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "こんにちは" + }] + }); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call events ingest (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call events ingest (admin)."); + + assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_publish_requires_admin() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let note_id = Uuid::new_v4(); + let payload = serde_json::json!({"space":"org_shared"}).to_string(); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.clone())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call note publish (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload)) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call note publish (admin)."); + + assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_grants_require_admin() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({"grantee_kind":"project","grantee_agent_id":null}).to_string(); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/org_shared/grants") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.clone())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call grant upsert (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/org_shared/grants") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload)) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call grant upsert (admin)."); + + assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_note_provenance_includes_request_id_on_success() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "off".to_string(); + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state.clone()); + let note_id = Uuid::new_v4(); + let request_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "Provenance integration test note.", + ) + .await; + + let response = app + .oneshot( + Request::builder() + .uri(format!("/v2/admin/notes/{note_id}/provenance")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("X-ELF-Request-Id", request_id.to_string()) + .body(Body::empty()) + .expect("Failed to build provenance request."), + ) + .await + .expect("Failed to call admin note provenance."); + + assert_eq!(response.status(), StatusCode::OK); + + let expected_request_id = request_id.to_string(); + + assert_eq!( + response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), + Some(expected_request_id.as_str()) + ); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read provenance response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["schema"], "elf.note_provenance_bundle/v1"); + assert_eq!(json["request_id"], request_id.to_string()); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_note_history_includes_request_id_on_success() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "off".to_string(); + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state.clone()); + let note_id = Uuid::new_v4(); + let request_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "History integration test note.", + ) + .await; + + let response = app + .oneshot( + Request::builder() + .uri(format!("/v2/admin/notes/{note_id}/history")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("X-ELF-Request-Id", request_id.to_string()) + .body(Body::empty()) + .expect("Failed to build history request."), + ) + .await + .expect("Failed to call admin note history."); + + assert_eq!(response.status(), StatusCode::OK); + + let expected_request_id = request_id.to_string(); + + assert_eq!( + response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), + Some(expected_request_id.as_str()) + ); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read history response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["schema"], "elf.memory_history/v1"); + assert_eq!(json["request_id"], request_id.to_string()); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_note_provenance_rejects_invalid_request_id_header() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "off".to_string(); + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state); + let note_id = Uuid::new_v4(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/v2/admin/notes/{note_id}/provenance")) + .header("X-ELF-Request-Id", "not-a-uuid") + .body(Body::empty()) + .expect("Failed to build provenance request."), + ) + .await + .expect("Failed to call admin note provenance."); + let response_request_id = response + .headers() + .get("X-ELF-Request-Id") + .and_then(|value| value.to_str().ok()) + .expect("Expected request id header in error response."); + let generated_request_id = Uuid::parse_str(response_request_id) + .expect("Expected valid generated request_id in response header."); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read provenance response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "INVALID_REQUEST"); + assert_eq!(json["fields"][0], "$.headers.X-ELF-Request-Id"); + assert_eq!(json["request_id"], serde_json::Value::String(generated_request_id.to_string()),); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn global_graph_predicate_write_requires_super_admin() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + SecurityAuthKey { + token_id: "super".to_string(), + token: "super-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::SuperAdmin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state.clone()); + let predicate_id = Uuid::new_v4(); + + sqlx::query( + "\ + INSERT INTO graph_predicates ( + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at + ) + VALUES ($1, '__global__', NULL, NULL, 'global_test', 'global_test', 'multi', 'pending', now(), now())", + ) + .bind(predicate_id) + .execute(&state.service.db.pool) + .await + .expect("Failed to insert global predicate."); + + let payload = serde_json::json!({ "status": "active" }); + let response_admin = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call admin graph predicate patch (admin)."); + + assert_eq!(response_admin.status(), StatusCode::FORBIDDEN); + + let body = body::to_bytes(response_admin.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "SCOPE_DENIED"); + + let response_super = app + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) + .header("Authorization", "Bearer super-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call admin graph predicate patch (super_admin)."); + + assert_eq!(response_super.status(), StatusCode::OK); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/contract.rs b/apps/elf-api/tests/http/contract.rs new file mode 100644 index 00000000..e2a00b7c --- /dev/null +++ b/apps/elf-api/tests/http/contract.rs @@ -0,0 +1,122 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use tower::util::ServiceExt as _; + +use elf_api::routes::{self, SCALAR_DOCS_PATH}; + +#[tokio::test] +async fn openapi_json_route_serves_generated_contract() { + let spec = crate::contract_json().await; + + assert_eq!(spec["info"]["title"], "ELF API"); + assert!(spec.get("request_id").is_none()); + + crate::assert_openapi_method(&spec, "/health", "get"); + crate::assert_openapi_method(&spec, "/v2/notes/ingest", "post"); + crate::assert_openapi_method(&spec, "/v2/events/ingest", "post"); + crate::assert_openapi_method(&spec, "/v2/core-blocks", "get"); + crate::assert_openapi_method(&spec, "/v2/entity-memory", "get"); + crate::assert_openapi_method(&spec, "/v2/docs/search/l0", "post"); + crate::assert_openapi_method(&spec, "/v2/work-journal/entries", "post"); + crate::assert_openapi_method(&spec, "/v2/work-journal/entries/{entry_id}", "get"); + crate::assert_openapi_method(&spec, "/v2/work-journal/readback", "post"); + crate::assert_openapi_method(&spec, "/v2/searches/{search_id}/notes", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/core-blocks", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/core-blocks/{block_id}/attachments", "post"); + crate::assert_openapi_method( + &spec, + "/v2/admin/core-blocks/attachments/{attachment_id}", + "delete", + ); + crate::assert_openapi_method(&spec, "/v2/admin/docs/{doc_id}", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/docs/search/l0", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/docs/excerpts", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/searches/raw", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "put"); + crate::assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/consolidation/runs/{run_id}", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/consolidation/proposals", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}", "get"); + crate::assert_openapi_method( + &spec, + "/v2/admin/consolidation/proposals/{proposal_id}/review", + "post", + ); + crate::assert_openapi_method(&spec, "/v2/admin/notes/{note_id}/corrections", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/rebuild", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/search", "post"); + crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}", "get"); + crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}/lint", "post"); +} + +#[tokio::test] +async fn scalar_docs_route_serves_api_reference_html() { + let app = routes::contract_router::<()>(); + let response = app + .oneshot( + Request::builder() + .uri(SCALAR_DOCS_PATH) + .body(Body::empty()) + .expect("Failed to build Scalar docs request."), + ) + .await + .expect("Failed to call Scalar docs route."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read Scalar docs response body."); + let html = String::from_utf8(body.to_vec()).expect("Scalar docs response was not UTF-8."); + + assert!(html.contains("@scalar/api-reference")); + assert!(html.contains("/v2/admin/events/ingestion-profiles/default")); + assert!(html.contains("/v2/admin/consolidation/proposals")); + assert!(html.contains("/v2/admin/docs/search/l0")); + assert!(html.contains("/v2/admin/knowledge/pages")); + assert!(html.contains("/v2/admin/knowledge/pages/search")); + assert!(html.contains("/v2/work-journal/readback")); +} + +#[tokio::test] +async fn openapi_includes_default_ingestion_profile_get_put_contract() { + let spec = crate::contract_json().await; + let default_path = &spec["paths"]["/v2/admin/events/ingestion-profiles/default"]; + let get_schema_ref = + default_path["get"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] + .as_str() + .expect("Missing default profile GET response schema ref."); + let put_request_schema_ref = default_path["put"]["requestBody"]["content"]["application/json"] + ["schema"]["$ref"] + .as_str() + .expect("Missing default profile PUT request schema ref."); + let put_response_schema_ref = + default_path["put"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] + .as_str() + .expect("Missing default profile PUT response schema ref."); + + assert!(get_schema_ref.ends_with("/AdminIngestionProfileDefaultResponseV2")); + assert!(put_request_schema_ref.ends_with("/AdminIngestionProfileDefaultSetBody")); + assert!(put_response_schema_ref.ends_with("/AdminIngestionProfileDefaultResponseV2")); + + let schemas = &spec["components"]["schemas"]; + let request_schema = &schemas["AdminIngestionProfileDefaultSetBody"]; + let response_schema = &schemas["AdminIngestionProfileDefaultResponseV2"]; + + assert!(request_schema["properties"].get("profile_id").is_some()); + assert!(request_schema["properties"].get("version").is_some()); + assert!( + request_schema["required"] + .as_array() + .expect("Missing request required fields") + .contains(&serde_json::json!("profile_id")) + ); + assert!(response_schema["properties"].get("profile_id").is_some()); + assert!(response_schema["properties"].get("version").is_some()); + assert!(response_schema["properties"].get("updated_at").is_some()); +} diff --git a/apps/elf-api/tests/http/request_validation.rs b/apps/elf-api/tests/http/request_validation.rs new file mode 100644 index 00000000..5d57b2be --- /dev/null +++ b/apps/elf-api/tests/http/request_validation.rs @@ -0,0 +1,485 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::{TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_api::{routes, state::AppState}; + +fn payload_level_source_ref() -> Value { + serde_json::json!({ + "schema": "note_source_ref/v1", + "locator": { + "document_id": Uuid::new_v4().to_string(), + "chunk_id": Uuid::new_v4().to_string(), + "revision": "payload-shaping-contract-test" + }, + "metadata": { + "heavy_field": "This field should be hidden when payload_level is below l2." + } + }) +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn health_ok() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let _ = routes::admin_router(state); + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call /health."); + + assert_eq!(response.status(), StatusCode::OK); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn rejects_non_english_in_add_note() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "agent_private", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_note."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.notes[0].text"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn rejects_cyrillic_in_add_note() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "agent_private", + "notes": [{ + "type": "fact", + "key": null, + "text": "Привет мир", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_note."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.notes[0].text"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn rejects_non_english_in_add_event() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "agent_private", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "こんにちは" + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_event."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.messages[0].content"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn rejects_cyrillic_in_add_event() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "agent_private", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "Это не английский текст." + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_event."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.messages[0].content"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn rejects_non_english_in_search() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + + for mode in ["quick_find", "planned_search"] { + let payload = serde_json::json!({ + "mode": mode, + "query": "안녕하세요", + "top_k": 5, + "candidate_k": 10, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/searches") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("X-ELF-Read-Profile", "private_only") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call search."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.query"); + } + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn rejects_cyrillic_in_search() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + + for mode in ["quick_find", "planned_search"] { + let payload = serde_json::json!({ + "mode": mode, + "query": "Привет", + "top_k": 5, + "candidate_k": 10, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/searches") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("X-ELF-Read-Profile", "private_only") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call search."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.query"); + } + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn searches_notes_payload_level_shapes_source_ref_and_structured() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let source_ref = payload_level_source_ref(); + let structured_summary = "Compact structured summary used for payload-level l1 and l2 shaping."; + let note_text = + "Payload shaping note used in contract tests for search details output shaping."; + let note_id = + crate::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) + .await; + + crate::insert_note_summary_field(&state, note_id, structured_summary).await; + + let search_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/searches") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("X-ELF-Read-Profile", "private_only") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "mode": "quick_find", + "query": "payload shaping", + "top_k": 5, + "candidate_k": 10, + }) + .to_string(), + )) + .expect("Failed to build searches request."), + ) + .await + .expect("Failed to call searches."); + + assert_eq!(search_response.status(), StatusCode::OK); + + let search_body = body::to_bytes(search_response.into_body(), usize::MAX) + .await + .expect("Failed to read searches response body."); + let search_json: Value = + serde_json::from_slice(&search_body).expect("Failed to parse searches response."); + let trajectory = &search_json["trajectory_summary"]; + + if !trajectory.is_null() { + assert!(trajectory.is_object()); + assert!(trajectory.get("stages").is_some()); + } + + let search_id = Uuid::parse_str( + search_json["search_id"].as_str().expect("Missing search_id in searches response."), + ) + .expect("Invalid search_id value."); + let notes_l0 = + crate::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l0").await; + let notes_l1 = + crate::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l1").await; + let notes_l2 = + crate::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l2").await; + let search_get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/v2/searches/{search_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("X-ELF-Read-Profile", "private_only") + .body(Body::empty()) + .expect("Failed to build searches get request."), + ) + .await + .expect("Failed to call searches get."); + + assert_eq!(search_get_response.status(), StatusCode::OK); + + let search_get_body = body::to_bytes(search_get_response.into_body(), usize::MAX) + .await + .expect("Failed to read searches get response body."); + let search_get_json: Value = + serde_json::from_slice(&search_get_body).expect("Failed to parse searches get response."); + let search_get_trajectory = &search_get_json["trajectory_summary"]; + + if !search_get_trajectory.is_null() { + assert!(search_get_trajectory.is_object()); + assert!(search_get_trajectory.get("stages").is_some()); + } + + let notes_l0_text = notes_l0["text"].as_str().expect("Missing l0 text."); + let notes_l1_text = notes_l1["text"].as_str().expect("Missing l1 text."); + let notes_l2_text = notes_l2["text"].as_str().expect("Missing l2 text."); + + assert_eq!(notes_l0["source_ref"], serde_json::json!({})); + assert_eq!(notes_l1["source_ref"], serde_json::json!({})); + assert_eq!(notes_l2["source_ref"], source_ref); + assert!(notes_l0["structured"].is_null()); + assert!(notes_l1["structured"].is_object()); + assert!(notes_l2["structured"].is_object()); + assert!(notes_l0_text.len() <= 240); + assert_eq!(notes_l0_text, note_text); + assert_eq!(notes_l1_text, structured_summary); + assert_eq!(notes_l2_text, note_text); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_searches_raw_payload_level_shapes_source_ref() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let admin_app = routes::admin_router(state.clone()); + let source_ref = serde_json::json!({ + "schema": "note_source_ref/v1", + "locator": { + "document_id": Uuid::new_v4().to_string(), + "chunk_id": Uuid::new_v4().to_string(), + "revision": "admin-raw-contract-test" + }, + "metadata": { + "heavy_field": "This field should be hidden when payload_level is below l2." + } + }); + let note_text = + "Admin raw search payload shaping contract note. This long note should be indexed."; + let _note_id = + crate::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) + .await; + let raw_l0 = + crate::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l0").await; + let raw_l1 = + crate::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l1").await; + let raw_l2 = + crate::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l2").await; + + assert_eq!(raw_l0, serde_json::json!({})); + assert_eq!(raw_l1, serde_json::json!({})); + assert_eq!(raw_l2, source_ref); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/sharing.rs b/apps/elf-api/tests/http/sharing.rs new file mode 100644 index 00000000..cf28b952 --- /dev/null +++ b/apps/elf-api/tests/http/sharing.rs @@ -0,0 +1,488 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::{TEST_AGENT_A, TEST_AGENT_B, TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_api::{routes, state::AppState}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn sharing_visibility_requires_explicit_project_grant() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let note_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "project_shared", + TEST_AGENT_A, + "Fact: shared note without grant", + ) + .await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_json: Value = serde_json::from_slice(&body).expect("Failed to parse list response."); + + assert_eq!(list_json["items"].as_array().expect("Missing items array.").len(), 0); + + let note_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(note_response.status(), StatusCode::BAD_REQUEST); + + let body = body::to_bytes(note_response.into_body(), usize::MAX) + .await + .expect("Failed to read get response body."); + let note_json: Value = serde_json::from_slice(&body).expect("Failed to parse get response."); + + assert_eq!(note_json["error_code"], "INVALID_REQUEST"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let admin_app = routes::admin_router(state.clone()); + let private_block_id = crate::create_core_block( + &admin_app, + "agent_private", + "private_operating_context", + "Preference: Keep core context separate from archival search.", + ) + .await; + let note_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "Fact: This archival note must not appear in attached core blocks.", + ) + .await; + + let (status, _) = + crate::attach_core_block(&admin_app, private_block_id, TEST_AGENT_A, "private_only").await; + let before_sessions = crate::search_session_count(&state).await; + let blocks = crate::get_core_blocks(&app, TEST_AGENT_A, "private_only").await; + let after_sessions = crate::search_session_count(&state).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(before_sessions, after_sessions); + assert_eq!(blocks["schema"], "elf.core_memory_blocks/v1"); + assert_eq!(blocks["items"].as_array().expect("items array").len(), 1); + assert_eq!( + blocks["items"][0]["content"], + "Preference: Keep core context separate from archival search." + ); + assert_eq!(blocks["items"][0]["source_ref"]["schema"], "core_block_source/v1"); + assert!(blocks["items"][0]["audit_history"].as_array().expect("audit history").len() >= 2); + assert!(!blocks.to_string().contains("archival note must not appear")); + + let b_private = crate::get_core_blocks(&app, TEST_AGENT_B, "private_only").await; + + assert_eq!(b_private["items"].as_array().expect("items array").len(), 0); + + let shared_block_id = crate::create_core_block( + &admin_app, + "project_shared", + "shared_operating_context", + "Constraint: Shared core context requires explicit project grant and attachment.", + ) + .await; + let (denied_status, _) = + crate::attach_core_block(&admin_app, shared_block_id, TEST_AGENT_B, "private_plus_project") + .await; + + assert_eq!(denied_status, StatusCode::FORBIDDEN); + + crate::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + + let (shared_status, _) = + crate::attach_core_block(&admin_app, shared_block_id, TEST_AGENT_B, "private_plus_project") + .await; + let b_shared = crate::get_core_blocks(&app, TEST_AGENT_B, "private_plus_project").await; + let b_wrong_profile = crate::get_core_blocks(&app, TEST_AGENT_B, "private_only").await; + + assert_eq!(shared_status, StatusCode::OK); + assert_eq!(b_shared["items"].as_array().expect("items array").len(), 1); + assert_eq!(b_shared["items"][0]["scope"], "project_shared"); + assert_eq!(b_wrong_profile["items"].as_array().expect("items array").len(), 0); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn org_shared_note_is_visible_across_projects() { + let Some((test_db, app, state, note_id)) = + crate::org_shared_note_is_visible_across_projects_fixture().await + else { + return; + }; + let list_before_json = crate::list_org_shared_notes_as_reader(&app).await; + + assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 0); + + crate::publish_org_shared_note_as_reader_can_see(&app, note_id).await; + + let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); + let grant_upsert_response = crate::post_with_authorization_and_json_body( + &app, + "/v2/spaces/org_shared/grants", + "Bearer admin-token", + &grant_upsert_payload, + "Failed to build grant upsert request.", + "Failed to call grant upsert.", + ) + .await; + + assert_eq!(grant_upsert_response.status(), StatusCode::OK); + + crate::assert_note_visible_to_project_reader(&app, &state, note_id).await; + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn sharing_project_grant_enables_agent_access_to_shared_note() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let note_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "project_shared", + TEST_AGENT_A, + "Fact: shared note with explicit grant.", + ) + .await; + crate::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_json: Value = serde_json::from_slice(&body).expect("Failed to parse list response."); + let items = list_json["items"].as_array().expect("Missing items array."); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["note_id"], note_id.to_string()); + + let note_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(note_response.status(), StatusCode::OK); + + let body = body::to_bytes(note_response.into_body(), usize::MAX) + .await + .expect("Failed to read get response body."); + let note_json: Value = serde_json::from_slice(&body).expect("Failed to parse get response."); + + assert_eq!(note_json["note_id"], note_id.to_string()); + assert_eq!(note_json["scope"], "project_shared"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn sharing_publish_creates_scope_and_grant_visibility() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let note_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "Fact: private note for publish test.", + ) + .await; + + let initial_grant_count = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(initial_grant_count, 0); + + let publish_payload = serde_json::json!({"space":"team_shared"}).to_string(); + let publish_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("content-type", "application/json") + .body(Body::from(publish_payload)) + .expect("Failed to build publish request."), + ) + .await + .expect("Failed to call note publish."); + + assert_eq!(publish_response.status(), StatusCode::OK); + + let publish_body = body::to_bytes(publish_response.into_body(), usize::MAX) + .await + .expect("Failed to read publish response body."); + let publish_json: Value = + serde_json::from_slice(&publish_body).expect("Failed to parse publish response."); + + assert_eq!(publish_json["note_id"], note_id.to_string()); + assert_eq!(publish_json["space"], "team_shared"); + + let after_grant_count = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(after_grant_count, 1); + + let list_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(list_response.status(), StatusCode::OK); + + let list_body = body::to_bytes(list_response.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_json: Value = + serde_json::from_slice(&list_body).expect("Failed to parse list response."); + let items = list_json["items"].as_array().expect("Missing items array."); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["note_id"], note_id.to_string()); + + let get_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(get_response.status(), StatusCode::OK); + + let get_body = body::to_bytes(get_response.into_body(), usize::MAX) + .await + .expect("Failed to read get response body."); + let get_json: Value = serde_json::from_slice(&get_body).expect("Failed to parse get response."); + + assert_eq!(get_json["note_id"], note_id.to_string()); + assert_eq!(get_json["scope"], "project_shared"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn sharing_revoke_project_grant_removes_visibility() { + let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + return; + }; + let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let note_id = Uuid::new_v4(); + + crate::insert_note( + &state, + note_id, + "project_shared", + TEST_AGENT_A, + "Fact: shared note for revoke test.", + ) + .await; + crate::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + + let grant_count_before = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(grant_count_before, 1); + + let list_before = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + let list_before_body = body::to_bytes(list_before.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_before_json: Value = + serde_json::from_slice(&list_before_body).expect("Failed to parse list response."); + + assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 1); + + let revoke_payload = serde_json::json!({"grantee_kind":"project"}).to_string(); + let revoke_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/team_shared/grants/revoke") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("content-type", "application/json") + .body(Body::from(revoke_payload)) + .expect("Failed to build revoke request."), + ) + .await + .expect("Failed to call grant revoke."); + + assert_eq!(revoke_response.status(), StatusCode::OK); + + let grant_count_after = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(grant_count_after, 0); + + let list_after = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(list_after.status(), StatusCode::OK); + + let list_after_body = body::to_bytes(list_after.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_after_json: Value = + serde_json::from_slice(&list_after_body).expect("Failed to parse list response."); + + assert_eq!(list_after_json["items"].as_array().expect("Missing items array.").len(), 0); + + let get_after = app + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(get_after.status(), StatusCode::BAD_REQUEST); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-cli/src/args.rs b/apps/elf-cli/src/args.rs new file mode 100644 index 00000000..37274c71 --- /dev/null +++ b/apps/elf-cli/src/args.rs @@ -0,0 +1,402 @@ +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +const DEFAULT_API_URL: &str = "http://127.0.0.1:51892"; +const DEFAULT_ADMIN_URL: &str = "http://127.0.0.1:51891"; +const DEFAULT_TENANT_ID: &str = "local-tenant"; +const DEFAULT_PROJECT_ID: &str = "local-project"; +const DEFAULT_AGENT_ID: &str = "local-agent"; +const DEFAULT_READ_PROFILE: &str = "private_only"; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), + about = "Local ELF workflow wrappers over the HTTP API and repo benchmark tasks." +)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Commands, +} + +#[derive(Debug, Args)] +pub(crate) struct PublicEndpointArgs { + /// Public ELF API base URL. + #[arg(long, env = "ELF_API_URL", default_value = DEFAULT_API_URL)] + pub(crate) api_url: String, + /// Optional bearer token for static-key auth. + #[arg(long, env = "ELF_USER_TOKEN")] + pub(crate) token: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct AdminEndpointArgs { + /// Admin ELF API base URL. + #[arg(long, env = "ELF_ADMIN_URL", default_value = DEFAULT_ADMIN_URL)] + pub(crate) admin_url: String, + /// Optional admin bearer token for static-key auth. + #[arg(long, env = "ELF_ADMIN_TOKEN")] + pub(crate) admin_token: Option, +} + +#[derive(Clone, Debug, Args)] +pub(crate) struct ContextArgs { + /// Tenant id sent in X-ELF-Tenant-Id. + #[arg(long, env = "ELF_TENANT_ID", default_value = DEFAULT_TENANT_ID)] + pub(crate) tenant_id: String, + /// Project id sent in X-ELF-Project-Id. + #[arg(long, env = "ELF_PROJECT_ID", default_value = DEFAULT_PROJECT_ID)] + pub(crate) project_id: String, + /// Agent id sent in X-ELF-Agent-Id. + #[arg(long, env = "ELF_AGENT_ID", default_value = DEFAULT_AGENT_ID)] + pub(crate) agent_id: String, +} + +#[derive(Clone, Debug, Args)] +pub(crate) struct ReadContextArgs { + #[command(flatten)] + pub(crate) context: ContextArgs, + /// Read profile sent in X-ELF-Read-Profile. + #[arg(long, env = "ELF_READ_PROFILE", default_value = DEFAULT_READ_PROFILE)] + pub(crate) read_profile: String, +} + +#[derive(Debug, Args)] +pub(crate) struct OutputArgs { + /// Pretty-print the JSON output. + #[arg(long)] + pub(crate) pretty: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct AddNoteArgs { + #[command(flatten)] + pub(crate) endpoint: PublicEndpointArgs, + #[command(flatten)] + pub(crate) context: ContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Scope applied to the note. + #[arg(long, default_value = "agent_private")] + pub(crate) scope: String, + /// Memory note type. + #[arg(long = "type", default_value = "fact")] + pub(crate) note_type: String, + /// Optional note key used by the update resolver. + #[arg(long)] + pub(crate) key: Option, + /// English note text. + #[arg(long)] + pub(crate) text: String, + /// Ranking importance value. + #[arg(long, default_value_t = 0.7)] + pub(crate) importance: f32, + /// Ranking confidence value. + #[arg(long, default_value_t = 0.9)] + pub(crate) confidence: f32, + /// Optional TTL override in days. + #[arg(long)] + pub(crate) ttl_days: Option, + /// Operator-visible source id copied into source_ref.ref.source_id. + #[arg(long)] + pub(crate) source_id: Option, + /// Full JSON object source_ref override. + #[arg(long)] + pub(crate) source_ref_json: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct SearchArgs { + #[command(flatten)] + pub(crate) endpoint: PublicEndpointArgs, + #[command(flatten)] + pub(crate) read_context: ReadContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, + /// English query string. + #[arg(long)] + pub(crate) query: String, + /// Search mode to request from the service. + #[arg(long, value_enum, default_value_t = SearchMode::QuickFind)] + pub(crate) mode: SearchMode, + /// Number of final items to return. + #[arg(long)] + pub(crate) top_k: Option, + /// Candidate breadth before ranking. + #[arg(long)] + pub(crate) candidate_k: Option, + /// Payload level requested from the service. + #[arg(long, value_enum, default_value_t = PayloadLevel::L0)] + pub(crate) payload_level: PayloadLevel, + /// Optional search filter JSON object. + #[arg(long)] + pub(crate) filter_json: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct StatusArgs { + #[command(flatten)] + pub(crate) endpoint: PublicEndpointArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, +} + +#[derive(Debug, Args)] +pub(crate) struct BackfillArgs { + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Backfill corpus document count override. + #[arg(long)] + pub(crate) docs: Option, + /// Worker concurrency override for the backfill runner. + #[arg(long)] + pub(crate) worker_concurrency: Option, + /// Use the checked-in 10k operator profile task. + #[arg(long)] + pub(crate) ten_k: bool, + /// Use the guarded 100k operator profile task. + #[arg(long, conflicts_with = "ten_k")] + pub(crate) hundred_k: bool, + /// Set the required expensive-run guard for the 100k task. + #[arg(long)] + pub(crate) enable_expensive: bool, + /// Print the resolved task and environment without running it. + #[arg(long)] + pub(crate) dry_run: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct BenchmarkArgs { + #[command(subcommand)] + pub(crate) command: BenchmarkCommand, +} + +#[derive(Debug, Args)] +pub(crate) struct BenchmarkRunArgs { + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Benchmark task wrapper to run. + #[arg(long, value_enum, default_value_t = BenchmarkRunKind::Live)] + pub(crate) kind: BenchmarkRunKind, + /// Project filter passed to ELF_BASELINE_PROJECTS. + #[arg(long)] + pub(crate) projects: Option, + /// Corpus profile passed to ELF_BASELINE_PROFILE. + #[arg(long)] + pub(crate) profile: Option, + /// Private production corpus manifest path. + #[arg(long)] + pub(crate) production_corpus_manifest: Option, + /// Markdown addendum path for production-private-addendum. + #[arg(long)] + pub(crate) private_addendum: Option, + /// Soak duration override in seconds. + #[arg(long)] + pub(crate) soak_seconds: Option, + /// Print the resolved task and environment without running it. + #[arg(long)] + pub(crate) dry_run: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct BenchmarkReportArgs { + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Source live-baseline report JSON path. + #[arg(long)] + pub(crate) report: Option, + /// Markdown output path. + #[arg(long)] + pub(crate) out: Option, + /// Print the resolved task and environment without running it. + #[arg(long)] + pub(crate) dry_run: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct DiagnosticsArgs { + #[command(subcommand)] + pub(crate) command: DiagnosticsCommand, +} + +#[derive(Debug, Args)] +pub(crate) struct AdminPostArgs { + #[command(flatten)] + pub(crate) endpoint: AdminEndpointArgs, + #[command(flatten)] + pub(crate) context: ContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, +} + +#[derive(Debug, Args)] +pub(crate) struct AdminSearchArgs { + #[command(flatten)] + pub(crate) endpoint: AdminEndpointArgs, + #[command(flatten)] + pub(crate) read_context: ReadContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, + /// English query string. + #[arg(long)] + pub(crate) query: String, + /// Search mode to request from the service. + #[arg(long, value_enum, default_value_t = SearchMode::QuickFind)] + pub(crate) mode: SearchMode, + /// Number of final items to return. + #[arg(long)] + pub(crate) top_k: Option, + /// Candidate breadth before ranking. + #[arg(long)] + pub(crate) candidate_k: Option, + /// Payload level requested from the service. + #[arg(long, value_enum, default_value_t = PayloadLevel::L2)] + pub(crate) payload_level: PayloadLevel, + /// Optional search filter JSON object. + #[arg(long)] + pub(crate) filter_json: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct RecentTracesArgs { + #[command(flatten)] + pub(crate) endpoint: AdminEndpointArgs, + #[command(flatten)] + pub(crate) context: ContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Maximum trace headers to return. + #[arg(long)] + pub(crate) limit: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct TraceBundleArgs { + #[command(flatten)] + pub(crate) endpoint: AdminEndpointArgs, + #[command(flatten)] + pub(crate) context: ContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Trace id to load. + #[arg(long)] + pub(crate) trace_id: String, + /// Bundle mode: bounded or full. + #[arg(long, default_value = "bounded")] + pub(crate) mode: String, + /// Optional per-stage item cap. + #[arg(long)] + pub(crate) stage_items_limit: Option, + /// Optional replay candidate cap. + #[arg(long)] + pub(crate) candidates_limit: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct NoteProvenanceArgs { + #[command(flatten)] + pub(crate) endpoint: AdminEndpointArgs, + #[command(flatten)] + pub(crate) context: ContextArgs, + #[command(flatten)] + pub(crate) output: OutputArgs, + /// Note id to inspect. + #[arg(long)] + pub(crate) note_id: String, +} + +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab")] +pub(crate) enum Commands { + /// Add one deterministic note through POST /v2/notes/ingest. + AddNote(AddNoteArgs), + /// Create a search session through POST /v2/searches. + Search(SearchArgs), + /// Check local API process health. + Status(StatusArgs), + /// Run the checked-in resumable backfill benchmark workflow. + Backfill(BackfillArgs), + /// Run or render checked-in live baseline benchmark reports. + Benchmark(BenchmarkArgs), + /// Read production diagnostics through admin HTTP endpoints. + Diagnostics(DiagnosticsArgs), +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +#[value(rename_all = "snake_case")] +pub(crate) enum SearchMode { + QuickFind, + PlannedSearch, +} +impl SearchMode { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::QuickFind => "quick_find", + Self::PlannedSearch => "planned_search", + } + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +#[value(rename_all = "lower")] +pub(crate) enum PayloadLevel { + L0, + L1, + L2, +} +impl PayloadLevel { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::L0 => "l0", + Self::L1 => "l1", + Self::L2 => "l2", + } + } +} + +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab")] +pub(crate) enum BenchmarkCommand { + /// Run one checked-in Docker baseline task. + Run(BenchmarkRunArgs), + /// Render Markdown from a live-baseline JSON report. + Report(BenchmarkReportArgs), +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +#[value(rename_all = "kebab")] +pub(crate) enum BenchmarkRunKind { + Live, + ProductionSynthetic, + ProductionPrivate, + ProductionPrivateAddendum, + Soak, +} +impl BenchmarkRunKind { + pub(crate) fn task_name(self) -> &'static str { + match self { + Self::Live => "baseline-live-docker", + Self::ProductionSynthetic => "baseline-production-synthetic", + Self::ProductionPrivate => "baseline-production-private", + Self::ProductionPrivateAddendum => "baseline-production-private-addendum", + Self::Soak => "baseline-soak-docker", + } + } +} + +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab")] +pub(crate) enum DiagnosticsCommand { + /// Rebuild Qdrant from Postgres vectors through the admin API. + QdrantRebuild(AdminPostArgs), + /// Run raw admin search and include trace/result/source_ref data. + RawSearch(AdminSearchArgs), + /// List recent persisted search traces. + RecentTraces(RecentTracesArgs), + /// Read a bounded or full trace bundle. + TraceBundle(TraceBundleArgs), + /// Read note provenance, ingest decisions, outbox rows, and recent traces. + NoteProvenance(NoteProvenanceArgs), +} diff --git a/apps/elf-cli/src/commands.rs b/apps/elf-cli/src/commands.rs new file mode 100644 index 00000000..3f13a413 --- /dev/null +++ b/apps/elf-cli/src/commands.rs @@ -0,0 +1,126 @@ +use color_eyre::{Result, eyre}; +use reqwest::{Client, Method, StatusCode}; +use serde_json::Value; + +use crate::{ + args::{AddNoteArgs, SearchArgs, StatusArgs}, + http::{self, JsonRequest, redact_url}, + json::{self}, +}; + +pub(crate) async fn run_add_note(client: &Client, args: AddNoteArgs) -> Result<()> { + let source_ref = json::source_ref(&args.source_id, args.source_ref_json.as_deref())?; + let body = serde_json::json!({ + "scope": args.scope, + "notes": [{ + "type": args.note_type, + "key": args.key, + "text": args.text, + "importance": args.importance, + "confidence": args.confidence, + "ttl_days": args.ttl_days, + "source_ref": source_ref, + }], + }); + let response = http::request_json( + client, + JsonRequest { + method: Method::POST, + base_url: &args.endpoint.api_url, + path: "/v2/notes/ingest", + token: args.endpoint.token.as_deref(), + context: Some(&args.context), + read_profile: None, + body: Some(&body), + }, + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.add_note/v1", + "request": { + "api_url": redact_url(&args.endpoint.api_url), + "tenant_id": args.context.tenant_id, + "project_id": args.context.project_id, + "agent_id": args.context.agent_id, + "scope": body["scope"], + "source_id": args.source_id, + "source_ref": body["notes"][0]["source_ref"], + }, + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} + +pub(crate) async fn run_search(client: &Client, args: SearchArgs) -> Result<()> { + let body = json::search_body( + args.query, + args.mode, + args.top_k, + args.candidate_k, + args.payload_level, + args.filter_json.as_deref(), + )?; + let response = http::request_json( + client, + JsonRequest { + method: Method::POST, + base_url: &args.endpoint.api_url, + path: "/v2/searches", + token: args.endpoint.token.as_deref(), + context: Some(&args.read_context.context), + read_profile: Some(&args.read_context.read_profile), + body: Some(&body), + }, + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.search/v1", + "request": { + "api_url": redact_url(&args.endpoint.api_url), + "tenant_id": args.read_context.context.tenant_id, + "project_id": args.read_context.context.project_id, + "agent_id": args.read_context.context.agent_id, + "read_profile": args.read_context.read_profile, + "mode": body["mode"], + "payload_level": body["payload_level"], + }, + "trace_id": response.get("trace_id").cloned().unwrap_or(Value::Null), + "search_id": response.get("search_id").cloned().unwrap_or(Value::Null), + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} + +pub(crate) async fn run_status(client: &Client, args: StatusArgs) -> Result<()> { + let url = http::join_url(&args.endpoint.api_url, "/health"); + let mut request = client.get(&url); + + if let Some(token) = args.endpoint.token.as_deref() { + request = request.bearer_auth(token); + } + + let response = request.send().await?; + let status = response.status(); + let request_id = http::header_string(response.headers(), "x-elf-request-id"); + let body = response.text().await?; + let output = serde_json::json!({ + "schema": "elf.cli.status/v1", + "api": { + "url": redact_url(&args.endpoint.api_url), + "healthy": status == StatusCode::OK, + "status": status.as_u16(), + "request_id": request_id, + "body": body, + }, + }); + + json::write_json(&output, args.output.pretty)?; + + if status.is_success() { + Ok(()) + } else { + Err(eyre::eyre!("ELF API health check failed with HTTP status {status}.")) + } +} diff --git a/apps/elf-cli/src/diagnostics.rs b/apps/elf-cli/src/diagnostics.rs new file mode 100644 index 00000000..0ebe69e4 --- /dev/null +++ b/apps/elf-cli/src/diagnostics.rs @@ -0,0 +1,161 @@ +use color_eyre::Result; +use reqwest::{Client, Method}; +use serde_json::Value; + +use crate::{ + args::{ + AdminPostArgs, AdminSearchArgs, DiagnosticsArgs, DiagnosticsCommand, NoteProvenanceArgs, + RecentTracesArgs, TraceBundleArgs, + }, + http::{self, JsonRequest, redact_url}, + json::{self}, +}; + +pub(crate) async fn run_diagnostics(client: &Client, args: DiagnosticsArgs) -> Result<()> { + match args.command { + DiagnosticsCommand::QdrantRebuild(args) => run_qdrant_rebuild(client, args).await, + DiagnosticsCommand::RawSearch(args) => run_raw_search(client, args).await, + DiagnosticsCommand::RecentTraces(args) => run_recent_traces(client, args).await, + DiagnosticsCommand::TraceBundle(args) => run_trace_bundle(client, args).await, + DiagnosticsCommand::NoteProvenance(args) => run_note_provenance(client, args).await, + } +} + +async fn run_qdrant_rebuild(client: &Client, args: AdminPostArgs) -> Result<()> { + let response = http::request_json( + client, + JsonRequest { + method: Method::POST, + base_url: &args.endpoint.admin_url, + path: "/v2/admin/qdrant/rebuild", + token: args.endpoint.admin_token.as_deref(), + context: Some(&args.context), + read_profile: None, + body: None, + }, + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.diagnostics.qdrant_rebuild/v1", + "admin_url": redact_url(&args.endpoint.admin_url), + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} + +async fn run_raw_search(client: &Client, args: AdminSearchArgs) -> Result<()> { + let body = json::search_body( + args.query, + args.mode, + args.top_k, + args.candidate_k, + args.payload_level, + args.filter_json.as_deref(), + )?; + let response = http::request_json( + client, + JsonRequest { + method: Method::POST, + base_url: &args.endpoint.admin_url, + path: "/v2/admin/searches/raw", + token: args.endpoint.admin_token.as_deref(), + context: Some(&args.read_context.context), + read_profile: Some(&args.read_context.read_profile), + body: Some(&body), + }, + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.diagnostics.raw_search/v1", + "request": { + "admin_url": redact_url(&args.endpoint.admin_url), + "tenant_id": args.read_context.context.tenant_id, + "project_id": args.read_context.context.project_id, + "agent_id": args.read_context.context.agent_id, + "read_profile": args.read_context.read_profile, + "mode": body["mode"], + "payload_level": body["payload_level"], + }, + "trace_id": response.get("trace_id").cloned().unwrap_or(Value::Null), + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} + +async fn run_recent_traces(client: &Client, args: RecentTracesArgs) -> Result<()> { + let mut query = Vec::new(); + + if let Some(limit) = args.limit { + query.push(("limit", limit.to_string())); + } + + let response = http::request_json_query( + client, + &args.endpoint.admin_url, + "/v2/admin/traces/recent", + args.endpoint.admin_token.as_deref(), + &args.context, + &query, + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.diagnostics.recent_traces/v1", + "admin_url": redact_url(&args.endpoint.admin_url), + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} + +async fn run_trace_bundle(client: &Client, args: TraceBundleArgs) -> Result<()> { + let path = format!("/v2/admin/traces/{}/bundle", args.trace_id); + let mut query = vec![("mode", args.mode)]; + + if let Some(limit) = args.stage_items_limit { + query.push(("stage_items_limit", limit.to_string())); + } + if let Some(limit) = args.candidates_limit { + query.push(("candidates_limit", limit.to_string())); + } + + let response = http::request_json_query( + client, + &args.endpoint.admin_url, + &path, + args.endpoint.admin_token.as_deref(), + &args.context, + &query, + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.diagnostics.trace_bundle/v1", + "admin_url": redact_url(&args.endpoint.admin_url), + "trace_id": response.pointer("/trace/trace_id").cloned().unwrap_or(Value::Null), + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} + +async fn run_note_provenance(client: &Client, args: NoteProvenanceArgs) -> Result<()> { + let path = format!("/v2/admin/notes/{}/provenance", args.note_id); + let response = http::request_json_query( + client, + &args.endpoint.admin_url, + &path, + args.endpoint.admin_token.as_deref(), + &args.context, + &[], + ) + .await?; + let output = serde_json::json!({ + "schema": "elf.cli.diagnostics.note_provenance/v1", + "admin_url": redact_url(&args.endpoint.admin_url), + "note_id": response.pointer("/note/note_id").cloned().unwrap_or(Value::String(args.note_id)), + "response": response, + }); + + json::write_json(&output, args.output.pretty) +} diff --git a/apps/elf-cli/src/http.rs b/apps/elf-cli/src/http.rs new file mode 100644 index 00000000..8ab53b76 --- /dev/null +++ b/apps/elf-cli/src/http.rs @@ -0,0 +1,95 @@ +use color_eyre::{Result, eyre}; +use reqwest::{Client, Method, RequestBuilder, Response, header::HeaderMap}; +use serde_json::Value; + +use crate::args::ContextArgs; + +pub(crate) struct JsonRequest<'a> { + pub(crate) method: Method, + pub(crate) base_url: &'a str, + pub(crate) path: &'a str, + pub(crate) token: Option<&'a str>, + pub(crate) context: Option<&'a ContextArgs>, + pub(crate) read_profile: Option<&'a str>, + pub(crate) body: Option<&'a Value>, +} + +pub(crate) fn join_url(base_url: &str, path: &str) -> String { + format!("{}/{}", base_url.trim_end_matches('/'), path.trim_start_matches('/')) +} + +pub(crate) fn redact_url(url: &str) -> String { + url.to_string() +} + +pub(crate) fn header_string(headers: &HeaderMap, name: &str) -> Option { + headers.get(name).and_then(|value| value.to_str().ok()).map(str::to_string) +} + +pub(crate) async fn request_json(client: &Client, args: JsonRequest<'_>) -> Result { + let mut request = client.request(args.method, join_url(args.base_url, args.path)); + + if let Some(token) = args.token { + request = request.bearer_auth(token); + } + if let Some(context) = args.context { + request = add_context_headers(request, context); + } + if let Some(read_profile) = args.read_profile { + request = request.header("X-ELF-Read-Profile", read_profile); + } + if let Some(body) = args.body { + request = request.json(body); + } + + parse_json_response(request.send().await?).await +} + +pub(crate) async fn request_json_query( + client: &Client, + base_url: &str, + path: &str, + token: Option<&str>, + context: &ContextArgs, + query: &[(&str, String)], +) -> Result { + let mut request = client.get(join_url(base_url, path)).query(query); + + if let Some(token) = token { + request = request.bearer_auth(token); + } + + request = add_context_headers(request, context); + + parse_json_response(request.send().await?).await +} + +fn add_context_headers(request: RequestBuilder, context: &ContextArgs) -> RequestBuilder { + request + .header("X-ELF-Tenant-Id", &context.tenant_id) + .header("X-ELF-Project-Id", &context.project_id) + .header("X-ELF-Agent-Id", &context.agent_id) +} + +async fn parse_json_response(response: Response) -> Result { + let status = response.status(); + let request_id = header_string(response.headers(), "x-elf-request-id"); + let text = response.text().await?; + + if !status.is_success() { + return Err(eyre::eyre!( + "ELF request failed with HTTP status {status} and request_id {}: {text}", + request_id.as_deref().unwrap_or("unknown") + )); + } + if text.trim().is_empty() { + return Ok(serde_json::json!({"status": status.as_u16(), "request_id": request_id})); + } + + serde_json::from_str(&text).map_err(|err| { + eyre::eyre!( + "ELF response was not valid JSON for request_id {}: {err}", + request_id.as_deref().unwrap_or("unknown") + ) + }) +} diff --git a/apps/elf-cli/src/json.rs b/apps/elf-cli/src/json.rs new file mode 100644 index 00000000..19d5fa64 --- /dev/null +++ b/apps/elf-cli/src/json.rs @@ -0,0 +1,66 @@ +use std::io::{self, Write as _}; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::args::{PayloadLevel, SearchMode}; + +pub(crate) fn search_body( + query: String, + mode: SearchMode, + top_k: Option, + candidate_k: Option, + payload_level: PayloadLevel, + filter_json: Option<&str>, +) -> Result { + let mut body = serde_json::json!({ + "mode": mode.as_str(), + "query": query, + "top_k": top_k, + "candidate_k": candidate_k, + "payload_level": payload_level.as_str(), + }); + + if let Some(filter_json) = filter_json { + body["filter"] = parse_json_object(filter_json, "--filter-json")?; + } + + Ok(body) +} + +pub(crate) fn source_ref( + source_id: &Option, + source_ref_json: Option<&str>, +) -> Result { + if let Some(source_ref_json) = source_ref_json { + return parse_json_object(source_ref_json, "--source-ref-json"); + } + + Ok(source_id.as_ref().map_or_else( + || serde_json::json!({}), + |source_id| serde_json::json!({"schema": "elf_cli/v1", "ref": {"source_id": source_id}}), + )) +} + +pub(crate) fn write_json(value: &Value, pretty: bool) -> Result<()> { + if pretty { + serde_json::to_writer_pretty(io::stdout(), value)?; + } else { + serde_json::to_writer(io::stdout(), value)?; + } + + writeln!(io::stdout())?; + + Ok(()) +} + +fn parse_json_object(raw: &str, flag: &str) -> Result { + let value: Value = + serde_json::from_str(raw).map_err(|err| eyre::eyre!("{flag} must be valid JSON: {err}"))?; + + if !value.is_object() { + return Err(eyre::eyre!("{flag} must be a JSON object.")); + } + + Ok(value) +} diff --git a/apps/elf-cli/src/main.rs b/apps/elf-cli/src/main.rs index 680058d1..5d099cbd 100644 --- a/apps/elf-cli/src/main.rs +++ b/apps/elf-cli/src/main.rs @@ -1,624 +1,17 @@ //! Local ELF CLI wrappers for production memory workflows. -use std::{ - collections::BTreeMap, - io::{self, Write as _}, - path::{Path, PathBuf}, - process::Command, -}; +mod args; +mod commands; +mod diagnostics; +mod http; +mod json; +mod tasks; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use color_eyre::{Result, eyre}; -use reqwest::{Client, Method, RequestBuilder, Response, StatusCode, header::HeaderMap}; -use serde_json::{self, Value}; +use clap::Parser; +use color_eyre::Result; +use reqwest::Client; -const DEFAULT_API_URL: &str = "http://127.0.0.1:51892"; -const DEFAULT_ADMIN_URL: &str = "http://127.0.0.1:51891"; -const DEFAULT_TENANT_ID: &str = "local-tenant"; -const DEFAULT_PROJECT_ID: &str = "local-project"; -const DEFAULT_AGENT_ID: &str = "local-agent"; -const DEFAULT_READ_PROFILE: &str = "private_only"; - -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), - about = "Local ELF workflow wrappers over the HTTP API and repo benchmark tasks." -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Debug, Args)] -struct PublicEndpointArgs { - /// Public ELF API base URL. - #[arg(long, env = "ELF_API_URL", default_value = DEFAULT_API_URL)] - api_url: String, - /// Optional bearer token for static-key auth. - #[arg(long, env = "ELF_USER_TOKEN")] - token: Option, -} - -#[derive(Debug, Args)] -struct AdminEndpointArgs { - /// Admin ELF API base URL. - #[arg(long, env = "ELF_ADMIN_URL", default_value = DEFAULT_ADMIN_URL)] - admin_url: String, - /// Optional admin bearer token for static-key auth. - #[arg(long, env = "ELF_ADMIN_TOKEN")] - admin_token: Option, -} - -#[derive(Clone, Debug, Args)] -struct ContextArgs { - /// Tenant id sent in X-ELF-Tenant-Id. - #[arg(long, env = "ELF_TENANT_ID", default_value = DEFAULT_TENANT_ID)] - tenant_id: String, - /// Project id sent in X-ELF-Project-Id. - #[arg(long, env = "ELF_PROJECT_ID", default_value = DEFAULT_PROJECT_ID)] - project_id: String, - /// Agent id sent in X-ELF-Agent-Id. - #[arg(long, env = "ELF_AGENT_ID", default_value = DEFAULT_AGENT_ID)] - agent_id: String, -} - -#[derive(Clone, Debug, Args)] -struct ReadContextArgs { - #[command(flatten)] - context: ContextArgs, - /// Read profile sent in X-ELF-Read-Profile. - #[arg(long, env = "ELF_READ_PROFILE", default_value = DEFAULT_READ_PROFILE)] - read_profile: String, -} - -#[derive(Debug, Args)] -struct OutputArgs { - /// Pretty-print the JSON output. - #[arg(long)] - pretty: bool, -} - -#[derive(Debug, Args)] -struct AddNoteArgs { - #[command(flatten)] - endpoint: PublicEndpointArgs, - #[command(flatten)] - context: ContextArgs, - #[command(flatten)] - output: OutputArgs, - /// Scope applied to the note. - #[arg(long, default_value = "agent_private")] - scope: String, - /// Memory note type. - #[arg(long = "type", default_value = "fact")] - note_type: String, - /// Optional note key used by the update resolver. - #[arg(long)] - key: Option, - /// English note text. - #[arg(long)] - text: String, - /// Ranking importance value. - #[arg(long, default_value_t = 0.7)] - importance: f32, - /// Ranking confidence value. - #[arg(long, default_value_t = 0.9)] - confidence: f32, - /// Optional TTL override in days. - #[arg(long)] - ttl_days: Option, - /// Operator-visible source id copied into source_ref.ref.source_id. - #[arg(long)] - source_id: Option, - /// Full JSON object source_ref override. - #[arg(long)] - source_ref_json: Option, -} - -#[derive(Debug, Args)] -struct SearchArgs { - #[command(flatten)] - endpoint: PublicEndpointArgs, - #[command(flatten)] - read_context: ReadContextArgs, - #[command(flatten)] - output: OutputArgs, - /// English query string. - #[arg(long)] - query: String, - /// Search mode to request from the service. - #[arg(long, value_enum, default_value_t = SearchMode::QuickFind)] - mode: SearchMode, - /// Number of final items to return. - #[arg(long)] - top_k: Option, - /// Candidate breadth before ranking. - #[arg(long)] - candidate_k: Option, - /// Payload level requested from the service. - #[arg(long, value_enum, default_value_t = PayloadLevel::L0)] - payload_level: PayloadLevel, - /// Optional search filter JSON object. - #[arg(long)] - filter_json: Option, -} - -#[derive(Debug, Args)] -struct StatusArgs { - #[command(flatten)] - endpoint: PublicEndpointArgs, - #[command(flatten)] - output: OutputArgs, -} - -#[derive(Debug, Args)] -struct BackfillArgs { - #[command(flatten)] - output: OutputArgs, - /// Backfill corpus document count override. - #[arg(long)] - docs: Option, - /// Worker concurrency override for the backfill runner. - #[arg(long)] - worker_concurrency: Option, - /// Use the checked-in 10k operator profile task. - #[arg(long)] - ten_k: bool, - /// Use the guarded 100k operator profile task. - #[arg(long, conflicts_with = "ten_k")] - hundred_k: bool, - /// Set the required expensive-run guard for the 100k task. - #[arg(long)] - enable_expensive: bool, - /// Print the resolved task and environment without running it. - #[arg(long)] - dry_run: bool, -} - -#[derive(Debug, Args)] -struct BenchmarkArgs { - #[command(subcommand)] - command: BenchmarkCommand, -} - -#[derive(Debug, Args)] -struct BenchmarkRunArgs { - #[command(flatten)] - output: OutputArgs, - /// Benchmark task wrapper to run. - #[arg(long, value_enum, default_value_t = BenchmarkRunKind::Live)] - kind: BenchmarkRunKind, - /// Project filter passed to ELF_BASELINE_PROJECTS. - #[arg(long)] - projects: Option, - /// Corpus profile passed to ELF_BASELINE_PROFILE. - #[arg(long)] - profile: Option, - /// Private production corpus manifest path. - #[arg(long)] - production_corpus_manifest: Option, - /// Markdown addendum path for production-private-addendum. - #[arg(long)] - private_addendum: Option, - /// Soak duration override in seconds. - #[arg(long)] - soak_seconds: Option, - /// Print the resolved task and environment without running it. - #[arg(long)] - dry_run: bool, -} - -#[derive(Debug, Args)] -struct BenchmarkReportArgs { - #[command(flatten)] - output: OutputArgs, - /// Source live-baseline report JSON path. - #[arg(long)] - report: Option, - /// Markdown output path. - #[arg(long)] - out: Option, - /// Print the resolved task and environment without running it. - #[arg(long)] - dry_run: bool, -} - -#[derive(Debug, Args)] -struct DiagnosticsArgs { - #[command(subcommand)] - command: DiagnosticsCommand, -} - -#[derive(Debug, Args)] -struct AdminPostArgs { - #[command(flatten)] - endpoint: AdminEndpointArgs, - #[command(flatten)] - context: ContextArgs, - #[command(flatten)] - output: OutputArgs, -} - -#[derive(Debug, Args)] -struct AdminSearchArgs { - #[command(flatten)] - endpoint: AdminEndpointArgs, - #[command(flatten)] - read_context: ReadContextArgs, - #[command(flatten)] - output: OutputArgs, - /// English query string. - #[arg(long)] - query: String, - /// Search mode to request from the service. - #[arg(long, value_enum, default_value_t = SearchMode::QuickFind)] - mode: SearchMode, - /// Number of final items to return. - #[arg(long)] - top_k: Option, - /// Candidate breadth before ranking. - #[arg(long)] - candidate_k: Option, - /// Payload level requested from the service. - #[arg(long, value_enum, default_value_t = PayloadLevel::L2)] - payload_level: PayloadLevel, - /// Optional search filter JSON object. - #[arg(long)] - filter_json: Option, -} - -#[derive(Debug, Args)] -struct RecentTracesArgs { - #[command(flatten)] - endpoint: AdminEndpointArgs, - #[command(flatten)] - context: ContextArgs, - #[command(flatten)] - output: OutputArgs, - /// Maximum trace headers to return. - #[arg(long)] - limit: Option, -} - -#[derive(Debug, Args)] -struct TraceBundleArgs { - #[command(flatten)] - endpoint: AdminEndpointArgs, - #[command(flatten)] - context: ContextArgs, - #[command(flatten)] - output: OutputArgs, - /// Trace id to load. - #[arg(long)] - trace_id: String, - /// Bundle mode: bounded or full. - #[arg(long, default_value = "bounded")] - mode: String, - /// Optional per-stage item cap. - #[arg(long)] - stage_items_limit: Option, - /// Optional replay candidate cap. - #[arg(long)] - candidates_limit: Option, -} - -#[derive(Debug, Args)] -struct NoteProvenanceArgs { - #[command(flatten)] - endpoint: AdminEndpointArgs, - #[command(flatten)] - context: ContextArgs, - #[command(flatten)] - output: OutputArgs, - /// Note id to inspect. - #[arg(long)] - note_id: String, -} - -struct JsonRequest<'a> { - method: Method, - base_url: &'a str, - path: &'a str, - token: Option<&'a str>, - context: Option<&'a ContextArgs>, - read_profile: Option<&'a str>, - body: Option<&'a Value>, -} - -#[derive(Debug, Subcommand)] -#[command(rename_all = "kebab")] -enum Commands { - /// Add one deterministic note through POST /v2/notes/ingest. - AddNote(AddNoteArgs), - /// Create a search session through POST /v2/searches. - Search(SearchArgs), - /// Check local API process health. - Status(StatusArgs), - /// Run the checked-in resumable backfill benchmark workflow. - Backfill(BackfillArgs), - /// Run or render checked-in live baseline benchmark reports. - Benchmark(BenchmarkArgs), - /// Read production diagnostics through admin HTTP endpoints. - Diagnostics(DiagnosticsArgs), -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -#[value(rename_all = "snake_case")] -enum SearchMode { - QuickFind, - PlannedSearch, -} -impl SearchMode { - fn as_str(self) -> &'static str { - match self { - Self::QuickFind => "quick_find", - Self::PlannedSearch => "planned_search", - } - } -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -#[value(rename_all = "lower")] -enum PayloadLevel { - L0, - L1, - L2, -} -impl PayloadLevel { - fn as_str(self) -> &'static str { - match self { - Self::L0 => "l0", - Self::L1 => "l1", - Self::L2 => "l2", - } - } -} - -#[derive(Debug, Subcommand)] -#[command(rename_all = "kebab")] -enum BenchmarkCommand { - /// Run one checked-in Docker baseline task. - Run(BenchmarkRunArgs), - /// Render Markdown from a live-baseline JSON report. - Report(BenchmarkReportArgs), -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -#[value(rename_all = "kebab")] -enum BenchmarkRunKind { - Live, - ProductionSynthetic, - ProductionPrivate, - ProductionPrivateAddendum, - Soak, -} -impl BenchmarkRunKind { - fn task_name(self) -> &'static str { - match self { - Self::Live => "baseline-live-docker", - Self::ProductionSynthetic => "baseline-production-synthetic", - Self::ProductionPrivate => "baseline-production-private", - Self::ProductionPrivateAddendum => "baseline-production-private-addendum", - Self::Soak => "baseline-soak-docker", - } - } -} - -#[derive(Debug, Subcommand)] -#[command(rename_all = "kebab")] -enum DiagnosticsCommand { - /// Rebuild Qdrant from Postgres vectors through the admin API. - QdrantRebuild(AdminPostArgs), - /// Run raw admin search and include trace/result/source_ref data. - RawSearch(AdminSearchArgs), - /// List recent persisted search traces. - RecentTraces(RecentTracesArgs), - /// Read a bounded or full trace bundle. - TraceBundle(TraceBundleArgs), - /// Read note provenance, ingest decisions, outbox rows, and recent traces. - NoteProvenance(NoteProvenanceArgs), -} - -fn run_backfill(args: BackfillArgs) -> Result<()> { - let task = if args.hundred_k { - "baseline-backfill-100k-docker" - } else if args.ten_k { - "baseline-backfill-10k-docker" - } else { - "baseline-backfill-docker" - }; - let mut env = BTreeMap::new(); - - if let Some(docs) = args.docs { - env.insert("ELF_BASELINE_BACKFILL_DOCS".to_string(), docs.to_string()); - } - if let Some(worker_concurrency) = args.worker_concurrency { - env.insert("ELF_BASELINE_WORKER_CONCURRENCY".to_string(), worker_concurrency.to_string()); - } - - if args.enable_expensive { - env.insert("ELF_BASELINE_ENABLE_EXPENSIVE".to_string(), "1".to_string()); - } - - run_cargo_make("elf.cli.backfill/v1", task, env, args.dry_run, args.output.pretty) -} - -fn run_benchmark(args: BenchmarkArgs) -> Result<()> { - match args.command { - BenchmarkCommand::Run(args) => run_benchmark_run(args), - BenchmarkCommand::Report(args) => run_benchmark_report(args), - } -} - -fn run_benchmark_run(args: BenchmarkRunArgs) -> Result<()> { - let task = args.kind.task_name(); - let mut env = BTreeMap::new(); - - if let Some(projects) = args.projects { - env.insert("ELF_BASELINE_PROJECTS".to_string(), projects); - } - if let Some(profile) = args.profile { - env.insert("ELF_BASELINE_PROFILE".to_string(), profile); - } - if let Some(path) = args.production_corpus_manifest { - env.insert("ELF_BASELINE_PRODUCTION_CORPUS_MANIFEST".to_string(), path_display(&path)); - } - if let Some(path) = args.private_addendum { - env.insert("ELF_BASELINE_PRIVATE_ADDENDUM".to_string(), path_display(&path)); - } - if let Some(seconds) = args.soak_seconds { - env.insert("ELF_BASELINE_SOAK_SECONDS".to_string(), seconds.to_string()); - } - - run_cargo_make("elf.cli.benchmark_run/v1", task, env, args.dry_run, args.output.pretty) -} - -fn run_benchmark_report(args: BenchmarkReportArgs) -> Result<()> { - let mut env = BTreeMap::new(); - - if let Some(path) = args.report { - env.insert("ELF_BASELINE_REPORT".to_string(), path_display(&path)); - } - if let Some(path) = args.out { - env.insert("ELF_BASELINE_MARKDOWN_REPORT".to_string(), path_display(&path)); - } - - run_cargo_make( - "elf.cli.benchmark_report/v1", - "baseline-live-report", - env, - args.dry_run, - args.output.pretty, - ) -} - -fn search_body( - query: String, - mode: SearchMode, - top_k: Option, - candidate_k: Option, - payload_level: PayloadLevel, - filter_json: Option<&str>, -) -> Result { - let mut body = serde_json::json!({ - "mode": mode.as_str(), - "query": query, - "top_k": top_k, - "candidate_k": candidate_k, - "payload_level": payload_level.as_str(), - }); - - if let Some(filter_json) = filter_json { - body["filter"] = parse_json_object(filter_json, "--filter-json")?; - } - - Ok(body) -} - -fn source_ref(source_id: &Option, source_ref_json: Option<&str>) -> Result { - if let Some(source_ref_json) = source_ref_json { - return parse_json_object(source_ref_json, "--source-ref-json"); - } - - Ok(source_id.as_ref().map_or_else( - || serde_json::json!({}), - |source_id| serde_json::json!({"schema": "elf_cli/v1", "ref": {"source_id": source_id}}), - )) -} - -fn parse_json_object(raw: &str, flag: &str) -> Result { - let value: Value = - serde_json::from_str(raw).map_err(|err| eyre::eyre!("{flag} must be valid JSON: {err}"))?; - - if !value.is_object() { - return Err(eyre::eyre!("{flag} must be a JSON object.")); - } - - Ok(value) -} - -fn add_context_headers(request: RequestBuilder, context: &ContextArgs) -> RequestBuilder { - request - .header("X-ELF-Tenant-Id", &context.tenant_id) - .header("X-ELF-Project-Id", &context.project_id) - .header("X-ELF-Agent-Id", &context.agent_id) -} - -fn run_cargo_make( - schema: &str, - task: &str, - env: BTreeMap, - dry_run: bool, - pretty: bool, -) -> Result<()> { - let command = serde_json::json!({ - "program": "cargo", - "args": ["make", task], - "env": env, - }); - - if dry_run { - let output = serde_json::json!({ - "schema": schema, - "dry_run": true, - "command": command, - }); - - return write_json(&output, pretty); - } - - let output = Command::new("cargo").arg("make").arg(task).envs(env.iter()).output()?; - - io::stderr().write_all(&output.stdout)?; - io::stderr().write_all(&output.stderr)?; - - let status_code = output.status.code(); - let summary = serde_json::json!({ - "schema": schema, - "dry_run": false, - "command": command, - "status_code": status_code, - "success": output.status.success(), - }); - - write_json(&summary, pretty)?; - - if output.status.success() { - Ok(()) - } else { - Err(eyre::eyre!("cargo make {task} failed with status {status_code:?}.")) - } -} - -fn write_json(value: &Value, pretty: bool) -> Result<()> { - if pretty { - serde_json::to_writer_pretty(io::stdout(), value)?; - } else { - serde_json::to_writer(io::stdout(), value)?; - } - - writeln!(io::stdout())?; - - Ok(()) -} - -fn join_url(base_url: &str, path: &str) -> String { - format!("{}/{}", base_url.trim_end_matches('/'), path.trim_start_matches('/')) -} - -fn redact_url(url: &str) -> String { - url.to_string() -} - -fn header_string(headers: &HeaderMap, name: &str) -> Option { - headers.get(name).and_then(|value| value.to_str().ok()).map(str::to_string) -} - -fn path_display(path: &Path) -> String { - path.display().to_string() -} +use crate::args::{Cli, Commands}; #[tokio::main] async fn main() -> Result<()> { @@ -631,338 +24,11 @@ async fn run(cli: Cli) -> Result<()> { let client = Client::new(); match cli.command { - Commands::AddNote(args) => run_add_note(&client, args).await, - Commands::Search(args) => run_search(&client, args).await, - Commands::Status(args) => run_status(&client, args).await, - Commands::Backfill(args) => run_backfill(args), - Commands::Benchmark(args) => run_benchmark(args), - Commands::Diagnostics(args) => run_diagnostics(&client, args).await, - } -} - -async fn run_add_note(client: &Client, args: AddNoteArgs) -> Result<()> { - let source_ref = source_ref(&args.source_id, args.source_ref_json.as_deref())?; - let body = serde_json::json!({ - "scope": args.scope, - "notes": [{ - "type": args.note_type, - "key": args.key, - "text": args.text, - "importance": args.importance, - "confidence": args.confidence, - "ttl_days": args.ttl_days, - "source_ref": source_ref, - }], - }); - let response = request_json( - client, - JsonRequest { - method: Method::POST, - base_url: &args.endpoint.api_url, - path: "/v2/notes/ingest", - token: args.endpoint.token.as_deref(), - context: Some(&args.context), - read_profile: None, - body: Some(&body), - }, - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.add_note/v1", - "request": { - "api_url": redact_url(&args.endpoint.api_url), - "tenant_id": args.context.tenant_id, - "project_id": args.context.project_id, - "agent_id": args.context.agent_id, - "scope": body["scope"], - "source_id": args.source_id, - "source_ref": body["notes"][0]["source_ref"], - }, - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn run_search(client: &Client, args: SearchArgs) -> Result<()> { - let body = search_body( - args.query, - args.mode, - args.top_k, - args.candidate_k, - args.payload_level, - args.filter_json.as_deref(), - )?; - let response = request_json( - client, - JsonRequest { - method: Method::POST, - base_url: &args.endpoint.api_url, - path: "/v2/searches", - token: args.endpoint.token.as_deref(), - context: Some(&args.read_context.context), - read_profile: Some(&args.read_context.read_profile), - body: Some(&body), - }, - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.search/v1", - "request": { - "api_url": redact_url(&args.endpoint.api_url), - "tenant_id": args.read_context.context.tenant_id, - "project_id": args.read_context.context.project_id, - "agent_id": args.read_context.context.agent_id, - "read_profile": args.read_context.read_profile, - "mode": body["mode"], - "payload_level": body["payload_level"], - }, - "trace_id": response.get("trace_id").cloned().unwrap_or(Value::Null), - "search_id": response.get("search_id").cloned().unwrap_or(Value::Null), - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn run_status(client: &Client, args: StatusArgs) -> Result<()> { - let url = join_url(&args.endpoint.api_url, "/health"); - let mut request = client.get(&url); - - if let Some(token) = args.endpoint.token.as_deref() { - request = request.bearer_auth(token); - } - - let response = request.send().await?; - let status = response.status(); - let request_id = header_string(response.headers(), "x-elf-request-id"); - let body = response.text().await?; - let output = serde_json::json!({ - "schema": "elf.cli.status/v1", - "api": { - "url": redact_url(&args.endpoint.api_url), - "healthy": status == StatusCode::OK, - "status": status.as_u16(), - "request_id": request_id, - "body": body, - }, - }); - - write_json(&output, args.output.pretty)?; - - if status.is_success() { - Ok(()) - } else { - Err(eyre::eyre!("ELF API health check failed with HTTP status {status}.")) + Commands::AddNote(args) => commands::run_add_note(&client, args).await, + Commands::Search(args) => commands::run_search(&client, args).await, + Commands::Status(args) => commands::run_status(&client, args).await, + Commands::Backfill(args) => tasks::run_backfill(args), + Commands::Benchmark(args) => tasks::run_benchmark(args), + Commands::Diagnostics(args) => diagnostics::run_diagnostics(&client, args).await, } } - -async fn run_diagnostics(client: &Client, args: DiagnosticsArgs) -> Result<()> { - match args.command { - DiagnosticsCommand::QdrantRebuild(args) => run_qdrant_rebuild(client, args).await, - DiagnosticsCommand::RawSearch(args) => run_raw_search(client, args).await, - DiagnosticsCommand::RecentTraces(args) => run_recent_traces(client, args).await, - DiagnosticsCommand::TraceBundle(args) => run_trace_bundle(client, args).await, - DiagnosticsCommand::NoteProvenance(args) => run_note_provenance(client, args).await, - } -} - -async fn run_qdrant_rebuild(client: &Client, args: AdminPostArgs) -> Result<()> { - let response = request_json( - client, - JsonRequest { - method: Method::POST, - base_url: &args.endpoint.admin_url, - path: "/v2/admin/qdrant/rebuild", - token: args.endpoint.admin_token.as_deref(), - context: Some(&args.context), - read_profile: None, - body: None, - }, - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.diagnostics.qdrant_rebuild/v1", - "admin_url": redact_url(&args.endpoint.admin_url), - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn run_raw_search(client: &Client, args: AdminSearchArgs) -> Result<()> { - let body = search_body( - args.query, - args.mode, - args.top_k, - args.candidate_k, - args.payload_level, - args.filter_json.as_deref(), - )?; - let response = request_json( - client, - JsonRequest { - method: Method::POST, - base_url: &args.endpoint.admin_url, - path: "/v2/admin/searches/raw", - token: args.endpoint.admin_token.as_deref(), - context: Some(&args.read_context.context), - read_profile: Some(&args.read_context.read_profile), - body: Some(&body), - }, - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.diagnostics.raw_search/v1", - "request": { - "admin_url": redact_url(&args.endpoint.admin_url), - "tenant_id": args.read_context.context.tenant_id, - "project_id": args.read_context.context.project_id, - "agent_id": args.read_context.context.agent_id, - "read_profile": args.read_context.read_profile, - "mode": body["mode"], - "payload_level": body["payload_level"], - }, - "trace_id": response.get("trace_id").cloned().unwrap_or(Value::Null), - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn run_recent_traces(client: &Client, args: RecentTracesArgs) -> Result<()> { - let mut query = Vec::new(); - - if let Some(limit) = args.limit { - query.push(("limit", limit.to_string())); - } - - let response = request_json_query( - client, - &args.endpoint.admin_url, - "/v2/admin/traces/recent", - args.endpoint.admin_token.as_deref(), - &args.context, - &query, - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.diagnostics.recent_traces/v1", - "admin_url": redact_url(&args.endpoint.admin_url), - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn run_trace_bundle(client: &Client, args: TraceBundleArgs) -> Result<()> { - let path = format!("/v2/admin/traces/{}/bundle", args.trace_id); - let mut query = vec![("mode", args.mode)]; - - if let Some(limit) = args.stage_items_limit { - query.push(("stage_items_limit", limit.to_string())); - } - if let Some(limit) = args.candidates_limit { - query.push(("candidates_limit", limit.to_string())); - } - - let response = request_json_query( - client, - &args.endpoint.admin_url, - &path, - args.endpoint.admin_token.as_deref(), - &args.context, - &query, - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.diagnostics.trace_bundle/v1", - "admin_url": redact_url(&args.endpoint.admin_url), - "trace_id": response.pointer("/trace/trace_id").cloned().unwrap_or(Value::Null), - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn run_note_provenance(client: &Client, args: NoteProvenanceArgs) -> Result<()> { - let path = format!("/v2/admin/notes/{}/provenance", args.note_id); - let response = request_json_query( - client, - &args.endpoint.admin_url, - &path, - args.endpoint.admin_token.as_deref(), - &args.context, - &[], - ) - .await?; - let output = serde_json::json!({ - "schema": "elf.cli.diagnostics.note_provenance/v1", - "admin_url": redact_url(&args.endpoint.admin_url), - "note_id": response.pointer("/note/note_id").cloned().unwrap_or(Value::String(args.note_id)), - "response": response, - }); - - write_json(&output, args.output.pretty) -} - -async fn request_json(client: &Client, args: JsonRequest<'_>) -> Result { - let mut request = client.request(args.method, join_url(args.base_url, args.path)); - - if let Some(token) = args.token { - request = request.bearer_auth(token); - } - if let Some(context) = args.context { - request = add_context_headers(request, context); - } - if let Some(read_profile) = args.read_profile { - request = request.header("X-ELF-Read-Profile", read_profile); - } - if let Some(body) = args.body { - request = request.json(body); - } - - parse_json_response(request.send().await?).await -} - -async fn request_json_query( - client: &Client, - base_url: &str, - path: &str, - token: Option<&str>, - context: &ContextArgs, - query: &[(&str, String)], -) -> Result { - let mut request = client.get(join_url(base_url, path)).query(query); - - if let Some(token) = token { - request = request.bearer_auth(token); - } - - request = add_context_headers(request, context); - - parse_json_response(request.send().await?).await -} - -async fn parse_json_response(response: Response) -> Result { - let status = response.status(); - let request_id = header_string(response.headers(), "x-elf-request-id"); - let text = response.text().await?; - - if !status.is_success() { - return Err(eyre::eyre!( - "ELF request failed with HTTP status {status} and request_id {}: {text}", - request_id.as_deref().unwrap_or("unknown") - )); - } - if text.trim().is_empty() { - return Ok(serde_json::json!({"status": status.as_u16(), "request_id": request_id})); - } - - serde_json::from_str(&text).map_err(|err| { - eyre::eyre!( - "ELF response was not valid JSON for request_id {}: {err}", - request_id.as_deref().unwrap_or("unknown") - ) - }) -} diff --git a/apps/elf-cli/src/tasks.rs b/apps/elf-cli/src/tasks.rs new file mode 100644 index 00000000..78a9bf24 --- /dev/null +++ b/apps/elf-cli/src/tasks.rs @@ -0,0 +1,136 @@ +use std::{ + collections::BTreeMap, + io::{self, Write as _}, + path::Path, + process::Command, +}; + +use color_eyre::{Result, eyre}; + +use crate::{ + args::{BackfillArgs, BenchmarkArgs, BenchmarkCommand, BenchmarkReportArgs, BenchmarkRunArgs}, + json, +}; + +pub(crate) fn run_backfill(args: BackfillArgs) -> Result<()> { + let task = if args.hundred_k { + "baseline-backfill-100k-docker" + } else if args.ten_k { + "baseline-backfill-10k-docker" + } else { + "baseline-backfill-docker" + }; + let mut env = BTreeMap::new(); + + if let Some(docs) = args.docs { + env.insert("ELF_BASELINE_BACKFILL_DOCS".to_string(), docs.to_string()); + } + if let Some(worker_concurrency) = args.worker_concurrency { + env.insert("ELF_BASELINE_WORKER_CONCURRENCY".to_string(), worker_concurrency.to_string()); + } + + if args.enable_expensive { + env.insert("ELF_BASELINE_ENABLE_EXPENSIVE".to_string(), "1".to_string()); + } + + run_cargo_make("elf.cli.backfill/v1", task, env, args.dry_run, args.output.pretty) +} + +pub(crate) fn run_benchmark(args: BenchmarkArgs) -> Result<()> { + match args.command { + BenchmarkCommand::Run(args) => run_benchmark_run(args), + BenchmarkCommand::Report(args) => run_benchmark_report(args), + } +} + +fn run_benchmark_run(args: BenchmarkRunArgs) -> Result<()> { + let task = args.kind.task_name(); + let mut env = BTreeMap::new(); + + if let Some(projects) = args.projects { + env.insert("ELF_BASELINE_PROJECTS".to_string(), projects); + } + if let Some(profile) = args.profile { + env.insert("ELF_BASELINE_PROFILE".to_string(), profile); + } + if let Some(path) = args.production_corpus_manifest { + env.insert("ELF_BASELINE_PRODUCTION_CORPUS_MANIFEST".to_string(), path_display(&path)); + } + if let Some(path) = args.private_addendum { + env.insert("ELF_BASELINE_PRIVATE_ADDENDUM".to_string(), path_display(&path)); + } + if let Some(seconds) = args.soak_seconds { + env.insert("ELF_BASELINE_SOAK_SECONDS".to_string(), seconds.to_string()); + } + + run_cargo_make("elf.cli.benchmark_run/v1", task, env, args.dry_run, args.output.pretty) +} + +fn run_benchmark_report(args: BenchmarkReportArgs) -> Result<()> { + let mut env = BTreeMap::new(); + + if let Some(path) = args.report { + env.insert("ELF_BASELINE_REPORT".to_string(), path_display(&path)); + } + if let Some(path) = args.out { + env.insert("ELF_BASELINE_MARKDOWN_REPORT".to_string(), path_display(&path)); + } + + run_cargo_make( + "elf.cli.benchmark_report/v1", + "baseline-live-report", + env, + args.dry_run, + args.output.pretty, + ) +} + +fn run_cargo_make( + schema: &str, + task: &str, + env: BTreeMap, + dry_run: bool, + pretty: bool, +) -> Result<()> { + let command = serde_json::json!({ + "program": "cargo", + "args": ["make", task], + "env": env, + }); + + if dry_run { + let output = serde_json::json!({ + "schema": schema, + "dry_run": true, + "command": command, + }); + + return json::write_json(&output, pretty); + } + + let output = Command::new("cargo").arg("make").arg(task).envs(env.iter()).output()?; + + io::stderr().write_all(&output.stdout)?; + io::stderr().write_all(&output.stderr)?; + + let status_code = output.status.code(); + let summary = serde_json::json!({ + "schema": schema, + "dry_run": false, + "command": command, + "status_code": status_code, + "success": output.status.success(), + }); + + json::write_json(&summary, pretty)?; + + if output.status.success() { + Ok(()) + } else { + Err(eyre::eyre!("cargo make {task} failed with status {status_code:?}.")) + } +} + +fn path_display(path: &Path) -> String { + path.display().to_string() +} diff --git a/apps/elf-eval/src/app.rs b/apps/elf-eval/src/app.rs index b5234bc9..14127a68 100644 --- a/apps/elf-eval/src/app.rs +++ b/apps/elf-eval/src/app.rs @@ -1,414 +1,17 @@ -use std::{ - cmp::Ordering, - collections::{HashMap, HashSet}, - fs, - path::{Path, PathBuf}, - time::Instant, -}; +mod cli; +mod compare; +mod dataset; +mod eval; +mod metrics; +mod trace_compare; +mod types; + +pub use cli::{Args, SearchMode}; -use clap::{Parser, ValueEnum}; use color_eyre::{Result, eyre}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::FromRow; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing_subscriber::EnvFilter; -use uuid::Uuid; - -use elf_config::Config; -use elf_service::{ - ElfService, RankingRequestOverride, SearchIndexItem, SearchIndexResponse, SearchRequest, - search::{self, TraceReplayContext, TraceReplayItem}, -}; -use elf_storage::{db::Db, qdrant::QdrantStore}; - -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), -)] -pub struct Args { - #[arg(long = "config-a", short = 'c', value_name = "FILE", visible_alias = "config")] - pub config_a: PathBuf, - #[arg(long = "config-b", value_name = "FILE")] - pub config_b: Option, - #[arg(long, short = 'd', value_name = "FILE", required_unless_present = "trace_id")] - pub dataset: Option, - #[arg(long, value_name = "N")] - pub top_k: Option, - #[arg(long, value_name = "N")] - pub candidate_k: Option, - #[arg(long, value_name = "N", default_value_t = 1)] - pub runs_per_query: u32, - #[arg(long, value_enum, default_value_t = SearchMode::PlannedSearch)] - pub search_mode: SearchMode, - #[arg(long = "search-mode-b", value_enum)] - pub search_mode_b: Option, - #[arg(long = "trace-id", value_name = "UUID", num_args = 1..)] - pub trace_id: Vec, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -pub enum SearchMode { - #[value(name = "quick_find")] - QuickFind, - #[value(name = "planned_search")] - PlannedSearch, -} - -#[derive(Debug, Deserialize)] -struct EvalDataset { - name: Option, - defaults: Option, - queries: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct EvalDefaults { - tenant_id: Option, - project_id: Option, - agent_id: Option, - read_profile: Option, - top_k: Option, - candidate_k: Option, - ranking: Option, -} - -#[derive(Debug, Deserialize)] -struct EvalQuery { - id: Option, - query: String, - tenant_id: Option, - project_id: Option, - agent_id: Option, - read_profile: Option, - top_k: Option, - candidate_k: Option, - #[serde(default)] - expected_note_ids: Vec, - #[serde(default)] - expected_keys: Vec, - ranking: Option, -} - -#[derive(Debug, Serialize)] -struct EvalOutput { - dataset: EvalDatasetInfo, - settings: EvalSettings, - summary: EvalSummary, - queries: Vec, -} - -#[derive(Debug, Serialize)] -struct EvalDatasetInfo { - name: String, - query_count: usize, -} - -#[derive(Debug, Serialize)] -struct EvalSettings { - config_path: String, - search_mode: SearchMode, - candidate_k: u32, - top_k: u32, - #[serde(skip_serializing_if = "Option::is_none")] - runs_per_query: Option, -} - -#[derive(Debug, Serialize)] -struct EvalSummary { - avg_recall_at_k: f64, - avg_precision_at_k: f64, - mean_rr: f64, - mean_ndcg: f64, - latency_ms_p50: f64, - latency_ms_p95: f64, - avg_retrieved_summary_chars: f64, - #[serde(skip_serializing_if = "Option::is_none")] - stability: Option, -} - -#[derive(Debug, Serialize)] -struct StabilitySummary { - runs_per_query: u32, - avg_positional_churn_at_k: f64, - avg_set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct QueryReport { - id: String, - query: String, - trace_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - trace_ids: Option>, - expected_count: usize, - retrieved_count: usize, - relevant_count: usize, - recall_at_k: f64, - precision_at_k: f64, - rr: f64, - ndcg: f64, - latency_ms: f64, - expected_note_ids: Vec, - expected_keys: Vec, - expected_kind: ExpectedKind, - retrieved_note_ids: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - retrieved_keys: Vec>, - retrieved_summary_chars: usize, - #[serde(skip_serializing_if = "Option::is_none")] - stability: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum ExpectedKind { - NoteId, - Key, -} - -#[derive(Clone, Copy, Debug, Serialize)] -struct QueryStability { - runs_per_query: u32, - positional_churn_at_k: f64, - set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct CompareOutput { - dataset: EvalDatasetInfo, - settings_a: EvalSettings, - settings_b: EvalSettings, - summary_a: EvalSummary, - summary_b: EvalSummary, - summary_delta: EvalSummaryDelta, - policy_stability: PolicyStabilitySummary, - queries: Vec, -} - -#[derive(Debug, Serialize)] -struct PolicyStabilitySummary { - k: u32, - avg_positional_churn_at_k: f64, - avg_set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct EvalSummaryDelta { - avg_recall_at_k: f64, - avg_precision_at_k: f64, - mean_rr: f64, - mean_ndcg: f64, - latency_ms_p50: f64, - latency_ms_p95: f64, - avg_retrieved_summary_chars: f64, - #[serde(skip_serializing_if = "Option::is_none")] - stability: Option, -} -#[derive(Debug, Serialize)] -struct StabilitySummaryDelta { - avg_positional_churn_at_k: f64, - avg_set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct CompareQueryReport { - id: String, - query: String, - expected_count: usize, - expected_note_ids: Vec, - a: QueryVariantReport, - b: QueryVariantReport, - delta: QueryVariantDelta, - policy_churn: PolicyChurn, -} - -#[derive(Debug, Serialize)] -struct PolicyChurn { - positional_churn_at_k: f64, - set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct QueryVariantReport { - trace_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - trace_ids: Option>, - retrieved_count: usize, - relevant_count: usize, - recall_at_k: f64, - precision_at_k: f64, - rr: f64, - ndcg: f64, - latency_ms: f64, - retrieved_note_ids: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - stability: Option, -} - -#[derive(Debug, Serialize)] -struct QueryVariantDelta { - retrieved_count: i64, - relevant_count: i64, - recall_at_k: f64, - precision_at_k: f64, - rr: f64, - ndcg: f64, - latency_ms: f64, - #[serde(skip_serializing_if = "Option::is_none")] - stability: Option, -} - -#[derive(Debug, Serialize)] -struct QueryStabilityDelta { - positional_churn_at_k: f64, - set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct TraceCompareOutput { - policies: TraceComparePolicies, - summary: TraceCompareSummary, - traces: Vec, -} - -#[derive(Debug, Serialize)] -struct TraceComparePolicies { - a: TraceComparePolicy, - b: TraceComparePolicy, -} - -#[derive(Debug, Serialize)] -struct TraceComparePolicy { - config_path: String, - policy_id: String, -} - -#[derive(Debug, Serialize)] -struct TraceCompareSummary { - trace_count: usize, - avg_positional_churn_at_k: f64, - avg_set_churn_at_k: f64, - avg_a_retrieval_top3_retention: f64, - avg_b_retrieval_top3_retention: f64, - avg_retrieval_top3_retention_delta: f64, -} - -#[derive(Debug, Serialize)] -struct TraceCompareTrace { - trace_id: Uuid, - query: String, - candidate_count: u32, - top_k: u32, - created_at: String, - a: TraceCompareVariant, - b: TraceCompareVariant, - churn: TraceCompareChurn, - guardrails: TraceCompareGuardrails, - stage_deltas: Vec, - regression_attribution: TraceCompareRegressionAttribution, -} - -#[derive(Debug, Serialize)] -struct TraceCompareVariant { - policy_id: String, - items: Vec, -} - -#[derive(Debug, Serialize)] -struct TraceCompareChurn { - positional_churn_at_k: f64, - set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -struct TraceCompareGuardrails { - retrieval_top3_total: usize, - a_retrieval_top3_retained: usize, - a_retrieval_top3_retention: f64, - b_retrieval_top3_retained: usize, - b_retrieval_top3_retention: f64, - retrieval_top3_retention_delta: f64, -} - -#[derive(Debug, Serialize)] -struct TraceCompareStageDelta { - stage_order: u32, - stage_name: String, - baseline_item_count: u32, - a_item_count: u32, - b_item_count: u32, - item_count_delta: i64, - #[serde(skip_serializing_if = "Option::is_none")] - baseline_stats: Option, -} - -#[derive(Debug, Serialize)] -struct TraceCompareRegressionAttribution { - primary_stage: String, - evidence: String, -} - -#[derive(FromRow)] -struct TraceCompareTraceRow { - trace_id: Uuid, - query: String, - candidate_count: i32, - top_k: i32, - created_at: OffsetDateTime, -} - -#[derive(FromRow)] -struct TraceCompareCandidateRow { - candidate_snapshot: Value, - note_id: Uuid, - chunk_id: Uuid, - chunk_index: i32, - snippet: String, - retrieval_rank: i32, - rerank_score: f32, - note_scope: String, - note_importance: f32, - note_updated_at: OffsetDateTime, - note_hit_count: i64, - note_last_hit_at: Option, -} - -#[derive(FromRow)] -struct TraceCompareStageRow { - stage_order: i32, - stage_name: String, - stage_payload: Value, - item_count: i64, -} - -struct MergedQuery { - id: String, - query: String, - expected_note_ids: Vec, - expected_keys: Vec, - expected_kind: ExpectedKind, - request: SearchRequest, -} - -struct Metrics { - recall_at_k: f64, - precision_at_k: f64, - rr: f64, - ndcg: f64, - relevant_count: usize, -} - -struct EvalRun { - dataset: EvalDatasetInfo, - settings: EvalSettings, - summary: EvalSummary, - queries: Vec, -} +use types::{CompareOutput, EvalOutput}; pub async fn run(args: Args) -> Result<()> { let config_a = elf_config::load(&args.config_a)?; @@ -421,7 +24,7 @@ pub async fn run(args: Args) -> Result<()> { return Err(eyre::eyre!("Trace compare mode requires --config-b.")); }; let config_b = elf_config::load(config_b_path)?; - let output = trace_compare( + let output = trace_compare::trace_compare( args.config_a.as_path(), config_a, config_b_path.as_path(), @@ -438,18 +41,21 @@ pub async fn run(args: Args) -> Result<()> { let dataset_path = args.dataset.as_ref().ok_or_else(|| eyre::eyre!("--dataset is required."))?; - let dataset = load_dataset(dataset_path.as_path())?; + let dataset = dataset::load_dataset(dataset_path.as_path())?; let run_a = - eval_config(args.config_a.as_path(), config_a, &dataset, &args, args.search_mode).await?; + eval::eval_config(args.config_a.as_path(), config_a, &dataset, &args, args.search_mode) + .await?; let search_mode_b = args.search_mode_b.unwrap_or(args.search_mode); if let Some(config_b_path) = &args.config_b { let config_b = elf_config::load(config_b_path)?; let run_b = - eval_config(config_b_path.as_path(), config_b, &dataset, &args, search_mode_b).await?; + eval::eval_config(config_b_path.as_path(), config_b, &dataset, &args, search_mode_b) + .await?; let k = run_a.settings.top_k.min(run_b.settings.top_k).max(1); - let (queries, policy_stability) = build_compare_queries(&run_a.queries, &run_b.queries, k); - let summary_delta = diff_summary(&run_a.summary, &run_b.summary); + let (queries, policy_stability) = + compare::build_compare_queries(&run_a.queries, &run_b.queries, k); + let summary_delta = compare::diff_summary(&run_a.summary, &run_b.summary); let output = CompareOutput { dataset: run_a.dataset, settings_a: run_a.settings, @@ -480,1097 +86,4 @@ pub async fn run(args: Args) -> Result<()> { Ok(()) } -fn retrieval_top_rank_retention( - candidates: &[elf_service::search::TraceReplayCandidate], - note_ids: &[Uuid], - max_retrieval_rank: u32, -) -> (usize, usize, f64) { - let mut top_notes = HashSet::new(); - - for candidate in candidates { - if candidate.retrieval_rank == 0 || candidate.retrieval_rank > max_retrieval_rank { - continue; - } - - top_notes.insert(candidate.note_id); - } - - let total = top_notes.len(); - - if total == 0 { - return (0, 0, 0.0); - } - - let out_set: HashSet = note_ids.iter().copied().collect(); - let retained = top_notes.intersection(&out_set).count(); - let retention = retained as f64 / total as f64; - - (total, retained, retention) -} - -fn load_dataset(path: &Path) -> Result { - let raw = fs::read_to_string(path)?; - let dataset: EvalDataset = serde_json::from_str(&raw)?; - - if dataset.queries.is_empty() { - return Err(eyre::eyre!("Dataset must include at least one query.")); - } - - Ok(dataset) -} - -fn churn_against_baseline_at_k(baseline: &[Uuid], other: &[Uuid], k: usize) -> (f64, f64) { - let k = k.max(1); - let mut positional_diff = 0_usize; - - for idx in 0..k { - let a = baseline.get(idx); - let b = other.get(idx); - - if a != b { - positional_diff += 1; - } - } - - let positional_churn = positional_diff as f64 / k as f64; - let base_set: HashSet = baseline.iter().take(k).copied().collect(); - let other_set: HashSet = other.iter().take(k).copied().collect(); - let overlap = base_set.intersection(&other_set).count(); - let set_churn = 1.0 - (overlap as f64 / k as f64); - - (positional_churn, set_churn) -} - -fn diff_summary(a: &EvalSummary, b: &EvalSummary) -> EvalSummaryDelta { - EvalSummaryDelta { - avg_recall_at_k: b.avg_recall_at_k - a.avg_recall_at_k, - avg_precision_at_k: b.avg_precision_at_k - a.avg_precision_at_k, - mean_rr: b.mean_rr - a.mean_rr, - mean_ndcg: b.mean_ndcg - a.mean_ndcg, - latency_ms_p50: b.latency_ms_p50 - a.latency_ms_p50, - latency_ms_p95: b.latency_ms_p95 - a.latency_ms_p95, - avg_retrieved_summary_chars: b.avg_retrieved_summary_chars - a.avg_retrieved_summary_chars, - stability: match (&a.stability, &b.stability) { - (Some(sa), Some(sb)) => Some(StabilitySummaryDelta { - avg_positional_churn_at_k: sb.avg_positional_churn_at_k - - sa.avg_positional_churn_at_k, - avg_set_churn_at_k: sb.avg_set_churn_at_k - sa.avg_set_churn_at_k, - }), - _ => None, - }, - } -} - -fn build_compare_queries( - a: &[QueryReport], - b: &[QueryReport], - k: u32, -) -> (Vec, PolicyStabilitySummary) { - let k_usize = k.max(1) as usize; - let mut positional_sum = 0.0_f64; - let mut set_sum = 0.0_f64; - let queries: Vec = a - .iter() - .zip(b.iter()) - .map(|(qa, qb)| { - let delta_stability = match (qa.stability, qb.stability) { - (Some(sa), Some(sb)) => Some(QueryStabilityDelta { - positional_churn_at_k: sb.positional_churn_at_k - sa.positional_churn_at_k, - set_churn_at_k: sb.set_churn_at_k - sa.set_churn_at_k, - }), - _ => None, - }; - let (positional_churn_at_k, set_churn_at_k) = churn_against_baseline_at_k( - &qa.retrieved_note_ids, - &qb.retrieved_note_ids, - k_usize, - ); - - positional_sum += positional_churn_at_k; - set_sum += set_churn_at_k; - - CompareQueryReport { - id: qa.id.clone(), - query: qa.query.clone(), - expected_count: qa.expected_count, - expected_note_ids: qa.expected_note_ids.clone(), - a: QueryVariantReport { - trace_id: qa.trace_id, - trace_ids: qa.trace_ids.clone(), - retrieved_count: qa.retrieved_count, - relevant_count: qa.relevant_count, - recall_at_k: qa.recall_at_k, - precision_at_k: qa.precision_at_k, - rr: qa.rr, - ndcg: qa.ndcg, - latency_ms: qa.latency_ms, - retrieved_note_ids: qa.retrieved_note_ids.clone(), - stability: qa.stability, - }, - b: QueryVariantReport { - trace_id: qb.trace_id, - trace_ids: qb.trace_ids.clone(), - retrieved_count: qb.retrieved_count, - relevant_count: qb.relevant_count, - recall_at_k: qb.recall_at_k, - precision_at_k: qb.precision_at_k, - rr: qb.rr, - ndcg: qb.ndcg, - latency_ms: qb.latency_ms, - retrieved_note_ids: qb.retrieved_note_ids.clone(), - stability: qb.stability, - }, - delta: QueryVariantDelta { - retrieved_count: qb.retrieved_count as i64 - qa.retrieved_count as i64, - relevant_count: qb.relevant_count as i64 - qa.relevant_count as i64, - recall_at_k: qb.recall_at_k - qa.recall_at_k, - precision_at_k: qb.precision_at_k - qa.precision_at_k, - rr: qb.rr - qa.rr, - ndcg: qb.ndcg - qa.ndcg, - latency_ms: qb.latency_ms - qa.latency_ms, - stability: delta_stability, - }, - policy_churn: PolicyChurn { positional_churn_at_k, set_churn_at_k }, - } - }) - .collect(); - let count = queries.len().max(1) as f64; - let summary = PolicyStabilitySummary { - k, - avg_positional_churn_at_k: positional_sum / count, - avg_set_churn_at_k: set_sum / count, - }; - - (queries, summary) -} - -fn merge_query( - defaults: &EvalDefaults, - query: &EvalQuery, - args: &Args, - cfg: &Config, - index: usize, -) -> Result { - let expected_kind = - resolve_expected_mode(index, &query.expected_note_ids, &query.expected_keys)?; - let tenant_id = query - .tenant_id - .clone() - .or_else(|| defaults.tenant_id.clone()) - .ok_or_else(|| eyre::eyre!("tenant_id is required for query at index {index}."))?; - let project_id = query - .project_id - .clone() - .or_else(|| defaults.project_id.clone()) - .ok_or_else(|| eyre::eyre!("project_id is required for query at index {index}."))?; - let agent_id = query - .agent_id - .clone() - .or_else(|| defaults.agent_id.clone()) - .ok_or_else(|| eyre::eyre!("agent_id is required for query at index {index}."))?; - let read_profile = query - .read_profile - .clone() - .or_else(|| defaults.read_profile.clone()) - .ok_or_else(|| eyre::eyre!("read_profile is required for query at index {index}."))?; - let top_k = args.top_k.or(query.top_k).or(defaults.top_k).unwrap_or(cfg.memory.top_k).max(1); - let candidate_k = args - .candidate_k - .or(query.candidate_k) - .or(defaults.candidate_k) - .unwrap_or(cfg.memory.candidate_k) - .max(top_k); - let id = query.id.clone().unwrap_or_else(|| format!("query-{index}")); - let ranking = query.ranking.clone().or_else(|| defaults.ranking.clone()); - - Ok(MergedQuery { - id, - query: query.query.clone(), - expected_note_ids: query.expected_note_ids.clone(), - expected_keys: query.expected_keys.clone(), - expected_kind, - request: SearchRequest { - tenant_id, - project_id, - agent_id, - token_id: None, - read_profile, - payload_level: Default::default(), - query: query.query.clone(), - top_k: Some(top_k), - candidate_k: Some(candidate_k), - filter: None, - record_hits: Some(false), - ranking, - }, - }) -} - -fn resolve_expected_mode(index: usize, note_ids: &[Uuid], keys: &[String]) -> Result { - let has_note_ids = !note_ids.is_empty(); - let has_keys = !keys.is_empty(); - - match (has_note_ids, has_keys) { - (true, false) => Ok(ExpectedKind::NoteId), - (false, true) => Ok(ExpectedKind::Key), - (true, true) => Err(eyre::eyre!( - "Query at index {index} must define exactly one expectation mode: expected_note_ids or expected_keys." - )), - (false, false) => Err(eyre::eyre!( - "Query at index {index} must include at least one expected_note_ids or expected_keys." - )), - } -} - -fn unique_items(items: &[SearchIndexItem]) -> Vec { - let mut seen = HashSet::new(); - let mut out = Vec::new(); - - for item in items { - if seen.insert(item.note_id) { - out.push(item.clone()); - } - } - - out -} - -fn compute_metrics(retrieved: &[Uuid], expected: &HashSet) -> Metrics { - let expected_count = expected.len(); - let mut relevant_count = 0_usize; - let mut dcg = 0.0_f64; - let mut rr = 0.0_f64; - let mut first_hit: Option = None; - - for (idx, id) in retrieved.iter().enumerate() { - if expected.contains(id) { - relevant_count += 1; - - let rank = idx + 1; - let denom = (rank as f64 + 1.0).log2(); - - dcg += 1.0 / denom; - - if first_hit.is_none() { - first_hit = Some(rank); - } - } - } - - if let Some(rank) = first_hit { - rr = 1.0 / rank as f64; - } - - let ideal_hits = expected_count.min(retrieved.len()); - let mut idcg = 0.0_f64; - - for idx in 0..ideal_hits { - let rank = idx + 1; - let denom = (rank as f64 + 1.0).log2(); - - idcg += 1.0 / denom; - } - - let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 }; - let precision_at_k = - if retrieved.is_empty() { 0.0 } else { relevant_count as f64 / retrieved.len() as f64 }; - let recall_at_k = - if expected_count == 0 { 0.0 } else { relevant_count as f64 / expected_count as f64 }; - - Metrics { recall_at_k, precision_at_k, rr, ndcg, relevant_count } -} - -fn compute_metrics_for_keys(retrieved: &[Option], expected: &HashSet) -> Metrics { - let expected_count = expected.len(); - let mut matched: HashSet = HashSet::new(); - let mut relevant_count = 0_usize; - let mut dcg = 0.0_f64; - let mut rr = 0.0_f64; - let mut first_hit: Option = None; - - for (idx, maybe_key) in retrieved.iter().enumerate() { - let Some(key) = maybe_key else { - continue; - }; - - if expected.contains(key) && !matched.contains(key) { - matched.insert(key.clone()); - - relevant_count += 1; - - let rank = idx + 1; - let denom = (rank as f64 + 1.0).log2(); - - dcg += 1.0 / denom; - - if first_hit.is_none() { - first_hit = Some(rank); - } - } - } - - if let Some(rank) = first_hit { - rr = 1.0 / rank as f64; - } - - let ideal_hits = expected_count.min(retrieved.len()); - let mut idcg = 0.0_f64; - - for idx in 0..ideal_hits { - let rank = idx + 1; - let denom = (rank as f64 + 1.0).log2(); - - idcg += 1.0 / denom; - } - - let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 }; - let precision_at_k = - if retrieved.is_empty() { 0.0 } else { relevant_count as f64 / retrieved.len() as f64 }; - let recall_at_k = - if expected_count == 0 { 0.0 } else { relevant_count as f64 / expected_count as f64 }; - - Metrics { recall_at_k, precision_at_k, rr, ndcg, relevant_count } -} - -fn compute_metrics_for_query( - merged: &MergedQuery, - retrieved_note_ids: &[Uuid], - retrieved_keys: &[Option], -) -> (Metrics, usize) { - match merged.expected_kind { - ExpectedKind::NoteId => { - let expected: HashSet = merged.expected_note_ids.iter().copied().collect(); - let expected_count = expected.len(); - - (compute_metrics(retrieved_note_ids, &expected), expected_count) - }, - ExpectedKind::Key => { - let expected: HashSet = merged.expected_keys.iter().cloned().collect(); - let expected_count = expected.len(); - - (compute_metrics_for_keys(retrieved_keys, &expected), expected_count) - }, - } -} - -fn summarize(reports: &[QueryReport], latencies_ms: &[f64]) -> EvalSummary { - let count = reports.len().max(1) as f64; - let avg_recall_at_k = reports.iter().map(|r| r.recall_at_k).sum::() / count; - let avg_precision_at_k = reports.iter().map(|r| r.precision_at_k).sum::() / count; - let mean_rr = reports.iter().map(|r| r.rr).sum::() / count; - let mean_ndcg = reports.iter().map(|r| r.ndcg).sum::() / count; - let avg_retrieved_summary_chars = - reports.iter().map(|r| r.retrieved_summary_chars as f64).sum::() / count; - let mut sorted = latencies_ms.to_vec(); - - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); - - let p50 = percentile(&sorted, 0.50); - let p95 = percentile(&sorted, 0.95); - - EvalSummary { - avg_recall_at_k, - avg_precision_at_k, - mean_rr, - mean_ndcg, - latency_ms_p50: p50, - latency_ms_p95: p95, - avg_retrieved_summary_chars, - stability: None, - } -} - -fn percentile(values: &[f64], percentile: f64) -> f64 { - if values.is_empty() { - return 0.0; - } - - let clamped = percentile.clamp(0.0, 1.0); - let pos = clamped * (values.len() as f64 - 1.0); - let lower = pos.floor() as usize; - let upper = pos.ceil() as usize; - - if lower == upper { - values[lower] - } else { - let weight = pos - lower as f64; - - values[lower] * (1.0 - weight) + values[upper] * weight - } -} - -fn decode_trace_replay_candidates( - rows: Vec, -) -> Vec { - rows.into_iter() - .map(|row| { - let decoded = serde_json::from_value::( - row.candidate_snapshot.clone(), - ) - .ok() - .filter(|value| value.note_id != Uuid::nil() && value.chunk_id != Uuid::nil()); - - decoded.unwrap_or_else(|| elf_service::search::TraceReplayCandidate { - note_id: row.note_id, - chunk_id: row.chunk_id, - chunk_index: row.chunk_index, - snippet: row.snippet, - retrieval_rank: u32::try_from(row.retrieval_rank).unwrap_or(0), - retrieval_score: None, - rerank_score: row.rerank_score, - note_scope: row.note_scope, - note_importance: row.note_importance, - note_updated_at: row.note_updated_at, - note_hit_count: row.note_hit_count, - note_last_hit_at: row.note_last_hit_at, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }) - }) - .collect() -} - -fn build_trace_compare_stage_deltas( - stage_rows: &[TraceCompareStageRow], - a_selected_count: u32, - b_selected_count: u32, -) -> Vec { - if stage_rows.is_empty() { - return vec![TraceCompareStageDelta { - stage_order: 1, - stage_name: "selection.final".to_string(), - baseline_item_count: 0, - a_item_count: a_selected_count, - b_item_count: b_selected_count, - item_count_delta: b_selected_count as i64 - a_selected_count as i64, - baseline_stats: None, - }]; - } - - let mut out = Vec::with_capacity(stage_rows.len()); - - for row in stage_rows { - let baseline_item_count = row.item_count.max(0) as u32; - let (a_item_count, b_item_count) = if row.stage_name == "selection.final" { - (a_selected_count, b_selected_count) - } else { - (baseline_item_count, baseline_item_count) - }; - let baseline_stats = row.stage_payload.get("stats").cloned(); - - out.push(TraceCompareStageDelta { - stage_order: row.stage_order.max(0) as u32, - stage_name: row.stage_name.clone(), - baseline_item_count, - a_item_count, - b_item_count, - item_count_delta: b_item_count as i64 - a_item_count as i64, - baseline_stats, - }); - } - - out -} - -fn build_trace_compare_regression_attribution( - churn: &TraceCompareChurn, - guardrails: &TraceCompareGuardrails, - stage_deltas: &[TraceCompareStageDelta], -) -> TraceCompareRegressionAttribution { - let stage_by_name: HashMap<&str, &TraceCompareStageDelta> = - stage_deltas.iter().map(|stage| (stage.stage_name.as_str(), stage)).collect(); - - if guardrails.retrieval_top3_retention_delta < 0.0 { - let recall_count = stage_by_name - .get("recall.candidates") - .map(|stage| stage.baseline_item_count) - .unwrap_or(0); - - return TraceCompareRegressionAttribution { - primary_stage: "selection.final".to_string(), - evidence: format!( - "retrieval_top3_retention dropped by {:.4} (a={:.4}, b={:.4}); recall baseline item_count={recall_count}", - guardrails.retrieval_top3_retention_delta, - guardrails.a_retrieval_top3_retention, - guardrails.b_retrieval_top3_retention - ), - }; - } - if churn.set_churn_at_k > 0.0 || churn.positional_churn_at_k > 0.0 { - return TraceCompareRegressionAttribution { - primary_stage: "rerank.score".to_string(), - evidence: format!( - "top-k churn changed without retrieval-top3 regression (set_churn_at_k={:.4}, positional_churn_at_k={:.4})", - churn.set_churn_at_k, churn.positional_churn_at_k - ), - }; - } - - TraceCompareRegressionAttribution { - primary_stage: "not_applicable".to_string(), - evidence: "No regression signal detected.".to_string(), - } -} - -async fn trace_compare( - config_a_path: &Path, - config_a: Config, - config_b_path: &Path, - config_b: Config, - args: &Args, -) -> Result { - let policy_id_a = - search::ranking_policy_id(&config_a, None).map_err(|err| eyre::eyre!("{err}"))?; - let policy_id_b = - search::ranking_policy_id(&config_b, None).map_err(|err| eyre::eyre!("{err}"))?; - let db = Db::connect(&config_a.storage.postgres).await?; - - db.ensure_schema(config_a.storage.qdrant.vector_dim).await?; - - let mut traces = Vec::with_capacity(args.trace_id.len()); - let mut positional_sum = 0.0_f64; - let mut set_sum = 0.0_f64; - let mut top3_retention_a_sum = 0.0_f64; - let mut top3_retention_b_sum = 0.0_f64; - - for trace_id in &args.trace_id { - let trace = compare_trace_id( - &db, - &config_a, - &config_b, - policy_id_a.as_str(), - policy_id_b.as_str(), - trace_id, - args, - ) - .await?; - - positional_sum += trace.churn.positional_churn_at_k; - set_sum += trace.churn.set_churn_at_k; - top3_retention_a_sum += trace.guardrails.a_retrieval_top3_retention; - top3_retention_b_sum += trace.guardrails.b_retrieval_top3_retention; - - traces.push(trace); - } - - let count = traces.len().max(1) as f64; - let summary = TraceCompareSummary { - trace_count: traces.len(), - avg_positional_churn_at_k: positional_sum / count, - avg_set_churn_at_k: set_sum / count, - avg_a_retrieval_top3_retention: top3_retention_a_sum / count, - avg_b_retrieval_top3_retention: top3_retention_b_sum / count, - avg_retrieval_top3_retention_delta: (top3_retention_b_sum - top3_retention_a_sum) / count, - }; - - Ok(TraceCompareOutput { - policies: TraceComparePolicies { - a: TraceComparePolicy { - config_path: config_a_path.display().to_string(), - policy_id: policy_id_a, - }, - b: TraceComparePolicy { - config_path: config_b_path.display().to_string(), - policy_id: policy_id_b, - }, - }, - summary, - traces, - }) -} - -async fn compare_trace_id( - db: &Db, - config_a: &Config, - config_b: &Config, - policy_id_a: &str, - policy_id_b: &str, - trace_id: &Uuid, - args: &Args, -) -> Result { - let trace_row = fetch_trace_compare_trace_row(db, trace_id).await?; - let candidate_rows = fetch_trace_compare_candidate_rows(db, trace_id).await?; - let stage_rows = fetch_trace_compare_stage_rows(db, trace_id).await?; - let context = TraceReplayContext { - trace_id: trace_row.trace_id, - query: trace_row.query.clone(), - candidate_count: u32::try_from(trace_row.candidate_count).unwrap_or(0), - top_k: u32::try_from(trace_row.top_k).unwrap_or(0), - created_at: trace_row.created_at, - }; - let created_at = context - .created_at - .format(&Rfc3339) - .map_err(|err| eyre::eyre!("Failed to format trace created_at: {err}"))?; - let candidates = decode_trace_replay_candidates(candidate_rows); - let top_k = args.top_k.unwrap_or(context.top_k).max(1); - let items_a = - search::replay_ranking_from_candidates(config_a, &context, None, &candidates, top_k) - .map_err(|err| eyre::eyre!("{err}"))?; - let items_b = - search::replay_ranking_from_candidates(config_b, &context, None, &candidates, top_k) - .map_err(|err| eyre::eyre!("{err}"))?; - let note_ids_a: Vec = items_a.iter().map(|item| item.note_id).collect(); - let note_ids_b: Vec = items_b.iter().map(|item| item.note_id).collect(); - let (positional_churn_at_k, set_churn_at_k) = - churn_against_baseline_at_k(¬e_ids_a, ¬e_ids_b, top_k as usize); - let (retrieval_top3_total, a_retained, a_retention) = - retrieval_top_rank_retention(&candidates, ¬e_ids_a, 3); - let (_, b_retained, b_retention) = retrieval_top_rank_retention(&candidates, ¬e_ids_b, 3); - let churn = TraceCompareChurn { positional_churn_at_k, set_churn_at_k }; - let guardrails = TraceCompareGuardrails { - retrieval_top3_total, - a_retrieval_top3_retained: a_retained, - a_retrieval_top3_retention: a_retention, - b_retrieval_top3_retained: b_retained, - b_retrieval_top3_retention: b_retention, - retrieval_top3_retention_delta: b_retention - a_retention, - }; - let stage_deltas = build_trace_compare_stage_deltas( - stage_rows.as_slice(), - items_a.len() as u32, - items_b.len() as u32, - ); - let regression_attribution = - build_trace_compare_regression_attribution(&churn, &guardrails, stage_deltas.as_slice()); - - Ok(TraceCompareTrace { - trace_id: context.trace_id, - query: context.query, - candidate_count: context.candidate_count, - top_k, - created_at, - a: TraceCompareVariant { policy_id: policy_id_a.to_string(), items: items_a }, - b: TraceCompareVariant { policy_id: policy_id_b.to_string(), items: items_b }, - churn, - guardrails, - stage_deltas, - regression_attribution, - }) -} - -async fn fetch_trace_compare_trace_row(db: &Db, trace_id: &Uuid) -> Result { - let row: TraceCompareTraceRow = sqlx::query_as::<_, TraceCompareTraceRow>( - "\ -SELECT - trace_id, - query, - candidate_count, - top_k, - created_at -FROM search_traces -WHERE trace_id = $1", - ) - .bind(trace_id) - .fetch_one(&db.pool) - .await?; - - Ok(row) -} - -async fn fetch_trace_compare_candidate_rows( - db: &Db, - trace_id: &Uuid, -) -> Result> { - let rows: Vec = sqlx::query_as::<_, TraceCompareCandidateRow>( - "\ -SELECT - candidate_snapshot, - note_id, - chunk_id, - chunk_index, - snippet, - retrieval_rank, - rerank_score, - note_scope, - note_importance, - note_updated_at, - note_hit_count, - note_last_hit_at -FROM search_trace_candidates -WHERE trace_id = $1 -ORDER BY retrieval_rank ASC", - ) - .bind(trace_id) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn fetch_trace_compare_stage_rows( - db: &Db, - trace_id: &Uuid, -) -> Result> { - let rows = sqlx::query_as::<_, TraceCompareStageRow>( - "\ -SELECT - s.stage_order, - s.stage_name, - s.stage_payload, - COUNT(i.id)::bigint AS item_count -FROM search_trace_stages s -LEFT JOIN search_trace_stage_items i ON i.stage_id = s.stage_id -WHERE s.trace_id = $1 -GROUP BY s.stage_id, s.stage_order, s.stage_name, s.stage_payload -ORDER BY s.stage_order ASC", - ) - .bind(trace_id) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn eval_config( - config_path: &Path, - config: Config, - dataset: &EvalDataset, - args: &Args, - search_mode: SearchMode, -) -> Result { - let db = Db::connect(&config.storage.postgres).await?; - - db.ensure_schema(config.storage.qdrant.vector_dim).await?; - - let qdrant = QdrantStore::new(&config.storage.qdrant)?; - let service = ElfService::new(config, db, qdrant); - let defaults = dataset.defaults.clone().unwrap_or(EvalDefaults { - tenant_id: None, - project_id: None, - agent_id: None, - read_profile: None, - top_k: None, - candidate_k: None, - ranking: None, - }); - let runs_per_query = args.runs_per_query.max(1); - let mut reports = Vec::with_capacity(dataset.queries.len()); - let mut latencies_ms = Vec::with_capacity(dataset.queries.len()); - let mut stability_positional = Vec::new(); - let mut stability_set = Vec::new(); - - for (index, query) in dataset.queries.iter().enumerate() { - let merged = merge_query(&defaults, query, args, &service.cfg, index)?; - let (first, latency_ms, stability, trace_ids) = - run_query_n_times(&service, merged.request.clone(), runs_per_query, search_mode) - .await?; - let retrieved = unique_items(&first.items); - let retrieved_note_ids: Vec = retrieved.iter().map(|item| item.note_id).collect(); - let retrieved_keys: Vec> = - retrieved.iter().map(|item| item.key.clone()).collect(); - let retrieved_summary_chars = - retrieved.iter().map(|item| item.summary.len()).sum::(); - let (metrics, expected_count) = - compute_metrics_for_query(&merged, &retrieved_note_ids, &retrieved_keys); - - if let Some(s) = stability { - stability_positional.push(s.positional_churn_at_k); - stability_set.push(s.set_churn_at_k); - } - - reports.push(QueryReport { - id: merged.id, - query: merged.query, - trace_id: first.trace_id, - trace_ids: (trace_ids.len() > 1).then_some(trace_ids), - expected_count, - retrieved_count: retrieved_note_ids.len(), - relevant_count: metrics.relevant_count, - recall_at_k: metrics.recall_at_k, - precision_at_k: metrics.precision_at_k, - rr: metrics.rr, - ndcg: metrics.ndcg, - latency_ms, - expected_note_ids: merged.expected_note_ids, - expected_keys: merged.expected_keys, - expected_kind: merged.expected_kind, - retrieved_note_ids, - retrieved_keys: if merged.expected_kind == ExpectedKind::Key { - retrieved_keys - } else { - Vec::new() - }, - retrieved_summary_chars, - stability, - }); - latencies_ms.push(latency_ms); - } - - let mut summary = summarize(&reports, &latencies_ms); - - if runs_per_query > 1 && !stability_positional.is_empty() { - let count = stability_positional.len().max(1) as f64; - let avg_positional_churn_at_k = stability_positional.iter().sum::() / count; - let avg_set_churn_at_k = stability_set.iter().sum::() / count; - - summary.stability = Some(StabilitySummary { - runs_per_query, - avg_positional_churn_at_k, - avg_set_churn_at_k, - }); - } - - let settings = EvalSettings { - config_path: config_path.display().to_string(), - search_mode, - candidate_k: args - .candidate_k - .or(dataset.defaults.as_ref().and_then(|d| d.candidate_k)) - .unwrap_or(service.cfg.memory.candidate_k), - top_k: args - .top_k - .or(dataset.defaults.as_ref().and_then(|d| d.top_k)) - .unwrap_or(service.cfg.memory.top_k), - runs_per_query: (runs_per_query > 1).then_some(runs_per_query), - }; - - Ok(EvalRun { - dataset: EvalDatasetInfo { - name: dataset.name.clone().unwrap_or_else(|| "eval".to_string()), - query_count: reports.len(), - }, - settings, - summary, - queries: reports, - }) -} - -async fn run_query_n_times( - service: &ElfService, - request: SearchRequest, - runs_per_query: u32, - search_mode: SearchMode, -) -> Result<(SearchIndexResponse, f64, Option, Vec)> { - let k = request.top_k.unwrap_or(1).max(1) as usize; - let runs = runs_per_query.max(1); - let mut first_response: Option = None; - let mut first_retrieved_ids: Vec = Vec::new(); - let mut trace_ids: Vec = Vec::with_capacity(runs as usize); - let mut latency_total_ms = 0.0_f64; - let mut positional_churn_sum = 0.0_f64; - let mut set_churn_sum = 0.0_f64; - let mut churn_count = 0_u32; - - for run_idx in 0..runs { - let start = Instant::now(); - let response = search_with_mode(service, request.clone(), search_mode).await?; - let latency_ms = start.elapsed().as_secs_f64() * 1_000.0; - - latency_total_ms += latency_ms; - - trace_ids.push(response.trace_id); - - let retrieved = unique_items(&response.items); - let retrieved_ids = retrieved.iter().map(|item| item.note_id).collect::>(); - - if run_idx == 0 { - first_retrieved_ids = retrieved_ids; - first_response = Some(response); - - continue; - } - - let (positional_churn_at_k, set_churn_at_k) = - churn_against_baseline_at_k(&first_retrieved_ids, &retrieved_ids, k); - - positional_churn_sum += positional_churn_at_k; - set_churn_sum += set_churn_at_k; - churn_count += 1; - } - - let latency_ms_mean = latency_total_ms / runs as f64; - let stability = if churn_count > 0 { - Some(QueryStability { - runs_per_query: runs, - positional_churn_at_k: positional_churn_sum / churn_count as f64, - set_churn_at_k: set_churn_sum / churn_count as f64, - }) - } else { - None - }; - - Ok(( - first_response.ok_or_else(|| eyre::eyre!("No search responses were collected."))?, - latency_ms_mean, - stability, - trace_ids, - )) -} - -async fn search_with_mode( - service: &ElfService, - request: SearchRequest, - search_mode: SearchMode, -) -> Result { - match search_mode { - SearchMode::QuickFind => service.search_quick(request).await.map_err(|err| err.into()), - SearchMode::PlannedSearch => { - let response = service.search_planned(request).await?; - - Ok(SearchIndexResponse { - trace_id: response.trace_id, - search_session_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - }) - }, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use crate::app::{self, ExpectedKind, OffsetDateTime, Uuid}; - - #[test] - fn resolve_expected_mode_requires_exactly_one_definition() { - let index = 0; - let note_ids = vec![Uuid::new_v4()]; - let expected_keys = vec!["key-1".to_string()]; - let note_only = app::resolve_expected_mode(index, ¬e_ids, &[]); - let key_only = app::resolve_expected_mode(index, &[], &expected_keys); - let none = app::resolve_expected_mode(index, &[], &[]); - let both = app::resolve_expected_mode(index, ¬e_ids, &expected_keys); - - assert!(matches!(note_only.unwrap(), ExpectedKind::NoteId)); - assert!(matches!(key_only.unwrap(), ExpectedKind::Key)); - assert!(none.is_err(), "Expected missing expectations to be rejected"); - assert!(both.is_err(), "Expected both expectation fields to be rejected"); - } - - #[test] - fn compute_metrics_for_keys_counts_first_hit_per_unique_key_and_ignores_missing_keys() { - let expected: HashSet = - ["alpha", "beta", "gamma"].into_iter().map(String::from).collect(); - let retrieved = vec![ - None, - Some("alpha".to_string()), - Some("alpha".to_string()), - Some("gamma".to_string()), - Some("missing".to_string()), - ]; - let metrics = app::compute_metrics_for_keys(&retrieved, &expected); - let expected_dcg = 1.0 / (3.0_f64).log2() + 1.0 / (5.0_f64).log2(); - let expected_idcg = 1.0 + 1.0 / (3.0_f64).log2() + 1.0 / (4.0_f64).log2(); - - assert_eq!(metrics.relevant_count, 2); - assert!((metrics.precision_at_k - (2.0 / 5.0)).abs() < 1e-12); - assert!((metrics.recall_at_k - (2.0 / 3.0)).abs() < 1e-12); - assert!((metrics.rr - (1.0 / 2.0)).abs() < 1e-12); - assert!((metrics.ndcg - (expected_dcg / expected_idcg)).abs() < 1e-12); - } - - #[test] - fn retrieval_top_rank_retention_counts_unique_notes_and_retained_notes() { - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let note_a = Uuid::new_v4(); - let note_b = Uuid::new_v4(); - let note_c = Uuid::new_v4(); - let candidates = vec![ - elf_service::search::TraceReplayCandidate { - note_id: note_a, - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "a".to_string(), - retrieval_rank: 1, - retrieval_score: None, - rerank_score: 0.1, - note_scope: "project_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - elf_service::search::TraceReplayCandidate { - note_id: note_a, - chunk_id: Uuid::new_v4(), - chunk_index: 1, - snippet: "a".to_string(), - retrieval_rank: 2, - retrieval_score: None, - rerank_score: 0.2, - note_scope: "project_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - elf_service::search::TraceReplayCandidate { - note_id: note_b, - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "b".to_string(), - retrieval_rank: 3, - retrieval_score: None, - rerank_score: 0.3, - note_scope: "org_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - elf_service::search::TraceReplayCandidate { - note_id: note_c, - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "c".to_string(), - retrieval_rank: 4, - retrieval_score: None, - rerank_score: 0.4, - note_scope: "org_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - ]; - let note_ids = vec![note_a, note_c]; - let (total, retained, retention) = - app::retrieval_top_rank_retention(&candidates, ¬e_ids, 3); - - assert_eq!(total, 2); - assert_eq!(retained, 1); - assert!((retention - 0.5).abs() < 1e-12, "Unexpected retention: {retention}"); - } -} +#[cfg(test)] mod tests; diff --git a/apps/elf-eval/src/app/cli.rs b/apps/elf-eval/src/app/cli.rs new file mode 100644 index 00000000..dddc1ba1 --- /dev/null +++ b/apps/elf-eval/src/app/cli.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use clap::{Parser, ValueEnum}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), +)] +pub struct Args { + #[arg(long = "config-a", short = 'c', value_name = "FILE", visible_alias = "config")] + pub config_a: PathBuf, + #[arg(long = "config-b", value_name = "FILE")] + pub config_b: Option, + #[arg(long, short = 'd', value_name = "FILE", required_unless_present = "trace_id")] + pub dataset: Option, + #[arg(long, value_name = "N")] + pub top_k: Option, + #[arg(long, value_name = "N")] + pub candidate_k: Option, + #[arg(long, value_name = "N", default_value_t = 1)] + pub runs_per_query: u32, + #[arg(long, value_enum, default_value_t = SearchMode::PlannedSearch)] + pub search_mode: SearchMode, + #[arg(long = "search-mode-b", value_enum)] + pub search_mode_b: Option, + #[arg(long = "trace-id", value_name = "UUID", num_args = 1..)] + pub trace_id: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +pub enum SearchMode { + #[value(name = "quick_find")] + QuickFind, + #[value(name = "planned_search")] + PlannedSearch, +} diff --git a/apps/elf-eval/src/app/compare.rs b/apps/elf-eval/src/app/compare.rs new file mode 100644 index 00000000..4a31a76f --- /dev/null +++ b/apps/elf-eval/src/app/compare.rs @@ -0,0 +1,111 @@ +use crate::app::{ + metrics, + types::{ + CompareQueryReport, EvalSummary, EvalSummaryDelta, PolicyChurn, PolicyStabilitySummary, + QueryReport, QueryStabilityDelta, QueryVariantDelta, QueryVariantReport, + StabilitySummaryDelta, + }, +}; + +pub(super) fn diff_summary(a: &EvalSummary, b: &EvalSummary) -> EvalSummaryDelta { + EvalSummaryDelta { + avg_recall_at_k: b.avg_recall_at_k - a.avg_recall_at_k, + avg_precision_at_k: b.avg_precision_at_k - a.avg_precision_at_k, + mean_rr: b.mean_rr - a.mean_rr, + mean_ndcg: b.mean_ndcg - a.mean_ndcg, + latency_ms_p50: b.latency_ms_p50 - a.latency_ms_p50, + latency_ms_p95: b.latency_ms_p95 - a.latency_ms_p95, + avg_retrieved_summary_chars: b.avg_retrieved_summary_chars - a.avg_retrieved_summary_chars, + stability: match (&a.stability, &b.stability) { + (Some(sa), Some(sb)) => Some(StabilitySummaryDelta { + avg_positional_churn_at_k: sb.avg_positional_churn_at_k + - sa.avg_positional_churn_at_k, + avg_set_churn_at_k: sb.avg_set_churn_at_k - sa.avg_set_churn_at_k, + }), + _ => None, + }, + } +} + +pub(super) fn build_compare_queries( + a: &[QueryReport], + b: &[QueryReport], + k: u32, +) -> (Vec, PolicyStabilitySummary) { + let k_usize = k.max(1) as usize; + let mut positional_sum = 0.0_f64; + let mut set_sum = 0.0_f64; + let queries: Vec = a + .iter() + .zip(b.iter()) + .map(|(qa, qb)| { + let delta_stability = match (qa.stability, qb.stability) { + (Some(sa), Some(sb)) => Some(QueryStabilityDelta { + positional_churn_at_k: sb.positional_churn_at_k - sa.positional_churn_at_k, + set_churn_at_k: sb.set_churn_at_k - sa.set_churn_at_k, + }), + _ => None, + }; + let (positional_churn_at_k, set_churn_at_k) = metrics::churn_against_baseline_at_k( + &qa.retrieved_note_ids, + &qb.retrieved_note_ids, + k_usize, + ); + + positional_sum += positional_churn_at_k; + set_sum += set_churn_at_k; + + CompareQueryReport { + id: qa.id.clone(), + query: qa.query.clone(), + expected_count: qa.expected_count, + expected_note_ids: qa.expected_note_ids.clone(), + a: QueryVariantReport { + trace_id: qa.trace_id, + trace_ids: qa.trace_ids.clone(), + retrieved_count: qa.retrieved_count, + relevant_count: qa.relevant_count, + recall_at_k: qa.recall_at_k, + precision_at_k: qa.precision_at_k, + rr: qa.rr, + ndcg: qa.ndcg, + latency_ms: qa.latency_ms, + retrieved_note_ids: qa.retrieved_note_ids.clone(), + stability: qa.stability, + }, + b: QueryVariantReport { + trace_id: qb.trace_id, + trace_ids: qb.trace_ids.clone(), + retrieved_count: qb.retrieved_count, + relevant_count: qb.relevant_count, + recall_at_k: qb.recall_at_k, + precision_at_k: qb.precision_at_k, + rr: qb.rr, + ndcg: qb.ndcg, + latency_ms: qb.latency_ms, + retrieved_note_ids: qb.retrieved_note_ids.clone(), + stability: qb.stability, + }, + delta: QueryVariantDelta { + retrieved_count: qb.retrieved_count as i64 - qa.retrieved_count as i64, + relevant_count: qb.relevant_count as i64 - qa.relevant_count as i64, + recall_at_k: qb.recall_at_k - qa.recall_at_k, + precision_at_k: qb.precision_at_k - qa.precision_at_k, + rr: qb.rr - qa.rr, + ndcg: qb.ndcg - qa.ndcg, + latency_ms: qb.latency_ms - qa.latency_ms, + stability: delta_stability, + }, + policy_churn: PolicyChurn { positional_churn_at_k, set_churn_at_k }, + } + }) + .collect(); + let count = queries.len().max(1) as f64; + let summary = PolicyStabilitySummary { + k, + avg_positional_churn_at_k: positional_sum / count, + avg_set_churn_at_k: set_sum / count, + }; + + (queries, summary) +} diff --git a/apps/elf-eval/src/app/dataset.rs b/apps/elf-eval/src/app/dataset.rs new file mode 100644 index 00000000..9b9d7586 --- /dev/null +++ b/apps/elf-eval/src/app/dataset.rs @@ -0,0 +1,104 @@ +use std::{fs, path::Path}; + +use color_eyre::{Result, eyre}; +use uuid::Uuid; + +use crate::app::{ + Args, + types::{EvalDataset, EvalDefaults, EvalQuery, ExpectedKind, MergedQuery}, +}; +use elf_config::Config; +use elf_service::SearchRequest; + +pub(super) fn load_dataset(path: &Path) -> Result { + let raw = fs::read_to_string(path)?; + let dataset: EvalDataset = serde_json::from_str(&raw)?; + + if dataset.queries.is_empty() { + return Err(eyre::eyre!("Dataset must include at least one query.")); + } + + Ok(dataset) +} + +pub(super) fn merge_query( + defaults: &EvalDefaults, + query: &EvalQuery, + args: &Args, + cfg: &Config, + index: usize, +) -> Result { + let expected_kind = + resolve_expected_mode(index, &query.expected_note_ids, &query.expected_keys)?; + let tenant_id = query + .tenant_id + .clone() + .or_else(|| defaults.tenant_id.clone()) + .ok_or_else(|| eyre::eyre!("tenant_id is required for query at index {index}."))?; + let project_id = query + .project_id + .clone() + .or_else(|| defaults.project_id.clone()) + .ok_or_else(|| eyre::eyre!("project_id is required for query at index {index}."))?; + let agent_id = query + .agent_id + .clone() + .or_else(|| defaults.agent_id.clone()) + .ok_or_else(|| eyre::eyre!("agent_id is required for query at index {index}."))?; + let read_profile = query + .read_profile + .clone() + .or_else(|| defaults.read_profile.clone()) + .ok_or_else(|| eyre::eyre!("read_profile is required for query at index {index}."))?; + let top_k = args.top_k.or(query.top_k).or(defaults.top_k).unwrap_or(cfg.memory.top_k).max(1); + let candidate_k = args + .candidate_k + .or(query.candidate_k) + .or(defaults.candidate_k) + .unwrap_or(cfg.memory.candidate_k) + .max(top_k); + let id = query.id.clone().unwrap_or_else(|| format!("query-{index}")); + let ranking = query.ranking.clone().or_else(|| defaults.ranking.clone()); + + Ok(MergedQuery { + id, + query: query.query.clone(), + expected_note_ids: query.expected_note_ids.clone(), + expected_keys: query.expected_keys.clone(), + expected_kind, + request: SearchRequest { + tenant_id, + project_id, + agent_id, + token_id: None, + read_profile, + payload_level: Default::default(), + query: query.query.clone(), + top_k: Some(top_k), + candidate_k: Some(candidate_k), + filter: None, + record_hits: Some(false), + ranking, + }, + }) +} + +pub(super) fn resolve_expected_mode( + index: usize, + note_ids: &[Uuid], + keys: &[String], +) -> Result { + let has_note_ids = !note_ids.is_empty(); + let has_keys = !keys.is_empty(); + + match (has_note_ids, has_keys) { + (true, false) => Ok(ExpectedKind::NoteId), + (false, true) => Ok(ExpectedKind::Key), + (true, true) => Err(eyre::eyre!( + "Query at index {index} must define exactly one expectation mode: expected_note_ids or expected_keys." + )), + (false, false) => Err(eyre::eyre!( + "Query at index {index} must include at least one expected_note_ids or expected_keys." + )), + } +} diff --git a/apps/elf-eval/src/app/eval.rs b/apps/elf-eval/src/app/eval.rs new file mode 100644 index 00000000..5f010cbc --- /dev/null +++ b/apps/elf-eval/src/app/eval.rs @@ -0,0 +1,205 @@ +use std::{path::Path, time::Instant}; + +use color_eyre::{Result, eyre}; +use uuid::Uuid; + +use crate::app::{ + Args, SearchMode, dataset, + metrics::{self}, + types::{ + EvalDataset, EvalDatasetInfo, EvalRun, EvalSettings, ExpectedKind, QueryReport, + QueryStability, StabilitySummary, default_eval_defaults, + }, +}; +use elf_config::Config; +use elf_service::{ElfService, SearchIndexResponse, SearchRequest}; +use elf_storage::{db::Db, qdrant::QdrantStore}; + +pub(super) async fn eval_config( + config_path: &Path, + config: Config, + dataset: &EvalDataset, + args: &Args, + search_mode: SearchMode, +) -> Result { + let db = Db::connect(&config.storage.postgres).await?; + + db.ensure_schema(config.storage.qdrant.vector_dim).await?; + + let qdrant = QdrantStore::new(&config.storage.qdrant)?; + let service = ElfService::new(config, db, qdrant); + let defaults = dataset.defaults.clone().unwrap_or_else(default_eval_defaults); + let runs_per_query = args.runs_per_query.max(1); + let mut reports = Vec::with_capacity(dataset.queries.len()); + let mut latencies_ms = Vec::with_capacity(dataset.queries.len()); + let mut stability_positional = Vec::new(); + let mut stability_set = Vec::new(); + + for (index, query) in dataset.queries.iter().enumerate() { + let merged = dataset::merge_query(&defaults, query, args, &service.cfg, index)?; + let (first, latency_ms, stability, trace_ids) = + run_query_n_times(&service, merged.request.clone(), runs_per_query, search_mode) + .await?; + let retrieved = metrics::unique_items(&first.items); + let retrieved_note_ids: Vec = retrieved.iter().map(|item| item.note_id).collect(); + let retrieved_keys: Vec> = + retrieved.iter().map(|item| item.key.clone()).collect(); + let retrieved_summary_chars = + retrieved.iter().map(|item| item.summary.len()).sum::(); + let (metrics, expected_count) = + metrics::compute_metrics_for_query(&merged, &retrieved_note_ids, &retrieved_keys); + + if let Some(s) = stability { + stability_positional.push(s.positional_churn_at_k); + stability_set.push(s.set_churn_at_k); + } + + reports.push(QueryReport { + id: merged.id, + query: merged.query, + trace_id: first.trace_id, + trace_ids: (trace_ids.len() > 1).then_some(trace_ids), + expected_count, + retrieved_count: retrieved_note_ids.len(), + relevant_count: metrics.relevant_count, + recall_at_k: metrics.recall_at_k, + precision_at_k: metrics.precision_at_k, + rr: metrics.rr, + ndcg: metrics.ndcg, + latency_ms, + expected_note_ids: merged.expected_note_ids, + expected_keys: merged.expected_keys, + expected_kind: merged.expected_kind, + retrieved_note_ids, + retrieved_keys: if merged.expected_kind == ExpectedKind::Key { + retrieved_keys + } else { + Vec::new() + }, + retrieved_summary_chars, + stability, + }); + latencies_ms.push(latency_ms); + } + + let mut summary = metrics::summarize(&reports, &latencies_ms); + + if runs_per_query > 1 && !stability_positional.is_empty() { + let count = stability_positional.len().max(1) as f64; + let avg_positional_churn_at_k = stability_positional.iter().sum::() / count; + let avg_set_churn_at_k = stability_set.iter().sum::() / count; + + summary.stability = Some(StabilitySummary { + runs_per_query, + avg_positional_churn_at_k, + avg_set_churn_at_k, + }); + } + + let settings = EvalSettings { + config_path: config_path.display().to_string(), + search_mode, + candidate_k: args + .candidate_k + .or(dataset.defaults.as_ref().and_then(|d| d.candidate_k)) + .unwrap_or(service.cfg.memory.candidate_k), + top_k: args + .top_k + .or(dataset.defaults.as_ref().and_then(|d| d.top_k)) + .unwrap_or(service.cfg.memory.top_k), + runs_per_query: (runs_per_query > 1).then_some(runs_per_query), + }; + + Ok(EvalRun { + dataset: EvalDatasetInfo { + name: dataset.name.clone().unwrap_or_else(|| "eval".to_string()), + query_count: reports.len(), + }, + settings, + summary, + queries: reports, + }) +} + +async fn run_query_n_times( + service: &ElfService, + request: SearchRequest, + runs_per_query: u32, + search_mode: SearchMode, +) -> Result<(SearchIndexResponse, f64, Option, Vec)> { + let k = request.top_k.unwrap_or(1).max(1) as usize; + let runs = runs_per_query.max(1); + let mut first_response: Option = None; + let mut first_retrieved_ids: Vec = Vec::new(); + let mut trace_ids: Vec = Vec::with_capacity(runs as usize); + let mut latency_total_ms = 0.0_f64; + let mut positional_churn_sum = 0.0_f64; + let mut set_churn_sum = 0.0_f64; + let mut churn_count = 0_u32; + + for run_idx in 0..runs { + let start = Instant::now(); + let response = search_with_mode(service, request.clone(), search_mode).await?; + let latency_ms = start.elapsed().as_secs_f64() * 1_000.0; + + latency_total_ms += latency_ms; + + trace_ids.push(response.trace_id); + + let retrieved = metrics::unique_items(&response.items); + let retrieved_ids = retrieved.iter().map(|item| item.note_id).collect::>(); + + if run_idx == 0 { + first_retrieved_ids = retrieved_ids; + first_response = Some(response); + + continue; + } + + let (positional_churn_at_k, set_churn_at_k) = + metrics::churn_against_baseline_at_k(&first_retrieved_ids, &retrieved_ids, k); + + positional_churn_sum += positional_churn_at_k; + set_churn_sum += set_churn_at_k; + churn_count += 1; + } + + let latency_ms_mean = latency_total_ms / runs as f64; + let stability = if churn_count > 0 { + Some(QueryStability { + runs_per_query: runs, + positional_churn_at_k: positional_churn_sum / churn_count as f64, + set_churn_at_k: set_churn_sum / churn_count as f64, + }) + } else { + None + }; + + Ok(( + first_response.ok_or_else(|| eyre::eyre!("No search responses were collected."))?, + latency_ms_mean, + stability, + trace_ids, + )) +} + +async fn search_with_mode( + service: &ElfService, + request: SearchRequest, + search_mode: SearchMode, +) -> Result { + match search_mode { + SearchMode::QuickFind => service.search_quick(request).await.map_err(|err| err.into()), + SearchMode::PlannedSearch => { + let response = service.search_planned(request).await?; + + Ok(SearchIndexResponse { + trace_id: response.trace_id, + search_session_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + }) + }, + } +} diff --git a/apps/elf-eval/src/app/metrics.rs b/apps/elf-eval/src/app/metrics.rs new file mode 100644 index 00000000..f02c076a --- /dev/null +++ b/apps/elf-eval/src/app/metrics.rs @@ -0,0 +1,240 @@ +use std::{cmp::Ordering, collections::HashSet}; + +use uuid::Uuid; + +use crate::app::types::{EvalSummary, ExpectedKind, MergedQuery, Metrics, QueryReport}; +use elf_service::{SearchIndexItem, search::TraceReplayCandidate}; + +pub(super) fn retrieval_top_rank_retention( + candidates: &[TraceReplayCandidate], + note_ids: &[Uuid], + max_retrieval_rank: u32, +) -> (usize, usize, f64) { + let mut top_notes = HashSet::new(); + + for candidate in candidates { + if candidate.retrieval_rank == 0 || candidate.retrieval_rank > max_retrieval_rank { + continue; + } + + top_notes.insert(candidate.note_id); + } + + let total = top_notes.len(); + + if total == 0 { + return (0, 0, 0.0); + } + + let out_set: HashSet = note_ids.iter().copied().collect(); + let retained = top_notes.intersection(&out_set).count(); + let retention = retained as f64 / total as f64; + + (total, retained, retention) +} + +pub(super) fn churn_against_baseline_at_k( + baseline: &[Uuid], + other: &[Uuid], + k: usize, +) -> (f64, f64) { + let k = k.max(1); + let mut positional_diff = 0_usize; + + for idx in 0..k { + let a = baseline.get(idx); + let b = other.get(idx); + + if a != b { + positional_diff += 1; + } + } + + let positional_churn = positional_diff as f64 / k as f64; + let base_set: HashSet = baseline.iter().take(k).copied().collect(); + let other_set: HashSet = other.iter().take(k).copied().collect(); + let overlap = base_set.intersection(&other_set).count(); + let set_churn = 1.0 - (overlap as f64 / k as f64); + + (positional_churn, set_churn) +} + +pub(super) fn unique_items(items: &[SearchIndexItem]) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + + for item in items { + if seen.insert(item.note_id) { + out.push(item.clone()); + } + } + + out +} + +pub(super) fn compute_metrics(retrieved: &[Uuid], expected: &HashSet) -> Metrics { + let expected_count = expected.len(); + let mut relevant_count = 0_usize; + let mut dcg = 0.0_f64; + let mut rr = 0.0_f64; + let mut first_hit: Option = None; + + for (idx, id) in retrieved.iter().enumerate() { + if expected.contains(id) { + relevant_count += 1; + + let rank = idx + 1; + let denom = (rank as f64 + 1.0).log2(); + + dcg += 1.0 / denom; + + if first_hit.is_none() { + first_hit = Some(rank); + } + } + } + + if let Some(rank) = first_hit { + rr = 1.0 / rank as f64; + } + + let ideal_hits = expected_count.min(retrieved.len()); + let mut idcg = 0.0_f64; + + for idx in 0..ideal_hits { + let rank = idx + 1; + let denom = (rank as f64 + 1.0).log2(); + + idcg += 1.0 / denom; + } + + let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 }; + let precision_at_k = + if retrieved.is_empty() { 0.0 } else { relevant_count as f64 / retrieved.len() as f64 }; + let recall_at_k = + if expected_count == 0 { 0.0 } else { relevant_count as f64 / expected_count as f64 }; + + Metrics { recall_at_k, precision_at_k, rr, ndcg, relevant_count } +} + +pub(super) fn compute_metrics_for_keys( + retrieved: &[Option], + expected: &HashSet, +) -> Metrics { + let expected_count = expected.len(); + let mut matched: HashSet = HashSet::new(); + let mut relevant_count = 0_usize; + let mut dcg = 0.0_f64; + let mut rr = 0.0_f64; + let mut first_hit: Option = None; + + for (idx, maybe_key) in retrieved.iter().enumerate() { + let Some(key) = maybe_key else { + continue; + }; + + if expected.contains(key) && !matched.contains(key) { + matched.insert(key.clone()); + + relevant_count += 1; + + let rank = idx + 1; + let denom = (rank as f64 + 1.0).log2(); + + dcg += 1.0 / denom; + + if first_hit.is_none() { + first_hit = Some(rank); + } + } + } + + if let Some(rank) = first_hit { + rr = 1.0 / rank as f64; + } + + let ideal_hits = expected_count.min(retrieved.len()); + let mut idcg = 0.0_f64; + + for idx in 0..ideal_hits { + let rank = idx + 1; + let denom = (rank as f64 + 1.0).log2(); + + idcg += 1.0 / denom; + } + + let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 }; + let precision_at_k = + if retrieved.is_empty() { 0.0 } else { relevant_count as f64 / retrieved.len() as f64 }; + let recall_at_k = + if expected_count == 0 { 0.0 } else { relevant_count as f64 / expected_count as f64 }; + + Metrics { recall_at_k, precision_at_k, rr, ndcg, relevant_count } +} + +pub(super) fn compute_metrics_for_query( + merged: &MergedQuery, + retrieved_note_ids: &[Uuid], + retrieved_keys: &[Option], +) -> (Metrics, usize) { + match merged.expected_kind { + ExpectedKind::NoteId => { + let expected: HashSet = merged.expected_note_ids.iter().copied().collect(); + let expected_count = expected.len(); + + (compute_metrics(retrieved_note_ids, &expected), expected_count) + }, + ExpectedKind::Key => { + let expected: HashSet = merged.expected_keys.iter().cloned().collect(); + let expected_count = expected.len(); + + (compute_metrics_for_keys(retrieved_keys, &expected), expected_count) + }, + } +} + +pub(super) fn summarize(reports: &[QueryReport], latencies_ms: &[f64]) -> EvalSummary { + let count = reports.len().max(1) as f64; + let avg_recall_at_k = reports.iter().map(|r| r.recall_at_k).sum::() / count; + let avg_precision_at_k = reports.iter().map(|r| r.precision_at_k).sum::() / count; + let mean_rr = reports.iter().map(|r| r.rr).sum::() / count; + let mean_ndcg = reports.iter().map(|r| r.ndcg).sum::() / count; + let avg_retrieved_summary_chars = + reports.iter().map(|r| r.retrieved_summary_chars as f64).sum::() / count; + let mut sorted = latencies_ms.to_vec(); + + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + let p50 = percentile(&sorted, 0.50); + let p95 = percentile(&sorted, 0.95); + + EvalSummary { + avg_recall_at_k, + avg_precision_at_k, + mean_rr, + mean_ndcg, + latency_ms_p50: p50, + latency_ms_p95: p95, + avg_retrieved_summary_chars, + stability: None, + } +} + +fn percentile(values: &[f64], percentile: f64) -> f64 { + if values.is_empty() { + return 0.0; + } + + let clamped = percentile.clamp(0.0, 1.0); + let pos = clamped * (values.len() as f64 - 1.0); + let lower = pos.floor() as usize; + let upper = pos.ceil() as usize; + + if lower == upper { + values[lower] + } else { + let weight = pos - lower as f64; + + values[lower] * (1.0 - weight) + values[upper] * weight + } +} diff --git a/apps/elf-eval/src/app/tests.rs b/apps/elf-eval/src/app/tests.rs new file mode 100644 index 00000000..f5c110f0 --- /dev/null +++ b/apps/elf-eval/src/app/tests.rs @@ -0,0 +1,149 @@ +use std::collections::HashSet; + +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::app::{dataset, metrics, types::ExpectedKind}; + +#[test] +fn resolve_expected_mode_requires_exactly_one_definition() { + let index = 0; + let note_ids = vec![Uuid::new_v4()]; + let expected_keys = vec!["key-1".to_string()]; + let note_only = dataset::resolve_expected_mode(index, ¬e_ids, &[]); + let key_only = dataset::resolve_expected_mode(index, &[], &expected_keys); + let none = dataset::resolve_expected_mode(index, &[], &[]); + let both = dataset::resolve_expected_mode(index, ¬e_ids, &expected_keys); + + assert!(matches!(note_only.unwrap(), ExpectedKind::NoteId)); + assert!(matches!(key_only.unwrap(), ExpectedKind::Key)); + assert!(none.is_err(), "Expected missing expectations to be rejected"); + assert!(both.is_err(), "Expected both expectation fields to be rejected"); +} + +#[test] +fn compute_metrics_for_keys_counts_first_hit_per_unique_key_and_ignores_missing_keys() { + let expected: HashSet = + ["alpha", "beta", "gamma"].into_iter().map(String::from).collect(); + let retrieved = vec![ + None, + Some("alpha".to_string()), + Some("alpha".to_string()), + Some("gamma".to_string()), + Some("missing".to_string()), + ]; + let metrics = metrics::compute_metrics_for_keys(&retrieved, &expected); + let expected_dcg = 1.0 / (3.0_f64).log2() + 1.0 / (5.0_f64).log2(); + let expected_idcg = 1.0 + 1.0 / (3.0_f64).log2() + 1.0 / (4.0_f64).log2(); + + assert_eq!(metrics.relevant_count, 2); + assert!((metrics.precision_at_k - (2.0 / 5.0)).abs() < 1e-12); + assert!((metrics.recall_at_k - (2.0 / 3.0)).abs() < 1e-12); + assert!((metrics.rr - (1.0 / 2.0)).abs() < 1e-12); + assert!((metrics.ndcg - (expected_dcg / expected_idcg)).abs() < 1e-12); +} + +#[test] +fn retrieval_top_rank_retention_counts_unique_notes_and_retained_notes() { + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let note_a = Uuid::new_v4(); + let note_b = Uuid::new_v4(); + let note_c = Uuid::new_v4(); + let candidates = vec![ + elf_service::search::TraceReplayCandidate { + note_id: note_a, + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "a".to_string(), + retrieval_rank: 1, + retrieval_score: None, + rerank_score: 0.1, + note_scope: "project_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + elf_service::search::TraceReplayCandidate { + note_id: note_a, + chunk_id: Uuid::new_v4(), + chunk_index: 1, + snippet: "a".to_string(), + retrieval_rank: 2, + retrieval_score: None, + rerank_score: 0.2, + note_scope: "project_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + elf_service::search::TraceReplayCandidate { + note_id: note_b, + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "b".to_string(), + retrieval_rank: 3, + retrieval_score: None, + rerank_score: 0.3, + note_scope: "org_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + elf_service::search::TraceReplayCandidate { + note_id: note_c, + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "c".to_string(), + retrieval_rank: 4, + retrieval_score: None, + rerank_score: 0.4, + note_scope: "org_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + ]; + let note_ids = vec![note_a, note_c]; + let (total, retained, retention) = + metrics::retrieval_top_rank_retention(&candidates, ¬e_ids, 3); + + assert_eq!(total, 2); + assert_eq!(retained, 1); + assert!((retention - 0.5).abs() < 1e-12, "Unexpected retention: {retention}"); +} diff --git a/apps/elf-eval/src/app/trace_compare.rs b/apps/elf-eval/src/app/trace_compare.rs new file mode 100644 index 00000000..51844e36 --- /dev/null +++ b/apps/elf-eval/src/app/trace_compare.rs @@ -0,0 +1,348 @@ +use std::{collections::HashMap, path::Path}; + +use color_eyre::{Result, eyre}; +use time::format_description::well_known::Rfc3339; +use uuid::Uuid; + +use crate::app::{ + Args, + metrics::{self}, + types::{ + TraceCompareCandidateRow, TraceCompareChurn, TraceCompareGuardrails, TraceCompareOutput, + TraceComparePolicies, TraceComparePolicy, TraceCompareRegressionAttribution, + TraceCompareStageDelta, TraceCompareStageRow, TraceCompareSummary, TraceCompareTrace, + TraceCompareTraceRow, TraceCompareVariant, + }, +}; +use elf_config::Config; +use elf_service::search::{self, TraceReplayCandidate, TraceReplayContext}; +use elf_storage::db::Db; + +pub(super) async fn trace_compare( + config_a_path: &Path, + config_a: Config, + config_b_path: &Path, + config_b: Config, + args: &Args, +) -> Result { + let policy_id_a = + search::ranking_policy_id(&config_a, None).map_err(|err| eyre::eyre!("{err}"))?; + let policy_id_b = + search::ranking_policy_id(&config_b, None).map_err(|err| eyre::eyre!("{err}"))?; + let db = Db::connect(&config_a.storage.postgres).await?; + + db.ensure_schema(config_a.storage.qdrant.vector_dim).await?; + + let mut traces = Vec::with_capacity(args.trace_id.len()); + let mut positional_sum = 0.0_f64; + let mut set_sum = 0.0_f64; + let mut top3_retention_a_sum = 0.0_f64; + let mut top3_retention_b_sum = 0.0_f64; + + for trace_id in &args.trace_id { + let trace = compare_trace_id( + &db, + &config_a, + &config_b, + policy_id_a.as_str(), + policy_id_b.as_str(), + trace_id, + args, + ) + .await?; + + positional_sum += trace.churn.positional_churn_at_k; + set_sum += trace.churn.set_churn_at_k; + top3_retention_a_sum += trace.guardrails.a_retrieval_top3_retention; + top3_retention_b_sum += trace.guardrails.b_retrieval_top3_retention; + + traces.push(trace); + } + + let count = traces.len().max(1) as f64; + let summary = TraceCompareSummary { + trace_count: traces.len(), + avg_positional_churn_at_k: positional_sum / count, + avg_set_churn_at_k: set_sum / count, + avg_a_retrieval_top3_retention: top3_retention_a_sum / count, + avg_b_retrieval_top3_retention: top3_retention_b_sum / count, + avg_retrieval_top3_retention_delta: (top3_retention_b_sum - top3_retention_a_sum) / count, + }; + + Ok(TraceCompareOutput { + policies: TraceComparePolicies { + a: TraceComparePolicy { + config_path: config_a_path.display().to_string(), + policy_id: policy_id_a, + }, + b: TraceComparePolicy { + config_path: config_b_path.display().to_string(), + policy_id: policy_id_b, + }, + }, + summary, + traces, + }) +} + +fn decode_trace_replay_candidates( + rows: Vec, +) -> Vec { + rows.into_iter() + .map(|row| { + let decoded = + serde_json::from_value::(row.candidate_snapshot.clone()) + .ok() + .filter(|value| value.note_id != Uuid::nil() && value.chunk_id != Uuid::nil()); + + decoded.unwrap_or_else(|| TraceReplayCandidate { + note_id: row.note_id, + chunk_id: row.chunk_id, + chunk_index: row.chunk_index, + snippet: row.snippet, + retrieval_rank: u32::try_from(row.retrieval_rank).unwrap_or(0), + retrieval_score: None, + rerank_score: row.rerank_score, + note_scope: row.note_scope, + note_importance: row.note_importance, + note_updated_at: row.note_updated_at, + note_hit_count: row.note_hit_count, + note_last_hit_at: row.note_last_hit_at, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }) + }) + .collect() +} + +fn build_trace_compare_stage_deltas( + stage_rows: &[TraceCompareStageRow], + a_selected_count: u32, + b_selected_count: u32, +) -> Vec { + if stage_rows.is_empty() { + return vec![TraceCompareStageDelta { + stage_order: 1, + stage_name: "selection.final".to_string(), + baseline_item_count: 0, + a_item_count: a_selected_count, + b_item_count: b_selected_count, + item_count_delta: b_selected_count as i64 - a_selected_count as i64, + baseline_stats: None, + }]; + } + + let mut out = Vec::with_capacity(stage_rows.len()); + + for row in stage_rows { + let baseline_item_count = row.item_count.max(0) as u32; + let (a_item_count, b_item_count) = if row.stage_name == "selection.final" { + (a_selected_count, b_selected_count) + } else { + (baseline_item_count, baseline_item_count) + }; + let baseline_stats = row.stage_payload.get("stats").cloned(); + + out.push(TraceCompareStageDelta { + stage_order: row.stage_order.max(0) as u32, + stage_name: row.stage_name.clone(), + baseline_item_count, + a_item_count, + b_item_count, + item_count_delta: b_item_count as i64 - a_item_count as i64, + baseline_stats, + }); + } + + out +} + +fn build_trace_compare_regression_attribution( + churn: &TraceCompareChurn, + guardrails: &TraceCompareGuardrails, + stage_deltas: &[TraceCompareStageDelta], +) -> TraceCompareRegressionAttribution { + let stage_by_name: HashMap<&str, &TraceCompareStageDelta> = + stage_deltas.iter().map(|stage| (stage.stage_name.as_str(), stage)).collect(); + + if guardrails.retrieval_top3_retention_delta < 0.0 { + let recall_count = stage_by_name + .get("recall.candidates") + .map(|stage| stage.baseline_item_count) + .unwrap_or(0); + + return TraceCompareRegressionAttribution { + primary_stage: "selection.final".to_string(), + evidence: format!( + "retrieval_top3_retention dropped by {:.4} (a={:.4}, b={:.4}); recall baseline item_count={recall_count}", + guardrails.retrieval_top3_retention_delta, + guardrails.a_retrieval_top3_retention, + guardrails.b_retrieval_top3_retention + ), + }; + } + if churn.set_churn_at_k > 0.0 || churn.positional_churn_at_k > 0.0 { + return TraceCompareRegressionAttribution { + primary_stage: "rerank.score".to_string(), + evidence: format!( + "top-k churn changed without retrieval-top3 regression (set_churn_at_k={:.4}, positional_churn_at_k={:.4})", + churn.set_churn_at_k, churn.positional_churn_at_k + ), + }; + } + + TraceCompareRegressionAttribution { + primary_stage: "not_applicable".to_string(), + evidence: "No regression signal detected.".to_string(), + } +} + +async fn compare_trace_id( + db: &Db, + config_a: &Config, + config_b: &Config, + policy_id_a: &str, + policy_id_b: &str, + trace_id: &Uuid, + args: &Args, +) -> Result { + let trace_row = fetch_trace_compare_trace_row(db, trace_id).await?; + let candidate_rows = fetch_trace_compare_candidate_rows(db, trace_id).await?; + let stage_rows = fetch_trace_compare_stage_rows(db, trace_id).await?; + let context = TraceReplayContext { + trace_id: trace_row.trace_id, + query: trace_row.query.clone(), + candidate_count: u32::try_from(trace_row.candidate_count).unwrap_or(0), + top_k: u32::try_from(trace_row.top_k).unwrap_or(0), + created_at: trace_row.created_at, + }; + let created_at = context + .created_at + .format(&Rfc3339) + .map_err(|err| eyre::eyre!("Failed to format trace created_at: {err}"))?; + let candidates = decode_trace_replay_candidates(candidate_rows); + let top_k = args.top_k.unwrap_or(context.top_k).max(1); + let items_a = + search::replay_ranking_from_candidates(config_a, &context, None, &candidates, top_k) + .map_err(|err| eyre::eyre!("{err}"))?; + let items_b = + search::replay_ranking_from_candidates(config_b, &context, None, &candidates, top_k) + .map_err(|err| eyre::eyre!("{err}"))?; + let note_ids_a: Vec = items_a.iter().map(|item| item.note_id).collect(); + let note_ids_b: Vec = items_b.iter().map(|item| item.note_id).collect(); + let (positional_churn_at_k, set_churn_at_k) = + metrics::churn_against_baseline_at_k(¬e_ids_a, ¬e_ids_b, top_k as usize); + let (retrieval_top3_total, a_retained, a_retention) = + metrics::retrieval_top_rank_retention(&candidates, ¬e_ids_a, 3); + let (_, b_retained, b_retention) = + metrics::retrieval_top_rank_retention(&candidates, ¬e_ids_b, 3); + let churn = TraceCompareChurn { positional_churn_at_k, set_churn_at_k }; + let guardrails = TraceCompareGuardrails { + retrieval_top3_total, + a_retrieval_top3_retained: a_retained, + a_retrieval_top3_retention: a_retention, + b_retrieval_top3_retained: b_retained, + b_retrieval_top3_retention: b_retention, + retrieval_top3_retention_delta: b_retention - a_retention, + }; + let stage_deltas = build_trace_compare_stage_deltas( + stage_rows.as_slice(), + items_a.len() as u32, + items_b.len() as u32, + ); + let regression_attribution = + build_trace_compare_regression_attribution(&churn, &guardrails, stage_deltas.as_slice()); + + Ok(TraceCompareTrace { + trace_id: context.trace_id, + query: context.query, + candidate_count: context.candidate_count, + top_k, + created_at, + a: TraceCompareVariant { policy_id: policy_id_a.to_string(), items: items_a }, + b: TraceCompareVariant { policy_id: policy_id_b.to_string(), items: items_b }, + churn, + guardrails, + stage_deltas, + regression_attribution, + }) +} + +async fn fetch_trace_compare_trace_row(db: &Db, trace_id: &Uuid) -> Result { + let row: TraceCompareTraceRow = sqlx::query_as::<_, TraceCompareTraceRow>( + "\ +SELECT + trace_id, + query, + candidate_count, + top_k, + created_at +FROM search_traces +WHERE trace_id = $1", + ) + .bind(trace_id) + .fetch_one(&db.pool) + .await?; + + Ok(row) +} + +async fn fetch_trace_compare_candidate_rows( + db: &Db, + trace_id: &Uuid, +) -> Result> { + let rows: Vec = sqlx::query_as::<_, TraceCompareCandidateRow>( + "\ +SELECT + candidate_snapshot, + note_id, + chunk_id, + chunk_index, + snippet, + retrieval_rank, + rerank_score, + note_scope, + note_importance, + note_updated_at, + note_hit_count, + note_last_hit_at +FROM search_trace_candidates +WHERE trace_id = $1 +ORDER BY retrieval_rank ASC", + ) + .bind(trace_id) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +async fn fetch_trace_compare_stage_rows( + db: &Db, + trace_id: &Uuid, +) -> Result> { + let rows = sqlx::query_as::<_, TraceCompareStageRow>( + "\ +SELECT + s.stage_order, + s.stage_name, + s.stage_payload, + COUNT(i.id)::bigint AS item_count +FROM search_trace_stages s +LEFT JOIN search_trace_stage_items i ON i.stage_id = s.stage_id +WHERE s.trace_id = $1 +GROUP BY s.stage_id, s.stage_order, s.stage_name, s.stage_payload +ORDER BY s.stage_order ASC", + ) + .bind(trace_id) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} diff --git a/apps/elf-eval/src/app/types.rs b/apps/elf-eval/src/app/types.rs new file mode 100644 index 00000000..fc6cb678 --- /dev/null +++ b/apps/elf-eval/src/app/types.rs @@ -0,0 +1,372 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::app::SearchMode; +use elf_service::{RankingRequestOverride, SearchRequest, search::TraceReplayItem}; + +#[derive(Debug, Deserialize)] +pub(super) struct EvalDataset { + pub(super) name: Option, + pub(super) defaults: Option, + pub(super) queries: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct EvalDefaults { + pub(super) tenant_id: Option, + pub(super) project_id: Option, + pub(super) agent_id: Option, + pub(super) read_profile: Option, + pub(super) top_k: Option, + pub(super) candidate_k: Option, + pub(super) ranking: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct EvalQuery { + pub(super) id: Option, + pub(super) query: String, + pub(super) tenant_id: Option, + pub(super) project_id: Option, + pub(super) agent_id: Option, + pub(super) read_profile: Option, + pub(super) top_k: Option, + pub(super) candidate_k: Option, + #[serde(default)] + pub(super) expected_note_ids: Vec, + #[serde(default)] + pub(super) expected_keys: Vec, + pub(super) ranking: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct EvalOutput { + pub(super) dataset: EvalDatasetInfo, + pub(super) settings: EvalSettings, + pub(super) summary: EvalSummary, + pub(super) queries: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct EvalDatasetInfo { + pub(super) name: String, + pub(super) query_count: usize, +} + +#[derive(Debug, Serialize)] +pub(super) struct EvalSettings { + pub(super) config_path: String, + pub(super) search_mode: SearchMode, + pub(super) candidate_k: u32, + pub(super) top_k: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) runs_per_query: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct EvalSummary { + pub(super) avg_recall_at_k: f64, + pub(super) avg_precision_at_k: f64, + pub(super) mean_rr: f64, + pub(super) mean_ndcg: f64, + pub(super) latency_ms_p50: f64, + pub(super) latency_ms_p95: f64, + pub(super) avg_retrieved_summary_chars: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stability: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct StabilitySummary { + pub(super) runs_per_query: u32, + pub(super) avg_positional_churn_at_k: f64, + pub(super) avg_set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct QueryReport { + pub(super) id: String, + pub(super) query: String, + pub(super) trace_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) trace_ids: Option>, + pub(super) expected_count: usize, + pub(super) retrieved_count: usize, + pub(super) relevant_count: usize, + pub(super) recall_at_k: f64, + pub(super) precision_at_k: f64, + pub(super) rr: f64, + pub(super) ndcg: f64, + pub(super) latency_ms: f64, + pub(super) expected_note_ids: Vec, + pub(super) expected_keys: Vec, + pub(super) expected_kind: ExpectedKind, + pub(super) retrieved_note_ids: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(super) retrieved_keys: Vec>, + pub(super) retrieved_summary_chars: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stability: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ExpectedKind { + NoteId, + Key, +} + +#[derive(Clone, Copy, Debug, Serialize)] +pub(super) struct QueryStability { + pub(super) runs_per_query: u32, + pub(super) positional_churn_at_k: f64, + pub(super) set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct CompareOutput { + pub(super) dataset: EvalDatasetInfo, + pub(super) settings_a: EvalSettings, + pub(super) settings_b: EvalSettings, + pub(super) summary_a: EvalSummary, + pub(super) summary_b: EvalSummary, + pub(super) summary_delta: EvalSummaryDelta, + pub(super) policy_stability: PolicyStabilitySummary, + pub(super) queries: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct PolicyStabilitySummary { + pub(super) k: u32, + pub(super) avg_positional_churn_at_k: f64, + pub(super) avg_set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct EvalSummaryDelta { + pub(super) avg_recall_at_k: f64, + pub(super) avg_precision_at_k: f64, + pub(super) mean_rr: f64, + pub(super) mean_ndcg: f64, + pub(super) latency_ms_p50: f64, + pub(super) latency_ms_p95: f64, + pub(super) avg_retrieved_summary_chars: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stability: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct StabilitySummaryDelta { + pub(super) avg_positional_churn_at_k: f64, + pub(super) avg_set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct CompareQueryReport { + pub(super) id: String, + pub(super) query: String, + pub(super) expected_count: usize, + pub(super) expected_note_ids: Vec, + pub(super) a: QueryVariantReport, + pub(super) b: QueryVariantReport, + pub(super) delta: QueryVariantDelta, + pub(super) policy_churn: PolicyChurn, +} + +#[derive(Debug, Serialize)] +pub(super) struct PolicyChurn { + pub(super) positional_churn_at_k: f64, + pub(super) set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct QueryVariantReport { + pub(super) trace_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) trace_ids: Option>, + pub(super) retrieved_count: usize, + pub(super) relevant_count: usize, + pub(super) recall_at_k: f64, + pub(super) precision_at_k: f64, + pub(super) rr: f64, + pub(super) ndcg: f64, + pub(super) latency_ms: f64, + pub(super) retrieved_note_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stability: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct QueryVariantDelta { + pub(super) retrieved_count: i64, + pub(super) relevant_count: i64, + pub(super) recall_at_k: f64, + pub(super) precision_at_k: f64, + pub(super) rr: f64, + pub(super) ndcg: f64, + pub(super) latency_ms: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stability: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct QueryStabilityDelta { + pub(super) positional_churn_at_k: f64, + pub(super) set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareOutput { + pub(super) policies: TraceComparePolicies, + pub(super) summary: TraceCompareSummary, + pub(super) traces: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceComparePolicies { + pub(super) a: TraceComparePolicy, + pub(super) b: TraceComparePolicy, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceComparePolicy { + pub(super) config_path: String, + pub(super) policy_id: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareSummary { + pub(super) trace_count: usize, + pub(super) avg_positional_churn_at_k: f64, + pub(super) avg_set_churn_at_k: f64, + pub(super) avg_a_retrieval_top3_retention: f64, + pub(super) avg_b_retrieval_top3_retention: f64, + pub(super) avg_retrieval_top3_retention_delta: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareTrace { + pub(super) trace_id: Uuid, + pub(super) query: String, + pub(super) candidate_count: u32, + pub(super) top_k: u32, + pub(super) created_at: String, + pub(super) a: TraceCompareVariant, + pub(super) b: TraceCompareVariant, + pub(super) churn: TraceCompareChurn, + pub(super) guardrails: TraceCompareGuardrails, + pub(super) stage_deltas: Vec, + pub(super) regression_attribution: TraceCompareRegressionAttribution, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareVariant { + pub(super) policy_id: String, + pub(super) items: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareChurn { + pub(super) positional_churn_at_k: f64, + pub(super) set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareGuardrails { + pub(super) retrieval_top3_total: usize, + pub(super) a_retrieval_top3_retained: usize, + pub(super) a_retrieval_top3_retention: f64, + pub(super) b_retrieval_top3_retained: usize, + pub(super) b_retrieval_top3_retention: f64, + pub(super) retrieval_top3_retention_delta: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareStageDelta { + pub(super) stage_order: u32, + pub(super) stage_name: String, + pub(super) baseline_item_count: u32, + pub(super) a_item_count: u32, + pub(super) b_item_count: u32, + pub(super) item_count_delta: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) baseline_stats: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct TraceCompareRegressionAttribution { + pub(super) primary_stage: String, + pub(super) evidence: String, +} + +#[derive(FromRow)] +pub(super) struct TraceCompareTraceRow { + pub(super) trace_id: Uuid, + pub(super) query: String, + pub(super) candidate_count: i32, + pub(super) top_k: i32, + pub(super) created_at: OffsetDateTime, +} + +#[derive(FromRow)] +pub(super) struct TraceCompareCandidateRow { + pub(super) candidate_snapshot: Value, + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + pub(super) chunk_index: i32, + pub(super) snippet: String, + pub(super) retrieval_rank: i32, + pub(super) rerank_score: f32, + pub(super) note_scope: String, + pub(super) note_importance: f32, + pub(super) note_updated_at: OffsetDateTime, + pub(super) note_hit_count: i64, + pub(super) note_last_hit_at: Option, +} + +#[derive(FromRow)] +pub(super) struct TraceCompareStageRow { + pub(super) stage_order: i32, + pub(super) stage_name: String, + pub(super) stage_payload: Value, + pub(super) item_count: i64, +} + +pub(super) struct MergedQuery { + pub(super) id: String, + pub(super) query: String, + pub(super) expected_note_ids: Vec, + pub(super) expected_keys: Vec, + pub(super) expected_kind: ExpectedKind, + pub(super) request: SearchRequest, +} + +pub(super) struct Metrics { + pub(super) recall_at_k: f64, + pub(super) precision_at_k: f64, + pub(super) rr: f64, + pub(super) ndcg: f64, + pub(super) relevant_count: usize, +} + +pub(super) struct EvalRun { + pub(super) dataset: EvalDatasetInfo, + pub(super) settings: EvalSettings, + pub(super) summary: EvalSummary, + pub(super) queries: Vec, +} + +pub(super) fn default_eval_defaults() -> EvalDefaults { + EvalDefaults { + tenant_id: None, + project_id: None, + agent_id: None, + read_profile: None, + top_k: None, + candidate_k: None, + ranking: None, + } +} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs index 91479958..5d8c267a 100644 --- a/apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs @@ -2,638 +2,36 @@ //! Offline adapter for agentmemory-style fixture exports. -use std::{collections::HashMap, fs, path::PathBuf}; +#[path = "agentmemory_fixture_adapter/adapt.rs"] mod adapt; +#[path = "agentmemory_fixture_adapter/cli.rs"] mod cli; +#[path = "agentmemory_fixture_adapter/io.rs"] mod io; +#[path = "agentmemory_fixture_adapter/mapping.rs"] mod mapping; +#[path = "agentmemory_fixture_adapter/types.rs"] mod types; +#[path = "agentmemory_fixture_adapter/util.rs"] mod util; use clap::Parser; -use color_eyre; -use serde::{Deserialize, Serialize}; -use serde_json::{self, Value}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use uuid::Uuid; +use color_eyre::Result; + +use self::cli::Args; const OUTPUT_SCHEMA: &str = "elf.agentmemory_adapter/v1"; const FIXTURE_RESOLVER: &str = "agentmemory_fixture/v1"; const DEFAULT_IMPORTANCE: f32 = 0.5; const DEFAULT_CONFIDENCE: f32 = 0.5; -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), -)] -struct Args { - /// Path to a sanitized agentmemory-style JSON fixture. - #[arg(long, short = 'f', value_name = "FILE")] - fixture: PathBuf, - /// Write adapter JSON to this file (defaults to stdout). - #[arg(long, value_name = "FILE")] - out: Option, - /// ELF write scope to attach to emitted note and doc candidates. - #[arg(long, default_value = "agent_private")] - scope: String, - /// Maximum note text length accepted for note candidates. - #[arg(long, default_value_t = 240)] - max_note_chars: usize, -} - -#[derive(Debug, Deserialize)] -struct AgentmemoryFixture { - schema: Option, - - fixture_id: Option, - #[serde(default)] - source: FixtureSource, - #[serde(default)] - sessions: Vec, -} - -#[derive(Debug, Default, Deserialize)] -struct FixtureSource { - system: Option, - - version: Option, - - export_id: Option, - - exported_at: Option, -} - -#[derive(Debug, Deserialize)] -struct AgentmemorySession { - session_id: String, - - agent: Option, - - project: Option, - - started_at: Option, - - ended_at: Option, - #[serde(default)] - observations: Vec, - #[serde(default)] - memories: Vec, - #[serde(default)] - retrieval_cases: Vec, -} - -#[derive(Debug, Deserialize)] -struct AgentmemoryObservation { - observation_id: String, - - ts: Option, - - role: Option, - - kind: Option, - text: String, - #[serde(default)] - metadata: Value, -} - -#[derive(Debug, Deserialize)] -struct AgentmemoryMemory { - memory_id: String, - - kind: Option, - - key: Option, - text: String, - - importance: Option, - - confidence: Option, - - ttl_days: Option, - - created_at: Option, - - updated_at: Option, - #[serde(default)] - source_observation_ids: Vec, - #[serde(default)] - metadata: Value, -} - -#[derive(Debug, Deserialize)] -struct AgentmemoryRetrievalCase { - query_id: String, - query: String, - #[serde(default)] - expected_memory_ids: Vec, - #[serde(default)] - agentmemory_results: Vec, - #[serde(default)] - metadata: Value, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AgentmemorySearchResult { - memory_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - rank: Option, - #[serde(skip_serializing_if = "Option::is_none")] - score: Option, -} - -#[derive(Debug, Serialize)] -struct AdapterOutput { - schema: &'static str, - fixture_id: String, - source: AdapterSource, - summary: AdapterSummary, - note_candidates: Vec, - doc_candidates: Vec, - baseline_queries: Vec, - ignored_items: Vec, -} - -#[derive(Debug, Serialize)] -struct AdapterSource { - system: String, - #[serde(skip_serializing_if = "Option::is_none")] - version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - export_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - exported_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - fixture_schema: Option, -} - -#[derive(Debug, Serialize)] -struct AdapterSummary { - session_count: usize, - observation_count: usize, - memory_count: usize, - note_candidate_count: usize, - doc_candidate_count: usize, - baseline_query_count: usize, - ignored_count: usize, -} - -#[derive(Clone, Debug, Serialize)] -struct NoteCandidate { - candidate_id: Uuid, - scope: String, - session_id: String, - source_memory_id: String, - source_observation_ids: Vec, - notes_ingest_item: ElfNoteCandidate, - #[serde(skip_serializing_if = "Value::is_null")] - source_metadata: Value, -} - -#[derive(Clone, Debug, Serialize)] -struct ElfNoteCandidate { - #[serde(rename = "type")] - note_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - key: Option, - text: String, - importance: f32, - confidence: f32, - #[serde(skip_serializing_if = "Option::is_none")] - ttl_days: Option, - source_ref: Value, -} - -#[derive(Debug, Serialize)] -struct DocCandidate { - candidate_id: Uuid, - scope: String, - session_id: String, - source_observation_id: String, - docs_put: DocsPutCandidate, - #[serde(skip_serializing_if = "Value::is_null")] - source_metadata: Value, -} - -#[derive(Debug, Serialize)] -struct DocsPutCandidate { - scope: String, - doc_type: &'static str, - title: String, - source_ref: Value, - content: String, -} - -#[derive(Debug, Serialize)] -struct BaselineQuery { - query_id: String, - session_id: String, - query: String, - expected_source_memory_ids: Vec, - expected_candidate_ids: Vec, - expected_keys: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - agentmemory_results: Vec, - #[serde(skip_serializing_if = "Value::is_null")] - source_metadata: Value, -} - -#[derive(Debug, Serialize)] -struct IgnoredItem { - item_kind: &'static str, - session_id: String, - source_id: String, - reason: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, -} - -#[derive(Clone)] -struct FixtureContext { - fixture_id: String, - source_system: String, - source_version: Option, - exported_at: Option, - scope: String, - max_note_chars: usize, -} - -fn main() -> color_eyre::Result<()> { +fn main() -> Result<()> { color_eyre::install()?; let args = Args::parse(); - let raw = fs::read_to_string(&args.fixture)?; - let fixture: AgentmemoryFixture = serde_json::from_str(&raw)?; - let output = adapt_fixture(&fixture, args.scope.as_str(), args.max_note_chars); + let fixture = self::io::read_fixture(&args.fixture)?; + let output = self::adapt::adapt_fixture(&fixture, args.scope.as_str(), args.max_note_chars); let json = serde_json::to_string_pretty(&output)?; if let Some(path) = args.out { - write_output(path, json.as_str())?; + self::io::write_output(path, json.as_str())?; } else { println!("{json}"); } Ok(()) } - -fn write_output(path: PathBuf, json: &str) -> color_eyre::Result<()> { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent)?; - } - - fs::write(path, json)?; - - Ok(()) -} - -fn adapt_fixture( - fixture: &AgentmemoryFixture, - scope: &str, - max_note_chars: usize, -) -> AdapterOutput { - let source = adapter_source(fixture); - let fixture_id = fixture_id(fixture, source.system.as_str()); - let ctx = FixtureContext { - fixture_id: fixture_id.clone(), - source_system: source.system.clone(), - source_version: source.version.clone(), - exported_at: source.exported_at.clone(), - scope: scope.to_string(), - max_note_chars, - }; - let mut notes = Vec::new(); - let mut docs = Vec::new(); - let mut baselines = Vec::new(); - let mut ignored = Vec::new(); - let mut memory_map = HashMap::new(); - - for session in &fixture.sessions { - map_observations(session, &ctx, &mut docs, &mut ignored); - map_memories(session, &ctx, &mut notes, &mut memory_map, &mut ignored); - map_baselines(session, &memory_map, &mut baselines, &mut ignored); - } - - AdapterOutput { - schema: OUTPUT_SCHEMA, - fixture_id, - source, - summary: AdapterSummary { - session_count: fixture.sessions.len(), - observation_count: fixture - .sessions - .iter() - .map(|session| session.observations.len()) - .sum(), - memory_count: fixture.sessions.iter().map(|session| session.memories.len()).sum(), - note_candidate_count: notes.len(), - doc_candidate_count: docs.len(), - baseline_query_count: baselines.len(), - ignored_count: ignored.len(), - }, - note_candidates: notes, - doc_candidates: docs, - baseline_queries: baselines, - ignored_items: ignored, - } -} - -fn adapter_source(fixture: &AgentmemoryFixture) -> AdapterSource { - AdapterSource { - system: clean_string(fixture.source.system.as_deref()) - .unwrap_or_else(|| "agentmemory".to_string()), - version: clean_string(fixture.source.version.as_deref()), - export_id: clean_string(fixture.source.export_id.as_deref()), - exported_at: clean_string(fixture.source.exported_at.as_deref()), - fixture_schema: clean_string(fixture.schema.as_deref()), - } -} - -fn fixture_id(fixture: &AgentmemoryFixture, source_system: &str) -> String { - clean_string(fixture.fixture_id.as_deref()) - .or_else(|| clean_string(fixture.source.export_id.as_deref())) - .unwrap_or_else(|| stable_uuid("fixture", &[source_system]).to_string()) -} - -fn map_observations( - session: &AgentmemorySession, - ctx: &FixtureContext, - docs: &mut Vec, - ignored: &mut Vec, -) { - for observation in &session.observations { - match doc_candidate(session, observation, ctx) { - Ok(candidate) => docs.push(candidate), - Err(reason) => ignored.push(IgnoredItem { - item_kind: "observation", - session_id: session.session_id.clone(), - source_id: observation.observation_id.clone(), - reason, - detail: None, - }), - } - } -} - -fn map_memories( - session: &AgentmemorySession, - ctx: &FixtureContext, - notes: &mut Vec, - memory_map: &mut HashMap, - ignored: &mut Vec, -) { - for memory in &session.memories { - match note_candidate(session, memory, ctx) { - Ok(candidate) => { - memory_map.insert(memory.memory_id.clone(), candidate.clone()); - notes.push(candidate); - }, - Err(reason) => ignored.push(IgnoredItem { - item_kind: "memory", - session_id: session.session_id.clone(), - source_id: memory.memory_id.clone(), - reason, - detail: None, - }), - } - } -} - -fn map_baselines( - session: &AgentmemorySession, - memory_map: &HashMap, - baselines: &mut Vec, - ignored: &mut Vec, -) { - for case in &session.retrieval_cases { - match baseline_query(session, case, memory_map) { - Some(baseline) => baselines.push(baseline), - None => ignored.push(IgnoredItem { - item_kind: "retrieval_case", - session_id: session.session_id.clone(), - source_id: case.query_id.clone(), - reason: "no_mapped_expected_memories", - detail: None, - }), - } - } -} - -fn doc_candidate( - session: &AgentmemorySession, - observation: &AgentmemoryObservation, - ctx: &FixtureContext, -) -> std::result::Result { - let text = observation.text.trim(); - - if text.is_empty() { - return Err("empty_text"); - } - - let Some(ts) = observation_timestamp(session, observation, ctx) else { - return Err("missing_or_invalid_timestamp"); - }; - let candidate_id = stable_uuid( - "observation", - &[ - ctx.fixture_id.as_str(), - session.session_id.as_str(), - observation.observation_id.as_str(), - ], - ); - let role = clean_string(observation.role.as_deref()) - .or_else(|| clean_string(observation.kind.as_deref())) - .unwrap_or_else(|| "observation".to_string()); - let title = format!("agentmemory observation {}", observation.observation_id); - let source_ref = serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "chat", - "ts": ts, - "thread_id": session.session_id, - "role": role, - "message_id": observation.observation_id, - "agentmemory_fixture_id": ctx.fixture_id, - "agentmemory_source_system": ctx.source_system, - "agentmemory_observation_kind": clean_string(observation.kind.as_deref()), - "agent": clean_string(session.agent.as_deref()), - "project": clean_string(session.project.as_deref()), - }); - - Ok(DocCandidate { - candidate_id, - scope: ctx.scope.clone(), - session_id: session.session_id.clone(), - source_observation_id: observation.observation_id.clone(), - docs_put: DocsPutCandidate { - scope: ctx.scope.clone(), - doc_type: "chat", - title, - source_ref, - content: observation.text.clone(), - }, - source_metadata: observation.metadata.clone(), - }) -} - -fn note_candidate( - session: &AgentmemorySession, - memory: &AgentmemoryMemory, - ctx: &FixtureContext, -) -> std::result::Result { - let text = memory.text.trim(); - - if text.is_empty() { - return Err("empty_text"); - } - if text.chars().count() > ctx.max_note_chars { - return Err("note_text_too_long"); - } - - let Some(note_type) = memory.kind.as_deref().and_then(map_note_type) else { - return Err("unsupported_memory_kind"); - }; - let Some(importance) = score_or_default(memory.importance, DEFAULT_IMPORTANCE) else { - return Err("invalid_importance"); - }; - let Some(confidence) = score_or_default(memory.confidence, DEFAULT_CONFIDENCE) else { - return Err("invalid_confidence"); - }; - let candidate_id = stable_uuid( - "memory", - &[ctx.fixture_id.as_str(), session.session_id.as_str(), memory.memory_id.as_str()], - ); - let source_ref = note_source_ref(session, memory, ctx); - - Ok(NoteCandidate { - candidate_id, - scope: ctx.scope.clone(), - session_id: session.session_id.clone(), - source_memory_id: memory.memory_id.clone(), - source_observation_ids: memory.source_observation_ids.clone(), - notes_ingest_item: ElfNoteCandidate { - note_type: note_type.to_string(), - key: clean_string(memory.key.as_deref()), - text: memory.text.clone(), - importance, - confidence, - ttl_days: memory.ttl_days.filter(|days| *days > 0), - source_ref, - }, - source_metadata: memory.metadata.clone(), - }) -} - -fn note_source_ref( - session: &AgentmemorySession, - memory: &AgentmemoryMemory, - ctx: &FixtureContext, -) -> Value { - serde_json::json!({ - "schema": "source_ref/v1", - "resolver": FIXTURE_RESOLVER, - "ref": { - "fixture_id": ctx.fixture_id, - "session_id": session.session_id, - "memory_id": memory.memory_id, - "observation_ids": memory.source_observation_ids, - }, - "state": { - "source_system": ctx.source_system, - "source_version": ctx.source_version, - "exported_at": ctx.exported_at, - "session_started_at": session.started_at, - "session_ended_at": session.ended_at, - "memory_created_at": memory.created_at, - "memory_updated_at": memory.updated_at, - }, - "locator": { - "memory_id": memory.memory_id, - "observation_ids": memory.source_observation_ids, - }, - "hints": { - "agent": session.agent, - "project": session.project, - "origin_kind": memory.kind, - }, - }) -} - -fn baseline_query( - session: &AgentmemorySession, - case: &AgentmemoryRetrievalCase, - memory_map: &HashMap, -) -> Option { - if case.query.trim().is_empty() || case.expected_memory_ids.is_empty() { - return None; - } - - let expected: Vec<&NoteCandidate> = - case.expected_memory_ids.iter().filter_map(|id| memory_map.get(id)).collect(); - - if expected.is_empty() { - return None; - } - - Some(BaselineQuery { - query_id: case.query_id.clone(), - session_id: session.session_id.clone(), - query: case.query.clone(), - expected_source_memory_ids: expected - .iter() - .map(|candidate| candidate.source_memory_id.clone()) - .collect(), - expected_candidate_ids: expected.iter().map(|candidate| candidate.candidate_id).collect(), - expected_keys: expected - .iter() - .filter_map(|candidate| candidate.notes_ingest_item.key.clone()) - .collect(), - agentmemory_results: case.agentmemory_results.clone(), - source_metadata: case.metadata.clone(), - }) -} - -fn observation_timestamp( - session: &AgentmemorySession, - observation: &AgentmemoryObservation, - ctx: &FixtureContext, -) -> Option { - [observation.ts.as_deref(), session.started_at.as_deref(), ctx.exported_at.as_deref()] - .into_iter() - .flatten() - .find_map(normalize_rfc3339) -} - -fn normalize_rfc3339(value: &str) -> Option { - OffsetDateTime::parse(value, &Rfc3339) - .ok() - .and_then(|timestamp| timestamp.format(&Rfc3339).ok()) -} - -fn map_note_type(kind: &str) -> Option<&'static str> { - match kind.trim().to_ascii_lowercase().as_str() { - "preference" => Some("preference"), - "constraint" => Some("constraint"), - "decision" => Some("decision"), - "profile" => Some("profile"), - "fact" => Some("fact"), - "plan" => Some("plan"), - _ => None, - } -} - -fn score_or_default(score: Option, default: f32) -> Option { - let score = score.unwrap_or(default); - - if score.is_finite() && (0.0..=1.0).contains(&score) { Some(score) } else { None } -} - -fn clean_string(value: Option<&str>) -> Option { - value.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string) -} - -fn stable_uuid(kind: &str, parts: &[&str]) -> Uuid { - let mut key = format!("https://hack.ink/elf/{OUTPUT_SCHEMA}/{kind}"); - - for part in parts { - key.push('/'); - key.push_str(part); - } - - Uuid::new_v5(&Uuid::NAMESPACE_URL, key.as_bytes()) -} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter/adapt.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/adapt.rs new file mode 100644 index 00000000..a01627c7 --- /dev/null +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/adapt.rs @@ -0,0 +1,143 @@ +use std::collections::HashMap; + +use crate::{ + OUTPUT_SCHEMA, + mapping::{self}, + types::{ + AdapterOutput, AdapterSource, AdapterSummary, AgentmemoryFixture, AgentmemorySession, + BaselineQuery, DocCandidate, FixtureContext, IgnoredItem, NoteCandidate, + }, + util::{self}, +}; + +pub(super) fn adapt_fixture( + fixture: &AgentmemoryFixture, + scope: &str, + max_note_chars: usize, +) -> AdapterOutput { + let source = adapter_source(fixture); + let fixture_id = fixture_id(fixture, source.system.as_str()); + let ctx = FixtureContext { + fixture_id: fixture_id.clone(), + source_system: source.system.clone(), + source_version: source.version.clone(), + exported_at: source.exported_at.clone(), + scope: scope.to_string(), + max_note_chars, + }; + let mut notes = Vec::new(); + let mut docs = Vec::new(); + let mut baselines = Vec::new(); + let mut ignored = Vec::new(); + let mut memory_map = HashMap::new(); + + for session in &fixture.sessions { + map_observations(session, &ctx, &mut docs, &mut ignored); + map_memories(session, &ctx, &mut notes, &mut memory_map, &mut ignored); + map_baselines(session, &memory_map, &mut baselines, &mut ignored); + } + + AdapterOutput { + schema: OUTPUT_SCHEMA, + fixture_id, + source, + summary: AdapterSummary { + session_count: fixture.sessions.len(), + observation_count: fixture + .sessions + .iter() + .map(|session| session.observations.len()) + .sum(), + memory_count: fixture.sessions.iter().map(|session| session.memories.len()).sum(), + note_candidate_count: notes.len(), + doc_candidate_count: docs.len(), + baseline_query_count: baselines.len(), + ignored_count: ignored.len(), + }, + note_candidates: notes, + doc_candidates: docs, + baseline_queries: baselines, + ignored_items: ignored, + } +} + +fn adapter_source(fixture: &AgentmemoryFixture) -> AdapterSource { + AdapterSource { + system: util::clean_string(fixture.source.system.as_deref()) + .unwrap_or_else(|| "agentmemory".to_string()), + version: util::clean_string(fixture.source.version.as_deref()), + export_id: util::clean_string(fixture.source.export_id.as_deref()), + exported_at: util::clean_string(fixture.source.exported_at.as_deref()), + fixture_schema: util::clean_string(fixture.schema.as_deref()), + } +} + +fn fixture_id(fixture: &AgentmemoryFixture, source_system: &str) -> String { + util::clean_string(fixture.fixture_id.as_deref()) + .or_else(|| util::clean_string(fixture.source.export_id.as_deref())) + .unwrap_or_else(|| util::stable_uuid("fixture", &[source_system]).to_string()) +} + +fn map_observations( + session: &AgentmemorySession, + ctx: &FixtureContext, + docs: &mut Vec, + ignored: &mut Vec, +) { + for observation in &session.observations { + match mapping::doc_candidate(session, observation, ctx) { + Ok(candidate) => docs.push(candidate), + Err(reason) => ignored.push(IgnoredItem { + item_kind: "observation", + session_id: session.session_id.clone(), + source_id: observation.observation_id.clone(), + reason, + detail: None, + }), + } + } +} + +fn map_memories( + session: &AgentmemorySession, + ctx: &FixtureContext, + notes: &mut Vec, + memory_map: &mut HashMap, + ignored: &mut Vec, +) { + for memory in &session.memories { + match mapping::note_candidate(session, memory, ctx) { + Ok(candidate) => { + memory_map.insert(memory.memory_id.clone(), candidate.clone()); + notes.push(candidate); + }, + Err(reason) => ignored.push(IgnoredItem { + item_kind: "memory", + session_id: session.session_id.clone(), + source_id: memory.memory_id.clone(), + reason, + detail: None, + }), + } + } +} + +fn map_baselines( + session: &AgentmemorySession, + memory_map: &HashMap, + baselines: &mut Vec, + ignored: &mut Vec, +) { + for case in &session.retrieval_cases { + match mapping::baseline_query(session, case, memory_map) { + Some(baseline) => baselines.push(baseline), + None => ignored.push(IgnoredItem { + item_kind: "retrieval_case", + session_id: session.session_id.clone(), + source_id: case.query_id.clone(), + reason: "no_mapped_expected_memories", + detail: None, + }), + } + } +} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter/cli.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/cli.rs new file mode 100644 index 00000000..d8b2a4d4 --- /dev/null +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/cli.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), +)] +pub(super) struct Args { + /// Path to a sanitized agentmemory-style JSON fixture. + #[arg(long, short = 'f', value_name = "FILE")] + pub(super) fixture: PathBuf, + /// Write adapter JSON to this file (defaults to stdout). + #[arg(long, value_name = "FILE")] + pub(super) out: Option, + /// ELF write scope to attach to emitted note and doc candidates. + #[arg(long, default_value = "agent_private")] + pub(super) scope: String, + /// Maximum note text length accepted for note candidates. + #[arg(long, default_value_t = 240)] + pub(super) max_note_chars: usize, +} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter/io.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/io.rs new file mode 100644 index 00000000..76f1ebb1 --- /dev/null +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/io.rs @@ -0,0 +1,24 @@ +use std::{fs, path::PathBuf}; + +use color_eyre::Result; + +use crate::types::AgentmemoryFixture; + +pub(super) fn read_fixture(path: &PathBuf) -> Result { + let raw = fs::read_to_string(path)?; + let fixture = serde_json::from_str(&raw)?; + + Ok(fixture) +} + +pub(super) fn write_output(path: PathBuf, json: &str) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + + fs::write(path, json)?; + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter/mapping.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/mapping.rs new file mode 100644 index 00000000..24c1a3f1 --- /dev/null +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/mapping.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; + +use serde_json::Value; + +use crate::{ + DEFAULT_CONFIDENCE, DEFAULT_IMPORTANCE, FIXTURE_RESOLVER, + types::{ + AgentmemoryMemory, AgentmemoryObservation, AgentmemoryRetrievalCase, AgentmemorySession, + BaselineQuery, DocCandidate, DocsPutCandidate, ElfNoteCandidate, FixtureContext, + NoteCandidate, + }, + util::{self, map_note_type}, +}; + +pub(super) fn doc_candidate( + session: &AgentmemorySession, + observation: &AgentmemoryObservation, + ctx: &FixtureContext, +) -> std::result::Result { + let text = observation.text.trim(); + + if text.is_empty() { + return Err("empty_text"); + } + + let Some(ts) = util::observation_timestamp(session, observation, ctx) else { + return Err("missing_or_invalid_timestamp"); + }; + let candidate_id = util::stable_uuid( + "observation", + &[ + ctx.fixture_id.as_str(), + session.session_id.as_str(), + observation.observation_id.as_str(), + ], + ); + let role = util::clean_string(observation.role.as_deref()) + .or_else(|| util::clean_string(observation.kind.as_deref())) + .unwrap_or_else(|| "observation".to_string()); + let title = format!("agentmemory observation {}", observation.observation_id); + let source_ref = serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "chat", + "ts": ts, + "thread_id": session.session_id, + "role": role, + "message_id": observation.observation_id, + "agentmemory_fixture_id": ctx.fixture_id, + "agentmemory_source_system": ctx.source_system, + "agentmemory_observation_kind": util::clean_string(observation.kind.as_deref()), + "agent": util::clean_string(session.agent.as_deref()), + "project": util::clean_string(session.project.as_deref()), + }); + + Ok(DocCandidate { + candidate_id, + scope: ctx.scope.clone(), + session_id: session.session_id.clone(), + source_observation_id: observation.observation_id.clone(), + docs_put: DocsPutCandidate { + scope: ctx.scope.clone(), + doc_type: "chat", + title, + source_ref, + content: observation.text.clone(), + }, + source_metadata: observation.metadata.clone(), + }) +} + +pub(super) fn note_candidate( + session: &AgentmemorySession, + memory: &AgentmemoryMemory, + ctx: &FixtureContext, +) -> std::result::Result { + let text = memory.text.trim(); + + if text.is_empty() { + return Err("empty_text"); + } + if text.chars().count() > ctx.max_note_chars { + return Err("note_text_too_long"); + } + + let Some(note_type) = memory.kind.as_deref().and_then(map_note_type) else { + return Err("unsupported_memory_kind"); + }; + let Some(importance) = util::score_or_default(memory.importance, DEFAULT_IMPORTANCE) else { + return Err("invalid_importance"); + }; + let Some(confidence) = util::score_or_default(memory.confidence, DEFAULT_CONFIDENCE) else { + return Err("invalid_confidence"); + }; + let candidate_id = util::stable_uuid( + "memory", + &[ctx.fixture_id.as_str(), session.session_id.as_str(), memory.memory_id.as_str()], + ); + let source_ref = note_source_ref(session, memory, ctx); + + Ok(NoteCandidate { + candidate_id, + scope: ctx.scope.clone(), + session_id: session.session_id.clone(), + source_memory_id: memory.memory_id.clone(), + source_observation_ids: memory.source_observation_ids.clone(), + notes_ingest_item: ElfNoteCandidate { + note_type: note_type.to_string(), + key: util::clean_string(memory.key.as_deref()), + text: memory.text.clone(), + importance, + confidence, + ttl_days: memory.ttl_days.filter(|days| *days > 0), + source_ref, + }, + source_metadata: memory.metadata.clone(), + }) +} + +pub(super) fn baseline_query( + session: &AgentmemorySession, + case: &AgentmemoryRetrievalCase, + memory_map: &HashMap, +) -> Option { + if case.query.trim().is_empty() || case.expected_memory_ids.is_empty() { + return None; + } + + let expected: Vec<&NoteCandidate> = + case.expected_memory_ids.iter().filter_map(|id| memory_map.get(id)).collect(); + + if expected.is_empty() { + return None; + } + + Some(BaselineQuery { + query_id: case.query_id.clone(), + session_id: session.session_id.clone(), + query: case.query.clone(), + expected_source_memory_ids: expected + .iter() + .map(|candidate| candidate.source_memory_id.clone()) + .collect(), + expected_candidate_ids: expected.iter().map(|candidate| candidate.candidate_id).collect(), + expected_keys: expected + .iter() + .filter_map(|candidate| candidate.notes_ingest_item.key.clone()) + .collect(), + agentmemory_results: case.agentmemory_results.clone(), + source_metadata: case.metadata.clone(), + }) +} + +fn note_source_ref( + session: &AgentmemorySession, + memory: &AgentmemoryMemory, + ctx: &FixtureContext, +) -> Value { + serde_json::json!({ + "schema": "source_ref/v1", + "resolver": FIXTURE_RESOLVER, + "ref": { + "fixture_id": ctx.fixture_id, + "session_id": session.session_id, + "memory_id": memory.memory_id, + "observation_ids": memory.source_observation_ids, + }, + "state": { + "source_system": ctx.source_system, + "source_version": ctx.source_version, + "exported_at": ctx.exported_at, + "session_started_at": session.started_at, + "session_ended_at": session.ended_at, + "memory_created_at": memory.created_at, + "memory_updated_at": memory.updated_at, + }, + "locator": { + "memory_id": memory.memory_id, + "observation_ids": memory.source_observation_ids, + }, + "hints": { + "agent": session.agent, + "project": session.project, + "origin_kind": memory.kind, + }, + }) +} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter/types.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/types.rs new file mode 100644 index 00000000..a4c04dbc --- /dev/null +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/types.rs @@ -0,0 +1,219 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub(super) struct AgentmemoryFixture { + pub(super) schema: Option, + + pub(super) fixture_id: Option, + #[serde(default)] + pub(super) source: FixtureSource, + #[serde(default)] + pub(super) sessions: Vec, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct FixtureSource { + pub(super) system: Option, + + pub(super) version: Option, + + pub(super) export_id: Option, + + pub(super) exported_at: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AgentmemorySession { + pub(super) session_id: String, + + pub(super) agent: Option, + + pub(super) project: Option, + + pub(super) started_at: Option, + + pub(super) ended_at: Option, + #[serde(default)] + pub(super) observations: Vec, + #[serde(default)] + pub(super) memories: Vec, + #[serde(default)] + pub(super) retrieval_cases: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AgentmemoryObservation { + pub(super) observation_id: String, + + pub(super) ts: Option, + + pub(super) role: Option, + + pub(super) kind: Option, + pub(super) text: String, + #[serde(default)] + pub(super) metadata: Value, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AgentmemoryMemory { + pub(super) memory_id: String, + + pub(super) kind: Option, + + pub(super) key: Option, + pub(super) text: String, + + pub(super) importance: Option, + + pub(super) confidence: Option, + + pub(super) ttl_days: Option, + + pub(super) created_at: Option, + + pub(super) updated_at: Option, + #[serde(default)] + pub(super) source_observation_ids: Vec, + #[serde(default)] + pub(super) metadata: Value, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AgentmemoryRetrievalCase { + pub(super) query_id: String, + pub(super) query: String, + #[serde(default)] + pub(super) expected_memory_ids: Vec, + #[serde(default)] + pub(super) agentmemory_results: Vec, + #[serde(default)] + pub(super) metadata: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AgentmemorySearchResult { + pub(super) memory_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) rank: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) score: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct AdapterOutput { + pub(super) schema: &'static str, + pub(super) fixture_id: String, + pub(super) source: AdapterSource, + pub(super) summary: AdapterSummary, + pub(super) note_candidates: Vec, + pub(super) doc_candidates: Vec, + pub(super) baseline_queries: Vec, + pub(super) ignored_items: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct AdapterSource { + pub(super) system: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) export_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) exported_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) fixture_schema: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct AdapterSummary { + pub(super) session_count: usize, + pub(super) observation_count: usize, + pub(super) memory_count: usize, + pub(super) note_candidate_count: usize, + pub(super) doc_candidate_count: usize, + pub(super) baseline_query_count: usize, + pub(super) ignored_count: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct NoteCandidate { + pub(super) candidate_id: Uuid, + pub(super) scope: String, + pub(super) session_id: String, + pub(super) source_memory_id: String, + pub(super) source_observation_ids: Vec, + pub(super) notes_ingest_item: ElfNoteCandidate, + #[serde(skip_serializing_if = "Value::is_null")] + pub(super) source_metadata: Value, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct ElfNoteCandidate { + #[serde(rename = "type")] + pub(super) note_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) key: Option, + pub(super) text: String, + pub(super) importance: f32, + pub(super) confidence: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) ttl_days: Option, + pub(super) source_ref: Value, +} + +#[derive(Debug, Serialize)] +pub(super) struct DocCandidate { + pub(super) candidate_id: Uuid, + pub(super) scope: String, + pub(super) session_id: String, + pub(super) source_observation_id: String, + pub(super) docs_put: DocsPutCandidate, + #[serde(skip_serializing_if = "Value::is_null")] + pub(super) source_metadata: Value, +} + +#[derive(Debug, Serialize)] +pub(super) struct DocsPutCandidate { + pub(super) scope: String, + pub(super) doc_type: &'static str, + pub(super) title: String, + pub(super) source_ref: Value, + pub(super) content: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct BaselineQuery { + pub(super) query_id: String, + pub(super) session_id: String, + pub(super) query: String, + pub(super) expected_source_memory_ids: Vec, + pub(super) expected_candidate_ids: Vec, + pub(super) expected_keys: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(super) agentmemory_results: Vec, + #[serde(skip_serializing_if = "Value::is_null")] + pub(super) source_metadata: Value, +} + +#[derive(Debug, Serialize)] +pub(super) struct IgnoredItem { + pub(super) item_kind: &'static str, + pub(super) session_id: String, + pub(super) source_id: String, + pub(super) reason: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) detail: Option, +} + +#[derive(Clone)] +pub(super) struct FixtureContext { + pub(super) fixture_id: String, + pub(super) source_system: String, + pub(super) source_version: Option, + pub(super) exported_at: Option, + pub(super) scope: String, + pub(super) max_note_chars: usize, +} diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter/util.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/util.rs new file mode 100644 index 00000000..4d814b61 --- /dev/null +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/util.rs @@ -0,0 +1,57 @@ +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +use crate::{ + OUTPUT_SCHEMA, + types::{AgentmemoryObservation, AgentmemorySession, FixtureContext}, +}; + +pub(super) fn observation_timestamp( + session: &AgentmemorySession, + observation: &AgentmemoryObservation, + ctx: &FixtureContext, +) -> Option { + [observation.ts.as_deref(), session.started_at.as_deref(), ctx.exported_at.as_deref()] + .into_iter() + .flatten() + .find_map(normalize_rfc3339) +} + +pub(super) fn map_note_type(kind: &str) -> Option<&'static str> { + match kind.trim().to_ascii_lowercase().as_str() { + "preference" => Some("preference"), + "constraint" => Some("constraint"), + "decision" => Some("decision"), + "profile" => Some("profile"), + "fact" => Some("fact"), + "plan" => Some("plan"), + _ => None, + } +} + +pub(super) fn score_or_default(score: Option, default: f32) -> Option { + let score = score.unwrap_or(default); + + if score.is_finite() && (0.0..=1.0).contains(&score) { Some(score) } else { None } +} + +pub(super) fn clean_string(value: Option<&str>) -> Option { + value.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string) +} + +pub(super) fn stable_uuid(kind: &str, parts: &[&str]) -> Uuid { + let mut key = format!("https://hack.ink/elf/{OUTPUT_SCHEMA}/{kind}"); + + for part in parts { + key.push('/'); + key.push_str(part); + } + + Uuid::new_v5(&Uuid::NAMESPACE_URL, key.as_bytes()) +} + +fn normalize_rfc3339(value: &str) -> Option { + OffsetDateTime::parse(value, &Rfc3339) + .ok() + .and_then(|timestamp| timestamp.format(&Rfc3339).ok()) +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar.rs index 208ca3fe..46fe4d62 100644 --- a/apps/elf-eval/src/bin/external_memory_pattern_radar.rs +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar.rs @@ -2,843 +2,31 @@ //! Weekly external memory pattern radar runner. -use std::{ - collections::BTreeSet, - env, fs, - path::{Path, PathBuf}, -}; +#[path = "external_memory_pattern_radar/cli.rs"] mod cli; +#[path = "external_memory_pattern_radar/decision.rs"] mod decision; +#[path = "external_memory_pattern_radar/github.rs"] mod github; +#[path = "external_memory_pattern_radar/io.rs"] mod io; +#[path = "external_memory_pattern_radar/render.rs"] mod render; +#[path = "external_memory_pattern_radar/runtime.rs"] mod runtime; +#[path = "external_memory_pattern_radar/types.rs"] mod types; +#[path = "external_memory_pattern_radar/validation.rs"] mod validation; -use clap::{Parser, Subcommand, ValueEnum}; -use color_eyre::{Result, eyre}; -use reqwest::{ - Client, StatusCode, - header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}, -}; -use serde::{Deserialize, Serialize}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use clap::Parser; +use color_eyre::Result; + +use self::cli::{Args, Command}; const CURSOR_SCHEMA: &str = "elf.external_memory_pattern_radar_cursor/v1"; const RUN_SCHEMA: &str = "elf.external_memory_pattern_radar_run/v1"; const DEFAULT_CURSOR: &str = "apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json"; const DEFAULT_SUMMARY: &str = "docs/evidence/external_memory_pattern_radar_latest.md"; -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), -)] -struct Args { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Parser)] -struct RunArgs { - /// Existing radar cursor file. - #[arg(long, value_name = "FILE", default_value = DEFAULT_CURSOR)] - cursor: PathBuf, - /// Output cursor path. Defaults to updating --cursor. - #[arg(long, value_name = "FILE")] - out_cursor: Option, - /// Output Markdown summary path. - #[arg(long, value_name = "FILE", default_value = DEFAULT_SUMMARY)] - summary: PathBuf, - /// Observation mode. Use offline for deterministic dry runs. - #[arg(long, value_enum, default_value_t = RadarMode::Live)] - mode: RadarMode, - /// Stable run id. Defaults to external-memory-pattern-radar-YYYY-MM-DD. - #[arg(long)] - run_id: Option, - /// Environment variable containing a GitHub token for live mode. - #[arg(long, default_value = "GITHUB_TOKEN")] - github_token_env: String, -} - -#[derive(Debug, Parser)] -struct ValidateArgs { - /// Cursor file to validate. - #[arg(long, value_name = "FILE", default_value = DEFAULT_CURSOR)] - cursor: PathBuf, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct RadarCursor { - schema: String, - cadence: String, - generated_at: String, - source_docs: Vec, - projects: Vec, - last_run: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct RadarProject { - id: String, - name: String, - repo: String, - homepage: String, - watch_focus: Vec, - primary_references: Vec, - coverage_evidence: Vec, - last_seen: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct EvidenceRef { - label: String, - path: String, - summary: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct ProjectObservation { - observed_at: String, - source_url: String, - default_branch: Option, - pushed_at: Option, - updated_at: Option, - latest_release: Option, - stars: Option, - open_issues: Option, - description: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct ReleaseObservation { - tag_name: String, - url: String, - published_at: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct RadarRun { - schema: String, - run_id: String, - generated_at: String, - mode: RadarMode, - summary: RunSummary, - decisions: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct RunSummary { - project_count: usize, - covered_count: usize, - rejected_count: usize, - gap_count: usize, - create_issue_count: usize, - defer_count: usize, - no_issue_count: usize, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct RadarDecision { - project_id: String, - upstream_change: String, - reusable_pattern: String, - elf_verdict: ElfVerdict, - product_value: String, - duplicate_coverage_evidence: Vec, - safety_boundary: String, - issue_decision: IssueDecision, - acceptance_evidence: Vec, - source_links: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct IssueDecision { - action: IssueAction, - rationale: String, - duplicate_search: DuplicateSearchEvidence, - proposed_issue: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct DuplicateSearchEvidence { - queried: bool, - query: String, - result: DuplicateSearchResult, - evidence: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -struct ProposedIssue { - title: String, - source_links: Vec, - repo_evidence: Vec, - non_goals: Vec, - validation_criteria: Vec, -} - -#[derive(Debug, Deserialize)] -struct GithubRepoResponse { - html_url: String, - default_branch: Option, - pushed_at: Option, - updated_at: Option, - stargazers_count: Option, - open_issues_count: Option, - description: Option, -} - -#[derive(Debug, Deserialize)] -struct GithubReleaseResponse { - tag_name: String, - html_url: String, - published_at: Option, -} - -#[derive(Debug, Subcommand)] -#[command(rename_all = "kebab")] -enum Command { - /// Run the external memory radar and write cursor plus Markdown summary. - Run(RunArgs), - /// Validate a radar cursor and its latest decision records. - Validate(ValidateArgs), -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -enum RadarMode { - Live, - Offline, -} -impl RadarMode { - fn as_str(self) -> &'static str { - match self { - Self::Live => "live", - Self::Offline => "offline", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum ElfVerdict { - Covered, - Reject, - Gap, -} -impl ElfVerdict { - fn as_str(self) -> &'static str { - match self { - Self::Covered => "covered", - Self::Reject => "reject", - Self::Gap => "gap", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum IssueAction { - NoIssue, - Defer, - CreateIssue, -} -impl IssueAction { - fn as_str(self) -> &'static str { - match self { - Self::NoIssue => "no_issue", - Self::Defer => "defer", - Self::CreateIssue => "create_issue", - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum DuplicateSearchResult { - NotRequiredNoIssue, - NoDuplicateFound, - DuplicateFound, -} - -fn validate_command(path: &Path) -> Result<()> { - let cursor = read_cursor(path)?; - - validate_cursor(&cursor) -} - -fn read_cursor(path: &Path) -> Result { - let raw = fs::read_to_string(path) - .map_err(|err| eyre::eyre!("failed to read cursor {}: {err}", path.display()))?; - let cursor = serde_json::from_str(&raw) - .map_err(|err| eyre::eyre!("failed to parse cursor {}: {err}", path.display()))?; - - Ok(cursor) -} - -fn write_json(path: &Path, value: &T) -> Result<()> -where - T: Serialize, -{ - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let raw = serde_json::to_string_pretty(value)?; - - fs::write(path, format!("{raw}\n"))?; - - Ok(()) -} - -fn write_text(path: &Path, content: &str) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - fs::write(path, content)?; - - Ok(()) -} - -fn github_client(token_env: &str) -> Result> { - let mut headers = HeaderMap::new(); - - headers.insert(USER_AGENT, HeaderValue::from_static("elf-external-memory-pattern-radar")); - headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); - - if let Ok(token) = env::var(token_env) - && !token.trim().is_empty() - { - let value = format!("Bearer {}", token.trim()).parse()?; - - headers.insert(AUTHORIZATION, value); - } - - Ok(Some(Client::builder().default_headers(headers).build()?)) -} - -fn fallback_observation(project: &RadarProject, generated_at: &str) -> ProjectObservation { - ProjectObservation { - observed_at: generated_at.to_string(), - source_url: project.homepage.clone(), - default_branch: None, - pushed_at: None, - updated_at: None, - latest_release: None, - stars: None, - open_issues: None, - description: None, - } -} - -fn decide_project( - project: &RadarProject, - prior: Option<&ProjectObservation>, - observed: &ProjectObservation, - mode: RadarMode, -) -> RadarDecision { - let source_links = source_links(project, observed); - let evidence = project.coverage_evidence.clone(); - let changed = prior.map(|previous| observation_changed(previous, observed)).unwrap_or(false); - - if changed { - return RadarDecision { - project_id: project.id.clone(), - upstream_change: metadata_delta(prior, observed), - reusable_pattern: "No reusable pattern is claimed from metadata alone; source review is required before a pattern can become a gap." - .to_string(), - elf_verdict: ElfVerdict::Reject, - product_value: "Metadata movement is useful as a review trigger, but it has no product value until source evidence identifies a reusable pattern." - .to_string(), - duplicate_coverage_evidence: evidence, - safety_boundary: "Reject issue creation from activity, star counts, release tags, or push timestamps alone." - .to_string(), - issue_decision: IssueDecision { - action: IssueAction::NoIssue, - rationale: "No issue was created because this run only proved a metadata delta; the Codex review step must gather source links, repo evidence, and Linear duplicate search first." - .to_string(), - duplicate_search: DuplicateSearchEvidence { - queried: false, - query: String::new(), - result: DuplicateSearchResult::NotRequiredNoIssue, - evidence: vec![ - "No Linear search is required when the issue decision is no_issue.".to_string(), - ], - }, - proposed_issue: None, - }, - acceptance_evidence: vec![ - "Metadata delta recorded in the structured cursor.".to_string(), - "No parity or adoption claim was made from activity alone.".to_string(), - ], - source_links, - }; - } - - let upstream_change = if prior.is_none() { - metadata_delta(None, observed) - } else { - match mode { - RadarMode::Live => - "No GitHub metadata delta was observed since the prior cursor.".to_string(), - RadarMode::Offline => - "No upstream fetch was performed; the dry run replayed the checked-in cursor." - .to_string(), - } - }; - - RadarDecision { - project_id: project.id.clone(), - upstream_change, - reusable_pattern: "No new candidate pattern was identified in this run.".to_string(), - elf_verdict: ElfVerdict::Covered, - product_value: "Current ELF coverage remains represented by the comparison and inventory evidence." - .to_string(), - duplicate_coverage_evidence: evidence, - safety_boundary: "No external runtime is adopted by default; existing ELF evidence remains authoritative." - .to_string(), - issue_decision: IssueDecision { - action: IssueAction::NoIssue, - rationale: "No issue was created because the run found no source-backed gap.".to_string(), - duplicate_search: DuplicateSearchEvidence { - queried: false, - query: String::new(), - result: DuplicateSearchResult::NotRequiredNoIssue, - evidence: vec![ - "No Linear search is required when the issue decision is no_issue.".to_string(), - ], - }, - proposed_issue: None, - }, - acceptance_evidence: vec![ - "No-issue decision recorded in the cursor.".to_string(), - "Coverage evidence points at checked-in ELF research docs.".to_string(), - ], - source_links, - } -} - -fn source_links(project: &RadarProject, observed: &ProjectObservation) -> Vec { - let mut links = BTreeSet::new(); - - links.insert(project.homepage.clone()); - links.insert(observed.source_url.clone()); - - if let Some(release) = &observed.latest_release { - links.insert(release.url.clone()); - } - - links.into_iter().collect() -} - -fn observation_changed(previous: &ProjectObservation, observed: &ProjectObservation) -> bool { - previous.pushed_at != observed.pushed_at - || previous.updated_at != observed.updated_at - || previous.latest_release.as_ref().map(|release| &release.tag_name) - != observed.latest_release.as_ref().map(|release| &release.tag_name) -} - -fn metadata_delta(prior: Option<&ProjectObservation>, observed: &ProjectObservation) -> String { - let Some(previous) = prior else { - return "First cursor observation recorded; no prior state exists for comparison." - .to_string(); - }; - let previous_release = - previous.latest_release.as_ref().map(|release| release.tag_name.as_str()).unwrap_or("none"); - let observed_release = - observed.latest_release.as_ref().map(|release| release.tag_name.as_str()).unwrap_or("none"); - - format!( - "Repository metadata changed: pushed_at {} -> {}, latest_release {} -> {}.", - previous.pushed_at.as_deref().unwrap_or("unknown"), - observed.pushed_at.as_deref().unwrap_or("unknown"), - previous_release, - observed_release - ) -} - -fn summarize_decisions(decisions: &[RadarDecision]) -> RunSummary { - let mut summary = RunSummary { project_count: decisions.len(), ..RunSummary::default() }; - - for decision in decisions { - match decision.elf_verdict { - ElfVerdict::Covered => summary.covered_count += 1, - ElfVerdict::Reject => summary.rejected_count += 1, - ElfVerdict::Gap => summary.gap_count += 1, - } - match decision.issue_decision.action { - IssueAction::NoIssue => summary.no_issue_count += 1, - IssueAction::Defer => summary.defer_count += 1, - IssueAction::CreateIssue => summary.create_issue_count += 1, - } - } - - summary -} - -fn validate_cursor(cursor: &RadarCursor) -> Result<()> { - let mut errors = Vec::new(); - - if cursor.schema != CURSOR_SCHEMA { - errors.push(format!("cursor schema must be {CURSOR_SCHEMA}")); - } - if cursor.projects.is_empty() { - errors.push("cursor must include at least one project".to_string()); - } - - let project_ids = - cursor.projects.iter().map(|project| project.id.as_str()).collect::>(); - - if project_ids.len() != cursor.projects.len() { - errors.push("project ids must be unique".to_string()); - } - - for project in &cursor.projects { - validate_project(project, &mut errors); - } - - if let Some(run) = &cursor.last_run { - validate_run(run, &project_ids, &mut errors); - } - - if errors.is_empty() { - Ok(()) - } else { - Err(eyre::eyre!("radar cursor validation failed:\n{}", errors.join("\n"))) - } -} - -fn validate_project(project: &RadarProject, errors: &mut Vec) { - if project.id.trim().is_empty() { - errors.push("project id must not be empty".to_string()); - } - if !project.repo.contains('/') { - errors.push(format!("project {} repo must be owner/name", project.id)); - } - if project.coverage_evidence.is_empty() { - errors.push(format!("project {} must include duplicate/coverage evidence", project.id)); - } -} - -fn validate_run(run: &RadarRun, project_ids: &BTreeSet<&str>, errors: &mut Vec) { - if run.schema != RUN_SCHEMA { - errors.push(format!("run schema must be {RUN_SCHEMA}")); - } - if run.decisions.len() != project_ids.len() { - errors.push("latest run must include one decision per project".to_string()); - } - - for decision in &run.decisions { - validate_decision(decision, project_ids, errors); - } -} - -fn validate_decision( - decision: &RadarDecision, - project_ids: &BTreeSet<&str>, - errors: &mut Vec, -) { - if !project_ids.contains(decision.project_id.as_str()) { - errors.push(format!("decision references unknown project {}", decision.project_id)); - } - - for (field, value) in [ - ("upstream_change", &decision.upstream_change), - ("reusable_pattern", &decision.reusable_pattern), - ("product_value", &decision.product_value), - ("safety_boundary", &decision.safety_boundary), - ] { - if value.trim().is_empty() { - errors.push(format!("decision {} has empty {field}", decision.project_id)); - } - } - - if decision.duplicate_coverage_evidence.is_empty() { - errors.push(format!( - "decision {} must include duplicate/coverage evidence", - decision.project_id - )); - } - if decision.acceptance_evidence.is_empty() { - errors.push(format!("decision {} must include acceptance evidence", decision.project_id)); - } - if decision.source_links.is_empty() { - errors.push(format!("decision {} must include source links", decision.project_id)); - } - - validate_issue_decision(decision, errors); -} - -fn validate_issue_decision(decision: &RadarDecision, errors: &mut Vec) { - let issue_decision = &decision.issue_decision; - - if issue_decision.rationale.trim().is_empty() { - errors.push(format!("decision {} issue rationale must not be empty", decision.project_id)); - } - - match issue_decision.action { - IssueAction::CreateIssue => validate_create_issue(decision, errors), - IssueAction::NoIssue => - if issue_decision.proposed_issue.is_some() { - errors.push(format!( - "decision {} must not include proposed_issue for no_issue", - decision.project_id - )); - }, - IssueAction::Defer => {}, - } -} - -fn validate_create_issue(decision: &RadarDecision, errors: &mut Vec) { - let issue_decision = &decision.issue_decision; - - if decision.elf_verdict != ElfVerdict::Gap { - errors.push(format!( - "decision {} can create issues only for gap verdicts", - decision.project_id - )); - } - if !issue_decision.duplicate_search.queried { - errors.push(format!( - "decision {} must search Linear before issue creation", - decision.project_id - )); - } - - let Some(proposed_issue) = &issue_decision.proposed_issue else { - errors.push(format!( - "decision {} create_issue must include proposed_issue", - decision.project_id - )); - - return; - }; - - if proposed_issue.source_links.is_empty() - || proposed_issue.repo_evidence.is_empty() - || proposed_issue.non_goals.is_empty() - || proposed_issue.validation_criteria.is_empty() - { - errors.push(format!( - "decision {} proposed issue must include source links, repo evidence, non-goals, and validation criteria", - decision.project_id - )); - } -} - -fn render_summary(cursor: &RadarCursor) -> Result { - let run = cursor.last_run.as_ref().ok_or_else(|| eyre::eyre!("cursor has no last_run"))?; - let last_verified = run.generated_at.get(..10).unwrap_or("unknown"); - let mut out = String::new(); - - out.push_str("---\n"); - out.push_str("type: Evidence\n"); - out.push_str("title: \"External Memory Pattern Radar Summary\"\n"); - out.push_str("description: \"Latest weekly ELF external memory pattern radar outcome.\"\n"); - out.push_str("resource: docs/evidence/external_memory_pattern_radar_latest.md\n"); - out.push_str("status: active\n"); - out.push_str("authority: current_state\n"); - out.push_str("owner: evidence\n"); - out.push_str(&format!("last_verified: {last_verified}\n")); - out.push_str("tags:\n"); - out.push_str(" - docs\n"); - out.push_str(" - external-memory-pattern-radar\n"); - out.push_str(" - evidence\n"); - out.push_str("source_refs: []\n"); - out.push_str("code_refs:\n"); - out.push_str(" - apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json\n"); - out.push_str(" - apps/elf-eval/src/bin/external_memory_pattern_radar.rs\n"); - out.push_str("related: []\n"); - out.push_str("drift_watch:\n"); - out.push_str(" - apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json\n"); - out.push_str(" - apps/elf-eval/src/bin/external_memory_pattern_radar.rs\n"); - out.push_str("---\n\n"); - out.push_str("# External Memory Pattern Radar Summary\n\n"); - out.push_str("Goal: Preserve the latest weekly ELF external memory pattern radar outcome.\n"); - out.push_str("Read this when: Feeding the next full comparison report or deciding whether a watched upstream memory project created an ELF follow-up.\n"); - out.push_str("Inputs: `apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json`, GitHub repository metadata, checked-in ELF comparison evidence, and any Codex source-review notes.\n"); - out.push_str("Depends on: `docs/spec/external_memory_pattern_radar_v1.md` and `docs/runbook/external_memory_pattern_radar.md`.\n"); - out.push_str("Outputs: Latest no-issue, rejection, or issue-ready radar decisions.\n\n"); - out.push_str(&format!("- Run id: `{}`\n", run.run_id)); - out.push_str(&format!("- Generated at: `{}`\n", run.generated_at)); - out.push_str(&format!("- Mode: `{}`\n", run.mode.as_str())); - out.push_str(&format!( - "- Projects: `{}`; covered: `{}`; rejected: `{}`; gaps: `{}`; create_issue: `{}`\n\n", - run.summary.project_count, - run.summary.covered_count, - run.summary.rejected_count, - run.summary.gap_count, - run.summary.create_issue_count - )); - out.push_str("## Decisions\n\n"); - out.push_str( - "| Project | Upstream change | ELF verdict | Issue decision | Acceptance evidence |\n", - ); - out.push_str("| --- | --- | --- | --- | --- |\n"); - - for decision in &run.decisions { - out.push_str(&format!( - "| `{}` | {} | `{}` | `{}` | {} |\n", - decision.project_id, - escape_markdown_table(&decision.upstream_change), - decision.elf_verdict.as_str(), - decision.issue_decision.action.as_str(), - escape_markdown_table(&decision.acceptance_evidence.join("; ")) - )); - } - - out.push_str("\n## Safety Boundary\n\n"); - out.push_str("- The radar records upstream movement as a trigger for source review, not as proof of parity or a reason to adopt an external runtime.\n"); - out.push_str("- `create_issue` decisions are valid only when the cursor includes source links, repo evidence, non-goals, validation criteria, and Linear duplicate-search evidence.\n"); - out.push_str("- No-issue runs remain useful because each project records why ELF is already covered or why metadata-only movement was rejected.\n"); - - Ok(out) -} - -fn escape_markdown_table(value: &str) -> String { - value.replace('|', "\\|").replace('\n', " ") -} - -fn format_rfc3339(value: OffsetDateTime) -> Result { - Ok(value.format(&Rfc3339)?) -} - #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; match Args::parse().command { - Command::Run(args) => run_radar(args).await, - Command::Validate(args) => validate_command(&args.cursor), - } -} - -async fn run_radar(args: RunArgs) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let generated_at = format_rfc3339(now)?; - let run_id = - args.run_id.unwrap_or_else(|| format!("external-memory-pattern-radar-{}", now.date())); - let client = github_client(&args.github_token_env)?; - let mut cursor = read_cursor(&args.cursor)?; - let mut decisions = Vec::with_capacity(cursor.projects.len()); - - for project in &mut cursor.projects { - let prior = project.last_seen.clone(); - let observed = observe_project(project, args.mode, client.as_ref(), &generated_at).await?; - - decisions.push(decide_project(project, prior.as_ref(), &observed, args.mode)); - - project.last_seen = Some(observed); + Command::Run(args) => self::runtime::run_radar(args).await, + Command::Validate(args) => self::validation::validate_command(&args.cursor), } - - let summary = summarize_decisions(&decisions); - - cursor.generated_at = generated_at.clone(); - cursor.last_run = Some(RadarRun { - schema: RUN_SCHEMA.to_string(), - run_id, - generated_at, - mode: args.mode, - summary, - decisions, - }); - - validate_cursor(&cursor)?; - - let out_cursor = args.out_cursor.unwrap_or(args.cursor); - - write_json(&out_cursor, &cursor)?; - write_text(&args.summary, &render_summary(&cursor)?)?; - - Ok(()) -} - -async fn observe_project( - project: &RadarProject, - mode: RadarMode, - client: Option<&Client>, - generated_at: &str, -) -> Result { - match mode { - RadarMode::Offline => Ok(project - .last_seen - .clone() - .unwrap_or_else(|| fallback_observation(project, generated_at))), - RadarMode::Live => - fetch_project( - project, - client.ok_or_else(|| eyre::eyre!("missing GitHub client"))?, - generated_at, - ) - .await, - } -} - -async fn fetch_project( - project: &RadarProject, - client: &Client, - generated_at: &str, -) -> Result { - let repo = fetch_repo(project, client).await?; - let latest_release = fetch_latest_release(project, client).await?; - - Ok(ProjectObservation { - observed_at: generated_at.to_string(), - source_url: repo.html_url, - default_branch: repo.default_branch, - pushed_at: repo.pushed_at, - updated_at: repo.updated_at, - latest_release, - stars: repo.stargazers_count, - open_issues: repo.open_issues_count, - description: repo.description, - }) -} - -async fn fetch_repo(project: &RadarProject, client: &Client) -> Result { - let url = format!("https://api.github.com/repos/{}", project.repo); - let response = client.get(url).send().await?; - - if !response.status().is_success() { - return Err(eyre::eyre!( - "GitHub repo metadata fetch failed for {} with status {}", - project.repo, - response.status() - )); - } - - Ok(response.json().await?) -} - -async fn fetch_latest_release( - project: &RadarProject, - client: &Client, -) -> Result> { - let url = format!("https://api.github.com/repos/{}/releases/latest", project.repo); - let response = client.get(url).send().await?; - - if response.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - if !response.status().is_success() { - return Err(eyre::eyre!( - "GitHub release metadata fetch failed for {} with status {}", - project.repo, - response.status() - )); - } - - let release: GithubReleaseResponse = response.json().await?; - - Ok(Some(ReleaseObservation { - tag_name: release.tag_name, - url: release.html_url, - published_at: release.published_at, - })) } diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/cli.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/cli.rs new file mode 100644 index 00000000..ca8069de --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/cli.rs @@ -0,0 +1,54 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use crate::{DEFAULT_CURSOR, DEFAULT_SUMMARY, types::RadarMode}; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), +)] +pub(super) struct Args { + #[command(subcommand)] + pub(super) command: Command, +} + +#[derive(Debug, Parser)] +pub(super) struct RunArgs { + /// Existing radar cursor file. + #[arg(long, value_name = "FILE", default_value = DEFAULT_CURSOR)] + pub(super) cursor: PathBuf, + /// Output cursor path. Defaults to updating --cursor. + #[arg(long, value_name = "FILE")] + pub(super) out_cursor: Option, + /// Output Markdown summary path. + #[arg(long, value_name = "FILE", default_value = DEFAULT_SUMMARY)] + pub(super) summary: PathBuf, + /// Observation mode. Use offline for deterministic dry runs. + #[arg(long, value_enum, default_value_t = RadarMode::Live)] + pub(super) mode: RadarMode, + /// Stable run id. Defaults to external-memory-pattern-radar-YYYY-MM-DD. + #[arg(long)] + pub(super) run_id: Option, + /// Environment variable containing a GitHub token for live mode. + #[arg(long, default_value = "GITHUB_TOKEN")] + pub(super) github_token_env: String, +} + +#[derive(Debug, Parser)] +pub(super) struct ValidateArgs { + /// Cursor file to validate. + #[arg(long, value_name = "FILE", default_value = DEFAULT_CURSOR)] + pub(super) cursor: PathBuf, +} + +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab")] +pub(super) enum Command { + /// Run the external memory radar and write cursor plus Markdown summary. + Run(RunArgs), + /// Validate a radar cursor and its latest decision records. + Validate(ValidateArgs), +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/decision.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/decision.rs new file mode 100644 index 00000000..eeb72e23 --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/decision.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeSet; + +use crate::types::{ + DuplicateSearchEvidence, DuplicateSearchResult, ElfVerdict, IssueAction, IssueDecision, + ProjectObservation, RadarDecision, RadarMode, RadarProject, RunSummary, +}; + +pub(super) fn decide_project( + project: &RadarProject, + prior: Option<&ProjectObservation>, + observed: &ProjectObservation, + mode: RadarMode, +) -> RadarDecision { + let source_links = source_links(project, observed); + let evidence = project.coverage_evidence.clone(); + let changed = prior.map(|previous| observation_changed(previous, observed)).unwrap_or(false); + + if changed { + return RadarDecision { + project_id: project.id.clone(), + upstream_change: metadata_delta(prior, observed), + reusable_pattern: "No reusable pattern is claimed from metadata alone; source review is required before a pattern can become a gap." + .to_string(), + elf_verdict: ElfVerdict::Reject, + product_value: "Metadata movement is useful as a review trigger, but it has no product value until source evidence identifies a reusable pattern." + .to_string(), + duplicate_coverage_evidence: evidence, + safety_boundary: "Reject issue creation from activity, star counts, release tags, or push timestamps alone." + .to_string(), + issue_decision: IssueDecision { + action: IssueAction::NoIssue, + rationale: "No issue was created because this run only proved a metadata delta; the Codex review step must gather source links, repo evidence, and Linear duplicate search first." + .to_string(), + duplicate_search: DuplicateSearchEvidence { + queried: false, + query: String::new(), + result: DuplicateSearchResult::NotRequiredNoIssue, + evidence: vec![ + "No Linear search is required when the issue decision is no_issue.".to_string(), + ], + }, + proposed_issue: None, + }, + acceptance_evidence: vec![ + "Metadata delta recorded in the structured cursor.".to_string(), + "No parity or adoption claim was made from activity alone.".to_string(), + ], + source_links, + }; + } + + let upstream_change = if prior.is_none() { + metadata_delta(None, observed) + } else { + match mode { + RadarMode::Live => + "No GitHub metadata delta was observed since the prior cursor.".to_string(), + RadarMode::Offline => + "No upstream fetch was performed; the dry run replayed the checked-in cursor." + .to_string(), + } + }; + + RadarDecision { + project_id: project.id.clone(), + upstream_change, + reusable_pattern: "No new candidate pattern was identified in this run.".to_string(), + elf_verdict: ElfVerdict::Covered, + product_value: "Current ELF coverage remains represented by the comparison and inventory evidence." + .to_string(), + duplicate_coverage_evidence: evidence, + safety_boundary: "No external runtime is adopted by default; existing ELF evidence remains authoritative." + .to_string(), + issue_decision: IssueDecision { + action: IssueAction::NoIssue, + rationale: "No issue was created because the run found no source-backed gap.".to_string(), + duplicate_search: DuplicateSearchEvidence { + queried: false, + query: String::new(), + result: DuplicateSearchResult::NotRequiredNoIssue, + evidence: vec![ + "No Linear search is required when the issue decision is no_issue.".to_string(), + ], + }, + proposed_issue: None, + }, + acceptance_evidence: vec![ + "No-issue decision recorded in the cursor.".to_string(), + "Coverage evidence points at checked-in ELF research docs.".to_string(), + ], + source_links, + } +} + +pub(super) fn summarize_decisions(decisions: &[RadarDecision]) -> RunSummary { + let mut summary = RunSummary { project_count: decisions.len(), ..RunSummary::default() }; + + for decision in decisions { + match decision.elf_verdict { + ElfVerdict::Covered => summary.covered_count += 1, + ElfVerdict::Reject => summary.rejected_count += 1, + ElfVerdict::Gap => summary.gap_count += 1, + } + match decision.issue_decision.action { + IssueAction::NoIssue => summary.no_issue_count += 1, + IssueAction::Defer => summary.defer_count += 1, + IssueAction::CreateIssue => summary.create_issue_count += 1, + } + } + + summary +} + +fn source_links(project: &RadarProject, observed: &ProjectObservation) -> Vec { + let mut links = BTreeSet::new(); + + links.insert(project.homepage.clone()); + links.insert(observed.source_url.clone()); + + if let Some(release) = &observed.latest_release { + links.insert(release.url.clone()); + } + + links.into_iter().collect() +} + +fn observation_changed(previous: &ProjectObservation, observed: &ProjectObservation) -> bool { + previous.pushed_at != observed.pushed_at + || previous.updated_at != observed.updated_at + || previous.latest_release.as_ref().map(|release| &release.tag_name) + != observed.latest_release.as_ref().map(|release| &release.tag_name) +} + +fn metadata_delta(prior: Option<&ProjectObservation>, observed: &ProjectObservation) -> String { + let Some(previous) = prior else { + return "First cursor observation recorded; no prior state exists for comparison." + .to_string(); + }; + let previous_release = + previous.latest_release.as_ref().map(|release| release.tag_name.as_str()).unwrap_or("none"); + let observed_release = + observed.latest_release.as_ref().map(|release| release.tag_name.as_str()).unwrap_or("none"); + + format!( + "Repository metadata changed: pushed_at {} -> {}, latest_release {} -> {}.", + previous.pushed_at.as_deref().unwrap_or("unknown"), + observed.pushed_at.as_deref().unwrap_or("unknown"), + previous_release, + observed_release + ) +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/github.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/github.rs new file mode 100644 index 00000000..3007badf --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/github.rs @@ -0,0 +1,143 @@ +use std::env; + +use color_eyre::{Result, eyre}; +use reqwest::{ + Client, StatusCode, + header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}, +}; +use serde::Deserialize; + +use crate::types::{ProjectObservation, RadarMode, RadarProject, ReleaseObservation}; + +#[derive(Debug, Deserialize)] +struct GithubRepoResponse { + html_url: String, + default_branch: Option, + pushed_at: Option, + updated_at: Option, + stargazers_count: Option, + open_issues_count: Option, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubReleaseResponse { + tag_name: String, + html_url: String, + published_at: Option, +} + +pub(super) fn github_client(token_env: &str) -> Result> { + let mut headers = HeaderMap::new(); + + headers.insert(USER_AGENT, HeaderValue::from_static("elf-external-memory-pattern-radar")); + headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); + + if let Ok(token) = env::var(token_env) + && !token.trim().is_empty() + { + let value = format!("Bearer {}", token.trim()).parse()?; + + headers.insert(AUTHORIZATION, value); + } + + Ok(Some(Client::builder().default_headers(headers).build()?)) +} + +pub(super) async fn observe_project( + project: &RadarProject, + mode: RadarMode, + client: Option<&Client>, + generated_at: &str, +) -> Result { + match mode { + RadarMode::Offline => Ok(project + .last_seen + .clone() + .unwrap_or_else(|| fallback_observation(project, generated_at))), + RadarMode::Live => + fetch_project( + project, + client.ok_or_else(|| eyre::eyre!("missing GitHub client"))?, + generated_at, + ) + .await, + } +} + +fn fallback_observation(project: &RadarProject, generated_at: &str) -> ProjectObservation { + ProjectObservation { + observed_at: generated_at.to_string(), + source_url: project.homepage.clone(), + default_branch: None, + pushed_at: None, + updated_at: None, + latest_release: None, + stars: None, + open_issues: None, + description: None, + } +} + +async fn fetch_project( + project: &RadarProject, + client: &Client, + generated_at: &str, +) -> Result { + let repo = fetch_repo(project, client).await?; + let latest_release = fetch_latest_release(project, client).await?; + + Ok(ProjectObservation { + observed_at: generated_at.to_string(), + source_url: repo.html_url, + default_branch: repo.default_branch, + pushed_at: repo.pushed_at, + updated_at: repo.updated_at, + latest_release, + stars: repo.stargazers_count, + open_issues: repo.open_issues_count, + description: repo.description, + }) +} + +async fn fetch_repo(project: &RadarProject, client: &Client) -> Result { + let url = format!("https://api.github.com/repos/{}", project.repo); + let response = client.get(url).send().await?; + + if !response.status().is_success() { + return Err(eyre::eyre!( + "GitHub repo metadata fetch failed for {} with status {}", + project.repo, + response.status() + )); + } + + Ok(response.json().await?) +} + +async fn fetch_latest_release( + project: &RadarProject, + client: &Client, +) -> Result> { + let url = format!("https://api.github.com/repos/{}/releases/latest", project.repo); + let response = client.get(url).send().await?; + + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(eyre::eyre!( + "GitHub release metadata fetch failed for {} with status {}", + project.repo, + response.status() + )); + } + + let release: GithubReleaseResponse = response.json().await?; + + Ok(Some(ReleaseObservation { + tag_name: release.tag_name, + url: release.html_url, + published_at: release.published_at, + })) +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/io.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/io.rs new file mode 100644 index 00000000..ce7a3200 --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/io.rs @@ -0,0 +1,40 @@ +use std::{fs, path::Path}; + +use color_eyre::{Result, eyre}; +use serde::Serialize; + +use crate::types::RadarCursor; + +pub(super) fn read_cursor(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .map_err(|err| eyre::eyre!("failed to read cursor {}: {err}", path.display()))?; + let cursor = serde_json::from_str(&raw) + .map_err(|err| eyre::eyre!("failed to parse cursor {}: {err}", path.display()))?; + + Ok(cursor) +} + +pub(super) fn write_json(path: &Path, value: &T) -> Result<()> +where + T: Serialize, +{ + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let raw = serde_json::to_string_pretty(value)?; + + fs::write(path, format!("{raw}\n"))?; + + Ok(()) +} + +pub(super) fn write_text(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, content)?; + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/render.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/render.rs new file mode 100644 index 00000000..2823962c --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/render.rs @@ -0,0 +1,76 @@ +use color_eyre::{Result, eyre}; + +use crate::types::RadarCursor; + +pub(super) fn render_summary(cursor: &RadarCursor) -> Result { + let run = cursor.last_run.as_ref().ok_or_else(|| eyre::eyre!("cursor has no last_run"))?; + let last_verified = run.generated_at.get(..10).unwrap_or("unknown"); + let mut out = String::new(); + + out.push_str("---\n"); + out.push_str("type: Evidence\n"); + out.push_str("title: \"External Memory Pattern Radar Summary\"\n"); + out.push_str("description: \"Latest weekly ELF external memory pattern radar outcome.\"\n"); + out.push_str("resource: docs/evidence/external_memory_pattern_radar_latest.md\n"); + out.push_str("status: active\n"); + out.push_str("authority: current_state\n"); + out.push_str("owner: evidence\n"); + out.push_str(&format!("last_verified: {last_verified}\n")); + out.push_str("tags:\n"); + out.push_str(" - docs\n"); + out.push_str(" - external-memory-pattern-radar\n"); + out.push_str(" - evidence\n"); + out.push_str("source_refs: []\n"); + out.push_str("code_refs:\n"); + out.push_str(" - apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json\n"); + out.push_str(" - apps/elf-eval/src/bin/external_memory_pattern_radar.rs\n"); + out.push_str("related: []\n"); + out.push_str("drift_watch:\n"); + out.push_str(" - apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json\n"); + out.push_str(" - apps/elf-eval/src/bin/external_memory_pattern_radar.rs\n"); + out.push_str("---\n\n"); + out.push_str("# External Memory Pattern Radar Summary\n\n"); + out.push_str("Goal: Preserve the latest weekly ELF external memory pattern radar outcome.\n"); + out.push_str("Read this when: Feeding the next full comparison report or deciding whether a watched upstream memory project created an ELF follow-up.\n"); + out.push_str("Inputs: `apps/elf-eval/fixtures/external_memory_pattern_radar/cursor.json`, GitHub repository metadata, checked-in ELF comparison evidence, and any Codex source-review notes.\n"); + out.push_str("Depends on: `docs/spec/external_memory_pattern_radar_v1.md` and `docs/runbook/external_memory_pattern_radar.md`.\n"); + out.push_str("Outputs: Latest no-issue, rejection, or issue-ready radar decisions.\n\n"); + out.push_str(&format!("- Run id: `{}`\n", run.run_id)); + out.push_str(&format!("- Generated at: `{}`\n", run.generated_at)); + out.push_str(&format!("- Mode: `{}`\n", run.mode.as_str())); + out.push_str(&format!( + "- Projects: `{}`; covered: `{}`; rejected: `{}`; gaps: `{}`; create_issue: `{}`\n\n", + run.summary.project_count, + run.summary.covered_count, + run.summary.rejected_count, + run.summary.gap_count, + run.summary.create_issue_count + )); + out.push_str("## Decisions\n\n"); + out.push_str( + "| Project | Upstream change | ELF verdict | Issue decision | Acceptance evidence |\n", + ); + out.push_str("| --- | --- | --- | --- | --- |\n"); + + for decision in &run.decisions { + out.push_str(&format!( + "| `{}` | {} | `{}` | `{}` | {} |\n", + decision.project_id, + escape_markdown_table(&decision.upstream_change), + decision.elf_verdict.as_str(), + decision.issue_decision.action.as_str(), + escape_markdown_table(&decision.acceptance_evidence.join("; ")) + )); + } + + out.push_str("\n## Safety Boundary\n\n"); + out.push_str("- The radar records upstream movement as a trigger for source review, not as proof of parity or a reason to adopt an external runtime.\n"); + out.push_str("- `create_issue` decisions are valid only when the cursor includes source links, repo evidence, non-goals, validation criteria, and Linear duplicate-search evidence.\n"); + out.push_str("- No-issue runs remain useful because each project records why ELF is already covered or why metadata-only movement was rejected.\n"); + + Ok(out) +} + +fn escape_markdown_table(value: &str) -> String { + value.replace('|', "\\|").replace('\n', " ") +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/runtime.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/runtime.rs new file mode 100644 index 00000000..f79f2b2d --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/runtime.rs @@ -0,0 +1,58 @@ +use color_eyre::Result; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::{ + RUN_SCHEMA, + cli::RunArgs, + decision::{self}, + github::{self}, + io::{self}, + render, + types::RadarRun, + validation, +}; + +pub(super) async fn run_radar(args: RunArgs) -> Result<()> { + let now = OffsetDateTime::now_utc(); + let generated_at = format_rfc3339(now)?; + let run_id = + args.run_id.unwrap_or_else(|| format!("external-memory-pattern-radar-{}", now.date())); + let client = github::github_client(&args.github_token_env)?; + let mut cursor = io::read_cursor(&args.cursor)?; + let mut decisions = Vec::with_capacity(cursor.projects.len()); + + for project in &mut cursor.projects { + let prior = project.last_seen.clone(); + let observed = + github::observe_project(project, args.mode, client.as_ref(), &generated_at).await?; + + decisions.push(decision::decide_project(project, prior.as_ref(), &observed, args.mode)); + + project.last_seen = Some(observed); + } + + let summary = decision::summarize_decisions(&decisions); + + cursor.generated_at = generated_at.clone(); + cursor.last_run = Some(RadarRun { + schema: RUN_SCHEMA.to_string(), + run_id, + generated_at, + mode: args.mode, + summary, + decisions, + }); + + validation::validate_cursor(&cursor)?; + + let out_cursor = args.out_cursor.unwrap_or(args.cursor); + + io::write_json(&out_cursor, &cursor)?; + io::write_text(&args.summary, &render::render_summary(&cursor)?)?; + + Ok(()) +} + +fn format_rfc3339(value: OffsetDateTime) -> Result { + Ok(value.format(&Rfc3339)?) +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/types.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/types.rs new file mode 100644 index 00000000..55482105 --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/types.rs @@ -0,0 +1,179 @@ +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct RadarCursor { + pub(super) schema: String, + pub(super) cadence: String, + pub(super) generated_at: String, + pub(super) source_docs: Vec, + pub(super) projects: Vec, + pub(super) last_run: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct RadarProject { + pub(super) id: String, + pub(super) name: String, + pub(super) repo: String, + pub(super) homepage: String, + pub(super) watch_focus: Vec, + pub(super) primary_references: Vec, + pub(super) coverage_evidence: Vec, + pub(super) last_seen: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct EvidenceRef { + pub(super) label: String, + pub(super) path: String, + pub(super) summary: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct ProjectObservation { + pub(super) observed_at: String, + pub(super) source_url: String, + pub(super) default_branch: Option, + pub(super) pushed_at: Option, + pub(super) updated_at: Option, + pub(super) latest_release: Option, + pub(super) stars: Option, + pub(super) open_issues: Option, + pub(super) description: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct ReleaseObservation { + pub(super) tag_name: String, + pub(super) url: String, + pub(super) published_at: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct RadarRun { + pub(super) schema: String, + pub(super) run_id: String, + pub(super) generated_at: String, + pub(super) mode: RadarMode, + pub(super) summary: RunSummary, + pub(super) decisions: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct RunSummary { + pub(super) project_count: usize, + pub(super) covered_count: usize, + pub(super) rejected_count: usize, + pub(super) gap_count: usize, + pub(super) create_issue_count: usize, + pub(super) defer_count: usize, + pub(super) no_issue_count: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct RadarDecision { + pub(super) project_id: String, + pub(super) upstream_change: String, + pub(super) reusable_pattern: String, + pub(super) elf_verdict: ElfVerdict, + pub(super) product_value: String, + pub(super) duplicate_coverage_evidence: Vec, + pub(super) safety_boundary: String, + pub(super) issue_decision: IssueDecision, + pub(super) acceptance_evidence: Vec, + pub(super) source_links: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct IssueDecision { + pub(super) action: IssueAction, + pub(super) rationale: String, + pub(super) duplicate_search: DuplicateSearchEvidence, + pub(super) proposed_issue: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct DuplicateSearchEvidence { + pub(super) queried: bool, + pub(super) query: String, + pub(super) result: DuplicateSearchResult, + pub(super) evidence: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct ProposedIssue { + pub(super) title: String, + pub(super) source_links: Vec, + pub(super) repo_evidence: Vec, + pub(super) non_goals: Vec, + pub(super) validation_criteria: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +pub(super) enum RadarMode { + Live, + Offline, +} +impl RadarMode { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Live => "live", + Self::Offline => "offline", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ElfVerdict { + Covered, + Reject, + Gap, +} +impl ElfVerdict { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Covered => "covered", + Self::Reject => "reject", + Self::Gap => "gap", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum IssueAction { + NoIssue, + Defer, + CreateIssue, +} +impl IssueAction { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::NoIssue => "no_issue", + Self::Defer => "defer", + Self::CreateIssue => "create_issue", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum DuplicateSearchResult { + NotRequiredNoIssue, + NoDuplicateFound, + DuplicateFound, +} diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar/validation.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/validation.rs new file mode 100644 index 00000000..c7cf5d40 --- /dev/null +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/validation.rs @@ -0,0 +1,164 @@ +use std::{collections::BTreeSet, path::Path}; + +use color_eyre::{Result, eyre}; + +use crate::{ + CURSOR_SCHEMA, RUN_SCHEMA, io, + types::{ElfVerdict, IssueAction, RadarCursor, RadarDecision, RadarProject, RadarRun}, +}; + +pub(super) fn validate_command(path: &Path) -> Result<()> { + let cursor = io::read_cursor(path)?; + + validate_cursor(&cursor) +} + +pub(super) fn validate_cursor(cursor: &RadarCursor) -> Result<()> { + let mut errors = Vec::new(); + + if cursor.schema != CURSOR_SCHEMA { + errors.push(format!("cursor schema must be {CURSOR_SCHEMA}")); + } + if cursor.projects.is_empty() { + errors.push("cursor must include at least one project".to_string()); + } + + let project_ids = + cursor.projects.iter().map(|project| project.id.as_str()).collect::>(); + + if project_ids.len() != cursor.projects.len() { + errors.push("project ids must be unique".to_string()); + } + + for project in &cursor.projects { + validate_project(project, &mut errors); + } + + if let Some(run) = &cursor.last_run { + validate_run(run, &project_ids, &mut errors); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(eyre::eyre!("radar cursor validation failed:\n{}", errors.join("\n"))) + } +} + +fn validate_project(project: &RadarProject, errors: &mut Vec) { + if project.id.trim().is_empty() { + errors.push("project id must not be empty".to_string()); + } + if !project.repo.contains('/') { + errors.push(format!("project {} repo must be owner/name", project.id)); + } + if project.coverage_evidence.is_empty() { + errors.push(format!("project {} must include duplicate/coverage evidence", project.id)); + } +} + +fn validate_run(run: &RadarRun, project_ids: &BTreeSet<&str>, errors: &mut Vec) { + if run.schema != RUN_SCHEMA { + errors.push(format!("run schema must be {RUN_SCHEMA}")); + } + if run.decisions.len() != project_ids.len() { + errors.push("latest run must include one decision per project".to_string()); + } + + for decision in &run.decisions { + validate_decision(decision, project_ids, errors); + } +} + +fn validate_decision( + decision: &RadarDecision, + project_ids: &BTreeSet<&str>, + errors: &mut Vec, +) { + if !project_ids.contains(decision.project_id.as_str()) { + errors.push(format!("decision references unknown project {}", decision.project_id)); + } + + for (field, value) in [ + ("upstream_change", &decision.upstream_change), + ("reusable_pattern", &decision.reusable_pattern), + ("product_value", &decision.product_value), + ("safety_boundary", &decision.safety_boundary), + ] { + if value.trim().is_empty() { + errors.push(format!("decision {} has empty {field}", decision.project_id)); + } + } + + if decision.duplicate_coverage_evidence.is_empty() { + errors.push(format!( + "decision {} must include duplicate/coverage evidence", + decision.project_id + )); + } + if decision.acceptance_evidence.is_empty() { + errors.push(format!("decision {} must include acceptance evidence", decision.project_id)); + } + if decision.source_links.is_empty() { + errors.push(format!("decision {} must include source links", decision.project_id)); + } + + validate_issue_decision(decision, errors); +} + +fn validate_issue_decision(decision: &RadarDecision, errors: &mut Vec) { + let issue_decision = &decision.issue_decision; + + if issue_decision.rationale.trim().is_empty() { + errors.push(format!("decision {} issue rationale must not be empty", decision.project_id)); + } + + match issue_decision.action { + IssueAction::CreateIssue => validate_create_issue(decision, errors), + IssueAction::NoIssue => + if issue_decision.proposed_issue.is_some() { + errors.push(format!( + "decision {} must not include proposed_issue for no_issue", + decision.project_id + )); + }, + IssueAction::Defer => {}, + } +} + +fn validate_create_issue(decision: &RadarDecision, errors: &mut Vec) { + let issue_decision = &decision.issue_decision; + + if decision.elf_verdict != ElfVerdict::Gap { + errors.push(format!( + "decision {} can create issues only for gap verdicts", + decision.project_id + )); + } + if !issue_decision.duplicate_search.queried { + errors.push(format!( + "decision {} must search Linear before issue creation", + decision.project_id + )); + } + + let Some(proposed_issue) = &issue_decision.proposed_issue else { + errors.push(format!( + "decision {} create_issue must include proposed_issue", + decision.project_id + )); + + return; + }; + + if proposed_issue.source_links.is_empty() + || proposed_issue.repo_evidence.is_empty() + || proposed_issue.non_goals.is_empty() + || proposed_issue.validation_criteria.is_empty() + { + errors.push(format!( + "decision {} proposed issue must include source links, repo evidence, non-goals, and validation criteria", + decision.project_id + )); + } +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf.rs b/apps/elf-eval/src/bin/live_baseline_elf.rs index c1a87143..35e59045 100644 --- a/apps/elf-eval/src/bin/live_baseline_elf.rs +++ b/apps/elf-eval/src/bin/live_baseline_elf.rs @@ -2,1742 +2,83 @@ //! Docker live-baseline runner for ELF's own same-corpus retrieval path. -use std::{ - collections::{BTreeMap, HashSet}, - env, fs, - path::{Path, PathBuf}, - process::Command, - sync::Arc, - time::{Duration, Instant}, -}; - -use blake3::Hasher; -use clap::Parser; -use color_eyre::{Report, eyre}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::{task::JoinSet, time}; -use uuid::Uuid; - -use elf_chunking::ChunkingConfig; -use elf_config::{Config, EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; -use elf_service::{ - AddNoteInput, AddNoteRequest, BoxFuture, DeleteRequest, ElfService, EmbeddingProvider, - ExtractorProvider, NoteOp, PayloadLevel, Providers, RerankProvider, SearchRequest, - UpdateRequest, -}; -use elf_storage::{db::Db, qdrant::QdrantStore}; -use elf_testkit::TestDatabase; -use elf_worker::worker::{self, WorkerState}; - -const TENANT_ID: &str = "elf-live-baseline"; -const PROJECT_ID: &str = "shared-corpus"; -const AGENT_ID: &str = "elf-bench-agent"; -const SCOPE: &str = "agent_private"; -const BACKFILL_CHECKPOINT_SCHEMA: &str = "elf.live_baseline.backfill_checkpoint/v1"; - -#[derive(Debug, Parser)] -#[command(version = elf_cli::VERSION, rename_all = "kebab", styles = elf_cli::styles())] -struct Args { - /// Base ELF config to load before Docker runtime overrides are applied. - #[arg(long, short = 'c', value_name = "FILE")] - config: PathBuf, - - /// Directory containing the generated benchmark corpus markdown files. - #[arg(long, value_name = "DIR")] - corpus: PathBuf, - - /// Query manifest generated by the live-baseline harness. - #[arg(long, value_name = "FILE")] - queries: PathBuf, - - /// Write ELF result JSON to this file. - #[arg(long, value_name = "FILE")] - out: PathBuf, -} - -#[derive(Debug, Deserialize)] -struct QueryManifest { - queries: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct QueryCase { - id: String, - task: Option, - query: String, - expected_doc: String, - expected_terms: Vec, - #[serde(default)] - allowed_alternate_docs: Vec, - #[serde(default)] - expected_evidence_ids: Vec, - #[serde(default)] - allowed_alternate_evidence_ids: Vec, -} -impl QueryCase { - fn generated( - id: String, - query: String, - expected_doc: String, - expected_terms: Vec, - ) -> Self { - Self { - id, - task: None, - query, - expected_evidence_ids: vec![evidence_id_for_doc(&expected_doc)], - allowed_alternate_docs: Vec::new(), - allowed_alternate_evidence_ids: Vec::new(), - expected_doc, - expected_terms, - } - } -} - -#[derive(Debug)] -struct CorpusNote { - key: String, - title: String, - text: String, - source_doc: String, -} - -#[derive(Debug)] -struct BackfillOutcome { - report: BackfillReport, - note_ids: Vec, -} - -#[derive(Debug)] -struct ExistingBackfillNote { - note_id: Uuid, - source_hash: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct BackfillCheckpoint { - schema: String, - corpus_hash: String, - completed: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct BackfillCheckpointEntry { - note_id: Uuid, - key: String, - source_hash: String, - op: String, -} - -#[derive(Debug, Serialize)] -struct BackfillReport { - checkpoint_path: String, - corpus_hash: String, - source_count: usize, - completed_count: usize, - batch_size: usize, - worker_concurrency: usize, - elapsed_seconds: f64, - attempted_writes: usize, - skipped_completed: usize, - duplicate_source_notes: Vec, - resume: BackfillResumeReport, - attempts: Vec, -} - -#[derive(Debug, Serialize)] -struct BackfillResumeReport { - enabled: bool, - interrupted: bool, - interrupt_after: Option, - resume_attempts: usize, - completed_before_resume: usize, - completed_after_resume: usize, -} - -#[derive(Debug, Serialize)] -struct BackfillAttemptEvidence { - attempt: usize, - resumed: bool, - interrupt_after: Option, - skipped_completed: usize, - attempted_writes: usize, - completed_writes: usize, - checkpoint_completed: usize, - interrupted: bool, -} - -#[derive(Debug, Serialize)] -struct DuplicateSourceNote { - source_doc: String, - count: i64, - note_ids: Vec, -} - -#[derive(Debug)] -struct BaselineRuntime { - config_path: PathBuf, - dsn: String, - qdrant_url: String, - collection: String, - docs_collection: String, -} - -#[derive(Debug, Serialize)] -struct WorkerRunEvidence { - label: String, - expected_note_count: usize, - concurrency: usize, - iterations: usize, - before: BTreeMap, - after: BTreeMap, - chunk_rows: i64, - chunk_embedding_rows: i64, - failed_jobs: Vec, -} - -#[derive(Debug, Serialize)] -struct FailedOutboxJob { - note_id: Uuid, - note_key: Option, - op: String, - attempts: i32, - last_error: Option, -} - -#[derive(Debug, Serialize)] -struct ResourceEnvelopeEvidence { - elapsed_seconds: f64, - max_elapsed_seconds: f64, - rss_kb: Option, - max_rss_kb: u64, - postgres_database_bytes: Option, - corpus_dir_bytes: u64, - report_dir_bytes: Option, - checkpoint_file_bytes: Option, -} - -#[derive(Debug, Serialize)] -struct CostProxyReport { - schema: &'static str, - scope: &'static str, - embedding_mode: EmbeddingMode, - estimated_input_chars: usize, - estimated_input_tokens: usize, - token_estimation: &'static str, - configured_usd_per_1k_tokens: Option, - estimated_usd: Option, - document_count: usize, - query_count: usize, -} - -#[derive(Debug, Serialize)] -struct EmbeddingRuntimeReport { - mode: EmbeddingMode, - provider_id: String, - model: String, - dimensions: u32, - timeout_ms: u64, - api_base: String, - path: String, -} - -#[derive(Debug, Serialize)] -struct SoakConfig { - target_seconds: u64, - write_rounds: usize, - probe_interval_millis: u64, -} - -#[derive(Debug, Serialize)] -struct ElfBaselineReport { - schema: &'static str, - status: &'static str, - retrieval_status: &'static str, - reason: String, - head: String, - embedding: EmbeddingRuntimeReport, - cost_proxy: CostProxyReport, - backfill: BackfillReport, - indexing: IndexingReport, - summary: QuerySummary, - check_summary: CheckSummary, - checks: Vec, - queries: Vec, - ops_cases: Vec, -} - -#[derive(Debug, Serialize)] -struct IndexingReport { - note_count: usize, - rebuild_rebuilt_count: u64, - rebuild_missing_vector_count: u64, - rebuild_error_count: u64, -} - -#[derive(Debug, Serialize)] -struct QuerySummary { - total: usize, - pass: usize, - fail: usize, - wrong_result_count: usize, - latency_ms_total: f64, - latency_ms_mean: f64, - latency_ms_p50: f64, - latency_ms_p95: f64, - latency_ms_p99: f64, - latency_ms_max: f64, -} - -#[derive(Debug, Serialize)] -struct OperationalCase { - name: &'static str, - default_status: &'static str, - operator_status: &'static str, - command: &'static str, - evidence: &'static str, - safety: &'static str, -} - -#[derive(Debug, Serialize)] -struct CheckSummary { - total: usize, - pass: usize, - fail: usize, - wrong_result: usize, - lifecycle_fail: usize, - incomplete: usize, - blocked: usize, - not_encoded: usize, -} - -#[derive(Debug, Serialize)] -struct CheckResult { - name: &'static str, - status: &'static str, - reason: String, - evidence: Value, -} - -#[derive(Debug, Serialize)] -struct QueryResult { - id: String, - task: Option, - trace_id: Uuid, - query: String, - expected_doc: String, - allowed_alternate_docs: Vec, - expected_terms: Vec, - expected_evidence_ids: Vec, - allowed_alternate_evidence_ids: Vec, - matched: bool, - matched_terms: Vec, - top_evidence_id: Option, - matched_evidence_id: Option, - top_note_key: Option, - top_snippet: Option, - latency_ms: f64, - returned_count: usize, -} - -#[derive(Debug)] -struct DeterministicEmbedding { - vector_dim: u32, -} -impl EmbeddingProvider for DeterministicEmbedding { - fn embed<'a>( - &'a self, - _cfg: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, elf_service::Result>>> { - let dim = self.vector_dim; - let vectors = texts.iter().map(|text| embed_text(text, dim)).collect(); - - Box::pin(async move { Ok(vectors) }) - } -} - -#[derive(Debug)] -struct TokenOverlapRerank; -impl RerankProvider for TokenOverlapRerank { - fn rerank<'a>( - &'a self, - _cfg: &'a ProviderConfig, - query: &'a str, - docs: &'a [String], - ) -> BoxFuture<'a, elf_service::Result>> { - let query_terms = terms(query); - let scores = docs - .iter() - .map(|doc| { - let doc_terms = terms(doc); - let hits = query_terms.intersection(&doc_terms).count() as f32; - - hits / query_terms.len().max(1) as f32 - }) - .collect(); - - Box::pin(async move { Ok(scores) }) - } -} - -#[derive(Debug)] -struct NoopExtractor; -impl ExtractorProvider for NoopExtractor { - fn extract<'a>( - &'a self, - _cfg: &'a LlmProviderConfig, - _messages: &'a [Value], - ) -> BoxFuture<'a, elf_service::Result> { - Box::pin(async move { Ok(serde_json::json!({ "notes": [] })) }) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum EmbeddingMode { - Local, - Provider, -} - -fn runtime_config(runtime: &BaselineRuntime) -> color_eyre::Result { - let embedding_mode = embedding_mode()?; - let mut cfg = elf_config::load(&runtime.config_path)?; - - cfg.storage.postgres.dsn = runtime.dsn.clone(); - cfg.storage.postgres.pool_max_conns = 12; - cfg.storage.qdrant.url = runtime.qdrant_url.clone(); - cfg.storage.qdrant.collection = runtime.collection.clone(); - cfg.storage.qdrant.docs_collection = runtime.docs_collection.clone(); - - if embedding_mode == EmbeddingMode::Provider { - apply_provider_embedding_overrides(&mut cfg)?; - - cfg.storage.qdrant.vector_dim = cfg.providers.embedding.dimensions; - } else { - cfg.providers.embedding.provider_id = "local".to_string(); - cfg.providers.embedding.model = "local-hash".to_string(); - cfg.providers.embedding.dimensions = cfg.storage.qdrant.vector_dim; - } - - cfg.providers.rerank.provider_id = "local".to_string(); - cfg.providers.rerank.model = "local-token-overlap".to_string(); - cfg.providers.llm_extractor.provider_id = "disabled".to_string(); - cfg.providers.llm_extractor.model = "disabled".to_string(); - cfg.context = None; - - Ok(cfg) -} - -fn deterministic_providers(vector_dim: u32) -> Providers { - Providers::new( - Arc::new(DeterministicEmbedding { vector_dim }), - Arc::new(TokenOverlapRerank), - Arc::new(NoopExtractor), - ) -} - -fn embedding_mode() -> color_eyre::Result { - let raw = env::var("ELF_BASELINE_ELF_EMBEDDING_MODE") - .unwrap_or_else(|_| "local".to_string()) - .to_ascii_lowercase(); - - match raw.as_str() { - "local" | "deterministic" => Ok(EmbeddingMode::Local), - "provider" | "production" => Ok(EmbeddingMode::Provider), - _ => Err(eyre::eyre!( - "Unsupported ELF_BASELINE_ELF_EMBEDDING_MODE={raw:?}; use local or provider." - )), - } -} - -fn apply_provider_embedding_overrides(cfg: &mut Config) -> color_eyre::Result<()> { - apply_env_string( - &mut cfg.providers.embedding.provider_id, - &[ - "ELF_BASELINE_ELF_EMBEDDING_PROVIDER_ID", - "QWEN_EMBEDDING_PROVIDER_ID", - "EMBEDDING_PROVIDER_ID", - ], - ); - apply_env_string( - &mut cfg.providers.embedding.api_base, - &[ - "ELF_BASELINE_ELF_EMBEDDING_API_BASE", - "QWEN_EMBEDDING_API_BASE", - "DASHSCOPE_API_BASE", - "EMBEDDING_API_BASE", - ], - ); - apply_env_string( - &mut cfg.providers.embedding.api_key, - &[ - "ELF_BASELINE_ELF_EMBEDDING_API_KEY", - "QWEN_API_KEY", - "DASHSCOPE_API_KEY", - "EMBEDDING_API_KEY", - ], - ); - apply_env_string( - &mut cfg.providers.embedding.path, - &["ELF_BASELINE_ELF_EMBEDDING_PATH", "QWEN_EMBEDDING_PATH", "EMBEDDING_PATH"], - ); - apply_env_string( - &mut cfg.providers.embedding.model, - &["ELF_BASELINE_ELF_EMBEDDING_MODEL", "QWEN_EMBEDDING_MODEL", "EMBEDDING_MODEL"], - ); - - if let Some(dimensions) = env_u32(&[ - "ELF_BASELINE_ELF_EMBEDDING_DIMENSIONS", - "QWEN_EMBEDDING_DIMENSIONS", - "DASHSCOPE_EMBEDDING_DIMENSIONS", - "EMBEDDING_DIMENSIONS", - ]) { - cfg.providers.embedding.dimensions = dimensions; - } - if let Some(timeout_ms) = env_u64(&[ - "ELF_BASELINE_ELF_EMBEDDING_TIMEOUT_MS", - "QWEN_EMBEDDING_TIMEOUT_MS", - "EMBEDDING_TIMEOUT_MS", - ]) { - cfg.providers.embedding.timeout_ms = timeout_ms; - } else { - cfg.providers.embedding.timeout_ms = cfg.providers.embedding.timeout_ms.max(30_000); - } - - if cfg.providers.embedding.provider_id == "local" { - if env_string(&["ELF_BASELINE_ELF_EMBEDDING_API_KEY", "QWEN_API_KEY"]).is_some() { - cfg.providers.embedding.provider_id = "qwen".to_string(); - } else if env_string(&["DASHSCOPE_API_KEY"]).is_some() { - cfg.providers.embedding.provider_id = "dashscope".to_string(); - } else if env_string(&["EMBEDDING_API_KEY"]).is_some() { - cfg.providers.embedding.provider_id = "provider".to_string(); - } - } - if cfg.providers.embedding.provider_id == "local" { - return Err(eyre::eyre!( - "Provider embedding mode requires a non-local provider id or QWEN_API_KEY/DASHSCOPE_API_KEY/EMBEDDING_API_KEY." - )); - } - if cfg.providers.embedding.api_base.trim().is_empty() - || cfg.providers.embedding.api_base == "http://127.0.0.1" - { - return Err(eyre::eyre!( - "Provider embedding mode requires ELF_BASELINE_ELF_EMBEDDING_API_BASE, QWEN_EMBEDDING_API_BASE, DASHSCOPE_API_BASE, or EMBEDDING_API_BASE." - )); - } - if cfg.providers.embedding.api_key.trim().is_empty() - || cfg.providers.embedding.api_key == "local-dev-placeholder" - { - return Err(eyre::eyre!( - "Provider embedding mode requires ELF_BASELINE_ELF_EMBEDDING_API_KEY, QWEN_API_KEY, DASHSCOPE_API_KEY, or EMBEDDING_API_KEY." - )); - } - if cfg.providers.embedding.model == "local-hash" - || cfg.providers.embedding.model.trim().is_empty() - { - return Err(eyre::eyre!( - "Provider embedding mode requires ELF_BASELINE_ELF_EMBEDDING_MODEL, QWEN_EMBEDDING_MODEL, or EMBEDDING_MODEL." - )); - } - if cfg.providers.embedding.dimensions == 0 { - return Err(eyre::eyre!( - "Provider embedding dimensions must be greater than zero; set ELF_BASELINE_ELF_EMBEDDING_DIMENSIONS, QWEN_EMBEDDING_DIMENSIONS, DASHSCOPE_EMBEDDING_DIMENSIONS, or EMBEDDING_DIMENSIONS." - )); - } - - Ok(()) -} - -fn embedding_runtime_report(cfg: &Config) -> EmbeddingRuntimeReport { - EmbeddingRuntimeReport { - mode: embedding_mode().unwrap_or(EmbeddingMode::Local), - provider_id: cfg.providers.embedding.provider_id.clone(), - model: cfg.providers.embedding.model.clone(), - dimensions: cfg.providers.embedding.dimensions, - timeout_ms: cfg.providers.embedding.timeout_ms, - api_base: cfg.providers.embedding.api_base.clone(), - path: cfg.providers.embedding.path.clone(), - } -} - -fn apply_env_string(target: &mut String, names: &[&str]) { - if let Some(value) = env_string(names) { - *target = value; - } -} - -fn env_string(names: &[&str]) -> Option { - names.iter().find_map(|name| { - env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) - }) -} - -fn env_u32(names: &[&str]) -> Option { - env_string(names).and_then(|value| value.parse::().ok()) -} - -fn env_u64(names: &[&str]) -> Option { - env_string(names).and_then(|value| value.parse::().ok()) -} - -fn load_corpus_notes(corpus_dir: &Path) -> color_eyre::Result> { - let mut paths = fs::read_dir(corpus_dir)? - .map(|entry| entry.map(|entry| entry.path())) - .collect::>>()?; - - paths.retain(|path| { - path.extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) - }); - paths.sort(); - - let mut out = Vec::with_capacity(paths.len()); - - for path in paths { - let source_doc = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - eyre::eyre!("Corpus path has no valid UTF-8 file name: {}", path.display()) - })? - .to_string(); - let raw = fs::read_to_string(&path)?; - let title = title_from_markdown(&raw, &source_doc); - let text = raw - .lines() - .filter(|line| !line.trim_start().starts_with('#')) - .collect::>() - .join(" ") - .split_whitespace() - .collect::>() - .join(" "); - - out.push(CorpusNote { key: key_for_doc(&source_doc), title, text, source_doc }); - } - - if out.is_empty() { - return Err(eyre::eyre!("No markdown corpus files found in {}.", corpus_dir.display())); - } - - Ok(out) -} - -fn load_queries(path: &PathBuf) -> color_eyre::Result { - let raw = fs::read_to_string(path)?; - - Ok(serde_json::from_str(&raw)?) -} - -fn worker_max_iterations(note_count: usize) -> usize { - env::var("ELF_BASELINE_WORKER_MAX_ITERATIONS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or_else(|| note_count.saturating_mul(3).saturating_add(32)) -} - -fn outbox_done(counts: &BTreeMap, expected_note_count: usize) -> bool { - let done = counts.get("DONE").copied().unwrap_or_default(); - let expected = i64::try_from(expected_note_count).unwrap_or(i64::MAX); - let pending = counts.get("PENDING").copied().unwrap_or_default(); - let failed = counts.get("FAILED").copied().unwrap_or_default(); - let claimed = counts.get("CLAIMED").copied().unwrap_or_default(); - - done >= expected && pending == 0 && failed == 0 && claimed == 0 -} - -fn retrieval_check(query_results: &[QueryResult]) -> CheckResult { - let pass_count = query_results.iter().filter(|result| result.matched).count(); - let fail_count = query_results.len().saturating_sub(pass_count); - let expected_evidence_ids = query_results - .iter() - .map(|result| { - serde_json::json!({ - "query_id": result.id, - "expected": result.expected_evidence_ids, - "allowed_alternates": result.allowed_alternate_evidence_ids, - }) - }) - .collect::>(); - - CheckResult { - name: "same_corpus_retrieval", - status: if fail_count == 0 { "pass" } else { "wrong_result" }, - reason: if fail_count == 0 { - "All same-corpus retrieval queries returned expected evidence.".to_string() - } else { - format!("{fail_count} same-corpus retrieval query case(s) missed expected evidence.") - }, - evidence: serde_json::json!({ - "total": query_results.len(), - "pass": pass_count, - "fail": fail_count, - "wrong_result_count": fail_count, - "expected_evidence_ids": expected_evidence_ids, - }), - } -} - -fn worker_indexing_check(evidence: WorkerRunEvidence) -> CheckResult { - let pass = outbox_done(&evidence.after, evidence.expected_note_count) - && evidence.chunk_rows >= i64::try_from(evidence.expected_note_count).unwrap_or(i64::MAX) - && evidence.chunk_embedding_rows >= evidence.chunk_rows; - - CheckResult { - name: "async_worker_indexing_e2e", - status: if pass { "pass" } else { "lifecycle_fail" }, - reason: if pass { - "ELF worker processed corpus outbox jobs into persisted chunks and embeddings." - .to_string() - } else { - "ELF worker did not fully process corpus outbox jobs into searchable chunks." - .to_string() - }, - evidence: serde_json::json!(evidence), - } -} - -fn resumable_backfill_check(report: &BackfillReport) -> CheckResult { - let resume_pass = !report.resume.enabled - || (report.resume.interrupted - && report.resume.resume_attempts >= 2 - && report.skipped_completed > 0); - let pass = report.completed_count == report.source_count - && report.duplicate_source_notes.is_empty() - && resume_pass; - - CheckResult { - name: "resumable_backfill_no_duplicates", - status: if pass { "pass" } else { "lifecycle_fail" }, - reason: if pass { - "Checkpointed backfill resumed from durable progress and did not duplicate source documents." - .to_string() - } else { - "Checkpointed backfill did not complete cleanly, did not prove resume, or duplicated source documents." - .to_string() - }, - evidence: serde_json::json!(report), - } -} - -fn backfill_batch_size() -> usize { - parse_env_usize("ELF_BASELINE_BACKFILL_BATCH_SIZE").unwrap_or(32).max(1) -} - -fn worker_concurrency() -> usize { - let default = match env::var("ELF_BASELINE_PROFILE").as_deref() { - Ok("backfill" | "large") => 4, - Ok("stress") => 4, - Ok("scale" | "full") => 2, - _ => 1, - }; - - parse_env_usize("ELF_BASELINE_WORKER_CONCURRENCY").unwrap_or(default).clamp(1, 32) -} - -fn backfill_resume_probe_enabled() -> bool { - env::var("ELF_BASELINE_BACKFILL_RESUME_PROBE") - .map(|value| value != "0" && !value.eq_ignore_ascii_case("false")) - .unwrap_or(true) -} - -fn backfill_interrupt_after(source_count: usize) -> Option { - if !backfill_resume_probe_enabled() || source_count <= 1 { - return None; - } - - let configured = parse_env_usize("ELF_BASELINE_BACKFILL_INTERRUPT_AFTER"); - let default = (source_count / 2).max(1); - - Some(configured.unwrap_or(default).clamp(1, source_count.saturating_sub(1))) -} - -fn backfill_checkpoint_path(out: &Path) -> PathBuf { - env_string(&["ELF_BASELINE_BACKFILL_CHECKPOINT"]) - .map(PathBuf::from) - .unwrap_or_else(|| out.with_file_name("elf-backfill-checkpoint.json")) -} - -fn empty_backfill_checkpoint(corpus_hash: &str) -> BackfillCheckpoint { - BackfillCheckpoint { - schema: BACKFILL_CHECKPOINT_SCHEMA.to_string(), - corpus_hash: corpus_hash.to_string(), - completed: BTreeMap::new(), - } -} - -fn load_backfill_checkpoint( - path: &Path, - corpus_hash: &str, -) -> color_eyre::Result { - if !path.exists() { - return Ok(empty_backfill_checkpoint(corpus_hash)); - } - - let raw = fs::read_to_string(path)?; - let checkpoint = serde_json::from_str::(&raw)?; - - if checkpoint.schema == BACKFILL_CHECKPOINT_SCHEMA && checkpoint.corpus_hash == corpus_hash { - Ok(checkpoint) - } else { - Ok(empty_backfill_checkpoint(corpus_hash)) - } -} - -fn write_backfill_checkpoint( - path: &Path, - checkpoint: &BackfillCheckpoint, -) -> color_eyre::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let raw = serde_json::to_string_pretty(checkpoint)?; - let tmp_path = path.with_extension("json.tmp"); - - fs::write(&tmp_path, raw)?; - fs::rename(tmp_path, path)?; - - Ok(()) -} - -fn source_hash(note: &CorpusNote) -> String { - let mut hasher = Hasher::new(); - - hasher.update(note.source_doc.as_bytes()); - hasher.update(b"\0"); - hasher.update(note.key.as_bytes()); - hasher.update(b"\0"); - hasher.update(note.text.as_bytes()); - - hasher.finalize().to_hex().to_string() -} - -fn corpus_hash(notes: &[CorpusNote]) -> String { - let mut hasher = Hasher::new(); - - for note in notes { - hasher.update(note.source_doc.as_bytes()); - hasher.update(b"\0"); - hasher.update(source_hash(note).as_bytes()); - hasher.update(b"\0"); - } - - hasher.finalize().to_hex().to_string() -} - -fn checkpoint_entry_valid( - note: &CorpusNote, - entry: &BackfillCheckpointEntry, - existing: &BTreeMap, -) -> bool { - let expected_hash = source_hash(note); - - if entry.source_hash != expected_hash { - return false; - } - - existing.get(¬e.source_doc).is_some_and(|stored| { - stored.note_id == entry.note_id - && stored.source_hash.as_deref() == Some(expected_hash.as_str()) - }) -} - -fn note_input(note: &CorpusNote) -> AddNoteInput { - let hash = source_hash(note); - - AddNoteInput { - r#type: "fact".to_string(), - key: Some(note.key.clone()), - text: note.text.clone(), - structured: None, - importance: 0.9, - confidence: 0.95, - ttl_days: None, - source_ref: serde_json::json!({ - "source": "ELF live baseline corpus", - "title": note.title, - "document": note.source_doc, - "source_hash": hash, - }), - write_policy: None, - } -} - -fn note_op_string(op: NoteOp) -> color_eyre::Result { - let value = serde_json::to_value(op)?; - - value - .as_str() - .map(ToString::to_string) - .ok_or_else(|| eyre::eyre!("Serialized note op was not a string.")) -} - -fn concurrent_note_count() -> usize { - if let Ok(value) = env::var("ELF_BASELINE_CONCURRENT_NOTES") - && let Ok(parsed) = value.parse::() - { - return parsed.max(1); - } - - match env::var("ELF_BASELINE_PROFILE").as_deref() { - Ok("backfill" | "large") => 32, - Ok("stress") => 32, - Ok("scale" | "full") => 16, - _ => 4, - } -} - -fn concurrent_add_request(index: usize) -> AddNoteRequest { - let marker = concurrent_marker(index); - - AddNoteRequest { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - agent_id: AGENT_ID.to_string(), - scope: SCOPE.to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some(format!("concurrent_{index:03}")), - text: format!( - "Concurrent benchmark note {index:03} records marker `{marker}` for write race validation." - ), - structured: None, - importance: 0.91, - confidence: 0.96, - ttl_days: None, - source_ref: serde_json::json!({ - "source": "ELF live baseline concurrent write check", - "document": format!("concurrent-{index:03}.md"), - }), - write_policy: None, - }], - } -} - -fn concurrent_query_case(index: usize) -> QueryCase { - let marker = concurrent_marker(index); - - QueryCase::generated( - format!("concurrent-{index:03}"), - format!("Find the concurrent benchmark note containing marker {marker}."), - format!("concurrent-{index:03}.md"), - vec![marker], - ) -} - -fn concurrent_marker(index: usize) -> String { - format!("concurrency-{}-{index:03}", marker_word(index)) -} - -fn soak_config() -> SoakConfig { - let profile = env::var("ELF_BASELINE_PROFILE").ok(); - let (default_seconds, default_rounds) = match profile.as_deref() { - Some("backfill" | "large") => (60, 6), - Some("stress") => (60, 6), - Some("scale" | "full") => (15, 3), - _ => (0, 0), - }; - - SoakConfig { - target_seconds: parse_env_u64("ELF_BASELINE_SOAK_SECONDS").unwrap_or(default_seconds), - write_rounds: parse_env_usize("ELF_BASELINE_SOAK_ROUNDS").unwrap_or(default_rounds), - probe_interval_millis: parse_env_u64("ELF_BASELINE_SOAK_PROBE_INTERVAL_MS") - .unwrap_or(1_000) - .max(100), - } -} - -fn parse_env_u64(name: &str) -> Option { - env::var(name).ok()?.parse::().ok() -} - -fn parse_env_usize(name: &str) -> Option { - env::var(name).ok()?.parse::().ok() -} - -fn soak_add_request(index: usize) -> AddNoteRequest { - let marker = soak_marker(index); - let (topic, detail) = soak_topic(index); - - AddNoteRequest { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - agent_id: AGENT_ID.to_string(), - scope: SCOPE.to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some(format!("soak_{index:03}")), - text: format!( - "Soak benchmark note {index:03} covers {topic}. {detail} It records stability marker `{marker}` for repeated worker and search probes." - ), - structured: None, - importance: 0.92, - confidence: 0.97, - ttl_days: None, - source_ref: serde_json::json!({ - "source": "ELF live baseline soak stability check", - "document": format!("soak-{index:03}.md"), - }), - write_policy: None, - }], - } -} - -fn soak_query_case(index: usize) -> QueryCase { - let marker = soak_marker(index); - let (topic, _) = soak_topic(index); - - QueryCase::generated( - format!("soak-{index:03}"), - format!("Find the soak benchmark note about {topic} containing marker {marker}."), - format!("soak-{index:03}.md"), - vec![marker], - ) -} - -fn soak_marker(index: usize) -> String { - format!("soak-stability-{}-{index:03}", marker_word(index)) -} - -fn marker_word(index: usize) -> &'static str { - const WORDS: &[&str] = &[ - "aurora", "banyan", "cobalt", "delta", "ember", "fennel", "granite", "harbor", "indigo", - "jasper", "keystone", "lantern", "meridian", "nebula", "onyx", "prairie", "quartz", - "raven", "solstice", "topaz", "umbra", "verdant", "willow", "xenon", "yarrow", "zephyr", - "atlas", "beacon", "citadel", "drift", "equinox", "forge", - ]; - - WORDS[index % WORDS.len()] -} - -fn soak_topic(index: usize) -> (&'static str, &'static str) { - const TOPICS: &[(&str, &str)] = &[ - ( - "release rollback fencing", - "The rollback controller waits for a signed deploy fence before the next canary.", - ), - ( - "invoice export batching", - "The exporter groups invoice CSV rows by merchant ledger before upload.", - ), - ("search shard warming", "The search router warms tenant shard caches before rank probes."), - ( - "incident pager routing", - "The incident desk routes page ownership through the release captain.", - ), - ( - "backup restore rehearsal", - "The restore rehearsal checks WAL freshness before dry-run recovery.", - ), - ( - "feature flag expiry", - "The flag sweeper archives expired toggles before deleting rollout rules.", - ), - ( - "support queue triage", - "The support classifier separates billing tickets from access tickets.", - ), - ( - "analytics job watermark", - "The analytics worker stores a warehouse watermark after each import.", - ), - ]; - - TOPICS[index % TOPICS.len()] -} - -fn concurrency_probe_indexes(note_count: usize) -> Vec { - let mut indexes = vec![0, note_count / 2, note_count.saturating_sub(1)]; - - indexes.sort_unstable(); - indexes.dedup(); - - indexes -} - -fn current_rss_kb() -> Option { - let status = fs::read_to_string("/proc/self/status").ok()?; - - status.lines().find_map(|line| { - let rest = line.strip_prefix("VmHWM:")?.trim(); - let value = rest.split_whitespace().next()?; - - value.parse::().ok() - }) -} - -fn path_size_bytes(path: &Path) -> color_eyre::Result { - let metadata = fs::metadata(path)?; - - if metadata.is_file() { - return Ok(metadata.len()); - } - if !metadata.is_dir() { - return Ok(0); - } - - let mut bytes = 0_u64; - - for entry in fs::read_dir(path)? { - let entry = entry?; - - bytes = bytes.saturating_add(path_size_bytes(&entry.path())?); - } - - Ok(bytes) -} - -fn cost_proxy_report( - notes: &[CorpusNote], - queries: &[QueryResult], - embedding: &EmbeddingRuntimeReport, -) -> CostProxyReport { - let note_chars = notes.iter().map(|note| note.text.len()).sum::(); - let query_chars = queries.iter().map(|query| query.query.len()).sum::(); - let estimated_input_chars = note_chars.saturating_add(query_chars); - let estimated_input_tokens = estimated_input_chars.saturating_add(3) / 4; - let configured_usd_per_1k_tokens = env::var("ELF_BASELINE_COST_PER_1K_TOKENS_USD") - .ok() - .and_then(|value| value.parse::().ok()); - let estimated_usd = - configured_usd_per_1k_tokens.map(|rate| estimated_input_tokens as f64 / 1_000.0 * rate); - - CostProxyReport { - schema: "elf.live_baseline.cost_proxy/v1", - scope: "primary corpus note text plus declared same-corpus query text", - embedding_mode: embedding.mode, - estimated_input_chars, - estimated_input_tokens, - token_estimation: "ceil(ascii_utf8_chars / 4)", - configured_usd_per_1k_tokens, - estimated_usd, - document_count: notes.len(), - query_count: queries.len(), - } -} - -fn latency_percentile(latencies: &[f64], percentile: f64) -> f64 { - if latencies.is_empty() { - return 0.0; - } - - let mut sorted = latencies.to_vec(); - - sorted.sort_by(f64::total_cmp); - - let rank = ((sorted.len().saturating_sub(1)) as f64 * percentile).ceil() as usize; - - sorted[rank.min(sorted.len().saturating_sub(1))] -} - -fn operational_case( - name: &'static str, - default_status: &'static str, - operator_status: &'static str, - command: &'static str, - evidence: &'static str, - safety: &'static str, -) -> OperationalCase { - OperationalCase { name, default_status, operator_status, command, evidence, safety } -} - -fn operational_cases() -> Vec { - vec![ - operational_case( - "private_corpus_addendum", - "fails_closed_without_manifest", - "opt_in", - "ELF_BASELINE_PRODUCTION_CORPUS_MANIFEST=tmp/private-production-corpus/manifest.json cargo make baseline-production-private-addendum", - "tmp/live-baseline/private-production-addendum.md", - "Markdown addendum reports manifest id, evidence ids, tasks, checks, latency, resource, and cost proxy fields; private text remains in tmp JSON/logs only.", - ), - operational_case( - "backfill_10k_resume", - "not_run", - "opt_in", - "cargo make baseline-backfill-10k-docker", - "tmp/live-baseline/live-baseline-report.json", - "Runs Docker-owned dependencies and records checkpoint resume, duplicates, latency percentiles, resource usage, and cost proxy fields.", - ), - operational_case( - "backfill_100k_resume", - "guarded", - "expensive_opt_in", - "ELF_BASELINE_ENABLE_EXPENSIVE=1 cargo make baseline-backfill-100k-docker", - "tmp/live-baseline/live-baseline-report.json", - "Fails closed unless the expensive-run guard is explicitly enabled.", - ), - operational_case( - "provider_outage", - "not_run", - "documented_operator_probe", - "ELF_BASELINE_ELF_EMBEDDING_MODE=provider with an unavailable embedding endpoint and cargo make baseline-production-synthetic", - "ELF project status incomplete or blocked with provider failure in tmp/live-baseline/ELF.log", - "Use only synthetic or sanitized manifests; do not place provider keys in committed files.", - ), - operational_case( - "compose_start_stop_upgrade", - "documented", - "runbook", - "docs/runbook/single_user_production.md Sections 2, 4, and 5", - "storage health, API health, migration check, and post-upgrade search smoke", - "Backup Postgres before binary/config upgrade; rollback restores the previous backup and rebuilds Qdrant.", - ), - operational_case( - "postgres_restore_qdrant_rebuild", - "documented", - "runbook_or_clean_volume_proof", - "docs/runbook/single_user_production.md Sections 6 through 9", - "Postgres restored row count, admin qdrant rebuild counts, and search-after-restore response", - "Qdrant remains derived and rebuild uses Postgres-held vectors without embedding provider calls.", - ), - operational_case( - "migration_rollback", - "documented", - "runbook", - "docs/runbook/single_user_production.md Section 5 rollback path", - "pre-upgrade backup path, restored source rows, qdrant rebuild, and health check", - "No reverse migration is claimed; rollback means previous binary/config plus restored Postgres backup.", - ), - operational_case( - "unattended_soak", - "bounded", - "opt_in", - "ELF_BASELINE_PROJECTS=ELF ELF_BASELINE_PROFILE=stress ELF_BASELINE_SOAK_SECONDS=3600 cargo make baseline-live-docker", - "soak_stability_e2e check and resource_envelope check in tmp/live-baseline/live-baseline-report.json", - "Long soak duration is env-controlled and not part of the default smoke profile.", - ), - ] -} - -fn incomplete_check(name: &'static str, reason: &str) -> CheckResult { - CheckResult { - name, - status: "incomplete", - reason: reason.to_string(), - evidence: serde_json::json!({}), - } -} - -fn summarize_checks(checks: &[CheckResult]) -> CheckSummary { - let wrong_result = checks.iter().filter(|check| check.status == "wrong_result").count(); - let lifecycle_fail = checks.iter().filter(|check| check.status == "lifecycle_fail").count(); - - CheckSummary { - total: checks.len(), - pass: checks.iter().filter(|check| check.status == "pass").count(), - fail: wrong_result + lifecycle_fail, - wrong_result, - lifecycle_fail, - incomplete: checks.iter().filter(|check| check.status == "incomplete").count(), - blocked: checks.iter().filter(|check| check.status == "blocked").count(), - not_encoded: checks.iter().filter(|check| check.status == "not_encoded").count(), - } -} - -fn project_status_from_summary(summary: &CheckSummary) -> &'static str { - if summary.wrong_result > 0 { - "wrong_result" - } else if summary.lifecycle_fail > 0 { - "lifecycle_fail" - } else if summary.blocked > 0 { - "blocked" - } else if summary.incomplete > 0 { - "incomplete" - } else if summary.not_encoded > 0 { - "not_encoded" - } else { - "pass" - } -} - -fn title_from_markdown(raw: &str, source_doc: &str) -> String { - raw.lines() - .find_map(|line| line.trim_start().strip_prefix("# ")) - .map(str::trim) - .filter(|title| !title.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| source_doc.to_string()) -} - -fn key_for_doc(doc: &str) -> String { - let stem = Path::new(doc).file_stem().and_then(|stem| stem.to_str()).unwrap_or(doc); - let mut key = String::with_capacity(stem.len()); - let mut last_was_separator = false; - - for ch in stem.chars() { - if ch.is_ascii_alphanumeric() { - key.push(ch.to_ascii_lowercase()); - - last_was_separator = false; - } else if !last_was_separator && !key.is_empty() { - key.push('_'); - - last_was_separator = true; - } - } - - if key.ends_with('_') { - key.pop(); - } - - if key.is_empty() { "doc".to_string() } else { key } -} - -fn evidence_id_for_doc(doc: &str) -> String { - Path::new(doc).file_stem().and_then(|stem| stem.to_str()).unwrap_or(doc).to_string() -} - -fn expected_docs_for_case(case: &QueryCase) -> Vec { - let mut docs = Vec::with_capacity(case.allowed_alternate_docs.len().saturating_add(1)); - - docs.push(case.expected_doc.clone()); - docs.extend(case.allowed_alternate_docs.iter().cloned()); - - docs -} - -fn embed_text(text: &str, vector_dim: u32) -> Vec { - let dim = vector_dim as usize; - let mut vector = vec![0.0_f32; dim]; - - if dim == 0 { - return vector; - } - - let normalized = normalize_ascii_alnum_lowercase(text); - - for term in normalized.split_whitespace() { - if term.len() < 2 { - continue; - } - - let hash = blake3::hash(term.as_bytes()); - let bytes = hash.as_bytes(); - let idx = (u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) % dim; - let sign = if bytes[4] & 1 == 0 { 1.0 } else { -1.0 }; - - vector[idx] += sign; - } - - if vector.iter().all(|value| *value == 0.0) { - let hash = blake3::hash(text.as_bytes()); - let bytes = hash.as_bytes(); - let idx = (u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) % dim; - - vector[idx] = 1.0; - } - - let norm = vector.iter().map(|value| value * value).sum::().sqrt(); +#[path = "live_baseline_elf/backfill.rs"] mod backfill; +#[path = "live_baseline_elf/checks.rs"] mod checks; +#[path = "live_baseline_elf/corpus.rs"] mod corpus; +#[path = "live_baseline_elf/providers.rs"] mod providers; +#[path = "live_baseline_elf/runtime.rs"] mod runtime; +#[path = "live_baseline_elf/types.rs"] mod types; - if norm > 0.0 { - for value in &mut vector { - *value /= norm; - } - } - - vector -} - -fn normalize_ascii_alnum_lowercase(text: &str) -> String { - let mut normalized = String::with_capacity(text.len()); - - for ch in text.chars() { - if ch.is_ascii_alphanumeric() { - normalized.push(ch.to_ascii_lowercase()); - } else { - normalized.push(' '); - } - } - - normalized -} - -fn terms(text: &str) -> HashSet { - text.split(|ch: char| !ch.is_ascii_alphanumeric()) - .map(str::trim) - .filter(|term| !term.is_empty()) - .map(str::to_ascii_lowercase) - .collect() -} - -fn distinctive_terms(text: &str, limit: usize) -> Vec { - let stop_words = [ - "the", "and", "for", "with", "that", "this", "from", "into", "must", "uses", "after", - "before", "query", "memory", "note", - ]; - let stop_words = stop_words.into_iter().collect::>(); - let mut out = Vec::new(); - - for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) { - let term = raw.trim(); - - if term.len() < 5 { - continue; - } - - let lowered = term.to_ascii_lowercase(); - - if stop_words.contains(lowered.as_str()) || out.iter().any(|existing| existing == term) { - continue; - } - - out.push(term.to_string()); - - if out.len() >= limit { - break; - } - } - - out -} - -fn contains_case_insensitive(haystack: &str, needle: &str) -> bool { - haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase()) -} - -fn git_head() -> color_eyre::Result { - if let Ok(head) = env::var("ELF_BASELINE_ELF_HEAD") { - let head = head.trim(); - - if !head.is_empty() { - return Ok(head.to_string()); - } - } - - let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?; - - if !output.status.success() { - return Err(eyre::eyre!("git rev-parse HEAD failed.")); - } - - Ok(String::from_utf8(output.stdout)?.trim().to_string()) -} - -async fn resource_envelope_check( - service: &ElfService, - corpus_dir: &Path, - report_path: &Path, - checkpoint_path: &Path, - elapsed_seconds: f64, -) -> CheckResult { - let max_elapsed_seconds = env::var("ELF_BASELINE_MAX_ELF_SECONDS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(600.0); - let max_rss_kb = env::var("ELF_BASELINE_MAX_ELF_RSS_KB") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(1_500_000); - let rss_kb = current_rss_kb(); - let pass = elapsed_seconds <= max_elapsed_seconds && rss_kb.is_none_or(|rss| rss <= max_rss_kb); - let postgres_database_bytes = postgres_database_bytes(service).await.ok(); - let corpus_dir_bytes = path_size_bytes(corpus_dir).unwrap_or_default(); - let report_dir_bytes = report_path.parent().and_then(|path| path_size_bytes(path).ok()); - let checkpoint_file_bytes = checkpoint_path.metadata().ok().map(|metadata| metadata.len()); - - CheckResult { - name: "resource_envelope", - status: if pass { "pass" } else { "lifecycle_fail" }, - reason: if pass { - "ELF live-baseline runtime stayed within the configured local resource envelope." - .to_string() - } else { - "ELF live-baseline runtime exceeded the configured local resource envelope.".to_string() - }, - evidence: serde_json::json!(ResourceEnvelopeEvidence { - elapsed_seconds, - max_elapsed_seconds, - rss_kb, - max_rss_kb, - postgres_database_bytes, - corpus_dir_bytes, - report_dir_bytes, - checkpoint_file_bytes, - }), - } -} - -async fn postgres_database_bytes(service: &ElfService) -> color_eyre::Result { - let bytes = sqlx::query_scalar::<_, i64>("SELECT pg_database_size(current_database())::bigint") - .fetch_one(&service.db.pool) - .await?; - - Ok(bytes) -} - -async fn load_existing_backfill_notes( - service: &ElfService, -) -> color_eyre::Result> { - let rows = sqlx::query_as::<_, (Uuid, String, Option)>( - "\ -SELECT note_id, source_ref->>'document' AS source_doc, source_ref->>'source_hash' AS source_hash -FROM memory_notes -WHERE tenant_id = $1 - AND project_id = $2 - AND agent_id = $3 - AND scope = $4 - AND status = 'active' - AND source_ref->>'source' = 'ELF live baseline corpus' - AND source_ref->>'document' IS NOT NULL -ORDER BY updated_at DESC", - ) - .bind(TENANT_ID) - .bind(PROJECT_ID) - .bind(AGENT_ID) - .bind(SCOPE) - .fetch_all(&service.db.pool) - .await?; - let mut out = BTreeMap::new(); - - for (note_id, source_doc, hash) in rows { - out.entry(source_doc).or_insert(ExistingBackfillNote { note_id, source_hash: hash }); - } - - Ok(out) -} - -async fn duplicate_source_notes( - service: &ElfService, -) -> color_eyre::Result> { - let rows = sqlx::query_as::<_, (String, i64, Vec)>( - "\ -SELECT - source_ref->>'document' AS source_doc, - COUNT(*)::bigint AS count, - array_agg(note_id ORDER BY note_id)::uuid[] AS note_ids -FROM memory_notes -WHERE tenant_id = $1 - AND project_id = $2 - AND agent_id = $3 - AND scope = $4 - AND status = 'active' - AND source_ref->>'source' = 'ELF live baseline corpus' - AND source_ref->>'document' IS NOT NULL -GROUP BY source_ref->>'document' -HAVING COUNT(*) > 1 -ORDER BY source_doc", - ) - .bind(TENANT_ID) - .bind(PROJECT_ID) - .bind(AGENT_ID) - .bind(SCOPE) - .fetch_all(&service.db.pool) - .await?; - - Ok(rows - .into_iter() - .map(|(source_doc, count, note_ids)| DuplicateSourceNote { source_doc, count, note_ids }) - .collect()) -} - -async fn run_resumable_backfill( - service: &ElfService, - notes: &[CorpusNote], - checkpoint_path: &Path, -) -> color_eyre::Result { - let started_at = Instant::now(); - let corpus_hash = corpus_hash(notes); - let batch_size = backfill_batch_size(); - let interrupt_after = backfill_interrupt_after(notes.len()); - let first_attempt = run_backfill_attempt( - service, - notes, - checkpoint_path, - &corpus_hash, - batch_size, - 1, - interrupt_after, - ) - .await?; - let interrupted = first_attempt.interrupted; - let completed_before_resume = first_attempt.checkpoint_completed; - let mut attempts = Vec::new(); - - attempts.push(first_attempt); - - if interrupted { - attempts.push( - run_backfill_attempt( - service, - notes, - checkpoint_path, - &corpus_hash, - batch_size, - 2, - None, - ) - .await?, - ); - } - - let checkpoint = load_backfill_checkpoint(checkpoint_path, &corpus_hash)?; - let existing = load_existing_backfill_notes(service).await?; - let mut note_ids = Vec::with_capacity(notes.len()); - - for note in notes { - let Some(entry) = checkpoint.completed.get(¬e.source_doc) else { - return Err(eyre::eyre!( - "Backfill checkpoint missing completed source {}.", - note.source_doc - )); - }; - - if !checkpoint_entry_valid(note, entry, &existing) { - return Err(eyre::eyre!( - "Backfill checkpoint entry for {} does not match Postgres state.", - note.source_doc - )); - } - - note_ids.push(entry.note_id); - } - - let duplicate_source_notes = duplicate_source_notes(service).await?; - let attempted_writes = attempts.iter().map(|attempt| attempt.attempted_writes).sum(); - let skipped_completed = attempts.iter().map(|attempt| attempt.skipped_completed).sum(); - let completed_after_resume = checkpoint.completed.len(); - let report = BackfillReport { - checkpoint_path: checkpoint_path.display().to_string(), - corpus_hash, - source_count: notes.len(), - completed_count: note_ids.len(), - batch_size, - worker_concurrency: worker_concurrency(), - elapsed_seconds: started_at.elapsed().as_secs_f64(), - attempted_writes, - skipped_completed, - duplicate_source_notes, - resume: BackfillResumeReport { - enabled: interrupt_after.is_some(), - interrupted, - interrupt_after, - resume_attempts: attempts.len(), - completed_before_resume, - completed_after_resume, - }, - attempts, - }; - - Ok(BackfillOutcome { report, note_ids }) -} - -async fn run_backfill_attempt( - service: &ElfService, - notes: &[CorpusNote], - checkpoint_path: &Path, - corpus_hash: &str, - batch_size: usize, - attempt: usize, - interrupt_after: Option, -) -> color_eyre::Result { - let mut checkpoint = load_backfill_checkpoint(checkpoint_path, corpus_hash)?; - let existing = load_existing_backfill_notes(service).await?; - let notes_by_source = - notes.iter().map(|note| (note.source_doc.as_str(), note)).collect::>(); - let checkpoint_len_before_prune = checkpoint.completed.len(); - - checkpoint.completed.retain(|source_doc, entry| { - notes_by_source - .get(source_doc.as_str()) - .is_some_and(|note| checkpoint_entry_valid(note, entry, &existing)) - }); - - if checkpoint.completed.len() != checkpoint_len_before_prune { - write_backfill_checkpoint(checkpoint_path, &checkpoint)?; - } - - let mut pending = Vec::new(); - let mut skipped_completed = 0_usize; - - for note in notes { - if checkpoint.completed.contains_key(¬e.source_doc) { - skipped_completed += 1; - } else { - pending.push(note); - } - } - - let max_writes = interrupt_after.unwrap_or(usize::MAX); - let mut attempted_writes = 0_usize; - let mut completed_writes = 0_usize; - let mut cursor = 0_usize; - - while cursor < pending.len() && attempted_writes < max_writes { - let remaining_budget = max_writes.saturating_sub(attempted_writes); - let take = batch_size.min(remaining_budget).min(pending.len() - cursor); - let batch = &pending[cursor..cursor + take]; - let response = service - .add_note(AddNoteRequest { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - agent_id: AGENT_ID.to_string(), - scope: SCOPE.to_string(), - notes: batch.iter().map(|note| note_input(note)).collect(), - }) - .await?; - - if response.results.len() != batch.len() { - return Err(eyre::eyre!( - "Backfill add_note returned {} results for {} inputs.", - response.results.len(), - batch.len() - )); - } - - for (note, result) in batch.iter().zip(response.results) { - let op = note_op_string(result.op)?; - - if op == "REJECTED" { - return Err(eyre::eyre!( - "Backfill note {} was rejected: {:?}.", - note.source_doc, - result.reason_code - )); - } - - let note_id = result.note_id.ok_or_else(|| { - eyre::eyre!("Backfill note {} did not return a note_id.", note.source_doc) - })?; +use std::{ + collections::{BTreeMap, HashSet}, + env, fs, + path::{Path, PathBuf}, + process::Command, + sync::Arc, + time::{Duration, Instant}, +}; - checkpoint.completed.insert( - note.source_doc.clone(), - BackfillCheckpointEntry { - note_id, - key: note.key.clone(), - source_hash: source_hash(note), - op, - }, - ); +use blake3::Hasher; +use clap::Parser; +use color_eyre::{Report, Result, eyre}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::{task::JoinSet, time}; +use uuid::Uuid; - completed_writes += 1; - } +use backfill::worker_concurrency; +use checks::{outbox_done, parse_env_usize}; +use corpus::{ + contains_case_insensitive, distinctive_terms, embed_text, evidence_id_for_doc, + expected_docs_for_case, key_for_doc, terms, +}; +use elf_chunking::ChunkingConfig; +use elf_config::{Config, EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; +use elf_service::{ + AddNoteInput, AddNoteRequest, BoxFuture, DeleteRequest, ElfService, EmbeddingProvider, + ExtractorProvider, NoteOp, PayloadLevel, Providers, RerankProvider, SearchRequest, + UpdateRequest, +}; +use elf_storage::{db::Db, qdrant::QdrantStore}; +use elf_testkit::TestDatabase; +use elf_worker::worker::{self, WorkerState}; +use providers::{ + EmbeddingMode, deterministic_providers, embedding_mode, env_string, runtime_config, +}; +use runtime::run_single_query; +use types::{ + Args, BackfillAttemptEvidence, BackfillCheckpoint, BackfillCheckpointEntry, BackfillOutcome, + BackfillReport, BackfillResumeReport, BaselineRuntime, CheckResult, CheckSummary, CorpusNote, + CostProxyReport, DuplicateSourceNote, ElfBaselineReport, EmbeddingRuntimeReport, + ExistingBackfillNote, FailedOutboxJob, IndexingReport, OperationalCase, QueryCase, + QueryManifest, QueryResult, QuerySummary, ResourceEnvelopeEvidence, SoakConfig, + WorkerRunEvidence, +}; - attempted_writes += batch.len(); - cursor += batch.len(); +const TENANT_ID: &str = "elf-live-baseline"; +const PROJECT_ID: &str = "shared-corpus"; +const AGENT_ID: &str = "elf-bench-agent"; +const SCOPE: &str = "agent_private"; +const BACKFILL_CHECKPOINT_SCHEMA: &str = "elf.live_baseline.backfill_checkpoint/v1"; - write_backfill_checkpoint(checkpoint_path, &checkpoint)?; +fn report_reason(status: &str, check_summary: &CheckSummary) -> String { + if status == "pass" { + "ELF added the corpus, rebuilt Qdrant, and returned expected evidence for every query" + .to_string() + } else { + format!( + "ELF reported {} wrong-result, {} lifecycle-failure, {} blocked, {} incomplete, and {} not-encoded live-baseline check(s)", + check_summary.wrong_result, + check_summary.lifecycle_fail, + check_summary.blocked, + check_summary.incomplete, + check_summary.not_encoded + ) } - - let interrupted = cursor < pending.len(); - - Ok(BackfillAttemptEvidence { - attempt, - resumed: skipped_completed > 0, - interrupt_after, - skipped_completed, - attempted_writes, - completed_writes, - checkpoint_completed: checkpoint.completed.len(), - interrupted, - }) } #[tokio::main] -async fn main() -> color_eyre::Result<()> { +async fn main() -> Result<()> { color_eyre::install()?; let args = Args::parse(); @@ -1750,7 +91,7 @@ async fn main() -> color_eyre::Result<()> { Ok(()) } -async fn run(args: Args) -> color_eyre::Result { +async fn run(args: Args) -> Result { let started_at = Instant::now(); let base_dsn = env::var("ELF_PG_DSN") .map_err(|_| eyre::eyre!("ELF_PG_DSN must be set for live ELF baseline."))?; @@ -1767,42 +108,45 @@ async fn run(args: Args) -> color_eyre::Result { collection, docs_collection, }; - let service = Arc::new(build_service(&runtime).await?); - let notes = load_corpus_notes(&args.corpus)?; - let backfill_checkpoint_path = backfill_checkpoint_path(&args.out); - let backfill = run_resumable_backfill(&service, ¬es, &backfill_checkpoint_path).await?; + let service = Arc::new(runtime::build_service(&runtime).await?); + let notes = corpus::load_corpus_notes(&args.corpus)?; + let backfill_checkpoint_path = backfill::backfill_checkpoint_path(&args.out); + let backfill = + backfill::run_resumable_backfill(&service, ¬es, &backfill_checkpoint_path).await?; let note_ids = backfill.note_ids; let initial_worker = - run_worker_until_indexed(&runtime, &service, ¬e_ids, "corpus_upsert").await?; + runtime::run_worker_until_indexed(&runtime, &service, ¬e_ids, "corpus_upsert").await?; let rebuild = service.rebuild_qdrant().await?; - let query_manifest = load_queries(&args.queries)?; - let query_results = run_queries(&service, query_manifest.queries).await?; + let query_manifest = corpus::load_queries(&args.queries)?; + let query_results = runtime::run_queries(&service, query_manifest.queries).await?; let pass_count = query_results.iter().filter(|result| result.matched).count(); let fail_count = query_results.len().saturating_sub(pass_count); let latency_ms_total = query_results.iter().map(|result| result.latency_ms).sum::(); let latency_ms_mean = latency_ms_total / query_results.len().max(1) as f64; let latency_values = query_results.iter().map(|result| result.latency_ms).collect::>(); - let latency_ms_p50 = latency_percentile(&latency_values, 0.50); - let latency_ms_p95 = latency_percentile(&latency_values, 0.95); - let latency_ms_p99 = latency_percentile(&latency_values, 0.99); + let latency_ms_p50 = checks::latency_percentile(&latency_values, 0.50); + let latency_ms_p95 = checks::latency_percentile(&latency_values, 0.95); + let latency_ms_p99 = checks::latency_percentile(&latency_values, 0.99); let latency_ms_max = latency_values.iter().copied().fold(0.0_f64, f64::max); let retrieval_status = if fail_count == 0 { "retrieval_pass" } else { "retrieval_wrong_result" }; let mut checks = vec![ - resumable_backfill_check(&backfill.report), - retrieval_check(&query_results), - worker_indexing_check(initial_worker), + checks::resumable_backfill_check(&backfill.report), + checks::retrieval_check(&query_results), + checks::worker_indexing_check(initial_worker), ]; - checks.extend(run_lifecycle_checks(&runtime, &service, ¬es, ¬e_ids).await?); - checks.push(run_concurrent_write_check(&runtime, Arc::clone(&service)).await?); + checks.extend(checks::run_lifecycle_checks(&runtime, &service, ¬es, ¬e_ids).await?); + checks.push(checks::run_concurrent_write_check(&runtime, Arc::clone(&service)).await?); - if let Some(soak_check) = run_soak_stability_check(&runtime, Arc::clone(&service)).await? { + if let Some(soak_check) = + checks::run_soak_stability_check(&runtime, Arc::clone(&service)).await? + { checks.push(soak_check); } checks.push( - resource_envelope_check( + checks::resource_envelope_check( &service, &args.corpus, &args.out, @@ -1812,29 +156,17 @@ async fn run(args: Args) -> color_eyre::Result { .await, ); - let check_summary = summarize_checks(&checks); - let status = project_status_from_summary(&check_summary); - let reason = if status == "pass" { - "ELF added the corpus, rebuilt Qdrant, and returned expected evidence for every query" - .to_string() - } else { - format!( - "ELF reported {} wrong-result, {} lifecycle-failure, {} blocked, {} incomplete, and {} not-encoded live-baseline check(s)", - check_summary.wrong_result, - check_summary.lifecycle_fail, - check_summary.blocked, - check_summary.incomplete, - check_summary.not_encoded - ) - }; - let embedding = embedding_runtime_report(&service.cfg); - let cost_proxy = cost_proxy_report(¬es, &query_results, &embedding); + let check_summary = checks::summarize_checks(&checks); + let status = checks::project_status_from_summary(&check_summary); + let reason = report_reason(status, &check_summary); + let embedding = providers::embedding_runtime_report(&service.cfg); + let cost_proxy = checks::cost_proxy_report(¬es, &query_results, &embedding); let report = ElfBaselineReport { schema: "elf.live_baseline.elf_result/v1", status, retrieval_status, reason, - head: git_head().unwrap_or_else(|_| "unknown".to_string()), + head: corpus::git_head().unwrap_or_else(|_| "unknown".to_string()), embedding, cost_proxy, backfill: backfill.report, @@ -1859,7 +191,7 @@ async fn run(args: Args) -> color_eyre::Result { check_summary, checks, queries: query_results, - ops_cases: operational_cases(), + ops_cases: checks::operational_cases(), }; drop(service); @@ -1868,655 +200,3 @@ async fn run(args: Args) -> color_eyre::Result { Ok(report) } - -async fn build_service(runtime: &BaselineRuntime) -> color_eyre::Result { - let cfg = runtime_config(runtime)?; - let embedding_mode = embedding_mode()?; - let vector_dim = cfg.storage.qdrant.vector_dim; - let db = Db::connect(&cfg.storage.postgres).await?; - - db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; - - let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; - - qdrant.ensure_collection().await?; - - if embedding_mode == EmbeddingMode::Provider { - Ok(ElfService::new(cfg, db, qdrant)) - } else { - Ok(ElfService::with_providers(cfg, db, qdrant, deterministic_providers(vector_dim))) - } -} - -async fn build_worker_state(runtime: &BaselineRuntime) -> color_eyre::Result { - let cfg = runtime_config(runtime)?; - let db = Db::connect(&cfg.storage.postgres).await?; - - db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; - - let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; - - qdrant.ensure_collection().await?; - - let docs_qdrant = - QdrantStore::new_with_collection(&cfg.storage.qdrant, &cfg.storage.qdrant.docs_collection)?; - - docs_qdrant.ensure_collection().await?; - - let tokenizer = elf_chunking::load_tokenizer(&cfg.chunking.tokenizer_repo) - .map_err(|err| eyre::eyre!("Failed to load tokenizer for live baseline worker: {err}"))?; - let chunking = ChunkingConfig { - max_tokens: cfg.chunking.max_tokens, - overlap_tokens: cfg.chunking.overlap_tokens, - }; - - Ok(WorkerState { - db, - qdrant, - docs_qdrant, - embedding: cfg.providers.embedding, - chunking, - tokenizer, - }) -} - -async fn run_worker_until_indexed( - runtime: &BaselineRuntime, - service: &ElfService, - note_ids: &[Uuid], - label: &str, -) -> color_eyre::Result { - let concurrency = worker_concurrency(); - let mut states = Vec::with_capacity(concurrency); - - for _ in 0..concurrency { - states.push(Arc::new(build_worker_state(runtime).await?)); - } - - let before = outbox_status_counts(service, note_ids).await?; - let max_iterations = worker_max_iterations(note_ids.len()); - let mut iterations = 0_usize; - - while iterations < max_iterations { - let after = outbox_status_counts(service, note_ids).await?; - - if outbox_done(&after, note_ids.len()) { - let (chunk_rows, chunk_embedding_rows) = chunk_counts(service, note_ids).await?; - let failed_jobs = failed_outbox_jobs(service, note_ids).await?; - - return Ok(WorkerRunEvidence { - label: label.to_string(), - expected_note_count: note_ids.len(), - concurrency, - iterations, - before, - after, - chunk_rows, - chunk_embedding_rows, - failed_jobs, - }); - } - - let mut set = JoinSet::new(); - - for state in &states { - let state = Arc::clone(state); - - set.spawn(async move { - worker::process_once(&state) - .await - .map_err(|err| eyre::eyre!("Worker process_once failed: {err}")) - }); - } - - while let Some(joined) = set.join_next().await { - joined??; - } - - iterations = iterations.saturating_add(concurrency); - } - - let after = outbox_status_counts(service, note_ids).await?; - let (chunk_rows, chunk_embedding_rows) = chunk_counts(service, note_ids).await?; - let failed_jobs = failed_outbox_jobs(service, note_ids).await?; - - Ok(WorkerRunEvidence { - label: label.to_string(), - expected_note_count: note_ids.len(), - concurrency, - iterations, - before, - after, - chunk_rows, - chunk_embedding_rows, - failed_jobs, - }) -} - -async fn outbox_status_counts( - service: &ElfService, - note_ids: &[Uuid], -) -> color_eyre::Result> { - if note_ids.is_empty() { - return Ok(BTreeMap::new()); - } - - let rows = sqlx::query_as::<_, (String, i64)>( - "\ -SELECT status, COUNT(*)::bigint -FROM indexing_outbox -WHERE note_id = ANY($1) -GROUP BY status -ORDER BY status", - ) - .bind(note_ids) - .fetch_all(&service.db.pool) - .await?; - - Ok(rows.into_iter().collect()) -} - -async fn chunk_counts(service: &ElfService, note_ids: &[Uuid]) -> color_eyre::Result<(i64, i64)> { - if note_ids.is_empty() { - return Ok((0, 0)); - } - - let chunk_rows = sqlx::query_scalar::<_, i64>( - "\ -SELECT COUNT(*)::bigint -FROM memory_note_chunks -WHERE note_id = ANY($1)", - ) - .bind(note_ids) - .fetch_one(&service.db.pool) - .await?; - let chunk_embedding_rows = sqlx::query_scalar::<_, i64>( - "\ -SELECT COUNT(*)::bigint -FROM memory_note_chunks c -JOIN note_chunk_embeddings e ON e.chunk_id = c.chunk_id -WHERE c.note_id = ANY($1)", - ) - .bind(note_ids) - .fetch_one(&service.db.pool) - .await?; - - Ok((chunk_rows, chunk_embedding_rows)) -} - -async fn failed_outbox_jobs( - service: &ElfService, - note_ids: &[Uuid], -) -> color_eyre::Result> { - if note_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, (Uuid, Option, String, i32, Option)>( - "\ -SELECT o.note_id, n.key, o.op, o.attempts, o.last_error -FROM indexing_outbox o -LEFT JOIN memory_notes n ON n.note_id = o.note_id -WHERE o.note_id = ANY($1) - AND o.status = 'FAILED' -ORDER BY n.key NULLS LAST, o.note_id", - ) - .bind(note_ids) - .fetch_all(&service.db.pool) - .await?; - - Ok(rows - .into_iter() - .map(|(note_id, note_key, op, attempts, last_error)| FailedOutboxJob { - note_id, - note_key, - op, - attempts, - last_error, - }) - .collect()) -} - -async fn run_queries( - service: &ElfService, - queries: Vec, -) -> color_eyre::Result> { - let mut out = Vec::with_capacity(queries.len()); - - for case in queries { - out.push(run_single_query(service, case).await?); - } - - Ok(out) -} - -async fn run_single_query( - service: &ElfService, - case: QueryCase, -) -> color_eyre::Result { - let top_k = env::var("ELF_BASELINE_TOP_K") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(10); - let started_at = Instant::now(); - let response = service - .search_raw(SearchRequest { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - agent_id: AGENT_ID.to_string(), - token_id: None, - payload_level: PayloadLevel::L2, - read_profile: "private_only".to_string(), - query: case.query.clone(), - top_k: Some(top_k), - candidate_k: Some(top_k.max(20).saturating_mul(4)), - filter: None, - record_hits: Some(false), - ranking: None, - }) - .await?; - let latency_ms = started_at.elapsed().as_secs_f64() * 1_000.0; - let top = response.items.first(); - let top_text = top.map(|item| item.snippet.clone()).unwrap_or_default(); - let matched_terms = case - .expected_terms - .iter() - .filter(|term| contains_case_insensitive(&top_text, term)) - .cloned() - .collect::>(); - let top_key = top.and_then(|item| item.key.clone()); - let expected_docs = expected_docs_for_case(&case); - let matched_doc = - top_key.as_deref().and_then(|key| expected_docs.iter().find(|doc| key_for_doc(doc) == key)); - let top_evidence_id = top.and_then(|item| { - item.source_ref.get("document").and_then(Value::as_str).map(evidence_id_for_doc) - }); - let matched_evidence_id = matched_doc.map(|doc| evidence_id_for_doc(doc)); - let matched = matched_terms.len() == case.expected_terms.len() || matched_doc.is_some(); - let expected_evidence_ids = if case.expected_evidence_ids.is_empty() { - vec![evidence_id_for_doc(&case.expected_doc)] - } else { - case.expected_evidence_ids.clone() - }; - let allowed_alternate_evidence_ids = if case.allowed_alternate_evidence_ids.is_empty() { - case.allowed_alternate_docs.iter().map(|doc| evidence_id_for_doc(doc)).collect() - } else { - case.allowed_alternate_evidence_ids.clone() - }; - - Ok(QueryResult { - id: case.id, - task: case.task, - trace_id: response.trace_id, - query: case.query, - expected_doc: case.expected_doc, - allowed_alternate_docs: case.allowed_alternate_docs, - expected_terms: case.expected_terms, - expected_evidence_ids, - allowed_alternate_evidence_ids, - matched, - matched_terms, - top_evidence_id, - matched_evidence_id, - top_note_key: top_key, - top_snippet: top.map(|item| item.snippet.clone()), - latency_ms, - returned_count: response.items.len(), - }) -} - -async fn run_lifecycle_checks( - runtime: &BaselineRuntime, - service: &ElfService, - notes: &[CorpusNote], - note_ids: &[Uuid], -) -> color_eyre::Result> { - let Some(update_note) = notes.first() else { - return Ok(vec![incomplete_check( - "update_replaces_note_text", - "Corpus has no note to update.", - )]); - }; - let Some(update_note_id) = note_ids.first().copied() else { - return Ok(vec![incomplete_check( - "update_replaces_note_text", - "ELF add_note returned no note_id for lifecycle update.", - )]); - }; - let Some(delete_note) = notes.get(1) else { - return Ok(vec![incomplete_check( - "delete_suppresses_retrieval", - "Corpus has no note to delete.", - )]); - }; - let Some(delete_note_id) = note_ids.get(1).copied() else { - return Ok(vec![incomplete_check( - "delete_suppresses_retrieval", - "ELF add_note returned no note_id for lifecycle delete.", - )]); - }; - let Some(recovery_note) = notes.get(2) else { - return Ok(vec![incomplete_check( - "cold_start_recovery_search", - "Corpus has no stable note for recovery search.", - )]); - }; - - Ok(vec![ - run_update_replacement_check(runtime, service, update_note, update_note_id).await?, - run_delete_suppression_check(runtime, service, delete_note, delete_note_id).await?, - run_cold_start_recovery_check(runtime, service, recovery_note).await?, - ]) -} - -async fn run_update_replacement_check( - runtime: &BaselineRuntime, - service: &ElfService, - update_note: &CorpusNote, - update_note_id: Uuid, -) -> color_eyre::Result { - let update_text = "\ - Rotated auth middleware validates JWT tokens with key id `kid-v4` under \ - `RotatedJwtKeyPlan`. It still requires tenant scope `project_shared` for deployment \ - operations after the emergency key rotation." - .to_string(); - let update_response = service - .update(UpdateRequest { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - agent_id: AGENT_ID.to_string(), - note_id: update_note_id, - text: Some(update_text.clone()), - importance: None, - confidence: None, - ttl_days: None, - }) - .await?; - let update_worker = - run_worker_until_indexed(runtime, service, &[update_note_id], "lifecycle_update").await?; - let update_query = run_single_query( - service, - QueryCase::generated( - "lifecycle-update-new-marker".to_string(), - "Which rotated JWT key id does the auth middleware require?".to_string(), - update_note.source_doc.clone(), - vec!["kid-v4".to_string(), "RotatedJwtKeyPlan".to_string()], - ), - ) - .await?; - let old_marker_absent = update_query - .top_snippet - .as_deref() - .is_some_and(|snippet| !contains_case_insensitive(snippet, "kid-v3")); - let update_pass = update_query.matched - && old_marker_absent - && outbox_done(&update_worker.after, update_worker.expected_note_count); - - Ok(CheckResult { - name: "update_replaces_note_text", - status: if update_pass { "pass" } else { "lifecycle_fail" }, - reason: if update_pass { - "Service update plus worker indexing returned the new marker and removed the old marker from the top snippet.".to_string() - } else { - "Service update plus worker indexing did not produce a clean search result for the replacement marker.".to_string() - }, - evidence: serde_json::json!({ - "note_id": update_note_id, - "op": update_response.op, - "worker": update_worker, - "query": update_query, - "old_marker_absent": old_marker_absent, - }), - }) -} - -async fn run_delete_suppression_check( - runtime: &BaselineRuntime, - service: &ElfService, - delete_note: &CorpusNote, - delete_note_id: Uuid, -) -> color_eyre::Result { - let delete_response = service - .delete(DeleteRequest { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - agent_id: AGENT_ID.to_string(), - note_id: delete_note_id, - }) - .await?; - let delete_worker = - run_worker_until_indexed(runtime, service, &[delete_note_id], "lifecycle_delete").await?; - let delete_query = run_single_query( - service, - QueryCase::generated( - "lifecycle-delete-suppresses-note".to_string(), - delete_note.text.clone(), - delete_note.source_doc.clone(), - distinctive_terms(&delete_note.text, 2), - ), - ) - .await?; - let delete_pass = !delete_query.matched - && outbox_done(&delete_worker.after, delete_worker.expected_note_count); - - Ok(CheckResult { - name: "delete_suppresses_retrieval", - status: if delete_pass { "pass" } else { "lifecycle_fail" }, - reason: if delete_pass { - "Service delete suppressed the deleted note from subsequent search results.".to_string() - } else { - "Deleted note was still retrievable after service delete and worker indexing." - .to_string() - }, - evidence: serde_json::json!({ - "note_id": delete_note_id, - "op": delete_response.op, - "worker": delete_worker, - "query": delete_query, - }), - }) -} - -async fn run_cold_start_recovery_check( - runtime: &BaselineRuntime, - service: &ElfService, - recovery_note: &CorpusNote, -) -> color_eyre::Result { - let recovery_service = build_service(runtime).await?; - let recovery_query = run_single_query( - &recovery_service, - QueryCase::generated( - "lifecycle-cold-start-recovery".to_string(), - recovery_note.text.clone(), - recovery_note.source_doc.clone(), - distinctive_terms(&recovery_note.text, 2), - ), - ) - .await?; - let outbox_counts = pending_outbox_counts(service).await?; - - Ok(CheckResult { - name: "cold_start_recovery_search", - status: if recovery_query.matched { "pass" } else { "lifecycle_fail" }, - reason: if recovery_query.matched { - "A newly constructed service over the same Postgres and Qdrant stores retrieved persisted evidence.".to_string() - } else { - "A newly constructed service over the same stores could not retrieve persisted evidence.".to_string() - }, - evidence: serde_json::json!({ - "query": recovery_query, - "pending_outbox_by_op": outbox_counts, - "note": recovery_note.source_doc, - }), - }) -} - -async fn pending_outbox_counts(service: &ElfService) -> color_eyre::Result> { - let rows = sqlx::query_as::<_, (String, i64)>( - "\ -SELECT op, COUNT(*)::bigint -FROM indexing_outbox -WHERE status = 'PENDING' -GROUP BY op -ORDER BY op", - ) - .fetch_all(&service.db.pool) - .await?; - - Ok(rows.into_iter().collect()) -} - -async fn run_concurrent_write_check( - runtime: &BaselineRuntime, - service: Arc, -) -> color_eyre::Result { - let note_count = concurrent_note_count(); - let mut set = JoinSet::new(); - - for index in 0..note_count { - let request = concurrent_add_request(index); - let service_ref = Arc::clone(&service); - - set.spawn(async move { - let response = service_ref.add_note(request).await?; - let note_id = response - .results - .first() - .and_then(|result| result.note_id) - .ok_or_else(|| eyre::eyre!("Concurrent add_note did not return a note_id."))?; - - Ok::(note_id) - }); - } - - let mut note_ids = Vec::with_capacity(note_count); - - while let Some(joined) = set.join_next().await { - note_ids.push(joined??); - } - - let worker_evidence = - run_worker_until_indexed(runtime, &service, ¬e_ids, "concurrent_upsert").await?; - let probe_indexes = concurrency_probe_indexes(note_count); - let mut query_results = Vec::new(); - - for index in probe_indexes { - query_results.push(run_single_query(&service, concurrent_query_case(index)).await?); - } - - let pass_count = query_results.iter().filter(|result| result.matched).count(); - let pass = outbox_done(&worker_evidence.after, worker_evidence.expected_note_count) - && pass_count == query_results.len(); - - Ok(CheckResult { - name: "concurrent_write_search_e2e", - status: if pass { "pass" } else { "lifecycle_fail" }, - reason: if pass { - "Concurrent add_note calls were indexed by the worker and remained searchable." - .to_string() - } else { - "Concurrent add_note calls did not all become searchable after worker indexing." - .to_string() - }, - evidence: serde_json::json!({ - "note_count": note_count, - "worker": worker_evidence, - "query_summary": { - "total": query_results.len(), - "pass": pass_count, - "fail": query_results.len().saturating_sub(pass_count), - }, - "queries": query_results, - }), - }) -} - -async fn run_soak_stability_check( - runtime: &BaselineRuntime, - service: Arc, -) -> color_eyre::Result> { - let config = soak_config(); - - if config.target_seconds == 0 && config.write_rounds == 0 { - return Ok(None); - } - - let target_duration = Duration::from_secs(config.target_seconds); - let started_at = Instant::now(); - let write_rounds = config.write_rounds.max(if config.target_seconds > 0 { 1 } else { 0 }); - let mut note_ids = Vec::with_capacity(write_rounds); - let mut worker_runs = Vec::with_capacity(write_rounds); - let mut query_results = Vec::new(); - - for index in 0..write_rounds { - let response = service.add_note(soak_add_request(index)).await?; - let note_id = response - .results - .first() - .and_then(|result| result.note_id) - .ok_or_else(|| eyre::eyre!("Soak add_note did not return a note_id."))?; - - note_ids.push(note_id); - worker_runs - .push(run_worker_until_indexed(runtime, &service, &[note_id], "soak_upsert").await?); - query_results.push(run_single_query(&service, soak_query_case(index)).await?); - - if config.target_seconds > 0 && write_rounds > 1 { - let target_elapsed = target_duration.mul_f64((index + 1) as f64 / write_rounds as f64); - - if started_at.elapsed() < target_elapsed { - time::sleep(target_elapsed.saturating_sub(started_at.elapsed())).await; - } - } - } - - let mut probe_index = 0; - - while started_at.elapsed() < target_duration { - let index = probe_index % write_rounds; - - query_results.push(run_single_query(&service, soak_query_case(index)).await?); - - probe_index += 1; - - let sleep_for = Duration::from_millis(config.probe_interval_millis) - .min(target_duration.saturating_sub(started_at.elapsed())); - - if !sleep_for.is_zero() { - time::sleep(sleep_for).await; - } - } - - let elapsed_seconds = started_at.elapsed().as_secs_f64(); - let pass_count = query_results.iter().filter(|result| result.matched).count(); - let query_fail_count = query_results.len().saturating_sub(pass_count); - let worker_pass = - worker_runs.iter().all(|run| outbox_done(&run.after, run.expected_note_count)); - let duration_pass = target_duration.is_zero() || started_at.elapsed() >= target_duration; - let pass = worker_pass && duration_pass && query_fail_count == 0; - let failed_queries = query_results.iter().filter(|result| !result.matched).collect::>(); - - Ok(Some(CheckResult { - name: "soak_stability_e2e", - status: if pass { "pass" } else { "lifecycle_fail" }, - reason: if pass { - "ELF sustained repeated write, worker indexing, and search probes for the configured soak window.".to_string() - } else { - "ELF did not sustain the configured soak write/search window without a failed worker or retrieval probe.".to_string() - }, - evidence: serde_json::json!({ - "config": config, - "elapsed_seconds": elapsed_seconds, - "duration_met": duration_pass, - "worker_pass": worker_pass, - "write_note_ids": note_ids, - "worker_runs": worker_runs, - "query_summary": { - "total": query_results.len(), - "pass": pass_count, - "fail": query_fail_count, - }, - "failed_queries": failed_queries, - }), - })) -} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/backfill.rs b/apps/elf-eval/src/bin/live_baseline_elf/backfill.rs new file mode 100644 index 00000000..d85632a9 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/backfill.rs @@ -0,0 +1,429 @@ +use color_eyre::Result; + +use crate::{ + AGENT_ID, AddNoteInput, AddNoteRequest, BACKFILL_CHECKPOINT_SCHEMA, BTreeMap, + BackfillAttemptEvidence, BackfillCheckpoint, BackfillCheckpointEntry, BackfillOutcome, + BackfillReport, BackfillResumeReport, CorpusNote, DuplicateSourceNote, ElfService, + ExistingBackfillNote, Hasher, Instant, NoteOp, PROJECT_ID, Path, PathBuf, SCOPE, TENANT_ID, + Uuid, env, eyre, fs, +}; + +pub(super) fn backfill_batch_size() -> usize { + crate::parse_env_usize("ELF_BASELINE_BACKFILL_BATCH_SIZE").unwrap_or(32).max(1) +} + +pub(super) fn worker_concurrency() -> usize { + let default = match env::var("ELF_BASELINE_PROFILE").as_deref() { + Ok("backfill" | "large") => 4, + Ok("stress") => 4, + Ok("scale" | "full") => 2, + _ => 1, + }; + + crate::parse_env_usize("ELF_BASELINE_WORKER_CONCURRENCY").unwrap_or(default).clamp(1, 32) +} + +pub(super) fn backfill_resume_probe_enabled() -> bool { + env::var("ELF_BASELINE_BACKFILL_RESUME_PROBE") + .map(|value| value != "0" && !value.eq_ignore_ascii_case("false")) + .unwrap_or(true) +} + +pub(super) fn backfill_interrupt_after(source_count: usize) -> Option { + if !backfill_resume_probe_enabled() || source_count <= 1 { + return None; + } + + let configured = crate::parse_env_usize("ELF_BASELINE_BACKFILL_INTERRUPT_AFTER"); + let default = (source_count / 2).max(1); + + Some(configured.unwrap_or(default).clamp(1, source_count.saturating_sub(1))) +} + +pub(super) fn backfill_checkpoint_path(out: &Path) -> PathBuf { + crate::env_string(&["ELF_BASELINE_BACKFILL_CHECKPOINT"]) + .map(PathBuf::from) + .unwrap_or_else(|| out.with_file_name("elf-backfill-checkpoint.json")) +} + +pub(super) fn empty_backfill_checkpoint(corpus_hash: &str) -> BackfillCheckpoint { + BackfillCheckpoint { + schema: BACKFILL_CHECKPOINT_SCHEMA.to_string(), + corpus_hash: corpus_hash.to_string(), + completed: BTreeMap::new(), + } +} + +pub(super) fn load_backfill_checkpoint( + path: &Path, + corpus_hash: &str, +) -> Result { + if !path.exists() { + return Ok(empty_backfill_checkpoint(corpus_hash)); + } + + let raw = fs::read_to_string(path)?; + let checkpoint = serde_json::from_str::(&raw)?; + + if checkpoint.schema == BACKFILL_CHECKPOINT_SCHEMA && checkpoint.corpus_hash == corpus_hash { + Ok(checkpoint) + } else { + Ok(empty_backfill_checkpoint(corpus_hash)) + } +} + +pub(super) fn write_backfill_checkpoint( + path: &Path, + checkpoint: &BackfillCheckpoint, +) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let raw = serde_json::to_string_pretty(checkpoint)?; + let tmp_path = path.with_extension("json.tmp"); + + fs::write(&tmp_path, raw)?; + fs::rename(tmp_path, path)?; + + Ok(()) +} + +pub(super) fn source_hash(note: &CorpusNote) -> String { + let mut hasher = Hasher::new(); + + hasher.update(note.source_doc.as_bytes()); + hasher.update(b"\0"); + hasher.update(note.key.as_bytes()); + hasher.update(b"\0"); + hasher.update(note.text.as_bytes()); + + hasher.finalize().to_hex().to_string() +} + +pub(super) fn corpus_hash(notes: &[CorpusNote]) -> String { + let mut hasher = Hasher::new(); + + for note in notes { + hasher.update(note.source_doc.as_bytes()); + hasher.update(b"\0"); + hasher.update(source_hash(note).as_bytes()); + hasher.update(b"\0"); + } + + hasher.finalize().to_hex().to_string() +} + +pub(super) fn checkpoint_entry_valid( + note: &CorpusNote, + entry: &BackfillCheckpointEntry, + existing: &BTreeMap, +) -> bool { + let expected_hash = source_hash(note); + + if entry.source_hash != expected_hash { + return false; + } + + existing.get(¬e.source_doc).is_some_and(|stored| { + stored.note_id == entry.note_id + && stored.source_hash.as_deref() == Some(expected_hash.as_str()) + }) +} + +pub(super) fn note_input(note: &CorpusNote) -> AddNoteInput { + let hash = source_hash(note); + + AddNoteInput { + r#type: "fact".to_string(), + key: Some(note.key.clone()), + text: note.text.clone(), + structured: None, + importance: 0.9, + confidence: 0.95, + ttl_days: None, + source_ref: serde_json::json!({ + "source": "ELF live baseline corpus", + "title": note.title, + "document": note.source_doc, + "source_hash": hash, + }), + write_policy: None, + } +} + +pub(super) fn note_op_string(op: NoteOp) -> Result { + let value = serde_json::to_value(op)?; + + value + .as_str() + .map(ToString::to_string) + .ok_or_else(|| eyre::eyre!("Serialized note op was not a string.")) +} + +pub(super) async fn load_existing_backfill_notes( + service: &ElfService, +) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, String, Option)>( + "\ +SELECT note_id, source_ref->>'document' AS source_doc, source_ref->>'source_hash' AS source_hash +FROM memory_notes +WHERE tenant_id = $1 + AND project_id = $2 + AND agent_id = $3 + AND scope = $4 + AND status = 'active' + AND source_ref->>'source' = 'ELF live baseline corpus' + AND source_ref->>'document' IS NOT NULL +ORDER BY updated_at DESC", + ) + .bind(TENANT_ID) + .bind(PROJECT_ID) + .bind(AGENT_ID) + .bind(SCOPE) + .fetch_all(&service.db.pool) + .await?; + let mut out = BTreeMap::new(); + + for (note_id, source_doc, hash) in rows { + out.entry(source_doc).or_insert(ExistingBackfillNote { note_id, source_hash: hash }); + } + + Ok(out) +} + +pub(super) async fn duplicate_source_notes( + service: &ElfService, +) -> Result> { + let rows = sqlx::query_as::<_, (String, i64, Vec)>( + "\ +SELECT + source_ref->>'document' AS source_doc, + COUNT(*)::bigint AS count, + array_agg(note_id ORDER BY note_id)::uuid[] AS note_ids +FROM memory_notes +WHERE tenant_id = $1 + AND project_id = $2 + AND agent_id = $3 + AND scope = $4 + AND status = 'active' + AND source_ref->>'source' = 'ELF live baseline corpus' + AND source_ref->>'document' IS NOT NULL +GROUP BY source_ref->>'document' +HAVING COUNT(*) > 1 +ORDER BY source_doc", + ) + .bind(TENANT_ID) + .bind(PROJECT_ID) + .bind(AGENT_ID) + .bind(SCOPE) + .fetch_all(&service.db.pool) + .await?; + + Ok(rows + .into_iter() + .map(|(source_doc, count, note_ids)| DuplicateSourceNote { source_doc, count, note_ids }) + .collect()) +} + +pub(super) async fn run_resumable_backfill( + service: &ElfService, + notes: &[CorpusNote], + checkpoint_path: &Path, +) -> Result { + let started_at = Instant::now(); + let corpus_hash = corpus_hash(notes); + let batch_size = backfill_batch_size(); + let interrupt_after = backfill_interrupt_after(notes.len()); + let first_attempt = run_backfill_attempt( + service, + notes, + checkpoint_path, + &corpus_hash, + batch_size, + 1, + interrupt_after, + ) + .await?; + let interrupted = first_attempt.interrupted; + let completed_before_resume = first_attempt.checkpoint_completed; + let mut attempts = Vec::new(); + + attempts.push(first_attempt); + + if interrupted { + attempts.push( + run_backfill_attempt( + service, + notes, + checkpoint_path, + &corpus_hash, + batch_size, + 2, + None, + ) + .await?, + ); + } + + let checkpoint = load_backfill_checkpoint(checkpoint_path, &corpus_hash)?; + let existing = load_existing_backfill_notes(service).await?; + let mut note_ids = Vec::with_capacity(notes.len()); + + for note in notes { + let Some(entry) = checkpoint.completed.get(¬e.source_doc) else { + return Err(eyre::eyre!( + "Backfill checkpoint missing completed source {}.", + note.source_doc + )); + }; + + if !checkpoint_entry_valid(note, entry, &existing) { + return Err(eyre::eyre!( + "Backfill checkpoint entry for {} does not match Postgres state.", + note.source_doc + )); + } + + note_ids.push(entry.note_id); + } + + let duplicate_source_notes = duplicate_source_notes(service).await?; + let attempted_writes = attempts.iter().map(|attempt| attempt.attempted_writes).sum(); + let skipped_completed = attempts.iter().map(|attempt| attempt.skipped_completed).sum(); + let completed_after_resume = checkpoint.completed.len(); + let report = BackfillReport { + checkpoint_path: checkpoint_path.display().to_string(), + corpus_hash, + source_count: notes.len(), + completed_count: note_ids.len(), + batch_size, + worker_concurrency: worker_concurrency(), + elapsed_seconds: started_at.elapsed().as_secs_f64(), + attempted_writes, + skipped_completed, + duplicate_source_notes, + resume: BackfillResumeReport { + enabled: interrupt_after.is_some(), + interrupted, + interrupt_after, + resume_attempts: attempts.len(), + completed_before_resume, + completed_after_resume, + }, + attempts, + }; + + Ok(BackfillOutcome { report, note_ids }) +} + +pub(super) async fn run_backfill_attempt( + service: &ElfService, + notes: &[CorpusNote], + checkpoint_path: &Path, + corpus_hash: &str, + batch_size: usize, + attempt: usize, + interrupt_after: Option, +) -> Result { + let mut checkpoint = load_backfill_checkpoint(checkpoint_path, corpus_hash)?; + let existing = load_existing_backfill_notes(service).await?; + let notes_by_source = + notes.iter().map(|note| (note.source_doc.as_str(), note)).collect::>(); + let checkpoint_len_before_prune = checkpoint.completed.len(); + + checkpoint.completed.retain(|source_doc, entry| { + notes_by_source + .get(source_doc.as_str()) + .is_some_and(|note| checkpoint_entry_valid(note, entry, &existing)) + }); + + if checkpoint.completed.len() != checkpoint_len_before_prune { + write_backfill_checkpoint(checkpoint_path, &checkpoint)?; + } + + let mut pending = Vec::new(); + let mut skipped_completed = 0_usize; + + for note in notes { + if checkpoint.completed.contains_key(¬e.source_doc) { + skipped_completed += 1; + } else { + pending.push(note); + } + } + + let max_writes = interrupt_after.unwrap_or(usize::MAX); + let mut attempted_writes = 0_usize; + let mut completed_writes = 0_usize; + let mut cursor = 0_usize; + + while cursor < pending.len() && attempted_writes < max_writes { + let remaining_budget = max_writes.saturating_sub(attempted_writes); + let take = batch_size.min(remaining_budget).min(pending.len() - cursor); + let batch = &pending[cursor..cursor + take]; + let response = service + .add_note(AddNoteRequest { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + agent_id: AGENT_ID.to_string(), + scope: SCOPE.to_string(), + notes: batch.iter().map(|note| note_input(note)).collect(), + }) + .await?; + + if response.results.len() != batch.len() { + return Err(eyre::eyre!( + "Backfill add_note returned {} results for {} inputs.", + response.results.len(), + batch.len() + )); + } + + for (note, result) in batch.iter().zip(response.results) { + let op = note_op_string(result.op)?; + + if op == "REJECTED" { + return Err(eyre::eyre!( + "Backfill note {} was rejected: {:?}.", + note.source_doc, + result.reason_code + )); + } + + let note_id = result.note_id.ok_or_else(|| { + eyre::eyre!("Backfill note {} did not return a note_id.", note.source_doc) + })?; + + checkpoint.completed.insert( + note.source_doc.clone(), + BackfillCheckpointEntry { + note_id, + key: note.key.clone(), + source_hash: source_hash(note), + op, + }, + ); + + completed_writes += 1; + } + + attempted_writes += batch.len(); + cursor += batch.len(); + + write_backfill_checkpoint(checkpoint_path, &checkpoint)?; + } + + let interrupted = cursor < pending.len(); + + Ok(BackfillAttemptEvidence { + attempt, + resumed: skipped_completed > 0, + interrupt_after, + skipped_completed, + attempted_writes, + completed_writes, + checkpoint_completed: checkpoint.completed.len(), + interrupted, + }) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/checks.rs b/apps/elf-eval/src/bin/live_baseline_elf/checks.rs new file mode 100644 index 00000000..7f427ead --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/checks.rs @@ -0,0 +1,352 @@ +#[path = "checks/lifecycle.rs"] mod lifecycle; +#[path = "checks/reporting.rs"] mod reporting; +#[path = "checks/resource.rs"] mod resource; +#[path = "checks/stress.rs"] mod stress; + +use color_eyre::Result; + +use crate::{ + AGENT_ID, AddNoteInput, AddNoteRequest, Arc, BTreeMap, BackfillReport, BaselineRuntime, + CheckResult, CheckSummary, CorpusNote, CostProxyReport, DeleteRequest, Duration, ElfService, + EmbeddingRuntimeReport, Instant, JoinSet, OperationalCase, PROJECT_ID, Path, QueryCase, + QueryResult, Report, ResourceEnvelopeEvidence, SCOPE, SoakConfig, TENANT_ID, UpdateRequest, + Uuid, WorkerRunEvidence, contains_case_insensitive, distinctive_terms, env, eyre, fs, + run_single_query, + runtime::{build_service, run_worker_until_indexed}, + time, +}; + +pub(super) fn outbox_done(counts: &BTreeMap, expected_note_count: usize) -> bool { + let done = counts.get("DONE").copied().unwrap_or_default(); + let expected = i64::try_from(expected_note_count).unwrap_or(i64::MAX); + let pending = counts.get("PENDING").copied().unwrap_or_default(); + let failed = counts.get("FAILED").copied().unwrap_or_default(); + let claimed = counts.get("CLAIMED").copied().unwrap_or_default(); + + done >= expected && pending == 0 && failed == 0 && claimed == 0 +} + +pub(super) fn retrieval_check(query_results: &[QueryResult]) -> CheckResult { + let pass_count = query_results.iter().filter(|result| result.matched).count(); + let fail_count = query_results.len().saturating_sub(pass_count); + let expected_evidence_ids = query_results + .iter() + .map(|result| { + serde_json::json!({ + "query_id": result.id, + "expected": result.expected_evidence_ids, + "allowed_alternates": result.allowed_alternate_evidence_ids, + }) + }) + .collect::>(); + + CheckResult { + name: "same_corpus_retrieval", + status: if fail_count == 0 { "pass" } else { "wrong_result" }, + reason: if fail_count == 0 { + "All same-corpus retrieval queries returned expected evidence.".to_string() + } else { + format!("{fail_count} same-corpus retrieval query case(s) missed expected evidence.") + }, + evidence: serde_json::json!({ + "total": query_results.len(), + "pass": pass_count, + "fail": fail_count, + "wrong_result_count": fail_count, + "expected_evidence_ids": expected_evidence_ids, + }), + } +} + +pub(super) fn worker_indexing_check(evidence: WorkerRunEvidence) -> CheckResult { + let pass = outbox_done(&evidence.after, evidence.expected_note_count) + && evidence.chunk_rows >= i64::try_from(evidence.expected_note_count).unwrap_or(i64::MAX) + && evidence.chunk_embedding_rows >= evidence.chunk_rows; + + CheckResult { + name: "async_worker_indexing_e2e", + status: if pass { "pass" } else { "lifecycle_fail" }, + reason: if pass { + "ELF worker processed corpus outbox jobs into persisted chunks and embeddings." + .to_string() + } else { + "ELF worker did not fully process corpus outbox jobs into searchable chunks." + .to_string() + }, + evidence: serde_json::json!(evidence), + } +} + +pub(super) fn resumable_backfill_check(report: &BackfillReport) -> CheckResult { + let resume_pass = !report.resume.enabled + || (report.resume.interrupted + && report.resume.resume_attempts >= 2 + && report.skipped_completed > 0); + let pass = report.completed_count == report.source_count + && report.duplicate_source_notes.is_empty() + && resume_pass; + + CheckResult { + name: "resumable_backfill_no_duplicates", + status: if pass { "pass" } else { "lifecycle_fail" }, + reason: if pass { + "Checkpointed backfill resumed from durable progress and did not duplicate source documents." + .to_string() + } else { + "Checkpointed backfill did not complete cleanly, did not prove resume, or duplicated source documents." + .to_string() + }, + evidence: serde_json::json!(report), + } +} + +pub(super) fn concurrent_note_count() -> usize { + if let Ok(value) = env::var("ELF_BASELINE_CONCURRENT_NOTES") + && let Ok(parsed) = value.parse::() + { + return parsed.max(1); + } + + match env::var("ELF_BASELINE_PROFILE").as_deref() { + Ok("backfill" | "large") => 32, + Ok("stress") => 32, + Ok("scale" | "full") => 16, + _ => 4, + } +} + +pub(super) fn concurrent_add_request(index: usize) -> AddNoteRequest { + let marker = concurrent_marker(index); + + AddNoteRequest { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + agent_id: AGENT_ID.to_string(), + scope: SCOPE.to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some(format!("concurrent_{index:03}")), + text: format!( + "Concurrent benchmark note {index:03} records marker `{marker}` for write race validation." + ), + structured: None, + importance: 0.91, + confidence: 0.96, + ttl_days: None, + source_ref: serde_json::json!({ + "source": "ELF live baseline concurrent write check", + "document": format!("concurrent-{index:03}.md"), + }), + write_policy: None, + }], + } +} + +pub(super) fn concurrent_query_case(index: usize) -> QueryCase { + let marker = concurrent_marker(index); + + QueryCase::generated( + format!("concurrent-{index:03}"), + format!("Find the concurrent benchmark note containing marker {marker}."), + format!("concurrent-{index:03}.md"), + vec![marker], + ) +} + +pub(super) fn concurrent_marker(index: usize) -> String { + format!("concurrency-{}-{index:03}", marker_word(index)) +} + +pub(super) fn soak_config() -> SoakConfig { + let profile = env::var("ELF_BASELINE_PROFILE").ok(); + let (default_seconds, default_rounds) = match profile.as_deref() { + Some("backfill" | "large") => (60, 6), + Some("stress") => (60, 6), + Some("scale" | "full") => (15, 3), + _ => (0, 0), + }; + + SoakConfig { + target_seconds: parse_env_u64("ELF_BASELINE_SOAK_SECONDS").unwrap_or(default_seconds), + write_rounds: parse_env_usize("ELF_BASELINE_SOAK_ROUNDS").unwrap_or(default_rounds), + probe_interval_millis: parse_env_u64("ELF_BASELINE_SOAK_PROBE_INTERVAL_MS") + .unwrap_or(1_000) + .max(100), + } +} + +pub(super) fn parse_env_u64(name: &str) -> Option { + env::var(name).ok()?.parse::().ok() +} + +pub(super) fn parse_env_usize(name: &str) -> Option { + env::var(name).ok()?.parse::().ok() +} + +pub(super) fn soak_add_request(index: usize) -> AddNoteRequest { + let marker = soak_marker(index); + let (topic, detail) = soak_topic(index); + + AddNoteRequest { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + agent_id: AGENT_ID.to_string(), + scope: SCOPE.to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some(format!("soak_{index:03}")), + text: format!( + "Soak benchmark note {index:03} covers {topic}. {detail} It records stability marker `{marker}` for repeated worker and search probes." + ), + structured: None, + importance: 0.92, + confidence: 0.97, + ttl_days: None, + source_ref: serde_json::json!({ + "source": "ELF live baseline soak stability check", + "document": format!("soak-{index:03}.md"), + }), + write_policy: None, + }], + } +} + +pub(super) fn soak_query_case(index: usize) -> QueryCase { + let marker = soak_marker(index); + let (topic, _) = soak_topic(index); + + QueryCase::generated( + format!("soak-{index:03}"), + format!("Find the soak benchmark note about {topic} containing marker {marker}."), + format!("soak-{index:03}.md"), + vec![marker], + ) +} + +pub(super) fn soak_marker(index: usize) -> String { + format!("soak-stability-{}-{index:03}", marker_word(index)) +} + +pub(super) fn marker_word(index: usize) -> &'static str { + const WORDS: &[&str] = &[ + "aurora", "banyan", "cobalt", "delta", "ember", "fennel", "granite", "harbor", "indigo", + "jasper", "keystone", "lantern", "meridian", "nebula", "onyx", "prairie", "quartz", + "raven", "solstice", "topaz", "umbra", "verdant", "willow", "xenon", "yarrow", "zephyr", + "atlas", "beacon", "citadel", "drift", "equinox", "forge", + ]; + + WORDS[index % WORDS.len()] +} + +pub(super) fn soak_topic(index: usize) -> (&'static str, &'static str) { + const TOPICS: &[(&str, &str)] = &[ + ( + "release rollback fencing", + "The rollback controller waits for a signed deploy fence before the next canary.", + ), + ( + "invoice export batching", + "The exporter groups invoice CSV rows by merchant ledger before upload.", + ), + ("search shard warming", "The search router warms tenant shard caches before rank probes."), + ( + "incident pager routing", + "The incident desk routes page ownership through the release captain.", + ), + ( + "backup restore rehearsal", + "The restore rehearsal checks WAL freshness before dry-run recovery.", + ), + ( + "feature flag expiry", + "The flag sweeper archives expired toggles before deleting rollout rules.", + ), + ( + "support queue triage", + "The support classifier separates billing tickets from access tickets.", + ), + ( + "analytics job watermark", + "The analytics worker stores a warehouse watermark after each import.", + ), + ]; + + TOPICS[index % TOPICS.len()] +} + +pub(super) fn concurrency_probe_indexes(note_count: usize) -> Vec { + let mut indexes = vec![0, note_count / 2, note_count.saturating_sub(1)]; + + indexes.sort_unstable(); + indexes.dedup(); + + indexes +} + +pub(super) fn cost_proxy_report( + notes: &[CorpusNote], + queries: &[QueryResult], + embedding: &EmbeddingRuntimeReport, +) -> CostProxyReport { + reporting::cost_proxy_report_impl(notes, queries, embedding) +} + +pub(super) fn latency_percentile(latencies: &[f64], percentile: f64) -> f64 { + reporting::latency_percentile_impl(latencies, percentile) +} + +pub(super) fn operational_cases() -> Vec { + reporting::operational_cases_impl() +} + +pub(super) fn incomplete_check(name: &'static str, reason: &str) -> CheckResult { + reporting::incomplete_check_impl(name, reason) +} + +pub(super) fn summarize_checks(checks: &[CheckResult]) -> CheckSummary { + reporting::summarize_checks_impl(checks) +} + +pub(super) fn project_status_from_summary(summary: &CheckSummary) -> &'static str { + reporting::project_status_from_summary_impl(summary) +} + +pub(super) async fn resource_envelope_check( + service: &ElfService, + corpus_dir: &Path, + report_path: &Path, + checkpoint_path: &Path, + elapsed_seconds: f64, +) -> CheckResult { + resource::resource_envelope_check_impl( + service, + corpus_dir, + report_path, + checkpoint_path, + elapsed_seconds, + ) + .await +} + +pub(super) async fn run_lifecycle_checks( + runtime: &BaselineRuntime, + service: &ElfService, + notes: &[CorpusNote], + note_ids: &[Uuid], +) -> Result> { + lifecycle::run_lifecycle_checks_impl(runtime, service, notes, note_ids).await +} + +pub(super) async fn run_concurrent_write_check( + runtime: &BaselineRuntime, + service: Arc, +) -> Result { + stress::run_concurrent_write_check_impl(runtime, service).await +} + +pub(super) async fn run_soak_stability_check( + runtime: &BaselineRuntime, + service: Arc, +) -> Result> { + stress::run_soak_stability_check_impl(runtime, service).await +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/checks/lifecycle.rs b/apps/elf-eval/src/bin/live_baseline_elf/checks/lifecycle.rs new file mode 100644 index 00000000..13a12ba2 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/checks/lifecycle.rs @@ -0,0 +1,209 @@ +use color_eyre::Result; + +use crate::checks::{ + self, AGENT_ID, BTreeMap, BaselineRuntime, CheckResult, CorpusNote, DeleteRequest, ElfService, + PROJECT_ID, QueryCase, TENANT_ID, UpdateRequest, Uuid, +}; + +pub(super) async fn run_lifecycle_checks_impl( + runtime: &BaselineRuntime, + service: &ElfService, + notes: &[CorpusNote], + note_ids: &[Uuid], +) -> Result> { + let Some(update_note) = notes.first() else { + return Ok(vec![checks::incomplete_check( + "update_replaces_note_text", + "Corpus has no note to update.", + )]); + }; + let Some(update_note_id) = note_ids.first().copied() else { + return Ok(vec![checks::incomplete_check( + "update_replaces_note_text", + "ELF add_note returned no note_id for lifecycle update.", + )]); + }; + let Some(delete_note) = notes.get(1) else { + return Ok(vec![checks::incomplete_check( + "delete_suppresses_retrieval", + "Corpus has no note to delete.", + )]); + }; + let Some(delete_note_id) = note_ids.get(1).copied() else { + return Ok(vec![checks::incomplete_check( + "delete_suppresses_retrieval", + "ELF add_note returned no note_id for lifecycle delete.", + )]); + }; + let Some(recovery_note) = notes.get(2) else { + return Ok(vec![checks::incomplete_check( + "cold_start_recovery_search", + "Corpus has no stable note for recovery search.", + )]); + }; + + Ok(vec![ + run_update_replacement_check(runtime, service, update_note, update_note_id).await?, + run_delete_suppression_check(runtime, service, delete_note, delete_note_id).await?, + run_cold_start_recovery_check(runtime, service, recovery_note).await?, + ]) +} + +async fn run_update_replacement_check( + runtime: &BaselineRuntime, + service: &ElfService, + update_note: &CorpusNote, + update_note_id: Uuid, +) -> Result { + let update_text = "\ + Rotated auth middleware validates JWT tokens with key id `kid-v4` under \ + `RotatedJwtKeyPlan`. It still requires tenant scope `project_shared` for deployment \ + operations after the emergency key rotation." + .to_string(); + let update_response = service + .update(UpdateRequest { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + agent_id: AGENT_ID.to_string(), + note_id: update_note_id, + text: Some(update_text.clone()), + importance: None, + confidence: None, + ttl_days: None, + }) + .await?; + let update_worker = + checks::run_worker_until_indexed(runtime, service, &[update_note_id], "lifecycle_update") + .await?; + let update_query = checks::run_single_query( + service, + QueryCase::generated( + "lifecycle-update-new-marker".to_string(), + "Which rotated JWT key id does the auth middleware require?".to_string(), + update_note.source_doc.clone(), + vec!["kid-v4".to_string(), "RotatedJwtKeyPlan".to_string()], + ), + ) + .await?; + let old_marker_absent = update_query + .top_snippet + .as_deref() + .is_some_and(|snippet| !checks::contains_case_insensitive(snippet, "kid-v3")); + let update_pass = update_query.matched + && old_marker_absent + && checks::outbox_done(&update_worker.after, update_worker.expected_note_count); + + Ok(CheckResult { + name: "update_replaces_note_text", + status: if update_pass { "pass" } else { "lifecycle_fail" }, + reason: if update_pass { + "Service update plus worker indexing returned the new marker and removed the old marker from the top snippet.".to_string() + } else { + "Service update plus worker indexing did not produce a clean search result for the replacement marker.".to_string() + }, + evidence: serde_json::json!({ + "note_id": update_note_id, + "op": update_response.op, + "worker": update_worker, + "query": update_query, + "old_marker_absent": old_marker_absent, + }), + }) +} + +async fn run_delete_suppression_check( + runtime: &BaselineRuntime, + service: &ElfService, + delete_note: &CorpusNote, + delete_note_id: Uuid, +) -> Result { + let delete_response = service + .delete(DeleteRequest { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + agent_id: AGENT_ID.to_string(), + note_id: delete_note_id, + }) + .await?; + let delete_worker = + checks::run_worker_until_indexed(runtime, service, &[delete_note_id], "lifecycle_delete") + .await?; + let delete_query = checks::run_single_query( + service, + QueryCase::generated( + "lifecycle-delete-suppresses-note".to_string(), + delete_note.text.clone(), + delete_note.source_doc.clone(), + checks::distinctive_terms(&delete_note.text, 2), + ), + ) + .await?; + let delete_pass = !delete_query.matched + && checks::outbox_done(&delete_worker.after, delete_worker.expected_note_count); + + Ok(CheckResult { + name: "delete_suppresses_retrieval", + status: if delete_pass { "pass" } else { "lifecycle_fail" }, + reason: if delete_pass { + "Service delete suppressed the deleted note from subsequent search results.".to_string() + } else { + "Deleted note was still retrievable after service delete and worker indexing." + .to_string() + }, + evidence: serde_json::json!({ + "note_id": delete_note_id, + "op": delete_response.op, + "worker": delete_worker, + "query": delete_query, + }), + }) +} + +async fn run_cold_start_recovery_check( + runtime: &BaselineRuntime, + service: &ElfService, + recovery_note: &CorpusNote, +) -> Result { + let recovery_service = checks::build_service(runtime).await?; + let recovery_query = checks::run_single_query( + &recovery_service, + QueryCase::generated( + "lifecycle-cold-start-recovery".to_string(), + recovery_note.text.clone(), + recovery_note.source_doc.clone(), + checks::distinctive_terms(&recovery_note.text, 2), + ), + ) + .await?; + let outbox_counts = pending_outbox_counts(service).await?; + + Ok(CheckResult { + name: "cold_start_recovery_search", + status: if recovery_query.matched { "pass" } else { "lifecycle_fail" }, + reason: if recovery_query.matched { + "A newly constructed service over the same Postgres and Qdrant stores retrieved persisted evidence.".to_string() + } else { + "A newly constructed service over the same stores could not retrieve persisted evidence.".to_string() + }, + evidence: serde_json::json!({ + "query": recovery_query, + "pending_outbox_by_op": outbox_counts, + "note": recovery_note.source_doc, + }), + }) +} + +async fn pending_outbox_counts(service: &ElfService) -> Result> { + let rows = sqlx::query_as::<_, (String, i64)>( + "\ +SELECT op, COUNT(*)::bigint +FROM indexing_outbox +WHERE status = 'PENDING' +GROUP BY op +ORDER BY op", + ) + .fetch_all(&service.db.pool) + .await?; + + Ok(rows.into_iter().collect()) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/checks/reporting.rs b/apps/elf-eval/src/bin/live_baseline_elf/checks/reporting.rs new file mode 100644 index 00000000..815bbb90 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/checks/reporting.rs @@ -0,0 +1,168 @@ +use crate::checks::{ + CheckResult, CheckSummary, CorpusNote, CostProxyReport, EmbeddingRuntimeReport, + OperationalCase, QueryResult, env, +}; + +pub(super) fn cost_proxy_report_impl( + notes: &[CorpusNote], + queries: &[QueryResult], + embedding: &EmbeddingRuntimeReport, +) -> CostProxyReport { + let note_chars = notes.iter().map(|note| note.text.len()).sum::(); + let query_chars = queries.iter().map(|query| query.query.len()).sum::(); + let estimated_input_chars = note_chars.saturating_add(query_chars); + let estimated_input_tokens = estimated_input_chars.saturating_add(3) / 4; + let configured_usd_per_1k_tokens = env::var("ELF_BASELINE_COST_PER_1K_TOKENS_USD") + .ok() + .and_then(|value| value.parse::().ok()); + let estimated_usd = + configured_usd_per_1k_tokens.map(|rate| estimated_input_tokens as f64 / 1_000.0 * rate); + + CostProxyReport { + schema: "elf.live_baseline.cost_proxy/v1", + scope: "primary corpus note text plus declared same-corpus query text", + embedding_mode: embedding.mode, + estimated_input_chars, + estimated_input_tokens, + token_estimation: "ceil(ascii_utf8_chars / 4)", + configured_usd_per_1k_tokens, + estimated_usd, + document_count: notes.len(), + query_count: queries.len(), + } +} + +pub(super) fn latency_percentile_impl(latencies: &[f64], percentile: f64) -> f64 { + if latencies.is_empty() { + return 0.0; + } + + let mut sorted = latencies.to_vec(); + + sorted.sort_by(f64::total_cmp); + + let rank = ((sorted.len().saturating_sub(1)) as f64 * percentile).ceil() as usize; + + sorted[rank.min(sorted.len().saturating_sub(1))] +} + +pub(super) fn operational_cases_impl() -> Vec { + vec![ + operational_case( + "private_corpus_addendum", + "fails_closed_without_manifest", + "opt_in", + "ELF_BASELINE_PRODUCTION_CORPUS_MANIFEST=tmp/private-production-corpus/manifest.json cargo make baseline-production-private-addendum", + "tmp/live-baseline/private-production-addendum.md", + "Markdown addendum reports manifest id, evidence ids, tasks, checks, latency, resource, and cost proxy fields; private text remains in tmp JSON/logs only.", + ), + operational_case( + "backfill_10k_resume", + "not_run", + "opt_in", + "cargo make baseline-backfill-10k-docker", + "tmp/live-baseline/live-baseline-report.json", + "Runs Docker-owned dependencies and records checkpoint resume, duplicates, latency percentiles, resource usage, and cost proxy fields.", + ), + operational_case( + "backfill_100k_resume", + "guarded", + "expensive_opt_in", + "ELF_BASELINE_ENABLE_EXPENSIVE=1 cargo make baseline-backfill-100k-docker", + "tmp/live-baseline/live-baseline-report.json", + "Fails closed unless the expensive-run guard is explicitly enabled.", + ), + operational_case( + "provider_outage", + "not_run", + "documented_operator_probe", + "ELF_BASELINE_ELF_EMBEDDING_MODE=provider with an unavailable embedding endpoint and cargo make baseline-production-synthetic", + "ELF project status incomplete or blocked with provider failure in tmp/live-baseline/ELF.log", + "Use only synthetic or sanitized manifests; do not place provider keys in committed files.", + ), + operational_case( + "compose_start_stop_upgrade", + "documented", + "runbook", + "docs/runbook/single_user_production.md Sections 2, 4, and 5", + "storage health, API health, migration check, and post-upgrade search smoke", + "Backup Postgres before binary/config upgrade; rollback restores the previous backup and rebuilds Qdrant.", + ), + operational_case( + "postgres_restore_qdrant_rebuild", + "documented", + "runbook_or_clean_volume_proof", + "docs/runbook/single_user_production.md Sections 6 through 9", + "Postgres restored row count, admin qdrant rebuild counts, and search-after-restore response", + "Qdrant remains derived and rebuild uses Postgres-held vectors without embedding provider calls.", + ), + operational_case( + "migration_rollback", + "documented", + "runbook", + "docs/runbook/single_user_production.md Section 5 rollback path", + "pre-upgrade backup path, restored source rows, qdrant rebuild, and health check", + "No reverse migration is claimed; rollback means previous binary/config plus restored Postgres backup.", + ), + operational_case( + "unattended_soak", + "bounded", + "opt_in", + "ELF_BASELINE_PROJECTS=ELF ELF_BASELINE_PROFILE=stress ELF_BASELINE_SOAK_SECONDS=3600 cargo make baseline-live-docker", + "soak_stability_e2e check and resource_envelope check in tmp/live-baseline/live-baseline-report.json", + "Long soak duration is env-controlled and not part of the default smoke profile.", + ), + ] +} + +pub(super) fn incomplete_check_impl(name: &'static str, reason: &str) -> CheckResult { + CheckResult { + name, + status: "incomplete", + reason: reason.to_string(), + evidence: serde_json::json!({}), + } +} + +pub(super) fn summarize_checks_impl(checks: &[CheckResult]) -> CheckSummary { + let wrong_result = checks.iter().filter(|check| check.status == "wrong_result").count(); + let lifecycle_fail = checks.iter().filter(|check| check.status == "lifecycle_fail").count(); + + CheckSummary { + total: checks.len(), + pass: checks.iter().filter(|check| check.status == "pass").count(), + fail: wrong_result + lifecycle_fail, + wrong_result, + lifecycle_fail, + incomplete: checks.iter().filter(|check| check.status == "incomplete").count(), + blocked: checks.iter().filter(|check| check.status == "blocked").count(), + not_encoded: checks.iter().filter(|check| check.status == "not_encoded").count(), + } +} + +pub(super) fn project_status_from_summary_impl(summary: &CheckSummary) -> &'static str { + if summary.wrong_result > 0 { + "wrong_result" + } else if summary.lifecycle_fail > 0 { + "lifecycle_fail" + } else if summary.blocked > 0 { + "blocked" + } else if summary.incomplete > 0 { + "incomplete" + } else if summary.not_encoded > 0 { + "not_encoded" + } else { + "pass" + } +} + +fn operational_case( + name: &'static str, + default_status: &'static str, + operator_status: &'static str, + command: &'static str, + evidence: &'static str, + safety: &'static str, +) -> OperationalCase { + OperationalCase { name, default_status, operator_status, command, evidence, safety } +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/checks/resource.rs b/apps/elf-eval/src/bin/live_baseline_elf/checks/resource.rs new file mode 100644 index 00000000..3c012dca --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/checks/resource.rs @@ -0,0 +1,87 @@ +use color_eyre::Result; + +use crate::checks::{CheckResult, ElfService, Path, ResourceEnvelopeEvidence, env, fs}; + +pub(super) async fn resource_envelope_check_impl( + service: &ElfService, + corpus_dir: &Path, + report_path: &Path, + checkpoint_path: &Path, + elapsed_seconds: f64, +) -> CheckResult { + let max_elapsed_seconds = env::var("ELF_BASELINE_MAX_ELF_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(600.0); + let max_rss_kb = env::var("ELF_BASELINE_MAX_ELF_RSS_KB") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1_500_000); + let rss_kb = current_rss_kb(); + let pass = elapsed_seconds <= max_elapsed_seconds && rss_kb.is_none_or(|rss| rss <= max_rss_kb); + let postgres_database_bytes = postgres_database_bytes(service).await.ok(); + let corpus_dir_bytes = path_size_bytes(corpus_dir).unwrap_or_default(); + let report_dir_bytes = report_path.parent().and_then(|path| path_size_bytes(path).ok()); + let checkpoint_file_bytes = checkpoint_path.metadata().ok().map(|metadata| metadata.len()); + + CheckResult { + name: "resource_envelope", + status: if pass { "pass" } else { "lifecycle_fail" }, + reason: if pass { + "ELF live-baseline runtime stayed within the configured local resource envelope." + .to_string() + } else { + "ELF live-baseline runtime exceeded the configured local resource envelope.".to_string() + }, + evidence: serde_json::json!(ResourceEnvelopeEvidence { + elapsed_seconds, + max_elapsed_seconds, + rss_kb, + max_rss_kb, + postgres_database_bytes, + corpus_dir_bytes, + report_dir_bytes, + checkpoint_file_bytes, + }), + } +} + +fn current_rss_kb() -> Option { + let status = fs::read_to_string("/proc/self/status").ok()?; + + status.lines().find_map(|line| { + let rest = line.strip_prefix("VmHWM:")?.trim(); + let value = rest.split_whitespace().next()?; + + value.parse::().ok() + }) +} + +fn path_size_bytes(path: &Path) -> Result { + let metadata = fs::metadata(path)?; + + if metadata.is_file() { + return Ok(metadata.len()); + } + if !metadata.is_dir() { + return Ok(0); + } + + let mut bytes = 0_u64; + + for entry in fs::read_dir(path)? { + let entry = entry?; + + bytes = bytes.saturating_add(path_size_bytes(&entry.path())?); + } + + Ok(bytes) +} + +async fn postgres_database_bytes(service: &ElfService) -> Result { + let bytes = sqlx::query_scalar::<_, i64>("SELECT pg_database_size(current_database())::bigint") + .fetch_one(&service.db.pool) + .await?; + + Ok(bytes) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/checks/stress.rs b/apps/elf-eval/src/bin/live_baseline_elf/checks/stress.rs new file mode 100644 index 00000000..de919fd2 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/checks/stress.rs @@ -0,0 +1,165 @@ +use color_eyre::Result; + +use crate::checks::{ + self, Arc, BaselineRuntime, CheckResult, Duration, ElfService, Instant, JoinSet, Report, Uuid, + eyre, time, +}; + +pub(super) async fn run_concurrent_write_check_impl( + runtime: &BaselineRuntime, + service: Arc, +) -> Result { + let note_count = checks::concurrent_note_count(); + let mut set = JoinSet::new(); + + for index in 0..note_count { + let request = checks::concurrent_add_request(index); + let service_ref = Arc::clone(&service); + + set.spawn(async move { + let response = service_ref.add_note(request).await?; + let note_id = response + .results + .first() + .and_then(|result| result.note_id) + .ok_or_else(|| eyre::eyre!("Concurrent add_note did not return a note_id."))?; + + Ok::(note_id) + }); + } + + let mut note_ids = Vec::with_capacity(note_count); + + while let Some(joined) = set.join_next().await { + note_ids.push(joined??); + } + + let worker_evidence = + checks::run_worker_until_indexed(runtime, &service, ¬e_ids, "concurrent_upsert").await?; + let probe_indexes = checks::concurrency_probe_indexes(note_count); + let mut query_results = Vec::new(); + + for index in probe_indexes { + query_results + .push(checks::run_single_query(&service, checks::concurrent_query_case(index)).await?); + } + + let pass_count = query_results.iter().filter(|result| result.matched).count(); + let pass = checks::outbox_done(&worker_evidence.after, worker_evidence.expected_note_count) + && pass_count == query_results.len(); + + Ok(CheckResult { + name: "concurrent_write_search_e2e", + status: if pass { "pass" } else { "lifecycle_fail" }, + reason: if pass { + "Concurrent add_note calls were indexed by the worker and remained searchable." + .to_string() + } else { + "Concurrent add_note calls did not all become searchable after worker indexing." + .to_string() + }, + evidence: serde_json::json!({ + "note_count": note_count, + "worker": worker_evidence, + "query_summary": { + "total": query_results.len(), + "pass": pass_count, + "fail": query_results.len().saturating_sub(pass_count), + }, + "queries": query_results, + }), + }) +} + +pub(super) async fn run_soak_stability_check_impl( + runtime: &BaselineRuntime, + service: Arc, +) -> Result> { + let config = checks::soak_config(); + + if config.target_seconds == 0 && config.write_rounds == 0 { + return Ok(None); + } + + let target_duration = Duration::from_secs(config.target_seconds); + let started_at = Instant::now(); + let write_rounds = config.write_rounds.max(if config.target_seconds > 0 { 1 } else { 0 }); + let mut note_ids = Vec::with_capacity(write_rounds); + let mut worker_runs = Vec::with_capacity(write_rounds); + let mut query_results = Vec::new(); + + for index in 0..write_rounds { + let response = service.add_note(checks::soak_add_request(index)).await?; + let note_id = response + .results + .first() + .and_then(|result| result.note_id) + .ok_or_else(|| eyre::eyre!("Soak add_note did not return a note_id."))?; + + note_ids.push(note_id); + worker_runs.push( + checks::run_worker_until_indexed(runtime, &service, &[note_id], "soak_upsert").await?, + ); + query_results + .push(checks::run_single_query(&service, checks::soak_query_case(index)).await?); + + if config.target_seconds > 0 && write_rounds > 1 { + let target_elapsed = target_duration.mul_f64((index + 1) as f64 / write_rounds as f64); + + if started_at.elapsed() < target_elapsed { + time::sleep(target_elapsed.saturating_sub(started_at.elapsed())).await; + } + } + } + + let mut probe_index = 0; + + while started_at.elapsed() < target_duration { + let index = probe_index % write_rounds; + + query_results + .push(checks::run_single_query(&service, checks::soak_query_case(index)).await?); + + probe_index += 1; + + let sleep_for = Duration::from_millis(config.probe_interval_millis) + .min(target_duration.saturating_sub(started_at.elapsed())); + + if !sleep_for.is_zero() { + time::sleep(sleep_for).await; + } + } + + let elapsed_seconds = started_at.elapsed().as_secs_f64(); + let pass_count = query_results.iter().filter(|result| result.matched).count(); + let query_fail_count = query_results.len().saturating_sub(pass_count); + let worker_pass = + worker_runs.iter().all(|run| checks::outbox_done(&run.after, run.expected_note_count)); + let duration_pass = target_duration.is_zero() || started_at.elapsed() >= target_duration; + let pass = worker_pass && duration_pass && query_fail_count == 0; + let failed_queries = query_results.iter().filter(|result| !result.matched).collect::>(); + + Ok(Some(CheckResult { + name: "soak_stability_e2e", + status: if pass { "pass" } else { "lifecycle_fail" }, + reason: if pass { + "ELF sustained repeated write, worker indexing, and search probes for the configured soak window.".to_string() + } else { + "ELF did not sustain the configured soak write/search window without a failed worker or retrieval probe.".to_string() + }, + evidence: serde_json::json!({ + "config": config, + "elapsed_seconds": elapsed_seconds, + "duration_met": duration_pass, + "worker_pass": worker_pass, + "write_note_ids": note_ids, + "worker_runs": worker_runs, + "query_summary": { + "total": query_results.len(), + "pass": pass_count, + "fail": query_fail_count, + }, + "failed_queries": failed_queries, + }), + })) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/corpus.rs b/apps/elf-eval/src/bin/live_baseline_elf/corpus.rs new file mode 100644 index 00000000..647a65a7 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/corpus.rs @@ -0,0 +1,213 @@ +use crate::{Command, CorpusNote, HashSet, Path, PathBuf, QueryCase, QueryManifest, env, eyre, fs}; + +pub(super) fn load_corpus_notes(corpus_dir: &Path) -> color_eyre::Result> { + let mut paths = fs::read_dir(corpus_dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + + paths.retain(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + }); + paths.sort(); + + let mut out = Vec::with_capacity(paths.len()); + + for path in paths { + let source_doc = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + eyre::eyre!("Corpus path has no valid UTF-8 file name: {}", path.display()) + })? + .to_string(); + let raw = fs::read_to_string(&path)?; + let title = title_from_markdown(&raw, &source_doc); + let text = raw + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .collect::>() + .join(" ") + .split_whitespace() + .collect::>() + .join(" "); + + out.push(CorpusNote { key: key_for_doc(&source_doc), title, text, source_doc }); + } + + if out.is_empty() { + return Err(eyre::eyre!("No markdown corpus files found in {}.", corpus_dir.display())); + } + + Ok(out) +} + +pub(super) fn load_queries(path: &PathBuf) -> color_eyre::Result { + let raw = fs::read_to_string(path)?; + + Ok(serde_json::from_str(&raw)?) +} + +pub(super) fn title_from_markdown(raw: &str, source_doc: &str) -> String { + raw.lines() + .find_map(|line| line.trim_start().strip_prefix("# ")) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| source_doc.to_string()) +} + +pub(super) fn key_for_doc(doc: &str) -> String { + let stem = Path::new(doc).file_stem().and_then(|stem| stem.to_str()).unwrap_or(doc); + let mut key = String::with_capacity(stem.len()); + let mut last_was_separator = false; + + for ch in stem.chars() { + if ch.is_ascii_alphanumeric() { + key.push(ch.to_ascii_lowercase()); + + last_was_separator = false; + } else if !last_was_separator && !key.is_empty() { + key.push('_'); + + last_was_separator = true; + } + } + + if key.ends_with('_') { + key.pop(); + } + + if key.is_empty() { "doc".to_string() } else { key } +} + +pub(super) fn evidence_id_for_doc(doc: &str) -> String { + Path::new(doc).file_stem().and_then(|stem| stem.to_str()).unwrap_or(doc).to_string() +} + +pub(super) fn expected_docs_for_case(case: &QueryCase) -> Vec { + let mut docs = Vec::with_capacity(case.allowed_alternate_docs.len().saturating_add(1)); + + docs.push(case.expected_doc.clone()); + docs.extend(case.allowed_alternate_docs.iter().cloned()); + + docs +} + +pub(super) fn embed_text(text: &str, vector_dim: u32) -> Vec { + let dim = vector_dim as usize; + let mut vector = vec![0.0_f32; dim]; + + if dim == 0 { + return vector; + } + + let normalized = normalize_ascii_alnum_lowercase(text); + + for term in normalized.split_whitespace() { + if term.len() < 2 { + continue; + } + + let hash = blake3::hash(term.as_bytes()); + let bytes = hash.as_bytes(); + let idx = (u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) % dim; + let sign = if bytes[4] & 1 == 0 { 1.0 } else { -1.0 }; + + vector[idx] += sign; + } + + if vector.iter().all(|value| *value == 0.0) { + let hash = blake3::hash(text.as_bytes()); + let bytes = hash.as_bytes(); + let idx = (u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) % dim; + + vector[idx] = 1.0; + } + + let norm = vector.iter().map(|value| value * value).sum::().sqrt(); + + if norm > 0.0 { + for value in &mut vector { + *value /= norm; + } + } + + vector +} + +pub(super) fn normalize_ascii_alnum_lowercase(text: &str) -> String { + let mut normalized = String::with_capacity(text.len()); + + for ch in text.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + } else { + normalized.push(' '); + } + } + + normalized +} + +pub(super) fn terms(text: &str) -> HashSet { + text.split(|ch: char| !ch.is_ascii_alphanumeric()) + .map(str::trim) + .filter(|term| !term.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +pub(super) fn distinctive_terms(text: &str, limit: usize) -> Vec { + let stop_words = [ + "the", "and", "for", "with", "that", "this", "from", "into", "must", "uses", "after", + "before", "query", "memory", "note", + ]; + let stop_words = stop_words.into_iter().collect::>(); + let mut out = Vec::new(); + + for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) { + let term = raw.trim(); + + if term.len() < 5 { + continue; + } + + let lowered = term.to_ascii_lowercase(); + + if stop_words.contains(lowered.as_str()) || out.iter().any(|existing| existing == term) { + continue; + } + + out.push(term.to_string()); + + if out.len() >= limit { + break; + } + } + + out +} + +pub(super) fn contains_case_insensitive(haystack: &str, needle: &str) -> bool { + haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase()) +} + +pub(super) fn git_head() -> color_eyre::Result { + if let Ok(head) = env::var("ELF_BASELINE_ELF_HEAD") { + let head = head.trim(); + + if !head.is_empty() { + return Ok(head.to_string()); + } + } + + let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?; + + if !output.status.success() { + return Err(eyre::eyre!("git rev-parse HEAD failed.")); + } + + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/providers.rs b/apps/elf-eval/src/bin/live_baseline_elf/providers.rs new file mode 100644 index 00000000..e3200058 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/providers.rs @@ -0,0 +1,246 @@ +use crate::{ + Arc, BaselineRuntime, BoxFuture, Config, EmbeddingProvider, EmbeddingProviderConfig, + EmbeddingRuntimeReport, ExtractorProvider, LlmProviderConfig, ProviderConfig, Providers, + RerankProvider, Serialize, Value, env, eyre, +}; + +#[derive(Debug)] +pub(super) struct DeterministicEmbedding { + vector_dim: u32, +} +impl EmbeddingProvider for DeterministicEmbedding { + fn embed<'a>( + &'a self, + _cfg: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, elf_service::Result>>> { + let dim = self.vector_dim; + let vectors = texts.iter().map(|text| crate::embed_text(text, dim)).collect(); + + Box::pin(async move { Ok(vectors) }) + } +} + +#[derive(Debug)] +pub(super) struct TokenOverlapRerank; +impl RerankProvider for TokenOverlapRerank { + fn rerank<'a>( + &'a self, + _cfg: &'a ProviderConfig, + query: &'a str, + docs: &'a [String], + ) -> BoxFuture<'a, elf_service::Result>> { + let query_terms = crate::terms(query); + let scores = docs + .iter() + .map(|doc| { + let doc_terms = crate::terms(doc); + let hits = query_terms.intersection(&doc_terms).count() as f32; + + hits / query_terms.len().max(1) as f32 + }) + .collect(); + + Box::pin(async move { Ok(scores) }) + } +} + +#[derive(Debug)] +pub(super) struct NoopExtractor; +impl ExtractorProvider for NoopExtractor { + fn extract<'a>( + &'a self, + _cfg: &'a LlmProviderConfig, + _messages: &'a [Value], + ) -> BoxFuture<'a, elf_service::Result> { + Box::pin(async move { Ok(serde_json::json!({ "notes": [] })) }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum EmbeddingMode { + Local, + Provider, +} + +pub(super) fn runtime_config(runtime: &BaselineRuntime) -> color_eyre::Result { + let embedding_mode = embedding_mode()?; + let mut cfg = elf_config::load(&runtime.config_path)?; + + cfg.storage.postgres.dsn = runtime.dsn.clone(); + cfg.storage.postgres.pool_max_conns = 12; + cfg.storage.qdrant.url = runtime.qdrant_url.clone(); + cfg.storage.qdrant.collection = runtime.collection.clone(); + cfg.storage.qdrant.docs_collection = runtime.docs_collection.clone(); + + if embedding_mode == EmbeddingMode::Provider { + apply_provider_embedding_overrides(&mut cfg)?; + + cfg.storage.qdrant.vector_dim = cfg.providers.embedding.dimensions; + } else { + cfg.providers.embedding.provider_id = "local".to_string(); + cfg.providers.embedding.model = "local-hash".to_string(); + cfg.providers.embedding.dimensions = cfg.storage.qdrant.vector_dim; + } + + cfg.providers.rerank.provider_id = "local".to_string(); + cfg.providers.rerank.model = "local-token-overlap".to_string(); + cfg.providers.llm_extractor.provider_id = "disabled".to_string(); + cfg.providers.llm_extractor.model = "disabled".to_string(); + cfg.context = None; + + Ok(cfg) +} + +pub(super) fn deterministic_providers(vector_dim: u32) -> Providers { + Providers::new( + Arc::new(DeterministicEmbedding { vector_dim }), + Arc::new(TokenOverlapRerank), + Arc::new(NoopExtractor), + ) +} + +pub(super) fn embedding_mode() -> color_eyre::Result { + let raw = env::var("ELF_BASELINE_ELF_EMBEDDING_MODE") + .unwrap_or_else(|_| "local".to_string()) + .to_ascii_lowercase(); + + match raw.as_str() { + "local" | "deterministic" => Ok(EmbeddingMode::Local), + "provider" | "production" => Ok(EmbeddingMode::Provider), + _ => Err(eyre::eyre!( + "Unsupported ELF_BASELINE_ELF_EMBEDDING_MODE={raw:?}; use local or provider." + )), + } +} + +pub(super) fn apply_provider_embedding_overrides(cfg: &mut Config) -> color_eyre::Result<()> { + apply_env_string( + &mut cfg.providers.embedding.provider_id, + &[ + "ELF_BASELINE_ELF_EMBEDDING_PROVIDER_ID", + "QWEN_EMBEDDING_PROVIDER_ID", + "EMBEDDING_PROVIDER_ID", + ], + ); + apply_env_string( + &mut cfg.providers.embedding.api_base, + &[ + "ELF_BASELINE_ELF_EMBEDDING_API_BASE", + "QWEN_EMBEDDING_API_BASE", + "DASHSCOPE_API_BASE", + "EMBEDDING_API_BASE", + ], + ); + apply_env_string( + &mut cfg.providers.embedding.api_key, + &[ + "ELF_BASELINE_ELF_EMBEDDING_API_KEY", + "QWEN_API_KEY", + "DASHSCOPE_API_KEY", + "EMBEDDING_API_KEY", + ], + ); + apply_env_string( + &mut cfg.providers.embedding.path, + &["ELF_BASELINE_ELF_EMBEDDING_PATH", "QWEN_EMBEDDING_PATH", "EMBEDDING_PATH"], + ); + apply_env_string( + &mut cfg.providers.embedding.model, + &["ELF_BASELINE_ELF_EMBEDDING_MODEL", "QWEN_EMBEDDING_MODEL", "EMBEDDING_MODEL"], + ); + + if let Some(dimensions) = env_u32(&[ + "ELF_BASELINE_ELF_EMBEDDING_DIMENSIONS", + "QWEN_EMBEDDING_DIMENSIONS", + "DASHSCOPE_EMBEDDING_DIMENSIONS", + "EMBEDDING_DIMENSIONS", + ]) { + cfg.providers.embedding.dimensions = dimensions; + } + if let Some(timeout_ms) = env_u64(&[ + "ELF_BASELINE_ELF_EMBEDDING_TIMEOUT_MS", + "QWEN_EMBEDDING_TIMEOUT_MS", + "EMBEDDING_TIMEOUT_MS", + ]) { + cfg.providers.embedding.timeout_ms = timeout_ms; + } else { + cfg.providers.embedding.timeout_ms = cfg.providers.embedding.timeout_ms.max(30_000); + } + + if cfg.providers.embedding.provider_id == "local" { + if env_string(&["ELF_BASELINE_ELF_EMBEDDING_API_KEY", "QWEN_API_KEY"]).is_some() { + cfg.providers.embedding.provider_id = "qwen".to_string(); + } else if env_string(&["DASHSCOPE_API_KEY"]).is_some() { + cfg.providers.embedding.provider_id = "dashscope".to_string(); + } else if env_string(&["EMBEDDING_API_KEY"]).is_some() { + cfg.providers.embedding.provider_id = "provider".to_string(); + } + } + if cfg.providers.embedding.provider_id == "local" { + return Err(eyre::eyre!( + "Provider embedding mode requires a non-local provider id or QWEN_API_KEY/DASHSCOPE_API_KEY/EMBEDDING_API_KEY." + )); + } + if cfg.providers.embedding.api_base.trim().is_empty() + || cfg.providers.embedding.api_base == "http://127.0.0.1" + { + return Err(eyre::eyre!( + "Provider embedding mode requires ELF_BASELINE_ELF_EMBEDDING_API_BASE, QWEN_EMBEDDING_API_BASE, DASHSCOPE_API_BASE, or EMBEDDING_API_BASE." + )); + } + if cfg.providers.embedding.api_key.trim().is_empty() + || cfg.providers.embedding.api_key == "local-dev-placeholder" + { + return Err(eyre::eyre!( + "Provider embedding mode requires ELF_BASELINE_ELF_EMBEDDING_API_KEY, QWEN_API_KEY, DASHSCOPE_API_KEY, or EMBEDDING_API_KEY." + )); + } + if cfg.providers.embedding.model == "local-hash" + || cfg.providers.embedding.model.trim().is_empty() + { + return Err(eyre::eyre!( + "Provider embedding mode requires ELF_BASELINE_ELF_EMBEDDING_MODEL, QWEN_EMBEDDING_MODEL, or EMBEDDING_MODEL." + )); + } + if cfg.providers.embedding.dimensions == 0 { + return Err(eyre::eyre!( + "Provider embedding dimensions must be greater than zero; set ELF_BASELINE_ELF_EMBEDDING_DIMENSIONS, QWEN_EMBEDDING_DIMENSIONS, DASHSCOPE_EMBEDDING_DIMENSIONS, or EMBEDDING_DIMENSIONS." + )); + } + + Ok(()) +} + +pub(super) fn embedding_runtime_report(cfg: &Config) -> EmbeddingRuntimeReport { + EmbeddingRuntimeReport { + mode: embedding_mode().unwrap_or(EmbeddingMode::Local), + provider_id: cfg.providers.embedding.provider_id.clone(), + model: cfg.providers.embedding.model.clone(), + dimensions: cfg.providers.embedding.dimensions, + timeout_ms: cfg.providers.embedding.timeout_ms, + api_base: cfg.providers.embedding.api_base.clone(), + path: cfg.providers.embedding.path.clone(), + } +} + +pub(super) fn apply_env_string(target: &mut String, names: &[&str]) { + if let Some(value) = env_string(names) { + *target = value; + } +} + +pub(super) fn env_string(names: &[&str]) -> Option { + names.iter().find_map(|name| { + env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) + }) +} + +pub(super) fn env_u32(names: &[&str]) -> Option { + env_string(names).and_then(|value| value.parse::().ok()) +} + +pub(super) fn env_u64(names: &[&str]) -> Option { + env_string(names).and_then(|value| value.parse::().ok()) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/runtime.rs b/apps/elf-eval/src/bin/live_baseline_elf/runtime.rs new file mode 100644 index 00000000..1f69397f --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/runtime.rs @@ -0,0 +1,309 @@ +use color_eyre::Result; + +use crate::{ + AGENT_ID, Arc, BTreeMap, BaselineRuntime, ChunkingConfig, Db, ElfService, EmbeddingMode, + FailedOutboxJob, Instant, JoinSet, PROJECT_ID, PayloadLevel, QdrantStore, QueryCase, + QueryResult, SearchRequest, TENANT_ID, Uuid, Value, WorkerRunEvidence, WorkerState, env, eyre, + worker, +}; + +pub(super) fn worker_max_iterations(note_count: usize) -> usize { + env::var("ELF_BASELINE_WORKER_MAX_ITERATIONS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| note_count.saturating_mul(3).saturating_add(32)) +} + +pub(super) async fn build_service(runtime: &BaselineRuntime) -> Result { + let cfg = crate::runtime_config(runtime)?; + let embedding_mode = crate::embedding_mode()?; + let vector_dim = cfg.storage.qdrant.vector_dim; + let db = Db::connect(&cfg.storage.postgres).await?; + + db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; + + let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; + + qdrant.ensure_collection().await?; + + if embedding_mode == EmbeddingMode::Provider { + Ok(ElfService::new(cfg, db, qdrant)) + } else { + Ok(ElfService::with_providers(cfg, db, qdrant, crate::deterministic_providers(vector_dim))) + } +} + +pub(super) async fn build_worker_state(runtime: &BaselineRuntime) -> Result { + let cfg = crate::runtime_config(runtime)?; + let db = Db::connect(&cfg.storage.postgres).await?; + + db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; + + let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; + + qdrant.ensure_collection().await?; + + let docs_qdrant = + QdrantStore::new_with_collection(&cfg.storage.qdrant, &cfg.storage.qdrant.docs_collection)?; + + docs_qdrant.ensure_collection().await?; + + let tokenizer = elf_chunking::load_tokenizer(&cfg.chunking.tokenizer_repo) + .map_err(|err| eyre::eyre!("Failed to load tokenizer for live baseline worker: {err}"))?; + let chunking = ChunkingConfig { + max_tokens: cfg.chunking.max_tokens, + overlap_tokens: cfg.chunking.overlap_tokens, + }; + + Ok(WorkerState { + db, + qdrant, + docs_qdrant, + embedding: cfg.providers.embedding, + chunking, + tokenizer, + }) +} + +pub(super) async fn run_worker_until_indexed( + runtime: &BaselineRuntime, + service: &ElfService, + note_ids: &[Uuid], + label: &str, +) -> Result { + let concurrency = crate::worker_concurrency(); + let mut states = Vec::with_capacity(concurrency); + + for _ in 0..concurrency { + states.push(Arc::new(build_worker_state(runtime).await?)); + } + + let before = outbox_status_counts(service, note_ids).await?; + let max_iterations = worker_max_iterations(note_ids.len()); + let mut iterations = 0_usize; + + while iterations < max_iterations { + let after = outbox_status_counts(service, note_ids).await?; + + if crate::outbox_done(&after, note_ids.len()) { + let (chunk_rows, chunk_embedding_rows) = chunk_counts(service, note_ids).await?; + let failed_jobs = failed_outbox_jobs(service, note_ids).await?; + + return Ok(WorkerRunEvidence { + label: label.to_string(), + expected_note_count: note_ids.len(), + concurrency, + iterations, + before, + after, + chunk_rows, + chunk_embedding_rows, + failed_jobs, + }); + } + + let mut set = JoinSet::new(); + + for state in &states { + let state = Arc::clone(state); + + set.spawn(async move { + worker::process_once(&state) + .await + .map_err(|err| eyre::eyre!("Worker process_once failed: {err}")) + }); + } + + while let Some(joined) = set.join_next().await { + joined??; + } + + iterations = iterations.saturating_add(concurrency); + } + + let after = outbox_status_counts(service, note_ids).await?; + let (chunk_rows, chunk_embedding_rows) = chunk_counts(service, note_ids).await?; + let failed_jobs = failed_outbox_jobs(service, note_ids).await?; + + Ok(WorkerRunEvidence { + label: label.to_string(), + expected_note_count: note_ids.len(), + concurrency, + iterations, + before, + after, + chunk_rows, + chunk_embedding_rows, + failed_jobs, + }) +} + +pub(super) async fn outbox_status_counts( + service: &ElfService, + note_ids: &[Uuid], +) -> Result> { + if note_ids.is_empty() { + return Ok(BTreeMap::new()); + } + + let rows = sqlx::query_as::<_, (String, i64)>( + "\ +SELECT status, COUNT(*)::bigint +FROM indexing_outbox +WHERE note_id = ANY($1) +GROUP BY status +ORDER BY status", + ) + .bind(note_ids) + .fetch_all(&service.db.pool) + .await?; + + Ok(rows.into_iter().collect()) +} + +pub(super) async fn chunk_counts(service: &ElfService, note_ids: &[Uuid]) -> Result<(i64, i64)> { + if note_ids.is_empty() { + return Ok((0, 0)); + } + + let chunk_rows = sqlx::query_scalar::<_, i64>( + "\ +SELECT COUNT(*)::bigint +FROM memory_note_chunks +WHERE note_id = ANY($1)", + ) + .bind(note_ids) + .fetch_one(&service.db.pool) + .await?; + let chunk_embedding_rows = sqlx::query_scalar::<_, i64>( + "\ +SELECT COUNT(*)::bigint +FROM memory_note_chunks c +JOIN note_chunk_embeddings e ON e.chunk_id = c.chunk_id +WHERE c.note_id = ANY($1)", + ) + .bind(note_ids) + .fetch_one(&service.db.pool) + .await?; + + Ok((chunk_rows, chunk_embedding_rows)) +} + +pub(super) async fn failed_outbox_jobs( + service: &ElfService, + note_ids: &[Uuid], +) -> Result> { + if note_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, (Uuid, Option, String, i32, Option)>( + "\ +SELECT o.note_id, n.key, o.op, o.attempts, o.last_error +FROM indexing_outbox o +LEFT JOIN memory_notes n ON n.note_id = o.note_id +WHERE o.note_id = ANY($1) + AND o.status = 'FAILED' +ORDER BY n.key NULLS LAST, o.note_id", + ) + .bind(note_ids) + .fetch_all(&service.db.pool) + .await?; + + Ok(rows + .into_iter() + .map(|(note_id, note_key, op, attempts, last_error)| FailedOutboxJob { + note_id, + note_key, + op, + attempts, + last_error, + }) + .collect()) +} + +pub(super) async fn run_queries( + service: &ElfService, + queries: Vec, +) -> Result> { + let mut out = Vec::with_capacity(queries.len()); + + for case in queries { + out.push(run_single_query(service, case).await?); + } + + Ok(out) +} + +pub(super) async fn run_single_query(service: &ElfService, case: QueryCase) -> Result { + let top_k = env::var("ELF_BASELINE_TOP_K") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(10); + let started_at = Instant::now(); + let response = service + .search_raw(SearchRequest { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + agent_id: AGENT_ID.to_string(), + token_id: None, + payload_level: PayloadLevel::L2, + read_profile: "private_only".to_string(), + query: case.query.clone(), + top_k: Some(top_k), + candidate_k: Some(top_k.max(20).saturating_mul(4)), + filter: None, + record_hits: Some(false), + ranking: None, + }) + .await?; + let latency_ms = started_at.elapsed().as_secs_f64() * 1_000.0; + let top = response.items.first(); + let top_text = top.map(|item| item.snippet.clone()).unwrap_or_default(); + let matched_terms = case + .expected_terms + .iter() + .filter(|term| crate::contains_case_insensitive(&top_text, term)) + .cloned() + .collect::>(); + let top_key = top.and_then(|item| item.key.clone()); + let expected_docs = crate::expected_docs_for_case(&case); + let matched_doc = top_key + .as_deref() + .and_then(|key| expected_docs.iter().find(|doc| crate::key_for_doc(doc) == key)); + let top_evidence_id = top.and_then(|item| { + item.source_ref.get("document").and_then(Value::as_str).map(crate::evidence_id_for_doc) + }); + let matched_evidence_id = matched_doc.map(|doc| crate::evidence_id_for_doc(doc)); + let matched = matched_terms.len() == case.expected_terms.len() || matched_doc.is_some(); + let expected_evidence_ids = if case.expected_evidence_ids.is_empty() { + vec![crate::evidence_id_for_doc(&case.expected_doc)] + } else { + case.expected_evidence_ids.clone() + }; + let allowed_alternate_evidence_ids = if case.allowed_alternate_evidence_ids.is_empty() { + case.allowed_alternate_docs.iter().map(|doc| crate::evidence_id_for_doc(doc)).collect() + } else { + case.allowed_alternate_evidence_ids.clone() + }; + + Ok(QueryResult { + id: case.id, + task: case.task, + trace_id: response.trace_id, + query: case.query, + expected_doc: case.expected_doc, + allowed_alternate_docs: case.allowed_alternate_docs, + expected_terms: case.expected_terms, + expected_evidence_ids, + allowed_alternate_evidence_ids, + matched, + matched_terms, + top_evidence_id, + matched_evidence_id, + top_note_key: top_key, + top_snippet: top.map(|item| item.snippet.clone()), + latency_ms, + returned_count: response.items.len(), + }) +} diff --git a/apps/elf-eval/src/bin/live_baseline_elf/types.rs b/apps/elf-eval/src/bin/live_baseline_elf/types.rs new file mode 100644 index 00000000..734005d5 --- /dev/null +++ b/apps/elf-eval/src/bin/live_baseline_elf/types.rs @@ -0,0 +1,306 @@ +use crate::{BTreeMap, Deserialize, EmbeddingMode, Parser, PathBuf, Serialize, Uuid, Value}; + +#[derive(Debug, Parser)] +#[command(version = elf_cli::VERSION, rename_all = "kebab", styles = elf_cli::styles())] +pub(super) struct Args { + /// Base ELF config to load before Docker runtime overrides are applied. + #[arg(long, short = 'c', value_name = "FILE")] + pub(super) config: PathBuf, + + /// Directory containing the generated benchmark corpus markdown files. + #[arg(long, value_name = "DIR")] + pub(super) corpus: PathBuf, + + /// Query manifest generated by the live-baseline harness. + #[arg(long, value_name = "FILE")] + pub(super) queries: PathBuf, + + /// Write ELF result JSON to this file. + #[arg(long, value_name = "FILE")] + pub(super) out: PathBuf, +} + +#[derive(Debug, Deserialize)] +pub(super) struct QueryManifest { + pub(super) queries: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct QueryCase { + pub(super) id: String, + pub(super) task: Option, + pub(super) query: String, + pub(super) expected_doc: String, + pub(super) expected_terms: Vec, + #[serde(default)] + pub(super) allowed_alternate_docs: Vec, + #[serde(default)] + pub(super) expected_evidence_ids: Vec, + #[serde(default)] + pub(super) allowed_alternate_evidence_ids: Vec, +} +impl QueryCase { + pub(super) fn generated( + id: String, + query: String, + expected_doc: String, + expected_terms: Vec, + ) -> Self { + Self { + id, + task: None, + query, + expected_evidence_ids: vec![crate::evidence_id_for_doc(&expected_doc)], + allowed_alternate_docs: Vec::new(), + allowed_alternate_evidence_ids: Vec::new(), + expected_doc, + expected_terms, + } + } +} + +#[derive(Debug)] +pub(super) struct CorpusNote { + pub(super) key: String, + pub(super) title: String, + pub(super) text: String, + pub(super) source_doc: String, +} + +#[derive(Debug)] +pub(super) struct BackfillOutcome { + pub(super) report: BackfillReport, + pub(super) note_ids: Vec, +} + +#[derive(Debug)] +pub(super) struct ExistingBackfillNote { + pub(super) note_id: Uuid, + pub(super) source_hash: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct BackfillCheckpoint { + pub(super) schema: String, + pub(super) corpus_hash: String, + pub(super) completed: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct BackfillCheckpointEntry { + pub(super) note_id: Uuid, + pub(super) key: String, + pub(super) source_hash: String, + pub(super) op: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct BackfillReport { + pub(super) checkpoint_path: String, + pub(super) corpus_hash: String, + pub(super) source_count: usize, + pub(super) completed_count: usize, + pub(super) batch_size: usize, + pub(super) worker_concurrency: usize, + pub(super) elapsed_seconds: f64, + pub(super) attempted_writes: usize, + pub(super) skipped_completed: usize, + pub(super) duplicate_source_notes: Vec, + pub(super) resume: BackfillResumeReport, + pub(super) attempts: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct BackfillResumeReport { + pub(super) enabled: bool, + pub(super) interrupted: bool, + pub(super) interrupt_after: Option, + pub(super) resume_attempts: usize, + pub(super) completed_before_resume: usize, + pub(super) completed_after_resume: usize, +} + +#[derive(Debug, Serialize)] +pub(super) struct BackfillAttemptEvidence { + pub(super) attempt: usize, + pub(super) resumed: bool, + pub(super) interrupt_after: Option, + pub(super) skipped_completed: usize, + pub(super) attempted_writes: usize, + pub(super) completed_writes: usize, + pub(super) checkpoint_completed: usize, + pub(super) interrupted: bool, +} + +#[derive(Debug, Serialize)] +pub(super) struct DuplicateSourceNote { + pub(super) source_doc: String, + pub(super) count: i64, + pub(super) note_ids: Vec, +} + +#[derive(Debug)] +pub(super) struct BaselineRuntime { + pub(super) config_path: PathBuf, + pub(super) dsn: String, + pub(super) qdrant_url: String, + pub(super) collection: String, + pub(super) docs_collection: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct WorkerRunEvidence { + pub(super) label: String, + pub(super) expected_note_count: usize, + pub(super) concurrency: usize, + pub(super) iterations: usize, + pub(super) before: BTreeMap, + pub(super) after: BTreeMap, + pub(super) chunk_rows: i64, + pub(super) chunk_embedding_rows: i64, + pub(super) failed_jobs: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct FailedOutboxJob { + pub(super) note_id: Uuid, + pub(super) note_key: Option, + pub(super) op: String, + pub(super) attempts: i32, + pub(super) last_error: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct ResourceEnvelopeEvidence { + pub(super) elapsed_seconds: f64, + pub(super) max_elapsed_seconds: f64, + pub(super) rss_kb: Option, + pub(super) max_rss_kb: u64, + pub(super) postgres_database_bytes: Option, + pub(super) corpus_dir_bytes: u64, + pub(super) report_dir_bytes: Option, + pub(super) checkpoint_file_bytes: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct CostProxyReport { + pub(super) schema: &'static str, + pub(super) scope: &'static str, + pub(super) embedding_mode: EmbeddingMode, + pub(super) estimated_input_chars: usize, + pub(super) estimated_input_tokens: usize, + pub(super) token_estimation: &'static str, + pub(super) configured_usd_per_1k_tokens: Option, + pub(super) estimated_usd: Option, + pub(super) document_count: usize, + pub(super) query_count: usize, +} + +#[derive(Debug, Serialize)] +pub(super) struct EmbeddingRuntimeReport { + pub(super) mode: EmbeddingMode, + pub(super) provider_id: String, + pub(super) model: String, + pub(super) dimensions: u32, + pub(super) timeout_ms: u64, + pub(super) api_base: String, + pub(super) path: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct SoakConfig { + pub(super) target_seconds: u64, + pub(super) write_rounds: usize, + pub(super) probe_interval_millis: u64, +} + +#[derive(Debug, Serialize)] +pub(super) struct ElfBaselineReport { + pub(super) schema: &'static str, + pub(super) status: &'static str, + pub(super) retrieval_status: &'static str, + pub(super) reason: String, + pub(super) head: String, + pub(super) embedding: EmbeddingRuntimeReport, + pub(super) cost_proxy: CostProxyReport, + pub(super) backfill: BackfillReport, + pub(super) indexing: IndexingReport, + pub(super) summary: QuerySummary, + pub(super) check_summary: CheckSummary, + pub(super) checks: Vec, + pub(super) queries: Vec, + pub(super) ops_cases: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct IndexingReport { + pub(super) note_count: usize, + pub(super) rebuild_rebuilt_count: u64, + pub(super) rebuild_missing_vector_count: u64, + pub(super) rebuild_error_count: u64, +} + +#[derive(Debug, Serialize)] +pub(super) struct QuerySummary { + pub(super) total: usize, + pub(super) pass: usize, + pub(super) fail: usize, + pub(super) wrong_result_count: usize, + pub(super) latency_ms_total: f64, + pub(super) latency_ms_mean: f64, + pub(super) latency_ms_p50: f64, + pub(super) latency_ms_p95: f64, + pub(super) latency_ms_p99: f64, + pub(super) latency_ms_max: f64, +} + +#[derive(Debug, Serialize)] +pub(super) struct OperationalCase { + pub(super) name: &'static str, + pub(super) default_status: &'static str, + pub(super) operator_status: &'static str, + pub(super) command: &'static str, + pub(super) evidence: &'static str, + pub(super) safety: &'static str, +} + +#[derive(Debug, Serialize)] +pub(super) struct CheckSummary { + pub(super) total: usize, + pub(super) pass: usize, + pub(super) fail: usize, + pub(super) wrong_result: usize, + pub(super) lifecycle_fail: usize, + pub(super) incomplete: usize, + pub(super) blocked: usize, + pub(super) not_encoded: usize, +} + +#[derive(Debug, Serialize)] +pub(super) struct CheckResult { + pub(super) name: &'static str, + pub(super) status: &'static str, + pub(super) reason: String, + pub(super) evidence: Value, +} + +#[derive(Debug, Serialize)] +pub(super) struct QueryResult { + pub(super) id: String, + pub(super) task: Option, + pub(super) trace_id: Uuid, + pub(super) query: String, + pub(super) expected_doc: String, + pub(super) allowed_alternate_docs: Vec, + pub(super) expected_terms: Vec, + pub(super) expected_evidence_ids: Vec, + pub(super) allowed_alternate_evidence_ids: Vec, + pub(super) matched: bool, + pub(super) matched_terms: Vec, + pub(super) top_evidence_id: Option, + pub(super) matched_evidence_id: Option, + pub(super) top_note_key: Option, + pub(super) top_snippet: Option, + pub(super) latency_ms: f64, + pub(super) returned_count: usize, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark.rs b/apps/elf-eval/src/bin/real_world_job_benchmark.rs index f55f1c7c..47f2b2e8 100644 --- a/apps/elf-eval/src/bin/real_world_job_benchmark.rs +++ b/apps/elf-eval/src/bin/real_world_job_benchmark.rs @@ -2,6 +2,29 @@ //! Offline runner and publisher for real-world job benchmark fixtures. +#[path = "real_world_job_benchmark/artifacts.rs"] mod artifacts; +#[path = "real_world_job_benchmark/cli.rs"] mod cli; +#[path = "real_world_job_benchmark/commands.rs"] mod commands; +#[path = "real_world_job_benchmark/diagnostic_reports.rs"] mod diagnostic_reports; +#[path = "real_world_job_benchmark/enums.rs"] mod enums; +#[path = "real_world_job_benchmark/external_adapter_reports.rs"] mod external_adapter_reports; +#[path = "real_world_job_benchmark/external_adapters.rs"] mod external_adapters; +#[path = "real_world_job_benchmark/feature_metrics.rs"] mod feature_metrics; +#[path = "real_world_job_benchmark/fixtures.rs"] mod fixtures; +#[path = "real_world_job_benchmark/formatting.rs"] mod formatting; +#[path = "real_world_job_benchmark/job_reports.rs"] mod job_reports; +#[path = "real_world_job_benchmark/markdown.rs"] mod markdown; +#[path = "real_world_job_benchmark/operational.rs"] mod operational; +#[path = "real_world_job_benchmark/operational_reports.rs"] mod operational_reports; +#[path = "real_world_job_benchmark/recovery.rs"] mod recovery; +#[path = "real_world_job_benchmark/report_root.rs"] mod report_root; +#[path = "real_world_job_benchmark/scoreboard.rs"] mod scoreboard; +#[path = "real_world_job_benchmark/scoreboard_reports.rs"] mod scoreboard_reports; +#[path = "real_world_job_benchmark/scoring.rs"] mod scoring; +#[path = "real_world_job_benchmark/summary.rs"] mod summary; +#[path = "real_world_job_benchmark/summary_reports.rs"] mod summary_reports; +#[path = "real_world_job_benchmark/validation.rs"] mod validation; + use std::{ collections::{BTreeMap, BTreeSet}, fs, @@ -14,7 +37,67 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use artifacts::{ + AuthorityRecordCount, AuthorityRecoveryDrillArtifact, ConsolidationFixture, + ConsolidationProposalFixture, CostReport, DerivedPageArtifact, DerivedPageRebuild, + DerivedPageSection, MemorySummaryArtifact, MemorySummaryEntry, MemorySummarySourceTrace, + ProactiveBriefArtifact, ProactiveSuggestion, ProducedAnswer, ProducedClaim, RecoveryBackupPitr, + RecoveryDeadLetterHandling, RecoveryDegradedRead, RecoveryDrillTopology, RecoveryMeasurement, + RecoveryMigrationRepair, RecoveryOutboxReplay, RecoveryQdrantRebuild, + ScheduledMemoryExecutionTrace, ScheduledMemoryOutput, ScheduledMemoryTaskArtifact, + WorkContinuityObserved, WorkJournalEntryArtifact, WorkJournalJanitorCandidateArtifact, + WorkJournalNextStepArtifact, WorkJournalReadbackArtifact, WorkJournalRejectedOptionArtifact, + WorkJournalWhereStoppedArtifact, +}; +use cli::{Args, Command, PublishArgs, RunArgs}; +use diagnostic_reports::{ + OperatorDebugEvidence, OperatorUxGap, TraceExplainability, TraceStageExplainability, +}; use elf_cli::VERSION; +use enums::{ + AdapterCoverageStatus, ConsolidationReviewAction, CorpusProfile, ElfScenarioPosition, + EvidenceLink, ExpectedClaim, ScenarioComparisonOutcome, TypedStatus, +}; +use external_adapter_reports::{ + AdapterReport, AdapterScenarioJudgment, AdapterSource, AdapterStatusCounts, + AdapterSuiteCoverage, CaptureIntegrationReport, ExternalAdapterManifest, ExternalAdapterReport, + ExternalAdapterSection, ExternalAdapterSummary, ExternalDockerIsolation, ScenarioOutcomeCounts, + ScenarioPositionCounts, +}; +use external_adapters::{external_adapter_section, scenario_comparison_outcome}; +use fixtures::{ + EvolutionConflict, FollowUpInput, MemoryEvolution, NegativeTrap, RealWorldJob, + RequiredEvidence, TemporalValidity, UpdateRationale, WorkContinuityExpectation, +}; +use job_reports::{ + ConsolidationExecutableGapReport, ConsolidationJobReport, ConsolidationProposalReport, + DimensionScoreReport, EvolutionJobReport, EvolutionSummary, ExpectedEvidenceReport, + FailureCounts, FollowUpReport, JobMetrics, JobReport, JobScoring, KnowledgeJobMetrics, + MemorySummaryJobMetrics, PrivateCorpusRedaction, ProactiveBriefJobMetrics, + RetrievalQualityReport, ScheduledMemoryJobMetrics, ScoreboardRankedMetrics, + UnsupportedClaimReport, WorkContinuityJobMetrics, +}; +use markdown::render_markdown; +use operational::operational_evidence_report; +use operational_reports::{ + OperationalAuthorityRecoveryReport, OperationalColdStartRestoreRebuild, OperationalCostSummary, + OperationalEvidenceReport, OperationalEvidenceTierReport, OperationalLatencyReport, + OperationalResourceSummary, +}; +use report_root::RealWorldReport; +use scoreboard::scoreboard_report; +use scoreboard_reports::{ + ScoreboardAnswerSafetyMetrics, ScoreboardCoverageMetrics, ScoreboardLifecycleMetrics, + ScoreboardMetrics, ScoreboardOperationalMetrics, ScoreboardReport, ScoreboardRetrievalMetrics, + ScoreboardRow, +}; +use scoring::{job_report, score_job}; +use summary::{evolution_summary, follow_up_reports, report_summary, suite_reports}; +use summary_reports::{ + ConsolidationSummaryReport, KnowledgeSummary, MemorySummaryReport, ProactiveBriefSummaryReport, + ReportSummary, ScheduledMemorySummaryReport, SuiteReport, WorkContinuitySummaryReport, +}; +use validation::validate_job; const JOB_SCHEMA: &str = "elf.real_world_job/v1"; const REPORT_SCHEMA: &str = "elf.real_world_job_report/v1"; @@ -79,11123 +162,12 @@ const SCOREBOARD_RESULT_STATES: &[&str] = &[ const SCOREBOARD_EVIDENCE_CLASSES: &[&str] = &["fixture_backed", "live_baseline", "live_real_world", "research_gate"]; const SCOREBOARD_RETRIEVAL_K: usize = 5; -const OPERATIONAL_EVIDENCE_TIERS: &[&str] = - &["local_fixture", "public_proxy", "private_corpus", "provider_backed"]; -const REQUIRED_AUTHORITY_PLANES: [&str; 7] = - ["source", "journal", "memory", "knowledge", "proposal", "trace", "audit"]; - -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), -)] -struct Args { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Parser)] -struct RunArgs { - /// Fixture file or directory containing real_world_job JSON fixtures. - #[arg(long, value_name = "PATH", default_value = DEFAULT_FIXTURE_PATH)] - fixtures: PathBuf, - /// Write report JSON to this file. Omit to print to stdout. - #[arg(long, value_name = "FILE")] - out: Option, - /// Stable run id recorded in the generated report. - #[arg(long, default_value = DEFAULT_RUN_ID)] - run_id: String, - /// Adapter id recorded for the offline smoke response. - #[arg(long, default_value = DEFAULT_ADAPTER_ID)] - adapter_id: String, - /// Human-readable adapter name recorded in the generated report. - #[arg(long, default_value = DEFAULT_ADAPTER_NAME)] - adapter_name: String, - /// Adapter behavior label recorded in the generated report. - #[arg(long, default_value = DEFAULT_ADAPTER_BEHAVIOR)] - adapter_behavior: String, - /// Adapter storage typed status recorded in the generated report. - #[arg(long, default_value = DEFAULT_ADAPTER_STORAGE_STATUS)] - adapter_storage_status: String, - /// Adapter runtime typed status recorded in the generated report. - #[arg(long, default_value = DEFAULT_ADAPTER_RUNTIME_STATUS)] - adapter_runtime_status: String, - /// Adapter notes recorded in the generated report. - #[arg(long, default_value = DEFAULT_ADAPTER_NOTES)] - adapter_notes: String, - /// Real-world external adapter manifest to include in report coverage. - #[arg(long, value_name = "FILE", default_value = DEFAULT_EXTERNAL_ADAPTER_MANIFEST_PATH)] - external_adapter_manifest: PathBuf, - /// Skip loading the real-world external adapter coverage manifest. - #[arg(long)] - skip_external_adapter_manifest: bool, -} - -#[derive(Debug, Parser)] -struct PublishArgs { - /// Generated real_world_job JSON report. - #[arg(long, value_name = "FILE", default_value = DEFAULT_REPORT_PATH)] - report: PathBuf, - /// Write Markdown to this file. Omit to print to stdout. - #[arg(long, value_name = "FILE", default_value = DEFAULT_MARKDOWN_PATH)] - out: Option, -} - -#[derive(Debug, Deserialize)] -struct RealWorldJob { - schema: String, - job_id: String, - suite: String, - title: String, - corpus: Corpus, - #[serde(default)] - timeline: Vec, - prompt: Prompt, - expected_answer: ExpectedAnswer, - #[serde(default)] - required_evidence: Vec, - #[serde(default)] - negative_traps: Vec, - scoring_rubric: ScoringRubric, - allowed_uncertainty: AllowedUncertainty, - operator_debug: Option, - #[serde(default)] - tags: Vec, - #[serde(default)] - encoding: JobEncoding, - memory_evolution: Option, - memory_summary: Option, - proactive_brief: Option, - scheduled_memory: Option, - work_continuity: Option, -} - -#[derive(Debug, Deserialize)] -struct Corpus { - corpus_id: String, - profile: CorpusProfile, - #[serde(default)] - items: Vec, - #[serde(default)] - capture_behaviors: CaptureIntegrationReport, - - adapter_response: Option, -} - -#[derive(Debug, Deserialize)] -struct CorpusItem { - evidence_id: String, - kind: String, - - text: Option, - - local_ref: Option, - #[serde(default)] - source_ref: Value, - - created_at: Option, -} - -#[derive(Debug, Deserialize)] -struct TimelineEvent { - event_id: String, - ts: String, - actor: String, - action: String, - #[serde(default)] - evidence_ids: Vec, - summary: String, -} - -#[derive(Debug, Deserialize)] -struct Prompt { - role: String, - content: String, - job_mode: String, - #[serde(default)] - constraints: Vec, -} - -#[derive(Debug, Deserialize)] -struct ExpectedAnswer { - #[serde(default)] - must_include: Vec, - #[serde(default)] - must_not_include: Vec, - #[serde(default)] - evidence_links: BTreeMap, - answer_type: String, - #[serde(default)] - accepted_alternates: Vec, - #[serde(default)] - requires_caveat: bool, - #[serde(default)] - requires_refusal: bool, -} - -#[derive(Debug, Deserialize)] -struct RequiredEvidence { - evidence_id: String, - claim_id: String, - requirement: String, - - quote: Option, - - selector: Option, -} - -#[derive(Debug, Deserialize)] -struct NegativeTrap { - trap_id: String, - #[serde(rename = "type")] - trap_type: String, - #[serde(default)] - evidence_ids: Vec, - #[serde(default)] - failure_if_used: bool, -} - -#[derive(Debug, Default, Deserialize)] -struct JobEncoding { - status: Option, - reason: Option, - follow_up: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct FollowUpInput { - title: String, - reason: String, -} - -#[derive(Debug, Deserialize)] -struct MemoryEvolution { - #[serde(default)] - current_evidence_ids: Vec, - #[serde(default)] - historical_evidence_ids: Vec, - #[serde(default)] - tombstone_evidence_ids: Vec, - #[serde(default)] - invalidation_evidence_ids: Vec, - #[serde(default)] - stale_trap_ids: Vec, - #[serde(default)] - conflicts: Vec, - update_rationale: Option, - temporal_validity: Option, - history_readback: Option, -} - -#[derive(Debug, Deserialize)] -struct EvolutionConflict { - conflict_id: String, - claim_id: String, - current_evidence_id: String, - historical_evidence_id: String, - resolved_by_evidence_id: Option, -} - -#[derive(Debug, Deserialize)] -struct UpdateRationale { - claim_id: String, - #[serde(default)] - evidence_ids: Vec, - available: bool, -} - -#[derive(Debug, Deserialize)] -struct TemporalValidity { - required: bool, - encoded: bool, - follow_up: Option, -} - -#[derive(Debug, Deserialize)] -struct HistoryReadback { - encoded: bool, - #[serde(default)] - required_event_types: Vec, - requires_note_version_links: bool, -} - -#[derive(Debug, Deserialize)] -struct MemorySummaryExpectation { - #[serde(default)] - required_categories: Vec, -} - -#[derive(Debug, Deserialize)] -struct ProactiveBriefExpectation { - #[serde(default)] - required_suggestion_kinds: Vec, -} - -#[derive(Debug, Deserialize)] -struct ScheduledMemoryExpectation { - #[serde(default)] - required_task_kinds: Vec, -} - -#[derive(Debug, Deserialize)] -struct WorkContinuityExpectation { - #[serde(default)] - required_reset_resume_entry_ids: Vec, - #[serde(default)] - required_decision_rationale_evidence_ids: Vec, - #[serde(default)] - required_rejected_option_ids: Vec, - #[serde(default)] - required_explicit_next_step_ids: Vec, - #[serde(default)] - required_inferred_next_step_ids: Vec, - #[serde(default)] - required_handoff_source_ref_ids: Vec, - #[serde(default)] - required_redaction_marker_ids: Vec, - #[serde(default)] - required_janitor_candidate_ids: Vec, -} - -#[derive(Debug, Deserialize)] -struct ScoringRubric { - #[serde(default)] - dimensions: BTreeMap, - pass_threshold: f64, - #[serde(default)] - hard_fail_rules: Vec, -} - -#[derive(Debug, Deserialize)] -struct RubricDimension { - weight: f64, - max_points: f64, - criteria: Value, -} - -#[derive(Debug, Deserialize)] -struct AllowedUncertainty { - can_answer_unknown: bool, - #[serde(default)] - acceptable_phrases: Vec, - fallback_action: String, -} - -#[derive(Clone, Debug, Deserialize)] -struct AdapterResponse { - adapter_id: Option, - answer: ProducedAnswer, - consolidation: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ProducedAnswer { - content: String, - #[serde(default)] - claims: Vec, - #[serde(default)] - evidence_ids: Vec, - #[serde(default)] - pages: Vec, - #[serde(default)] - memory_summaries: Vec, - #[serde(default)] - proactive_briefs: Vec, - #[serde(default)] - scheduled_tasks: Vec, - #[serde(default)] - work_journal_readbacks: Vec, - #[serde(default)] - recovery_drills: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - latency_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - cost: Option, - #[serde(skip_serializing_if = "Option::is_none")] - trace_explainability: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ProducedClaim { - #[serde(skip_serializing_if = "Option::is_none")] - claim_id: Option, - text: String, - #[serde(default)] - evidence_ids: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - confidence: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct DerivedPageArtifact { - page_id: String, - page_type: String, - title: String, - #[serde(skip_serializing_if = "Option::is_none")] - path: Option, - #[serde(default)] - sections: Vec, - #[serde(default)] - backlinks: Vec, - #[serde(default)] - lint_findings: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - rebuild: Option, - #[serde(skip_serializing_if = "Option::is_none")] - page_version_diff: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct DerivedPageSection { - section_id: String, - heading: String, - role: String, - content: String, - #[serde(default)] - evidence_ids: Vec, - #[serde(default)] - timeline_event_ids: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - unsupported_reason: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct DerivedPageLintFinding { - finding_id: String, - finding_type: String, - severity: String, - text: String, - #[serde(default)] - evidence_ids: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - trap_id: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct DerivedPageRebuild { - first_hash: String, - second_hash: String, - deterministic: bool, - #[serde(default)] - allowed_variance: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct MemorySummaryArtifact { - summary_id: String, - contract_schema: String, - generated_at: String, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - #[serde(default)] - entries: Vec, - source_trace: MemorySummarySourceTrace, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct MemorySummaryEntry { - entry_id: String, - category: String, - text: String, - #[serde(default)] - source_refs: Vec, - freshness: MemorySummaryFreshness, - rationale: MemorySummaryRationale, - #[serde(default)] - unsupported_claim_flags: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct MemorySummaryFreshness { - status: String, - #[serde(skip_serializing_if = "Option::is_none")] - observed_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - valid_from: Option, - #[serde(skip_serializing_if = "Option::is_none")] - valid_to: Option, - #[serde(skip_serializing_if = "Option::is_none")] - last_confirmed_at: Option, - #[serde(default)] - superseded_by: Vec, - #[serde(default)] - tombstone_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct MemorySummaryRationale { - decision: String, - reason_code: String, - reason: String, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct MemorySummarySourceTrace { - #[serde(default)] - selected_source_refs: Vec, - #[serde(default)] - dropped_source_refs: Vec, - #[serde(default)] - stale_source_refs: Vec, - #[serde(default)] - superseded_source_refs: Vec, - #[serde(default)] - tombstone_source_refs: Vec, - #[serde(default)] - unsupported_claim_flags: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct MemorySummarySourceTraceItem { - evidence_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - superseded_by: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ProactiveBriefArtifact { - brief_id: String, - contract_schema: String, - generated_at: String, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - brief_kind: String, - #[serde(default)] - suggestions: Vec, - source_trace: MemorySummarySourceTrace, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ProactiveSuggestion { - suggestion_id: String, - suggestion_kind: String, - title: String, - body: String, - #[serde(default)] - evidence_refs: Vec, - freshness: MemorySummaryFreshness, - action: ProactiveSuggestionAction, - #[serde(default)] - unsupported_claim_flags: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ProactiveSuggestionAction { - decision: String, - reason_code: String, - reason: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ScheduledMemoryTaskArtifact { - task_run_id: String, - contract_schema: String, - generated_at: String, - scheduled_for: String, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - task_kind: String, - #[serde(default)] - outputs: Vec, - source_trace: MemorySummarySourceTrace, - #[serde(skip_serializing_if = "Option::is_none")] - execution_trace: Option, - #[serde(default)] - source_mutations: Vec, - #[serde(default)] - unsupported_claim_flags: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ScheduledMemoryOutput { - output_id: String, - output_kind: String, - text: String, - #[serde(default)] - evidence_refs: Vec, - freshness: MemorySummaryFreshness, - action: ProactiveSuggestionAction, - #[serde(default)] - unsupported_claim_flags: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ScheduledMemoryExecutionTrace { - trace_id: String, - trigger_kind: String, - status: String, - started_at: String, - completed_at: String, - output_ref: String, - #[serde(default)] - stages: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ScheduledMemoryTraceStage { - stage_name: String, - summary: String, - #[serde(default)] - evidence_refs: Vec, -} - -struct WorkContinuityObserved<'a> { - reset_resume_entry_ids: BTreeSet<&'a str>, - decision_rationale_evidence_ids: BTreeSet<&'a str>, - rejected_options: Vec<&'a WorkJournalRejectedOptionArtifact>, - explicit_next_steps: Vec<&'a WorkJournalNextStepArtifact>, - inferred_next_steps: Vec<&'a WorkJournalNextStepArtifact>, - handoff_source_refs: BTreeSet<&'a str>, - redacted_marker_ids: BTreeSet<&'a str>, - janitor_candidates: Vec<&'a WorkJournalJanitorCandidateArtifact>, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct WorkJournalReadbackArtifact { - readback_id: String, - contract_schema: String, - generated_at: String, - session_id: String, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - #[serde(default)] - items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - where_stopped: Option, - promotion_boundary: WorkJournalPromotionBoundaryArtifact, - #[serde(default)] - janitor_candidates: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct WorkJournalEntryArtifact { - entry_id: String, - family: String, - title: String, - body: String, - #[serde(default)] - source_refs: Vec, - #[serde(default)] - redaction_audit: WorkJournalRedactionAuditArtifact, - #[serde(default)] - explicit_next_steps: Vec, - #[serde(default)] - inferred_next_steps: Vec, - #[serde(default)] - rejected_options: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct WorkJournalRedactionAuditArtifact { - #[serde(default)] - required_marker_ids: Vec, - #[serde(default)] - redacted_marker_ids: Vec, - #[serde(default)] - persisted_sensitive_marker_ids: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct WorkJournalNextStepArtifact { - step_id: String, - text: String, - label: String, - instruction: bool, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct WorkJournalRejectedOptionArtifact { - option_id: String, - text: String, - #[serde(default)] - evidence_refs: Vec, - resurrected_as_current: bool, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct WorkJournalWhereStoppedArtifact { - #[serde(default)] - reset_resume_entry_ids: Vec, - #[serde(default)] - decision_rationale_evidence_ids: Vec, - #[serde(default)] - current_explicit_next_step_ids: Vec, - #[serde(default)] - labeled_inferred_next_step_ids: Vec, - #[serde(default)] - handoff_source_refs: Vec, - #[serde(default)] - journal_only_authority_claims: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct WorkJournalPromotionBoundaryArtifact { - journal_entry_authority: String, - memory_promotion_required: bool, - #[serde(default)] - accepted_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct WorkJournalJanitorCandidateArtifact { - candidate_id: String, - #[serde(default)] - evidence_refs: Vec, - review_required: bool, - promoted_to_memory: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AuthorityRecoveryDrillArtifact { - drill_id: String, - contract_schema: String, - generated_at: String, - topology: RecoveryDrillTopology, - #[serde(default)] - failure_injections: Vec, - backup_pitr: RecoveryBackupPitr, - degraded_read: RecoveryDegradedRead, - rpo: RecoveryMeasurement, - rto: RecoveryMeasurement, - #[serde(default)] - authority_record_counts: Vec, - outbox_replay: RecoveryOutboxReplay, - qdrant_rebuild: RecoveryQdrantRebuild, - migration_repair: RecoveryMigrationRepair, - dead_letter: RecoveryDeadLetterHandling, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryDrillTopology { - authority_store: String, - #[serde(default)] - derived_indexes: Vec, - #[serde(default)] - adapters: Vec, - failover: String, -} -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryFailureInjection { - injection_id: String, - target: String, - fault: String, - started_at: String, - completed_at: String, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryBackupPitr { - backup_ref: String, - pitr_target: String, - restored: bool, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryDegradedRead { - source_of_truth_visible: bool, - #[serde(default)] - unavailable_derived_indexes: Vec, - #[serde(default)] - unavailable_adapters: Vec, - #[serde(default)] - unavailable_labels: Vec, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryMeasurement { - target_seconds: f64, - measured_seconds: f64, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AuthorityRecordCount { - plane: String, - before_count: u64, - after_count: u64, - source_refs_preserved: bool, - lifecycle_history_preserved: bool, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryOutboxReplay { - idempotent: bool, - replayed_count: u64, - duplicate_write_count: u64, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryQdrantRebuild { - complete: bool, - rebuilt_count: u64, - missing_vector_count: u64, - error_count: u64, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryMigrationRepair { - applied: bool, - repaired_count: u64, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryDeadLetterHandling { - dead_letter_count: u64, - handled_count: u64, - #[serde(default)] - evidence_refs: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationFixture { - #[serde(default)] - proposals: Vec, - #[serde(default)] - executable_gaps: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationProposalFixture { - proposal_id: String, - proposal_kind: String, - #[serde(default)] - source_refs: Vec, - #[serde(default)] - expected_source_refs: Vec, - usefulness_score: f64, - min_usefulness_score: f64, - expected_review_action: ConsolidationReviewAction, - actual_review_action: ConsolidationReviewAction, - #[serde(default)] - source_mutations: Vec, - #[serde(default)] - unsupported_claim_count: usize, - #[serde(default)] - unsupported_claim_flags: Vec, - #[serde(default)] - diff: Value, -} - -#[derive(Clone, Debug, Deserialize)] -struct ConsolidationExecutableGap { - primitive: String, - follow_up_issue: String, - reason: String, - #[serde(default)] - blocks_fixture_pass: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct CostReport { - #[serde(skip_serializing_if = "Option::is_none")] - currency: Option, - #[serde(skip_serializing_if = "Option::is_none")] - amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - input_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - output_tokens: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct OperatorDebugEvidence { - failure_mode: String, - #[serde(skip_serializing_if = "Option::is_none")] - trace_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - viewer_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - admin_trace_bundle_url: Option, - root_cause: String, - steps_to_root_cause: u32, - raw_sql_needed: bool, - dropped_candidate_visibility: String, - trace_completeness: String, - repair_action_clarity: String, - #[serde(skip_serializing_if = "Option::is_none")] - trace_available: Option, - #[serde(skip_serializing_if = "Option::is_none")] - replay_command_available: Option, - #[serde(skip_serializing_if = "Option::is_none")] - replay_command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - replay_artifact: Option, - #[serde(default)] - viewer_panels: Vec, - #[serde(default)] - cli_steps: Vec, - #[serde(default)] - trace_evidence: Vec, - #[serde(default)] - ux_gaps: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct OperatorUxGap { - gap_id: String, - severity: String, - description: String, - follow_up_issue: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceExplainability { - #[serde(skip_serializing_if = "Option::is_none")] - trace_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - failure_stage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - failure_reason: Option, - #[serde(default)] - stages: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceStageExplainability { - stage_name: String, - #[serde(default)] - kept_evidence: Vec, - #[serde(default)] - dropped_evidence: Vec, - #[serde(default)] - demoted_evidence: Vec, - #[serde(default)] - distractor_evidence: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - notes: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct RealWorldReport { - schema: String, - run_id: String, - generated_at: String, - runner_version: String, - corpus_profile: String, - adapter: AdapterReport, - #[serde(default)] - scoreboard: ScoreboardReport, - #[serde(default)] - operational_evidence: OperationalEvidenceReport, - #[serde(default)] - external_adapters: ExternalAdapterSection, - capture_integration: CaptureIntegrationReport, - summary: ReportSummary, - suites: Vec, - jobs: Vec, - unsupported_claims: Vec, - not_encoded_suites: Vec, - private_corpus_redaction: PrivateCorpusRedaction, - #[serde(default)] - evolution: EvolutionSummary, - #[serde(default)] - follow_ups: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardReport { - schema: String, - result_states: Vec, - evidence_classes: Vec, - metric_basis: String, - retrieval_k: usize, - job_typed_non_pass_count: usize, - job_typed_non_pass_states_present: Vec, - job_summary_claim: String, - external_adapter_typed_non_pass_count: usize, - external_adapter_typed_non_pass_states_present: Vec, - typed_non_pass_count: usize, - typed_non_pass_states_present: Vec, - evidence_class_counts: BTreeMap, - summary_claim: String, - unqualified_win_claim_allowed: bool, - claim_boundary: String, - #[serde(default)] - rows: Vec, - #[serde(default)] - optimization_roadmap: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardRow { - product_id: String, - product_name: String, - row_source: String, - evidence_class: String, - result_state: String, - comparable: bool, - same_corpus: bool, - source_id_mapped: bool, - held_out: bool, - leakage_audited: bool, - product_runtime: bool, - container_digest_identified: bool, - metrics: ScoreboardMetrics, - #[serde(default)] - strengths: Vec, - #[serde(default)] - weaknesses: Vec, - #[serde(default)] - next_evidence: Vec, - #[serde(default)] - source_provenance: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardMetrics { - retrieval: ScoreboardRetrievalMetrics, - lifecycle: ScoreboardLifecycleMetrics, - answer_safety: ScoreboardAnswerSafetyMetrics, - operations: ScoreboardOperationalMetrics, - coverage: ScoreboardCoverageMetrics, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardRetrievalMetrics { - k: usize, - metric_basis: String, - recall_at_k: Option, - precision_at_k: Option, - mrr: Option, - ndcg: Option, - expected_evidence_recall: Option, - citation_source_ref_coverage: Option, - expected_evidence_matched: usize, - expected_evidence_total: usize, - produced_evidence_total: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardLifecycleMetrics { - stale_suppression: Option, - stale_suppressed_count: usize, - stale_check_count: usize, - update_correctness: Option, - update_correct_count: usize, - update_check_count: usize, - delete_correctness: Option, - delete_correct_count: usize, - delete_check_count: usize, - rollback_history_readback_rate: Option, - rollback_history_readback_count: usize, - rollback_history_check_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardAnswerSafetyMetrics { - unsupported_claim_rate: Option, - unsupported_claim_count: usize, - stale_answer_rate: Option, - stale_answer_count: usize, - hallucinated_evidence_rate: Option, - redaction_leak_count: usize, - irrelevant_context_ratio: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardOperationalMetrics { - mean_latency_ms: Option, - total_cost: Option, - resource_envelope_status: String, - resource_envelope_job_count: usize, - resource_envelope_pass_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScoreboardCoverageMetrics { - job_count: usize, - encoded_suite_count: usize, - pass_count: usize, - typed_non_pass_count: usize, - source_ref_coverage: Option, - evidence_coverage: Option, - evidence_class: String, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct OperationalEvidenceReport { - schema: String, - #[serde(default)] - tiers: Vec, - latency: OperationalLatencyReport, - cost: OperationalCostSummary, - resource: OperationalResourceSummary, - cold_start_restore_rebuild: OperationalColdStartRestoreRebuild, - #[serde(default)] - authority_recovery: OperationalAuthorityRecoveryReport, - missing_private_provider_inputs_are_typed_blockers: bool, - private_corpus_pass_claim_allowed: bool, - provider_backed_pass_claim_allowed: bool, - claim_boundary: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct OperationalEvidenceTierReport { - tier: String, - status: TypedStatus, - job_count: usize, - pass: usize, - wrong_result: usize, - lifecycle_fail: usize, - incomplete: usize, - blocked: usize, - not_encoded: usize, - unsupported_claim: usize, - mean_latency_ms: Option, - total_cost: Option, - resource_evidence_count: usize, - cold_start_evidence_count: usize, - restore_evidence_count: usize, - qdrant_rebuild_evidence_count: usize, - pass_claim_allowed: bool, - #[serde(default)] - blocker_reasons: Vec, - #[serde(default)] - job_ids: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct OperationalLatencyReport { - measured_job_count: usize, - missing_latency_job_count: usize, - mean_ms: Option, - max_ms: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct OperationalCostSummary { - jobs_with_cost_report: usize, - missing_cost_job_count: usize, - zero_cost_job_count: usize, - total: Option, - claim_boundary: String, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct OperationalResourceSummary { - resource_envelope_job_count: usize, - resource_envelope_pass_count: usize, - latency_resource_dimension_job_count: usize, - #[serde(default)] - job_ids: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct OperationalColdStartRestoreRebuild { - cold_start_job_count: usize, - cold_start_pass_count: usize, - restore_job_count: usize, - restore_pass_count: usize, - qdrant_rebuild_job_count: usize, - qdrant_rebuild_pass_count: usize, - #[serde(default)] - job_ids: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct OperationalAuthorityRecoveryReport { - drill_count: usize, - drill_pass_count: usize, - topology_reported_count: usize, - failure_injection_count: usize, - degraded_read_labeled_count: usize, - source_of_truth_visible_count: usize, - backup_pitr_restored_count: usize, - rpo_target_count: usize, - rpo_met_count: usize, - rto_target_count: usize, - rto_met_count: usize, - authority_plane_count: usize, - record_count_preserved_count: usize, - source_ref_preserved_count: usize, - lifecycle_history_preserved_count: usize, - idempotent_outbox_replay_count: usize, - qdrant_rebuild_complete_count: usize, - migration_repair_count: usize, - dead_letter_handled_count: usize, - #[serde(default)] - job_ids: Vec, -} - -#[derive(Debug, Deserialize, Serialize)] -struct AdapterReport { - adapter_id: String, - name: String, - behavior: String, - storage: TypedStatus, - runtime: TypedStatus, - notes: String, -} - -#[derive(Debug, Deserialize)] -struct ExternalAdapterManifest { - schema: String, - manifest_id: String, - docker_isolation: ExternalDockerIsolation, - #[serde(default)] - adapters: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ExternalAdapterSection { - schema: String, - manifest_id: String, - docker_isolation: ExternalDockerIsolation, - summary: ExternalAdapterSummary, - #[serde(default)] - adapters: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ExternalDockerIsolation { - default: bool, - compose_file: String, - runner: String, - artifact_dir: String, - host_global_installs_required: bool, - #[serde(default)] - notes: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ExternalAdapterReport { - adapter_id: String, - project: String, - adapter_kind: String, - evidence_class: String, - docker_default: bool, - host_global_installs_required: bool, - overall_status: AdapterCoverageStatus, - setup: AdapterExecutionEvidence, - run: AdapterExecutionEvidence, - result: AdapterExecutionEvidence, - #[serde(default)] - capabilities: Vec, - #[serde(default)] - suites: Vec, - #[serde(default)] - scenarios: Vec, - #[serde(default)] - evidence: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - execution_metadata: Option, - #[serde(default)] - notes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - follow_up: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterExecutionEvidence { - status: AdapterCoverageStatus, - evidence: String, - #[serde(skip_serializing_if = "Option::is_none")] - command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - artifact: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterCapabilityCoverage { - capability: String, - status: AdapterCoverageStatus, - evidence: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterSuiteCoverage { - suite_id: String, - status: AdapterCoverageStatus, - evidence: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterScenarioJudgment { - scenario_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - suite_id: Option, - status: AdapterCoverageStatus, - elf_position: ElfScenarioPosition, - #[serde(skip_serializing_if = "Option::is_none")] - comparison_outcome: Option, - evidence: String, - #[serde(skip_serializing_if = "Option::is_none")] - command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - artifact: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterEvidencePointer { - kind: String, - #[serde(rename = "ref")] - reference: String, - status: AdapterCoverageStatus, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterExecutionMetadata { - #[serde(default)] - sources: Vec, - setup_path: String, - runtime_boundary: String, - resource_expectation: String, - #[serde(default)] - retry_guidance: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - research_depth: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdapterSource { - label: String, - url: String, - evidence: String, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ExternalAdapterSummary { - adapter_count: usize, - external_project_count: usize, - docker_default_count: usize, - host_global_install_required_count: usize, - fixture_backed_count: usize, - live_baseline_only_count: usize, - live_real_world_count: usize, - #[serde(default)] - research_gate_count: usize, - overall_status_counts: AdapterStatusCounts, - capability_status_counts: AdapterStatusCounts, - suite_status_counts: AdapterStatusCounts, - #[serde(default)] - scenario_status_counts: AdapterStatusCounts, - #[serde(default)] - scenario_position_counts: ScenarioPositionCounts, - #[serde(default)] - scenario_outcome_counts: ScenarioOutcomeCounts, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct AdapterStatusCounts { - real: usize, - mocked: usize, - unsupported: usize, - blocked: usize, - incomplete: usize, - wrong_result: usize, - lifecycle_fail: usize, - pass: usize, - not_encoded: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScenarioPositionCounts { - wins: usize, - ties: usize, - loses: usize, - untested: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScenarioOutcomeCounts { - win: usize, - tie: usize, - loss: usize, - not_tested: usize, - blocked: usize, - non_goal: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct CaptureIntegrationReport { - #[serde(default)] - real: Vec, - #[serde(default)] - fixture_backed: Vec, - #[serde(default)] - mocked: Vec, - #[serde(default)] - blocked: Vec, - #[serde(default)] - not_encoded: Vec, - #[serde(default)] - notes: Vec, -} - -#[derive(Debug, Default, Deserialize, Serialize)] -struct ReportSummary { - job_count: usize, - encoded_suite_count: usize, - pass: usize, - wrong_result: usize, - lifecycle_fail: usize, - incomplete: usize, - blocked: usize, - not_encoded: usize, - unsupported_claim: usize, - unsupported_claim_count: usize, - wrong_result_count: usize, - #[serde(default)] - stale_answer_count: usize, - #[serde(default)] - conflict_detection_count: usize, - #[serde(default)] - update_rationale_available_count: usize, - #[serde(default)] - temporal_validity_not_encoded_count: usize, - #[serde(default)] - history_readback_encoded_count: usize, - expected_evidence_total: usize, - expected_evidence_matched: usize, - expected_evidence_recall: f64, - irrelevant_context_count: usize, - irrelevant_context_ratio: f64, - trace_explainability_count: usize, - wrong_result_stage_attribution_count: usize, - mean_score: f64, - mean_latency_ms: Option, - total_cost: Option, - #[serde(default)] - evidence_required_count: usize, - #[serde(default)] - evidence_covered_count: usize, - #[serde(default)] - evidence_coverage: f64, - #[serde(default)] - source_ref_required_count: usize, - #[serde(default)] - source_ref_covered_count: usize, - #[serde(default)] - source_ref_coverage: f64, - #[serde(default)] - quote_required_count: usize, - #[serde(default)] - quote_covered_count: usize, - #[serde(default)] - quote_coverage: f64, - #[serde(default)] - stale_retrieval_count: usize, - #[serde(default)] - scope_check_count: usize, - #[serde(default)] - scope_correct_count: usize, - #[serde(default)] - scope_correctness: f64, - #[serde(default)] - scope_violation_count: usize, - #[serde(default)] - redaction_leak_count: usize, - #[serde(default)] - qdrant_rebuild_case_count: usize, - #[serde(default)] - qdrant_rebuild_pass_count: usize, - #[serde(default)] - operator_debug_job_count: usize, - #[serde(default)] - raw_sql_needed_count: usize, - #[serde(default)] - trace_incomplete_count: usize, - #[serde(default)] - operator_ux_gap_count: usize, - #[serde(default)] - consolidation: ConsolidationSummaryReport, - #[serde(skip_serializing_if = "Option::is_none")] - memory_summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - proactive_brief: Option, - #[serde(skip_serializing_if = "Option::is_none")] - scheduled_memory: Option, - #[serde(skip_serializing_if = "Option::is_none")] - work_continuity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - knowledge: Option, -} - -#[derive(Debug, Default, Deserialize, Serialize)] -struct ConsolidationSummaryReport { - proposal_count: usize, - proposal_usefulness: Option, - lineage_completeness: Option, - review_action_correctness: Option, - source_mutation_count: usize, - proposal_unsupported_claim_count: usize, - executable_gap_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct MemorySummaryReport { - job_count: usize, - summary_count: usize, - entry_count: usize, - required_category_count: usize, - covered_required_category_count: usize, - missing_required_category_count: usize, - top_of_mind_count: usize, - background_count: usize, - stale_count: usize, - superseded_count: usize, - tombstone_count: usize, - derived_project_profile_count: usize, - source_ref_required_count: usize, - source_ref_entry_count: usize, - source_ref_coverage: f64, - freshness_marker_count: usize, - freshness_coverage: f64, - rationale_count: usize, - rationale_coverage: f64, - invalid_top_of_mind_count: usize, - untraced_entry_count: usize, - derived_with_source_or_unsupported_count: usize, - derived_missing_source_or_unsupported_count: usize, - unsupported_derived_entry_count: usize, - unsupported_current_entry_count: usize, - tombstone_ref_count: usize, - source_trace_selected_count: usize, - source_trace_dropped_count: usize, - source_trace_stale_count: usize, - source_trace_superseded_count: usize, - source_trace_tombstone_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ProactiveBriefSummaryReport { - job_count: usize, - brief_count: usize, - suggestion_count: usize, - required_suggestion_kind_count: usize, - covered_required_suggestion_kind_count: usize, - missing_required_suggestion_kind_count: usize, - evidence_ref_required_count: usize, - evidence_ref_suggestion_count: usize, - evidence_ref_coverage: f64, - freshness_marker_count: usize, - freshness_coverage: f64, - action_rationale_count: usize, - action_rationale_coverage: f64, - recommended_count: usize, - deferred_count: usize, - rejected_count: usize, - current_suggestion_count: usize, - non_current_suggestion_count: usize, - stale_warning_count: usize, - invalid_current_suggestion_count: usize, - untraced_suggestion_count: usize, - unsupported_current_suggestion_count: usize, - tombstone_violation_count: usize, - source_trace_selected_count: usize, - source_trace_dropped_count: usize, - source_trace_stale_count: usize, - source_trace_superseded_count: usize, - source_trace_tombstone_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScheduledMemorySummaryReport { - job_count: usize, - task_run_count: usize, - output_count: usize, - required_task_kind_count: usize, - covered_required_task_kind_count: usize, - missing_required_task_kind_count: usize, - evidence_ref_required_count: usize, - evidence_ref_output_count: usize, - evidence_ref_coverage: f64, - freshness_marker_count: usize, - freshness_coverage: f64, - action_rationale_count: usize, - action_rationale_coverage: f64, - trace_required_count: usize, - trace_complete_count: usize, - trace_coverage: f64, - source_mutation_count: usize, - current_output_count: usize, - non_current_output_count: usize, - invalid_current_output_count: usize, - untraced_output_count: usize, - unsupported_current_output_count: usize, - tombstone_violation_count: usize, - source_trace_selected_count: usize, - source_trace_dropped_count: usize, - source_trace_stale_count: usize, - source_trace_superseded_count: usize, - source_trace_tombstone_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct WorkContinuitySummaryReport { - job_count: usize, - readback_count: usize, - entry_count: usize, - reset_resume_required_count: usize, - reset_resume_success_count: usize, - reset_resume_success_rate: f64, - decision_rationale_required_count: usize, - decision_rationale_recalled_count: usize, - decision_rationale_recall_rate: f64, - rejected_option_required_count: usize, - rejected_option_suppressed_count: usize, - rejected_option_resurrection_count: usize, - rejected_option_suppression_rate: f64, - explicit_next_step_required_count: usize, - explicit_next_step_returned_count: usize, - explicit_next_step_correct_count: usize, - explicit_next_step_precision: f64, - inferred_next_step_required_count: usize, - inferred_next_step_labeled_count: usize, - inferred_step_instruction_count: usize, - inferred_next_step_labeling_rate: f64, - handoff_source_ref_required_count: usize, - handoff_source_ref_covered_count: usize, - handoff_source_ref_coverage: f64, - redaction_required_count: usize, - redaction_applied_count: usize, - sensitive_marker_persistence_count: usize, - redaction_rate: f64, - janitor_candidate_count: usize, - janitor_false_promotion_count: usize, - janitor_false_promotion_rate: f64, - journal_only_authority_claim_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct KnowledgeSummary { - job_count: usize, - page_count: usize, - section_count: usize, - backlink_count: usize, - pages_with_backlinks: usize, - pages_with_version_diff: usize, - citation_coverage: f64, - stale_claim_detection: f64, - rebuild_determinism: f64, - backlink_coverage: f64, - version_diff_coverage: f64, - page_usefulness: f64, - unsupported_summary_count: usize, - untraced_section_count: usize, - allowed_variance_count: usize, -} - -#[derive(Debug, Deserialize, Serialize)] -struct SuiteReport { - suite_id: String, - status: TypedStatus, - encoded_job_count: usize, - score_mean: Option, - unsupported_claim_count: usize, - wrong_result_count: usize, - #[serde(default)] - stale_answer_count: usize, - #[serde(default)] - conflict_detection_count: usize, - #[serde(default)] - update_rationale_available_count: usize, - #[serde(default)] - temporal_validity_not_encoded_count: usize, - #[serde(default)] - history_readback_encoded_count: usize, - expected_evidence_recall: Option, - irrelevant_context_ratio: Option, - trace_explainability_count: usize, - reason: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct JobReport { - suite_id: String, - job_id: String, - title: String, - status: TypedStatus, - operational_evidence_tier: String, - answer_type: String, - requires_caveat: bool, - requires_refusal: bool, - can_answer_unknown: bool, - normalized_score: f64, - hard_fail_hits: Vec, - expected_evidence: Vec, - produced_answer: String, - produced_evidence: Vec, - unsupported_claim_count: usize, - wrong_result_count: usize, - #[serde(default)] - stale_answer_count: usize, - #[serde(default)] - conflict_detection_count: usize, - #[serde(default)] - update_rationale_available: bool, - #[serde(default)] - temporal_validity_not_encoded: bool, - #[serde(default)] - history_readback_encoded: bool, - retrieval_quality: RetrievalQualityReport, - latency_ms: Option, - cost: Option, - trace_explainability: Option, - #[serde(skip_serializing_if = "Option::is_none")] - knowledge: Option, - #[serde(skip_serializing_if = "Option::is_none")] - memory_summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - proactive_brief: Option, - #[serde(skip_serializing_if = "Option::is_none")] - scheduled_memory: Option, - #[serde(skip_serializing_if = "Option::is_none")] - work_continuity: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - recovery_drills: Vec, - trap_ids_used: Vec, - dimension_scores: Vec, - reason: String, - #[serde(default)] - evidence_required_count: usize, - #[serde(default)] - evidence_covered_count: usize, - #[serde(default)] - source_ref_required_count: usize, - #[serde(default)] - source_ref_covered_count: usize, - #[serde(default)] - quote_required_count: usize, - #[serde(default)] - quote_covered_count: usize, - #[serde(default)] - stale_retrieval_count: usize, - #[serde(default)] - scope_check_count: usize, - #[serde(default)] - scope_correct_count: usize, - #[serde(default)] - scope_violation_count: usize, - #[serde(default)] - redaction_leak_count: usize, - #[serde(default)] - qdrant_rebuild_case: bool, - #[serde(skip_serializing_if = "Option::is_none")] - operator_debug: Option, - #[serde(skip_serializing_if = "Option::is_none")] - evolution: Option, - #[serde(skip_serializing_if = "Option::is_none")] - consolidation: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct ExpectedEvidenceReport { - evidence_id: String, - claim_id: String, - requirement: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct DimensionScoreReport { - dimension: String, - score: f64, - max_points: f64, - weight: f64, -} - -#[derive(Debug, Deserialize, Serialize)] -struct RetrievalQualityReport { - expected_evidence_total: usize, - expected_evidence_matched: usize, - expected_evidence_recall: f64, - produced_evidence_total: usize, - irrelevant_context_count: usize, - irrelevant_context_ratio: f64, - trap_context_count: usize, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ConsolidationJobReport { - proposal_count: usize, - proposal_usefulness: Option, - lineage_completeness: Option, - review_action_correctness: Option, - source_mutation_count: usize, - proposal_unsupported_claim_count: usize, - executable_gaps: Vec, - proposals: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ConsolidationProposalReport { - proposal_id: String, - proposal_kind: String, - usefulness_score: f64, - min_usefulness_score: f64, - lineage_completeness: f64, - expected_review_action: ConsolidationReviewAction, - actual_review_action: ConsolidationReviewAction, - review_action_correct: bool, - source_mutation_count: usize, - unsupported_claim_count: usize, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ConsolidationExecutableGapReport { - primitive: String, - follow_up_issue: String, - reason: String, - blocks_fixture_pass: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct UnsupportedClaimReport { - suite_id: String, - job_id: String, - claim_id: Option, - claim_text: String, - reason: String, - evidence_ids: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct KnowledgeJobMetrics { - page_count: usize, - section_count: usize, - traced_section_count: usize, - flagged_unsupported_section_count: usize, - untraced_section_count: usize, - unsupported_summary_count: usize, - backlink_count: usize, - pages_with_backlinks: usize, - pages_with_version_diff: usize, - stale_trap_count: usize, - stale_traps_detected: usize, - rebuild_page_count: usize, - deterministic_rebuild_count: usize, - rebuild_failure_count: usize, - allowed_variance_count: usize, - citation_coverage: f64, - stale_claim_detection: f64, - rebuild_determinism: f64, - backlink_coverage: f64, - version_diff_coverage: f64, - page_usefulness: f64, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct MemorySummaryJobMetrics { - summary_count: usize, - entry_count: usize, - required_category_count: usize, - covered_required_category_count: usize, - missing_required_category_count: usize, - top_of_mind_count: usize, - background_count: usize, - stale_count: usize, - superseded_count: usize, - tombstone_count: usize, - derived_project_profile_count: usize, - source_ref_required_count: usize, - source_ref_entry_count: usize, - source_ref_coverage: f64, - freshness_marker_count: usize, - freshness_coverage: f64, - rationale_count: usize, - rationale_coverage: f64, - invalid_top_of_mind_count: usize, - untraced_entry_count: usize, - derived_with_source_or_unsupported_count: usize, - derived_missing_source_or_unsupported_count: usize, - unsupported_derived_entry_count: usize, - unsupported_current_entry_count: usize, - tombstone_ref_count: usize, - source_trace_selected_count: usize, - source_trace_dropped_count: usize, - source_trace_stale_count: usize, - source_trace_superseded_count: usize, - source_trace_tombstone_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ProactiveBriefJobMetrics { - brief_count: usize, - suggestion_count: usize, - required_suggestion_kind_count: usize, - covered_required_suggestion_kind_count: usize, - missing_required_suggestion_kind_count: usize, - evidence_ref_required_count: usize, - evidence_ref_suggestion_count: usize, - evidence_ref_coverage: f64, - freshness_marker_count: usize, - freshness_coverage: f64, - action_rationale_count: usize, - action_rationale_coverage: f64, - recommended_count: usize, - deferred_count: usize, - rejected_count: usize, - current_suggestion_count: usize, - non_current_suggestion_count: usize, - stale_warning_count: usize, - invalid_current_suggestion_count: usize, - untraced_suggestion_count: usize, - unsupported_current_suggestion_count: usize, - tombstone_violation_count: usize, - source_trace_selected_count: usize, - source_trace_dropped_count: usize, - source_trace_stale_count: usize, - source_trace_superseded_count: usize, - source_trace_tombstone_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct ScheduledMemoryJobMetrics { - task_run_count: usize, - output_count: usize, - required_task_kind_count: usize, - covered_required_task_kind_count: usize, - missing_required_task_kind_count: usize, - evidence_ref_required_count: usize, - evidence_ref_output_count: usize, - evidence_ref_coverage: f64, - freshness_marker_count: usize, - freshness_coverage: f64, - action_rationale_count: usize, - action_rationale_coverage: f64, - trace_required_count: usize, - trace_complete_count: usize, - trace_coverage: f64, - source_mutation_count: usize, - current_output_count: usize, - non_current_output_count: usize, - invalid_current_output_count: usize, - untraced_output_count: usize, - unsupported_current_output_count: usize, - tombstone_violation_count: usize, - source_trace_selected_count: usize, - source_trace_dropped_count: usize, - source_trace_stale_count: usize, - source_trace_superseded_count: usize, - source_trace_tombstone_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct WorkContinuityJobMetrics { - readback_count: usize, - entry_count: usize, - reset_resume_required_count: usize, - reset_resume_success_count: usize, - reset_resume_success_rate: f64, - decision_rationale_required_count: usize, - decision_rationale_recalled_count: usize, - decision_rationale_recall_rate: f64, - rejected_option_required_count: usize, - rejected_option_suppressed_count: usize, - rejected_option_resurrection_count: usize, - rejected_option_suppression_rate: f64, - explicit_next_step_required_count: usize, - explicit_next_step_returned_count: usize, - explicit_next_step_correct_count: usize, - explicit_next_step_precision: f64, - inferred_next_step_required_count: usize, - inferred_next_step_labeled_count: usize, - inferred_step_instruction_count: usize, - inferred_next_step_labeling_rate: f64, - handoff_source_ref_required_count: usize, - handoff_source_ref_covered_count: usize, - handoff_source_ref_coverage: f64, - redaction_required_count: usize, - redaction_applied_count: usize, - sensitive_marker_persistence_count: usize, - redaction_rate: f64, - janitor_candidate_count: usize, - janitor_false_promotion_count: usize, - janitor_false_promotion_rate: f64, - journal_only_authority_claim_count: usize, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct EvolutionSummary { - stale_answer_count: usize, - conflict_detection_count: usize, - update_rationale_available_count: usize, - temporal_validity_not_encoded_count: usize, - history_readback_encoded_count: usize, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct EvolutionJobReport { - current_evidence: Vec, - historical_evidence: Vec, - tombstone_evidence: Vec, - invalidation_evidence: Vec, - selected_current_evidence: Vec, - selected_historical_evidence: Vec, - selected_rationale_evidence: Vec, - selected_tombstone_evidence: Vec, - selected_invalidation_evidence: Vec, - conflict_candidate_evidence: Vec, - retrieved_but_dropped_evidence: Vec, - selected_but_not_narrated_evidence: Vec, - stale_trap_ids_used: Vec, - stale_answer_count: usize, - conflict_count: usize, - conflict_detection_count: usize, - update_rationale_available: bool, - temporal_validity_required: bool, - temporal_validity_encoded: bool, - temporal_validity_not_encoded: bool, - history_readback_encoded: bool, - history_event_types: Vec, - history_requires_note_version_links: bool, - #[serde(skip_serializing_if = "Option::is_none")] - follow_up: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct FollowUpReport { - suite_id: String, - job_id: String, - title: String, - reason: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct PrivateCorpusRedaction { - policy: String, - private_fixture_count: usize, -} - -#[derive(Debug)] -struct JobScoring { - status: TypedStatus, - normalized_score: f64, - hard_fail_hits: Vec, - unsupported_claims: Vec, - wrong_result_count: usize, - knowledge: Option, - trap_ids_used: Vec, - dimension_scores: Vec, - reason: String, - evolution: Option, - consolidation: Option, - memory_summary: Option, - proactive_brief: Option, - scheduled_memory: Option, - work_continuity: Option, -} - -#[derive(Debug, Default)] -struct FailureCounts { - missing_claims: usize, - forbidden_claims: usize, - missing_evidence: usize, - trap_uses: usize, - unsupported_claims: usize, - operator_debug_missing: usize, - operator_debug_raw_sql: usize, - operator_debug_trace_gaps: usize, - operator_debug_repair_unclear: usize, - stale_answers: usize, - conflict_detection_missing: usize, - update_rationale_missing: usize, - latency_violations: usize, - proposal_usefulness_failures: usize, - lineage_failures: usize, - review_action_failures: usize, - source_mutations: usize, - blocking_executable_gaps: usize, - memory_summary_invalid_current_entries: usize, - memory_summary_untraced_entries: usize, - memory_summary_missing_freshness: usize, - memory_summary_missing_rationale: usize, - memory_summary_missing_categories: usize, - memory_summary_unsupported_current_entries: usize, - proactive_brief_invalid_current_suggestions: usize, - proactive_brief_untraced_suggestions: usize, - proactive_brief_missing_freshness: usize, - proactive_brief_missing_action_rationale: usize, - proactive_brief_missing_kinds: usize, - proactive_brief_unsupported_current_suggestions: usize, - proactive_brief_tombstone_violations: usize, - scheduled_memory_invalid_current_outputs: usize, - scheduled_memory_untraced_outputs: usize, - scheduled_memory_missing_freshness: usize, - scheduled_memory_missing_action_rationale: usize, - scheduled_memory_missing_task_kinds: usize, - scheduled_memory_unsupported_current_outputs: usize, - scheduled_memory_tombstone_violations: usize, - scheduled_memory_missing_trace: usize, - work_continuity_reset_resume_missing: usize, - work_continuity_decision_rationale_missing: usize, - work_continuity_rejected_option_unsuppressed: usize, - work_continuity_rejected_option_resurrection: usize, - work_continuity_explicit_next_step_missing: usize, - work_continuity_explicit_next_step_extra: usize, - work_continuity_inferred_step_unlabeled: usize, - work_continuity_inferred_step_as_instruction: usize, - work_continuity_handoff_source_ref_missing: usize, - work_continuity_redaction_missing: usize, - work_continuity_sensitive_marker_persistence: usize, - work_continuity_janitor_false_promotion: usize, - work_continuity_journal_only_authority_claim: usize, - untraced_page_sections: usize, - missed_stale_findings: usize, - rebuild_failures: usize, - page_usefulness_failures: usize, -} - -#[derive(Debug, Default)] -struct JobMetrics { - evidence_required_count: usize, - evidence_covered_count: usize, - source_ref_required_count: usize, - source_ref_covered_count: usize, - quote_required_count: usize, - quote_covered_count: usize, - stale_retrieval_count: usize, - scope_check_count: usize, - scope_correct_count: usize, - scope_violation_count: usize, - redaction_leak_count: usize, - qdrant_rebuild_case: bool, -} - -struct ScoreboardRankedMetrics { - relevant_at_k: usize, - precision_denominator_at_k: usize, - reciprocal_rank: f64, - ndcg: f64, -} - -#[derive(Debug, Subcommand)] -#[command(rename_all = "kebab")] -enum Command { - /// Parse and score real_world_job fixtures, then emit a JSON report. - Run(RunArgs), - /// Render Markdown from a generated real_world_job JSON report. - Publish(PublishArgs), -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum CorpusProfile { - Synthetic, - PrivateSanitized, - GeneratedPublic, - ExternalAdapter, -} -impl CorpusProfile { - fn as_str(&self) -> &'static str { - match self { - Self::Synthetic => "synthetic", - Self::PrivateSanitized => "private_sanitized", - Self::GeneratedPublic => "generated_public", - Self::ExternalAdapter => "external_adapter", - } - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(untagged)] -enum ExpectedClaim { - Text(String), - Object { claim_id: Option, text: String }, -} -impl ExpectedClaim { - fn claim_id(&self) -> Option<&str> { - match self { - Self::Text(_) => None, - Self::Object { claim_id, .. } => claim_id.as_deref(), - } - } - - fn text(&self) -> &str { - match self { - Self::Text(text) => text, - Self::Object { text, .. } => text, - } - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(untagged)] -enum EvidenceLink { - One(String), - Many(Vec), -} -impl EvidenceLink { - fn ids(&self) -> BTreeSet { - match self { - Self::One(id) => BTreeSet::from([id.clone()]), - Self::Many(ids) => ids.iter().cloned().collect(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum ConsolidationReviewAction { - Apply, - Discard, - Defer, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum TypedStatus { - Pass, - WrongResult, - LifecycleFail, - Incomplete, - Blocked, - NotEncoded, - UnsupportedClaim, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum AdapterCoverageStatus { - Real, - Mocked, - Unsupported, - Blocked, - Incomplete, - WrongResult, - LifecycleFail, - Pass, - NotEncoded, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum ElfScenarioPosition { - Wins, - Ties, - Loses, - Untested, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum ScenarioComparisonOutcome { - Win, - Tie, - Loss, - NotTested, - Blocked, - NonGoal, -} - -fn main() -> Result<()> { - color_eyre::install()?; +fn main() -> Result<()> { + color_eyre::install()?; match Args::parse().command { - Command::Run(args) => run_command(args), - Command::Publish(args) => publish_command(args), - } -} - -fn run_command(args: RunArgs) -> Result<()> { - let jobs = load_jobs(&args.fixtures)?; - let report = build_report(&jobs, &args)?; - let json = serde_json::to_string_pretty(&report)?; - - write_or_print(args.out.as_deref(), json.as_str()) -} - -fn publish_command(args: PublishArgs) -> Result<()> { - let raw = fs::read_to_string(&args.report)?; - let report = serde_json::from_str::(&raw)?; - let markdown = render_markdown(&report, &args.report); - - write_or_print(args.out.as_deref(), markdown.as_str()) -} - -fn load_jobs(path: &Path) -> Result> { - let paths = fixture_paths(path)?; - let mut jobs = Vec::with_capacity(paths.len()); - - for fixture in paths { - let raw = fs::read_to_string(&fixture)?; - let job = serde_json::from_str::(&raw) - .map_err(|err| eyre::eyre!("Failed to parse {}: {err}", fixture.display()))?; - - validate_job(&job, &fixture)?; - - jobs.push(job); - } - - Ok(jobs) -} - -fn fixture_paths(path: &Path) -> Result> { - if path.is_file() { - return Ok(vec![path.to_path_buf()]); - } - if !path.is_dir() { - return Err(eyre::eyre!("Fixture path does not exist: {}", path.display())); - } - - let mut paths = Vec::new(); - - collect_fixture_paths(path, &mut paths)?; - - paths.sort(); - - if paths.is_empty() { - return Err(eyre::eyre!("No JSON fixtures found in {}.", path.display())); - } - - Ok(paths) -} - -fn collect_fixture_paths(path: &Path, paths: &mut Vec) -> Result<()> { - for entry in fs::read_dir(path)? { - let entry = entry?; - let entry_path = entry.path(); - - if entry_path.is_dir() { - collect_fixture_paths(entry_path.as_path(), paths)?; - } else if entry_path.extension().and_then(|ext| ext.to_str()) == Some("json") { - paths.push(entry_path); - } - } - - Ok(()) -} - -fn validate_job(job: &RealWorldJob, path: &Path) -> Result<()> { - if job.schema != JOB_SCHEMA { - return Err(eyre::eyre!( - "{} has schema {}, expected {JOB_SCHEMA}.", - path.display(), - job.schema - )); - } - - validate_job_identity(job, path)?; - - if !SUITES.contains(&job.suite.as_str()) { - return Err(eyre::eyre!("{} uses unknown suite {}.", path.display(), job.suite)); - } - - validate_corpus_items(job, path)?; - validate_timeline(job, path)?; - validate_prompt(job, path)?; - validate_expected_answer(job, path)?; - validate_required_evidence(job, path)?; - validate_consolidation_fixture(job, path)?; - validate_adapter_response(job, path)?; - validate_scoring_rubric(job, path)?; - validate_allowed_uncertainty(job, path)?; - validate_operator_debug(job, path)?; - validate_job_encoding(job, path)?; - validate_memory_evolution(job, path)?; - validate_memory_summary_expectation(job, path)?; - validate_proactive_brief_expectation(job, path)?; - validate_scheduled_memory_expectation(job, path)?; - validate_work_continuity_expectation(job, path)?; - validate_trace_explainability(job, path)?; - - Ok(()) -} - -fn validate_job_identity(job: &RealWorldJob, path: &Path) -> Result<()> { - if job.job_id.trim().is_empty() - || job.suite.trim().is_empty() - || job.title.trim().is_empty() - || job.corpus.corpus_id.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete job identity.", path.display())); - } - - for tag in &job.tags { - if tag.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty tag.", path.display())); - } - } - - if let Some(adapter_response) = &job.corpus.adapter_response - && adapter_response.adapter_id.as_deref().is_some_and(str::is_empty) - { - return Err(eyre::eyre!("{} has an empty adapter_response adapter_id.", path.display())); - } - - Ok(()) -} - -fn validate_corpus_items(job: &RealWorldJob, path: &Path) -> Result<()> { - let mut evidence_ids = BTreeSet::new(); - - for item in &job.corpus.items { - if item.evidence_id.trim().is_empty() { - return Err(eyre::eyre!( - "{} has a corpus item with an empty evidence_id.", - path.display() - )); - } - if item.kind.trim().is_empty() { - return Err(eyre::eyre!( - "{} has corpus item {} with an empty kind.", - path.display(), - item.evidence_id - )); - } - if item.text.is_none() && item.local_ref.is_none() { - return Err(eyre::eyre!( - "{} corpus item {} must provide text or local_ref.", - path.display(), - item.evidence_id - )); - } - if !item.source_ref.is_object() { - return Err(eyre::eyre!( - "{} corpus item {} must provide an object source_ref.", - path.display(), - item.evidence_id - )); - } - - if let Some(created_at) = &item.created_at { - validate_optional_rfc3339(created_at, path, item.evidence_id.as_str())?; - } - - evidence_ids.insert(item.evidence_id.clone()); - } - for trap in &job.negative_traps { - if trap.trap_id.trim().is_empty() || trap.trap_type.trim().is_empty() { - return Err(eyre::eyre!("{} has an incomplete negative trap.", path.display())); - } - - for evidence_id in &trap.evidence_ids { - ensure_known_evidence(path, &evidence_ids, evidence_id)?; - } - } - - Ok(()) -} - -fn validate_timeline(job: &RealWorldJob, path: &Path) -> Result<()> { - let evidence_ids = corpus_evidence_ids(job); - - for event in &job.timeline { - if event.event_id.trim().is_empty() - || event.actor.trim().is_empty() - || event.action.trim().is_empty() - || event.summary.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete timeline event.", path.display())); - } - - validate_required_rfc3339(event.ts.as_str(), path, event.event_id.as_str())?; - - for evidence_id in &event.evidence_ids { - ensure_known_evidence(path, &evidence_ids, evidence_id)?; - } - } - - Ok(()) -} - -fn validate_prompt(job: &RealWorldJob, path: &Path) -> Result<()> { - if job.prompt.role.trim().is_empty() - || job.prompt.content.trim().is_empty() - || job.prompt.job_mode.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete prompt.", path.display())); - } - - for constraint in &job.prompt.constraints { - if constraint.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty prompt constraint.", path.display())); - } - } - - Ok(()) -} - -fn validate_expected_answer(job: &RealWorldJob, path: &Path) -> Result<()> { - if job.expected_answer.answer_type.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty expected answer type.", path.display())); - } - - for claim in &job.expected_answer.must_include { - if claim.text().trim().is_empty() { - return Err(eyre::eyre!("{} has an empty expected claim.", path.display())); - } - } - for claim in &job.expected_answer.must_not_include { - if claim.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty forbidden claim.", path.display())); - } - } - for phrase in &job.expected_answer.accepted_alternates { - if phrase.is_null() { - return Err(eyre::eyre!("{} has a null accepted alternate.", path.display())); - } - } - - Ok(()) -} - -fn validate_required_evidence(job: &RealWorldJob, path: &Path) -> Result<()> { - let evidence_ids = corpus_evidence_ids(job); - let corpus_text = corpus_text_by_id(job); - - for evidence in &job.required_evidence { - if evidence.claim_id.trim().is_empty() || evidence.requirement.trim().is_empty() { - return Err(eyre::eyre!("{} has incomplete required evidence.", path.display())); - } - - ensure_known_evidence(path, &evidence_ids, evidence.evidence_id.as_str())?; - - if evidence.quote.is_none() && evidence.selector.is_none() { - return Err(eyre::eyre!( - "{} required evidence {} must provide quote or selector.", - path.display(), - evidence.evidence_id - )); - } - - if let Some(quote) = &evidence.quote - && let Some(text) = corpus_text.get(evidence.evidence_id.as_str()) - && !text.contains(quote) - { - return Err(eyre::eyre!( - "{} required evidence quote for {} is not present in corpus text.", - path.display(), - evidence.evidence_id - )); - } - } - for (claim_id, link) in &job.expected_answer.evidence_links { - if claim_id.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty evidence link claim id.", path.display())); - } - - for evidence_id in link.ids() { - ensure_known_evidence(path, &evidence_ids, evidence_id.as_str())?; - } - } - - Ok(()) -} - -fn validate_consolidation_fixture(job: &RealWorldJob, path: &Path) -> Result<()> { - let consolidation = - job.corpus.adapter_response.as_ref().and_then(|response| response.consolidation.as_ref()); - - if job.suite == "consolidation" && consolidation.is_none() && job.encoding.status.is_none() { - return Err(eyre::eyre!( - "{} consolidation jobs must provide adapter_response.consolidation.", - path.display() - )); - } - - let Some(consolidation) = consolidation else { - return Ok(()); - }; - - if consolidation.proposals.is_empty() && consolidation.executable_gaps.is_empty() { - return Err(eyre::eyre!( - "{} consolidation fixture must provide proposals or executable_gaps.", - path.display() - )); - } - - for proposal in &consolidation.proposals { - validate_consolidation_proposal(proposal, path)?; - } - for gap in &consolidation.executable_gaps { - if gap.primitive.trim().is_empty() - || gap.follow_up_issue.trim().is_empty() - || gap.reason.trim().is_empty() - { - return Err(eyre::eyre!( - "{} has an incomplete consolidation executable gap.", - path.display() - )); - } - } - - Ok(()) -} - -fn validate_consolidation_proposal( - proposal: &ConsolidationProposalFixture, - path: &Path, -) -> Result<()> { - if proposal.proposal_id.trim().is_empty() - || proposal.proposal_kind.trim().is_empty() - || proposal.source_refs.is_empty() - || proposal.expected_source_refs.is_empty() - { - return Err(eyre::eyre!( - "{} has an incomplete consolidation proposal fixture.", - path.display() - )); - } - if !proposal.usefulness_score.is_finite() - || !proposal.min_usefulness_score.is_finite() - || !(0.0..=1.0).contains(&proposal.usefulness_score) - || !(0.0..=1.0).contains(&proposal.min_usefulness_score) - { - return Err(eyre::eyre!( - "{} has invalid consolidation proposal usefulness scores.", - path.display() - )); - } - if !proposal.diff.is_null() && !proposal.diff.is_object() { - return Err(eyre::eyre!( - "{} consolidation proposal diff must be a JSON object when present.", - path.display() - )); - } - if proposal.unsupported_claim_flags.iter().any(|flag| !flag.is_object()) { - return Err(eyre::eyre!( - "{} consolidation unsupported-claim flags must be JSON objects.", - path.display() - )); - } - - Ok(()) -} - -fn validate_adapter_response(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(adapter_response) = &job.corpus.adapter_response else { - return Ok(()); - }; - let evidence_ids = corpus_evidence_ids(job); - let event_ids = timeline_event_ids(job); - - for page in &adapter_response.answer.pages { - validate_page_artifact(page, path, &evidence_ids, &event_ids)?; - } - for summary in &adapter_response.answer.memory_summaries { - validate_memory_summary_artifact(summary, path, &evidence_ids)?; - } - for brief in &adapter_response.answer.proactive_briefs { - validate_proactive_brief_artifact(brief, path, &evidence_ids)?; - } - for task in &adapter_response.answer.scheduled_tasks { - validate_scheduled_memory_artifact(task, path, &evidence_ids)?; - } - for readback in &adapter_response.answer.work_journal_readbacks { - validate_work_journal_readback_artifact(readback, path, &evidence_ids)?; - } - for drill in &adapter_response.answer.recovery_drills { - validate_authority_recovery_drill_artifact(drill, path, &evidence_ids)?; - } - - if job.suite == "memory_summary" - && adapter_response.answer.memory_summaries.is_empty() - && job.encoding.status.is_none() - { - return Err(eyre::eyre!( - "{} memory_summary jobs must provide adapter_response.answer.memory_summaries.", - path.display() - )); - } - if job.suite == "proactive_brief" - && adapter_response.answer.proactive_briefs.is_empty() - && job.encoding.status.is_none() - { - return Err(eyre::eyre!( - "{} proactive_brief jobs must provide adapter_response.answer.proactive_briefs.", - path.display() - )); - } - if job.suite == "scheduled_memory" - && adapter_response.answer.scheduled_tasks.is_empty() - && job.encoding.status.is_none() - { - return Err(eyre::eyre!( - "{} scheduled_memory jobs must provide adapter_response.answer.scheduled_tasks.", - path.display() - )); - } - if job.suite == "work_continuity" - && adapter_response.answer.work_journal_readbacks.is_empty() - && job.encoding.status.is_none() - { - return Err(eyre::eyre!( - "{} work_continuity jobs must provide adapter_response.answer.work_journal_readbacks.", - path.display() - )); - } - - Ok(()) -} - -fn validate_page_artifact( - page: &DerivedPageArtifact, - path: &Path, - evidence_ids: &BTreeSet, - event_ids: &BTreeSet, -) -> Result<()> { - if page.page_id.trim().is_empty() - || page.page_type.trim().is_empty() - || page.title.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete derived page.", path.display())); - } - - for section in &page.sections { - if section.section_id.trim().is_empty() - || section.heading.trim().is_empty() - || section.role.trim().is_empty() - || section.content.trim().is_empty() - { - return Err(eyre::eyre!( - "{} page {} has an incomplete section.", - path.display(), - page.page_id - )); - } - - for evidence_id in §ion.evidence_ids { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for event_id in §ion.timeline_event_ids { - ensure_known_event(path, event_ids, event_id)?; - } - } - for backlink in &page.backlinks { - if backlink.trim().is_empty() { - return Err(eyre::eyre!( - "{} page {} has an empty backlink.", - path.display(), - page.page_id - )); - } - } - for finding in &page.lint_findings { - if finding.finding_id.trim().is_empty() - || finding.finding_type.trim().is_empty() - || finding.severity.trim().is_empty() - || finding.text.trim().is_empty() - { - return Err(eyre::eyre!( - "{} page {} has an incomplete lint finding.", - path.display(), - page.page_id - )); - } - - for evidence_id in &finding.evidence_ids { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - } - - if let Some(rebuild) = &page.rebuild - && (rebuild.first_hash.trim().is_empty() || rebuild.second_hash.trim().is_empty()) - { - return Err(eyre::eyre!( - "{} page {} has an incomplete rebuild record.", - path.display(), - page.page_id - )); - } - if let Some(diff) = &page.page_version_diff { - if !diff.is_object() { - return Err(eyre::eyre!( - "{} page {} previous-version diff must be a JSON object.", - path.display(), - page.page_id - )); - } - if diff.get("schema").and_then(Value::as_str) != Some("elf.knowledge_page.version_diff/v1") - { - return Err(eyre::eyre!( - "{} page {} previous-version diff has an unexpected schema.", - path.display(), - page.page_id - )); - } - } - - Ok(()) -} - -fn validate_memory_summary_artifact( - summary: &MemorySummaryArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if summary.summary_id.trim().is_empty() - || summary.contract_schema != "elf.memory_summary/v1" - || summary.generated_at.trim().is_empty() - || summary.tenant_id.trim().is_empty() - || summary.project_id.trim().is_empty() - || summary.agent_id.trim().is_empty() - || summary.read_profile.trim().is_empty() - || summary.entries.is_empty() - { - return Err(eyre::eyre!("{} has an incomplete memory summary.", path.display())); - } - - validate_optional_rfc3339(&summary.generated_at, path, summary.summary_id.as_str())?; - - for entry in &summary.entries { - validate_memory_summary_entry(entry, path, evidence_ids)?; - } - - validate_memory_summary_source_trace(&summary.source_trace, path, evidence_ids)?; - - Ok(()) -} - -fn validate_memory_summary_entry( - entry: &MemorySummaryEntry, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if entry.entry_id.trim().is_empty() - || entry.category.trim().is_empty() - || entry.text.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete memory summary entry.", path.display())); - } - if !is_memory_summary_category(entry.category.as_str()) { - return Err(eyre::eyre!( - "{} has unknown memory summary category {}.", - path.display(), - entry.category - )); - } - if !is_memory_summary_freshness_status(entry.freshness.status.as_str()) { - return Err(eyre::eyre!( - "{} has unknown memory summary freshness status {}.", - path.display(), - entry.freshness.status - )); - } - if !is_memory_summary_rationale_decision(entry.rationale.decision.as_str()) { - return Err(eyre::eyre!( - "{} has unknown memory summary rationale decision {}.", - path.display(), - entry.rationale.decision - )); - } - - for evidence_id in &entry.source_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for evidence_id in &entry.freshness.tombstone_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for flag in &entry.unsupported_claim_flags { - if !flag.is_object() { - return Err(eyre::eyre!( - "{} memory summary unsupported-claim flags must be JSON objects.", - path.display() - )); - } - } - - validate_optional_summary_time( - path, - entry.freshness.observed_at.as_deref(), - entry.entry_id.as_str(), - )?; - validate_optional_summary_time( - path, - entry.freshness.valid_from.as_deref(), - entry.entry_id.as_str(), - )?; - validate_optional_summary_time( - path, - entry.freshness.valid_to.as_deref(), - entry.entry_id.as_str(), - )?; - validate_optional_summary_time( - path, - entry.freshness.last_confirmed_at.as_deref(), - entry.entry_id.as_str(), - )?; - - Ok(()) -} - -fn validate_memory_summary_source_trace( - trace: &MemorySummarySourceTrace, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - for item in trace - .selected_source_refs - .iter() - .chain(trace.dropped_source_refs.iter()) - .chain(trace.stale_source_refs.iter()) - .chain(trace.superseded_source_refs.iter()) - .chain(trace.tombstone_source_refs.iter()) - { - if item.evidence_id.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty memory summary trace item.", path.display())); - } - - ensure_known_evidence(path, evidence_ids, item.evidence_id.as_str())?; - } - for flag in &trace.unsupported_claim_flags { - if !flag.is_object() { - return Err(eyre::eyre!( - "{} memory summary source-trace unsupported-claim flags must be JSON objects.", - path.display() - )); - } - } - - Ok(()) -} - -fn validate_proactive_brief_artifact( - brief: &ProactiveBriefArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if brief.brief_id.trim().is_empty() - || brief.contract_schema != "elf.proactive_project_brief/v1" - || brief.generated_at.trim().is_empty() - || brief.tenant_id.trim().is_empty() - || brief.project_id.trim().is_empty() - || brief.agent_id.trim().is_empty() - || brief.read_profile.trim().is_empty() - || brief.brief_kind.trim().is_empty() - || brief.suggestions.is_empty() - { - return Err(eyre::eyre!("{} has an incomplete proactive brief.", path.display())); - } - - validate_optional_rfc3339(&brief.generated_at, path, brief.brief_id.as_str())?; - - for suggestion in &brief.suggestions { - validate_proactive_suggestion(suggestion, path, evidence_ids)?; - } - - validate_memory_summary_source_trace(&brief.source_trace, path, evidence_ids)?; - - Ok(()) -} - -fn validate_proactive_suggestion( - suggestion: &ProactiveSuggestion, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if suggestion.suggestion_id.trim().is_empty() - || suggestion.suggestion_kind.trim().is_empty() - || suggestion.title.trim().is_empty() - || suggestion.body.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete proactive suggestion.", path.display())); - } - if !is_proactive_suggestion_kind(suggestion.suggestion_kind.as_str()) { - return Err(eyre::eyre!( - "{} has unknown proactive suggestion kind {}.", - path.display(), - suggestion.suggestion_kind - )); - } - if !is_memory_summary_freshness_status(suggestion.freshness.status.as_str()) { - return Err(eyre::eyre!( - "{} has unknown proactive freshness status {}.", - path.display(), - suggestion.freshness.status - )); - } - if !is_proactive_action_decision(suggestion.action.decision.as_str()) { - return Err(eyre::eyre!( - "{} has unknown proactive action decision {}.", - path.display(), - suggestion.action.decision - )); - } - if suggestion.action.reason_code.trim().is_empty() || suggestion.action.reason.trim().is_empty() - { - return Err(eyre::eyre!("{} has incomplete proactive action rationale.", path.display())); - } - - for evidence_id in &suggestion.evidence_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for evidence_id in &suggestion.freshness.tombstone_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for flag in &suggestion.unsupported_claim_flags { - if !flag.is_object() { - return Err(eyre::eyre!( - "{} proactive unsupported-claim flags must be JSON objects.", - path.display() - )); - } - } - - validate_optional_summary_time( - path, - suggestion.freshness.observed_at.as_deref(), - suggestion.suggestion_id.as_str(), - )?; - validate_optional_summary_time( - path, - suggestion.freshness.valid_from.as_deref(), - suggestion.suggestion_id.as_str(), - )?; - validate_optional_summary_time( - path, - suggestion.freshness.valid_to.as_deref(), - suggestion.suggestion_id.as_str(), - )?; - validate_optional_summary_time( - path, - suggestion.freshness.last_confirmed_at.as_deref(), - suggestion.suggestion_id.as_str(), - )?; - - Ok(()) -} - -fn validate_scheduled_memory_artifact( - task: &ScheduledMemoryTaskArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if task.task_run_id.trim().is_empty() - || task.contract_schema != "elf.scheduled_memory_task/v1" - || task.generated_at.trim().is_empty() - || task.scheduled_for.trim().is_empty() - || task.tenant_id.trim().is_empty() - || task.project_id.trim().is_empty() - || task.agent_id.trim().is_empty() - || task.read_profile.trim().is_empty() - || task.task_kind.trim().is_empty() - || task.outputs.is_empty() - { - return Err(eyre::eyre!("{} has an incomplete scheduled memory task.", path.display())); - } - if !is_scheduled_task_kind(task.task_kind.as_str()) { - return Err(eyre::eyre!( - "{} has unknown scheduled task kind {}.", - path.display(), - task.task_kind - )); - } - - validate_optional_rfc3339(&task.generated_at, path, task.task_run_id.as_str())?; - validate_optional_rfc3339(&task.scheduled_for, path, task.task_run_id.as_str())?; - - for output in &task.outputs { - validate_scheduled_memory_output(output, path, evidence_ids)?; - } - for mutation in &task.source_mutations { - if !mutation.is_object() { - return Err(eyre::eyre!( - "{} scheduled memory source mutations must be JSON objects.", - path.display() - )); - } - } - for flag in &task.unsupported_claim_flags { - if !flag.is_object() { - return Err(eyre::eyre!( - "{} scheduled memory unsupported-claim flags must be JSON objects.", - path.display() - )); - } - } - - validate_memory_summary_source_trace(&task.source_trace, path, evidence_ids)?; - - if let Some(trace) = &task.execution_trace { - validate_scheduled_memory_trace(trace, path, evidence_ids)?; - } - - Ok(()) -} - -fn validate_scheduled_memory_output( - output: &ScheduledMemoryOutput, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if output.output_id.trim().is_empty() - || output.output_kind.trim().is_empty() - || output.text.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete scheduled memory output.", path.display())); - } - if !is_scheduled_task_kind(output.output_kind.as_str()) { - return Err(eyre::eyre!( - "{} has unknown scheduled output kind {}.", - path.display(), - output.output_kind - )); - } - if !is_memory_summary_freshness_status(output.freshness.status.as_str()) { - return Err(eyre::eyre!( - "{} has unknown scheduled output freshness status {}.", - path.display(), - output.freshness.status - )); - } - if !is_proactive_action_decision(output.action.decision.as_str()) { - return Err(eyre::eyre!( - "{} has unknown scheduled output action decision {}.", - path.display(), - output.action.decision - )); + Command::Run(args) => commands::run_command(args), + Command::Publish(args) => commands::publish_command(args), } - if output.action.reason_code.trim().is_empty() || output.action.reason.trim().is_empty() { - return Err(eyre::eyre!( - "{} has incomplete scheduled output action rationale.", - path.display() - )); - } - - for evidence_id in &output.evidence_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for evidence_id in &output.freshness.tombstone_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - for flag in &output.unsupported_claim_flags { - if !flag.is_object() { - return Err(eyre::eyre!( - "{} scheduled output unsupported-claim flags must be JSON objects.", - path.display() - )); - } - } - - validate_optional_summary_time( - path, - output.freshness.observed_at.as_deref(), - output.output_id.as_str(), - )?; - validate_optional_summary_time( - path, - output.freshness.valid_from.as_deref(), - output.output_id.as_str(), - )?; - validate_optional_summary_time( - path, - output.freshness.valid_to.as_deref(), - output.output_id.as_str(), - )?; - validate_optional_summary_time( - path, - output.freshness.last_confirmed_at.as_deref(), - output.output_id.as_str(), - )?; - - Ok(()) -} - -fn validate_scheduled_memory_trace( - trace: &ScheduledMemoryExecutionTrace, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if trace.trace_id.trim().is_empty() - || trace.trigger_kind.trim().is_empty() - || trace.status.trim().is_empty() - || trace.started_at.trim().is_empty() - || trace.completed_at.trim().is_empty() - || trace.output_ref.trim().is_empty() - { - return Err(eyre::eyre!( - "{} has an incomplete scheduled memory execution trace.", - path.display() - )); - } - - validate_optional_rfc3339(&trace.started_at, path, trace.trace_id.as_str())?; - validate_optional_rfc3339(&trace.completed_at, path, trace.trace_id.as_str())?; - - for stage in &trace.stages { - if stage.stage_name.trim().is_empty() || stage.summary.trim().is_empty() { - return Err(eyre::eyre!( - "{} has an incomplete scheduled memory trace stage.", - path.display() - )); - } - - for evidence_id in &stage.evidence_refs { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - } - - Ok(()) -} - -fn validate_work_journal_readback_artifact( - readback: &WorkJournalReadbackArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if readback.readback_id.trim().is_empty() - || readback.contract_schema != "elf.work_journal/v1" - || readback.generated_at.trim().is_empty() - || readback.session_id.trim().is_empty() - || readback.tenant_id.trim().is_empty() - || readback.project_id.trim().is_empty() - || readback.agent_id.trim().is_empty() - || readback.read_profile.trim().is_empty() - || readback.items.is_empty() - { - return Err(eyre::eyre!("{} has an incomplete Work Journal readback.", path.display())); - } - - validate_optional_rfc3339(&readback.generated_at, path, readback.readback_id.as_str())?; - - if readback.promotion_boundary.journal_entry_authority.trim().is_empty() { - return Err(eyre::eyre!( - "{} Work Journal readback {} has an incomplete promotion boundary.", - path.display(), - readback.readback_id - )); - } - - for accepted_ref in &readback.promotion_boundary.accepted_refs { - if accepted_ref.trim().is_empty() { - return Err(eyre::eyre!( - "{} Work Journal readback {} has an empty accepted ref.", - path.display(), - readback.readback_id - )); - } - } - for item in &readback.items { - validate_work_journal_entry(item, path, evidence_ids)?; - } - - if let Some(where_stopped) = &readback.where_stopped { - validate_work_journal_where_stopped(where_stopped, path, evidence_ids)?; - } - - for candidate in &readback.janitor_candidates { - if candidate.candidate_id.trim().is_empty() { - return Err(eyre::eyre!( - "{} Work Journal readback {} has an empty janitor candidate id.", - path.display(), - readback.readback_id - )); - } - - for evidence_ref in &candidate.evidence_refs { - ensure_known_evidence(path, evidence_ids, evidence_ref)?; - } - } - - Ok(()) -} - -fn validate_work_journal_entry( - entry: &WorkJournalEntryArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if entry.entry_id.trim().is_empty() - || entry.family.trim().is_empty() - || entry.title.trim().is_empty() - || entry.body.trim().is_empty() - || entry.source_refs.is_empty() - { - return Err(eyre::eyre!("{} has an incomplete Work Journal entry.", path.display())); - } - - for source_ref in &entry.source_refs { - ensure_known_evidence(path, evidence_ids, source_ref)?; - } - for marker_id in entry - .redaction_audit - .required_marker_ids - .iter() - .chain(entry.redaction_audit.redacted_marker_ids.iter()) - .chain(entry.redaction_audit.persisted_sensitive_marker_ids.iter()) - { - if marker_id.trim().is_empty() { - return Err(eyre::eyre!( - "{} Work Journal entry {} has an empty redaction marker id.", - path.display(), - entry.entry_id - )); - } - } - for step in entry.explicit_next_steps.iter().chain(entry.inferred_next_steps.iter()) { - validate_work_journal_next_step(step, path, evidence_ids)?; - } - for option in &entry.rejected_options { - if option.option_id.trim().is_empty() || option.text.trim().is_empty() { - return Err(eyre::eyre!( - "{} Work Journal entry {} has an incomplete rejected option.", - path.display(), - entry.entry_id - )); - } - - for evidence_ref in &option.evidence_refs { - ensure_known_evidence(path, evidence_ids, evidence_ref)?; - } - } - - Ok(()) -} - -fn validate_work_journal_next_step( - step: &WorkJournalNextStepArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if step.step_id.trim().is_empty() || step.text.trim().is_empty() || step.label.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete Work Journal next step.", path.display())); - } - - for evidence_ref in &step.evidence_refs { - ensure_known_evidence(path, evidence_ids, evidence_ref)?; - } - - Ok(()) -} - -fn validate_work_journal_where_stopped( - where_stopped: &WorkJournalWhereStoppedArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - for evidence_ref in where_stopped - .decision_rationale_evidence_ids - .iter() - .chain(where_stopped.handoff_source_refs.iter()) - { - ensure_known_evidence(path, evidence_ids, evidence_ref)?; - } - for claim in &where_stopped.journal_only_authority_claims { - if claim.trim().is_empty() { - return Err(eyre::eyre!( - "{} has an empty Work Journal journal-only authority claim.", - path.display() - )); - } - } - - Ok(()) -} - -fn validate_authority_recovery_drill_artifact( - drill: &AuthorityRecoveryDrillArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if drill.drill_id.trim().is_empty() - || drill.contract_schema != AUTHORITY_RECOVERY_DRILL_SCHEMA - || drill.generated_at.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete authority recovery drill.", path.display())); - } - - validate_optional_rfc3339(&drill.generated_at, path, drill.drill_id.as_str())?; - validate_recovery_topology(&drill.topology, path, drill.drill_id.as_str())?; - validate_recovery_backup_pitr(&drill.backup_pitr, path, evidence_ids)?; - validate_recovery_degraded_read(&drill.degraded_read, path, evidence_ids)?; - validate_recovery_measurement("rpo", &drill.rpo, path, evidence_ids)?; - validate_recovery_measurement("rto", &drill.rto, path, evidence_ids)?; - validate_recovery_authority_record_counts(drill, path, evidence_ids)?; - validate_recovery_outbox_replay(&drill.outbox_replay, path, evidence_ids)?; - validate_recovery_qdrant_rebuild(&drill.qdrant_rebuild, path, evidence_ids)?; - validate_recovery_migration_repair(&drill.migration_repair, path, evidence_ids)?; - validate_recovery_dead_letter(&drill.dead_letter, path, evidence_ids)?; - - for injection in &drill.failure_injections { - if injection.injection_id.trim().is_empty() - || injection.target.trim().is_empty() - || injection.fault.trim().is_empty() - || injection.started_at.trim().is_empty() - || injection.completed_at.trim().is_empty() - || injection.evidence_refs.is_empty() - { - return Err(eyre::eyre!( - "{} authority recovery drill {} has an incomplete failure injection.", - path.display(), - drill.drill_id - )); - } - - validate_optional_rfc3339(&injection.started_at, path, injection.injection_id.as_str())?; - validate_optional_rfc3339(&injection.completed_at, path, injection.injection_id.as_str())?; - ensure_known_evidence_refs(path, evidence_ids, &injection.evidence_refs)?; - } - - if drill.failure_injections.is_empty() { - return Err(eyre::eyre!( - "{} authority recovery drill {} must include failure injection evidence.", - path.display(), - drill.drill_id - )); - } - - Ok(()) -} - -fn validate_recovery_topology( - topology: &RecoveryDrillTopology, - path: &Path, - drill_id: &str, -) -> Result<()> { - if topology.authority_store.trim().is_empty() - || topology.derived_indexes.is_empty() - || topology.failover.trim().is_empty() - { - return Err(eyre::eyre!( - "{} authority recovery drill {} has incomplete topology.", - path.display(), - drill_id - )); - } - - Ok(()) -} - -fn validate_recovery_backup_pitr( - backup_pitr: &RecoveryBackupPitr, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if backup_pitr.backup_ref.trim().is_empty() - || backup_pitr.pitr_target.trim().is_empty() - || backup_pitr.evidence_refs.is_empty() - || !backup_pitr.restored - { - return Err(eyre::eyre!("{} has incomplete backup/PITR drill evidence.", path.display())); - } - - validate_optional_rfc3339(&backup_pitr.pitr_target, path, backup_pitr.backup_ref.as_str())?; - - ensure_known_evidence_refs(path, evidence_ids, &backup_pitr.evidence_refs) -} - -fn validate_recovery_degraded_read( - degraded_read: &RecoveryDegradedRead, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if degraded_read.unavailable_labels.is_empty() || degraded_read.evidence_refs.is_empty() { - return Err(eyre::eyre!("{} has incomplete degraded-read drill evidence.", path.display())); - } - if !degraded_read.source_of_truth_visible { - return Err(eyre::eyre!( - "{} has hidden source-of-truth records during degraded read.", - path.display() - )); - } - - ensure_known_evidence_refs(path, evidence_ids, °raded_read.evidence_refs) -} - -fn validate_recovery_measurement( - label: &str, - measurement: &RecoveryMeasurement, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if !measurement.target_seconds.is_finite() - || !measurement.measured_seconds.is_finite() - || measurement.target_seconds < 0.0 - || measurement.measured_seconds < 0.0 - || measurement.evidence_refs.is_empty() - { - return Err(eyre::eyre!("{} has invalid {label} recovery measurement.", path.display())); - } - if !recovery_measurement_met(measurement) { - return Err(eyre::eyre!("{} exceeded {label} recovery target.", path.display())); - } - - ensure_known_evidence_refs(path, evidence_ids, &measurement.evidence_refs) -} - -fn validate_recovery_authority_record_counts( - drill: &AuthorityRecoveryDrillArtifact, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - let present_planes = drill - .authority_record_counts - .iter() - .map(|count| count.plane.as_str()) - .collect::>(); - - for plane in REQUIRED_AUTHORITY_PLANES { - if !present_planes.contains(plane) { - return Err(eyre::eyre!( - "{} authority recovery drill {} is missing {} authority counts.", - path.display(), - drill.drill_id, - plane - )); - } - } - for count in &drill.authority_record_counts { - if count.plane.trim().is_empty() || count.evidence_refs.is_empty() { - return Err(eyre::eyre!( - "{} authority recovery drill {} has incomplete authority record counts.", - path.display(), - drill.drill_id - )); - } - if count.before_count != count.after_count { - return Err(eyre::eyre!( - "{} authority recovery drill {} lost or gained {} authority records.", - path.display(), - drill.drill_id, - count.plane - )); - } - if !count.source_refs_preserved { - return Err(eyre::eyre!( - "{} authority recovery drill {} did not preserve {} authority source refs.", - path.display(), - drill.drill_id, - count.plane - )); - } - if !count.lifecycle_history_preserved { - return Err(eyre::eyre!( - "{} authority recovery drill {} did not preserve {} authority lifecycle history.", - path.display(), - drill.drill_id, - count.plane - )); - } - - ensure_known_evidence_refs(path, evidence_ids, &count.evidence_refs)?; - } - - Ok(()) -} - -fn validate_recovery_outbox_replay( - replay: &RecoveryOutboxReplay, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if replay.evidence_refs.is_empty() || !recovery_outbox_replay_succeeded(replay) { - return Err(eyre::eyre!("{} has incomplete outbox replay drill evidence.", path.display())); - } - - ensure_known_evidence_refs(path, evidence_ids, &replay.evidence_refs) -} - -fn validate_recovery_qdrant_rebuild( - rebuild: &RecoveryQdrantRebuild, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if rebuild.evidence_refs.is_empty() || !recovery_qdrant_rebuild_succeeded(rebuild) { - return Err(eyre::eyre!( - "{} has incomplete Qdrant rebuild drill evidence.", - path.display() - )); - } - - ensure_known_evidence_refs(path, evidence_ids, &rebuild.evidence_refs) -} - -fn validate_recovery_migration_repair( - repair: &RecoveryMigrationRepair, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if repair.evidence_refs.is_empty() || !recovery_migration_repair_succeeded(repair) { - return Err(eyre::eyre!( - "{} has incomplete migration repair drill evidence.", - path.display() - )); - } - - ensure_known_evidence_refs(path, evidence_ids, &repair.evidence_refs) -} - -fn validate_recovery_dead_letter( - dead_letter: &RecoveryDeadLetterHandling, - path: &Path, - evidence_ids: &BTreeSet, -) -> Result<()> { - if dead_letter.evidence_refs.is_empty() || !recovery_dead_letter_succeeded(dead_letter) { - return Err(eyre::eyre!( - "{} has incomplete dead-letter handling drill evidence.", - path.display() - )); - } - - ensure_known_evidence_refs(path, evidence_ids, &dead_letter.evidence_refs) -} - -fn recovery_drill_succeeded(drill: &AuthorityRecoveryDrillArtifact) -> bool { - drill.backup_pitr.restored - && drill.degraded_read.source_of_truth_visible - && recovery_measurement_met(&drill.rpo) - && recovery_measurement_met(&drill.rto) - && recovery_authority_record_counts_succeeded(drill) - && recovery_outbox_replay_succeeded(&drill.outbox_replay) - && recovery_qdrant_rebuild_succeeded(&drill.qdrant_rebuild) - && recovery_migration_repair_succeeded(&drill.migration_repair) - && recovery_dead_letter_succeeded(&drill.dead_letter) -} - -fn recovery_measurement_met(measurement: &RecoveryMeasurement) -> bool { - measurement.measured_seconds <= measurement.target_seconds -} - -fn recovery_authority_record_counts_succeeded(drill: &AuthorityRecoveryDrillArtifact) -> bool { - let present_planes = drill - .authority_record_counts - .iter() - .map(|count| count.plane.as_str()) - .collect::>(); - - REQUIRED_AUTHORITY_PLANES.iter().all(|plane| present_planes.contains(*plane)) - && drill.authority_record_counts.iter().all(authority_record_count_succeeded) -} - -fn authority_record_count_succeeded(count: &AuthorityRecordCount) -> bool { - authority_record_count_balanced(count) - && count.source_refs_preserved - && count.lifecycle_history_preserved -} - -fn authority_record_count_balanced(count: &AuthorityRecordCount) -> bool { - count.before_count == count.after_count -} - -fn recovery_outbox_replay_succeeded(replay: &RecoveryOutboxReplay) -> bool { - replay.idempotent && replay.duplicate_write_count == 0 -} - -fn recovery_qdrant_rebuild_succeeded(rebuild: &RecoveryQdrantRebuild) -> bool { - rebuild.complete && rebuild.missing_vector_count == 0 && rebuild.error_count == 0 -} - -fn recovery_migration_repair_succeeded(repair: &RecoveryMigrationRepair) -> bool { - repair.applied -} - -fn recovery_dead_letter_succeeded(dead_letter: &RecoveryDeadLetterHandling) -> bool { - dead_letter.handled_count >= dead_letter.dead_letter_count -} - -fn ensure_known_evidence_refs( - path: &Path, - evidence_ids: &BTreeSet, - refs: &[String], -) -> Result<()> { - for evidence_ref in refs { - ensure_known_evidence(path, evidence_ids, evidence_ref)?; - } - - Ok(()) -} - -fn validate_optional_summary_time(path: &Path, value: Option<&str>, id: &str) -> Result<()> { - if let Some(value) = value { - validate_optional_rfc3339(value, path, id)?; - } - - Ok(()) -} - -fn is_memory_summary_category(category: &str) -> bool { - matches!( - category, - "top_of_mind" - | "background" - | "stale" | "superseded" - | "tombstone" - | "derived_project_profile" - ) -} - -fn is_memory_summary_freshness_status(status: &str) -> bool { - matches!( - status, - "current" - | "background" - | "historical" - | "stale" | "superseded" - | "tombstoned" - | "unsupported" - ) -} - -fn is_memory_summary_rationale_decision(decision: &str) -> bool { - matches!(decision, "included" | "downgraded" | "excluded") -} - -fn is_proactive_suggestion_kind(kind: &str) -> bool { - matches!( - kind, - "daily_project_brief" - | "resume_work" - | "stale_decision_audit" - | "stale_plan_preference_warning" - | "private_corpus_refresh" - ) -} - -fn is_scheduled_task_kind(kind: &str) -> bool { - matches!( - kind, - "weekly_project_status_summary" - | "stale_preference_plan_audit" - | "stale_decision_audit" - | "knowledge_page_refresh_suggestion" - | "private_provider_scheduler" - ) -} - -fn is_proactive_action_decision(decision: &str) -> bool { - matches!(decision, "recommend" | "defer" | "reject") -} - -fn validate_scoring_rubric(job: &RealWorldJob, path: &Path) -> Result<()> { - if !(0.0..=1.0).contains(&job.scoring_rubric.pass_threshold) { - return Err(eyre::eyre!("{} has invalid pass_threshold.", path.display())); - } - if job.scoring_rubric.dimensions.is_empty() { - return Err(eyre::eyre!("{} has no scoring dimensions.", path.display())); - } - - for (dimension_id, dimension) in &job.scoring_rubric.dimensions { - if dimension_id.trim().is_empty() - || !dimension.weight.is_finite() - || !dimension.max_points.is_finite() - || dimension.weight <= 0.0 - || dimension.max_points <= 0.0 - || dimension.criteria.is_null() - { - return Err(eyre::eyre!( - "{} has invalid scoring dimension {}.", - path.display(), - dimension_id - )); - } - } - for rule in &job.scoring_rubric.hard_fail_rules { - if rule.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty hard fail rule.", path.display())); - } - } - - Ok(()) -} - -fn validate_allowed_uncertainty(job: &RealWorldJob, path: &Path) -> Result<()> { - if job.allowed_uncertainty.fallback_action.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty fallback action.", path.display())); - } - if job.allowed_uncertainty.can_answer_unknown - && job.allowed_uncertainty.acceptable_phrases.is_empty() - { - return Err(eyre::eyre!( - "{} allows unknown answers but defines no acceptable uncertainty phrase.", - path.display() - )); - } - - for phrase in &job.allowed_uncertainty.acceptable_phrases { - if phrase.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty uncertainty phrase.", path.display())); - } - } - - Ok(()) -} - -fn validate_operator_debug(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(debug) = &job.operator_debug else { - if job.suite == "operator_debugging_ux" { - return Err(eyre::eyre!( - "{} operator_debugging_ux job must include operator_debug.", - path.display() - )); - } - - return Ok(()); - }; - - if debug.failure_mode.trim().is_empty() - || debug.root_cause.trim().is_empty() - || debug.dropped_candidate_visibility.trim().is_empty() - || debug.trace_completeness.trim().is_empty() - || debug.repair_action_clarity.trim().is_empty() - || debug.steps_to_root_cause == 0 - { - return Err(eyre::eyre!("{} has incomplete operator_debug evidence.", path.display())); - } - - validate_optional_debug_field(path, debug.trace_id.as_deref(), "trace_id")?; - validate_optional_debug_field(path, debug.viewer_url.as_deref(), "viewer_url")?; - validate_optional_debug_field( - path, - debug.admin_trace_bundle_url.as_deref(), - "admin_trace_bundle_url", - )?; - validate_optional_debug_field(path, debug.replay_command.as_deref(), "replay_command")?; - validate_optional_debug_field(path, debug.replay_artifact.as_deref(), "replay_artifact")?; - validate_non_empty_debug_list(path, &debug.viewer_panels, "viewer_panels")?; - validate_non_empty_debug_list(path, &debug.cli_steps, "cli_steps")?; - validate_non_empty_debug_list(path, &debug.trace_evidence, "trace_evidence")?; - - for gap in &debug.ux_gaps { - if gap.gap_id.trim().is_empty() - || gap.severity.trim().is_empty() - || gap.description.trim().is_empty() - || gap.follow_up_issue.trim().is_empty() - { - return Err(eyre::eyre!("{} has incomplete operator_debug ux_gaps.", path.display())); - } - } - - Ok(()) -} - -fn validate_job_encoding(job: &RealWorldJob, path: &Path) -> Result<()> { - if let Some(status) = job.encoding.status { - if !matches!( - status, - TypedStatus::NotEncoded | TypedStatus::Blocked | TypedStatus::Incomplete - ) { - return Err(eyre::eyre!( - "{} job {} uses encoding.status {}; only not_encoded, blocked, or incomplete are allowed.", - path.display(), - job.job_id, - status_str(status) - )); - } - if job.encoding.reason.as_deref().is_none_or(|reason| reason.trim().is_empty()) { - return Err(eyre::eyre!( - "{} job {} declares encoding.status but no reason.", - path.display(), - job.job_id - )); - } - } - if let Some(follow_up) = &job.encoding.follow_up - && (follow_up.title.trim().is_empty() || follow_up.reason.trim().is_empty()) - { - return Err(eyre::eyre!( - "{} job {} has an incomplete encoding follow-up.", - path.display(), - job.job_id - )); - } - - Ok(()) -} - -fn validate_memory_evolution(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(evolution) = &job.memory_evolution else { - return Ok(()); - }; - let evidence_ids = corpus_evidence_ids(job); - let trap_ids = - job.negative_traps.iter().map(|trap| trap.trap_id.as_str()).collect::>(); - - for evidence_id in evolution - .current_evidence_ids - .iter() - .chain(evolution.historical_evidence_ids.iter()) - .chain(evolution.tombstone_evidence_ids.iter()) - .chain(evolution.invalidation_evidence_ids.iter()) - { - ensure_known_evidence(path, &evidence_ids, evidence_id)?; - } - for trap_id in &evolution.stale_trap_ids { - if !trap_ids.contains(trap_id.as_str()) { - return Err(eyre::eyre!( - "{} job {} references unknown stale trap id {}.", - path.display(), - job.job_id, - trap_id - )); - } - } - for conflict in &evolution.conflicts { - validate_evolution_conflict(path, &evidence_ids, conflict)?; - } - - if let Some(rationale) = &evolution.update_rationale { - validate_update_rationale(path, &evidence_ids, rationale)?; - } - if let Some(temporal) = &evolution.temporal_validity { - validate_temporal_validity(job, path, temporal)?; - } - - Ok(()) -} - -fn validate_memory_summary_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(summary) = &job.memory_summary else { - if job.suite == "memory_summary" && job.encoding.status.is_none() { - return Err(eyre::eyre!( - "{} memory_summary jobs must provide memory_summary expectations.", - path.display() - )); - } - - return Ok(()); - }; - - for category in &summary.required_categories { - if !is_memory_summary_category(category.as_str()) { - return Err(eyre::eyre!( - "{} memory_summary expectation references unknown category {}.", - path.display(), - category - )); - } - } - - Ok(()) -} - -fn validate_proactive_brief_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(brief) = &job.proactive_brief else { - if job.suite == "proactive_brief" && job.encoding.status.is_none() { - return Err(eyre::eyre!( - "{} proactive_brief jobs must provide proactive_brief expectations.", - path.display() - )); - } - - return Ok(()); - }; - - for kind in &brief.required_suggestion_kinds { - if !is_proactive_suggestion_kind(kind.as_str()) { - return Err(eyre::eyre!( - "{} proactive_brief expectation references unknown suggestion kind {}.", - path.display(), - kind - )); - } - } - - Ok(()) -} - -fn validate_scheduled_memory_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(scheduled) = &job.scheduled_memory else { - if job.suite == "scheduled_memory" && job.encoding.status.is_none() { - return Err(eyre::eyre!( - "{} scheduled_memory jobs must provide scheduled_memory expectations.", - path.display() - )); - } - - return Ok(()); - }; - - for kind in &scheduled.required_task_kinds { - if !is_scheduled_task_kind(kind.as_str()) { - return Err(eyre::eyre!( - "{} scheduled_memory expectation references unknown task kind {}.", - path.display(), - kind - )); - } - } - - Ok(()) -} - -fn validate_work_continuity_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(work_continuity) = &job.work_continuity else { - if job.suite == "work_continuity" && job.encoding.status.is_none() { - return Err(eyre::eyre!( - "{} work_continuity jobs must provide work_continuity expectations.", - path.display() - )); - } - - return Ok(()); - }; - let evidence_ids = corpus_evidence_ids(job); - - for value in work_continuity - .required_reset_resume_entry_ids - .iter() - .chain(work_continuity.required_rejected_option_ids.iter()) - .chain(work_continuity.required_explicit_next_step_ids.iter()) - .chain(work_continuity.required_inferred_next_step_ids.iter()) - .chain(work_continuity.required_redaction_marker_ids.iter()) - .chain(work_continuity.required_janitor_candidate_ids.iter()) - { - if value.trim().is_empty() { - return Err(eyre::eyre!( - "{} work_continuity expectations contain an empty required id.", - path.display() - )); - } - } - for evidence_ref in work_continuity - .required_decision_rationale_evidence_ids - .iter() - .chain(work_continuity.required_handoff_source_ref_ids.iter()) - { - ensure_known_evidence(path, &evidence_ids, evidence_ref)?; - } - - Ok(()) -} - -fn validate_evolution_conflict( - path: &Path, - evidence_ids: &BTreeSet, - conflict: &EvolutionConflict, -) -> Result<()> { - if conflict.conflict_id.trim().is_empty() || conflict.claim_id.trim().is_empty() { - return Err(eyre::eyre!("{} has an incomplete evolution conflict.", path.display())); - } - - ensure_known_evidence(path, evidence_ids, conflict.current_evidence_id.as_str())?; - ensure_known_evidence(path, evidence_ids, conflict.historical_evidence_id.as_str())?; - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - - Ok(()) -} - -fn validate_update_rationale( - path: &Path, - evidence_ids: &BTreeSet, - rationale: &UpdateRationale, -) -> Result<()> { - if rationale.claim_id.trim().is_empty() { - return Err(eyre::eyre!( - "{} has an update rationale with an empty claim_id.", - path.display() - )); - } - - for evidence_id in &rationale.evidence_ids { - ensure_known_evidence(path, evidence_ids, evidence_id)?; - } - - Ok(()) -} - -fn validate_temporal_validity( - job: &RealWorldJob, - path: &Path, - temporal: &TemporalValidity, -) -> Result<()> { - if temporal.follow_up.as_deref().is_some_and(|follow_up| follow_up.trim().is_empty()) { - return Err(eyre::eyre!( - "{} job {} has an empty temporal validity follow-up.", - path.display(), - job.job_id - )); - } - if temporal.required - && !temporal.encoded - && !matches!(job.encoding.status, Some(TypedStatus::NotEncoded | TypedStatus::Blocked)) - { - return Err(eyre::eyre!( - "{} job {} requires temporal validity but does not declare a not_encoded or blocked encoding status.", - path.display(), - job.job_id - )); - } - - Ok(()) -} - -fn validate_trace_explainability(job: &RealWorldJob, path: &Path) -> Result<()> { - let Some(trace) = job - .corpus - .adapter_response - .as_ref() - .and_then(|response| response.answer.trace_explainability.as_ref()) - else { - return Ok(()); - }; - let known = corpus_evidence_ids(job); - let stage_names = - trace.stages.iter().map(|stage| stage.stage_name.as_str()).collect::>(); - - if trace.trace_id.as_deref().is_some_and(str::is_empty) { - return Err(eyre::eyre!("{} has an empty trace_explainability trace_id.", path.display())); - } - if trace.failure_stage.as_deref().is_some_and(str::is_empty) { - return Err(eyre::eyre!( - "{} has an empty trace_explainability failure_stage.", - path.display() - )); - } - - if let Some(failure_stage) = trace.failure_stage.as_deref() - && !stage_names.is_empty() - && !stage_names.contains(failure_stage) - { - return Err(eyre::eyre!( - "{} trace_explainability failure_stage {} is not present in stages.", - path.display(), - failure_stage - )); - } - - for stage in &trace.stages { - validate_trace_stage(stage, &known, path)?; - } - - Ok(()) -} - -fn validate_optional_debug_field(path: &Path, value: Option<&str>, field: &str) -> Result<()> { - if value.is_some_and(|value| value.trim().is_empty()) { - return Err(eyre::eyre!("{} has empty operator_debug {field}.", path.display())); - } - - Ok(()) -} - -fn validate_non_empty_debug_list(path: &Path, values: &[String], field: &str) -> Result<()> { - if values.iter().any(|value| value.trim().is_empty()) { - return Err(eyre::eyre!("{} has empty operator_debug {field} entry.", path.display())); - } - - Ok(()) -} - -fn validate_trace_stage( - stage: &TraceStageExplainability, - known: &BTreeSet, - path: &Path, -) -> Result<()> { - if stage.stage_name.trim().is_empty() { - return Err(eyre::eyre!("{} has a trace stage with an empty stage_name.", path.display())); - } - - for evidence_id in stage - .kept_evidence - .iter() - .chain(stage.dropped_evidence.iter()) - .chain(stage.demoted_evidence.iter()) - .chain(stage.distractor_evidence.iter()) - { - ensure_known_evidence(path, known, evidence_id)?; - } - - Ok(()) -} - -fn validate_required_rfc3339(value: &str, path: &Path, id: &str) -> Result<()> { - if OffsetDateTime::parse(value, &Rfc3339).is_err() { - return Err(eyre::eyre!("{} has invalid RFC3339 timestamp for {}.", path.display(), id)); - } - - Ok(()) -} - -fn validate_optional_rfc3339(value: &str, path: &Path, id: &str) -> Result<()> { - if !value.trim().is_empty() { - validate_required_rfc3339(value, path, id)?; - } - - Ok(()) -} - -fn ensure_known_evidence(path: &Path, known: &BTreeSet, evidence_id: &str) -> Result<()> { - if !known.contains(evidence_id) { - return Err(eyre::eyre!( - "{} references unknown evidence id {}.", - path.display(), - evidence_id - )); - } - - Ok(()) -} - -fn corpus_evidence_ids(job: &RealWorldJob) -> BTreeSet { - job.corpus.items.iter().map(|item| item.evidence_id.clone()).collect() -} - -fn corpus_text_by_id(job: &RealWorldJob) -> BTreeMap<&str, &str> { - job.corpus - .items - .iter() - .filter_map(|item| item.text.as_deref().map(|text| (item.evidence_id.as_str(), text))) - .collect() -} - -fn timeline_event_ids(job: &RealWorldJob) -> BTreeSet { - job.timeline.iter().map(|event| event.event_id.clone()).collect() -} - -fn ensure_known_event(path: &Path, known: &BTreeSet, event_id: &str) -> Result<()> { - if !known.contains(event_id) { - return Err(eyre::eyre!( - "{} references unknown timeline event id {}.", - path.display(), - event_id - )); - } - - Ok(()) -} - -fn build_report(jobs: &[RealWorldJob], args: &RunArgs) -> Result { - if jobs.is_empty() { - return Err(eyre::eyre!("At least one real_world_job fixture is required.")); - } - - let mut job_reports = Vec::with_capacity(jobs.len()); - let mut unsupported_claims = Vec::new(); - - for job in jobs { - let scoring = score_job(job); - - unsupported_claims.extend(scoring.unsupported_claims.clone()); - job_reports.push(job_report(job, scoring)); - } - - let suites = suite_reports(&job_reports); - let not_encoded_suites = suites - .iter() - .filter(|suite| suite.status == TypedStatus::NotEncoded) - .map(|suite| suite.suite_id.clone()) - .collect::>(); - let summary = report_summary(&job_reports, &suites); - let evolution = evolution_summary(&job_reports); - let follow_ups = follow_up_reports(jobs); - let external_adapters = external_adapter_section( - &args.external_adapter_manifest, - args.skip_external_adapter_manifest, - )?; - let scoreboard = scoreboard_report(jobs, &job_reports, &summary, &external_adapters); - let operational_evidence = operational_evidence_report(jobs, &job_reports); - - Ok(RealWorldReport { - schema: REPORT_SCHEMA.to_string(), - run_id: args.run_id.clone(), - generated_at: OffsetDateTime::now_utc().format(&Rfc3339)?, - runner_version: VERSION.to_string(), - corpus_profile: corpus_profile(jobs), - adapter: adapter_report(args)?, - scoreboard, - operational_evidence, - external_adapters, - capture_integration: capture_integration_report(jobs), - summary, - suites, - jobs: job_reports, - unsupported_claims, - not_encoded_suites, - private_corpus_redaction: private_corpus_redaction(jobs), - evolution, - follow_ups, - }) -} - -fn score_job(job: &RealWorldJob) -> JobScoring { - let answer = produced_answer(job); - let produced_evidence = produced_evidence_ids(answer); - let trap_ids_used = trap_ids_used(job, &produced_evidence); - let consolidation = consolidation_job_report(job); - - if let Some(status) = job.encoding.status { - let evolution = evolution_job_report(job, answer, &trap_ids_used, 0); - - return score_declared_job(job, status, trap_ids_used, evolution, consolidation); - } - - let missing_claims = missing_required_claims(job, answer); - let forbidden_claims = forbidden_claim_hits(job, answer); - let missing_evidence = missing_required_evidence(job, &produced_evidence); - let knowledge = knowledge_metrics(job, answer); - let memory_summary = memory_summary_metrics(job, answer); - let proactive_brief = proactive_brief_metrics(job, answer); - let scheduled_memory = scheduled_memory_metrics(job, answer); - let work_continuity = work_continuity_metrics(job, answer); - let mut unsupported_claims = unsupported_claims(job, answer); - - unsupported_claims.extend(unsupported_page_claims(answer)); - unsupported_claims.extend(unsupported_memory_summary_claims(job, answer)); - unsupported_claims.extend(unsupported_proactive_suggestions(job, answer)); - unsupported_claims.extend(unsupported_scheduled_outputs(job, answer)); - - let operator_counts = operator_debug_failure_counts(job); - let latency_violations = latency_violations(job, answer); - let hard_fail_hits = hard_fail_hits(job, &unsupported_claims, &trap_ids_used); - let evolution = evolution_job_report(job, answer, &trap_ids_used, forbidden_claims.len()); - let stale_answers = evolution.as_ref().map_or(0, |report| report.stale_answer_count); - let conflict_detection_missing = evolution - .as_ref() - .map_or(0, |report| report.conflict_count - report.conflict_detection_count); - let update_rationale_missing = evolution.as_ref().map_or(0, update_rationale_missing_count); - let mut counts = FailureCounts { - missing_claims: missing_claims.len(), - forbidden_claims: forbidden_claims.len(), - missing_evidence: missing_evidence.len(), - trap_uses: trap_ids_used.len(), - unsupported_claims: unsupported_claims.len(), - operator_debug_missing: operator_counts.operator_debug_missing, - operator_debug_raw_sql: operator_counts.operator_debug_raw_sql, - operator_debug_trace_gaps: operator_counts.operator_debug_trace_gaps, - operator_debug_repair_unclear: operator_counts.operator_debug_repair_unclear, - stale_answers, - conflict_detection_missing, - update_rationale_missing, - latency_violations, - proposal_usefulness_failures: proposal_usefulness_failures(consolidation.as_ref()), - lineage_failures: lineage_failures(consolidation.as_ref()), - review_action_failures: review_action_failures(consolidation.as_ref()), - source_mutations: consolidation.as_ref().map_or(0, |report| report.source_mutation_count), - blocking_executable_gaps: blocking_executable_gaps(consolidation.as_ref()), - untraced_page_sections: knowledge - .as_ref() - .map_or(0, |metrics| metrics.untraced_section_count), - missed_stale_findings: knowledge.as_ref().map_or(0, missed_stale_finding_count), - rebuild_failures: knowledge.as_ref().map_or(0, |metrics| metrics.rebuild_failure_count), - page_usefulness_failures: knowledge.as_ref().map_or(0, page_usefulness_failure_count), - ..FailureCounts::default() - }; - - apply_memory_summary_failure_counts(&mut counts, memory_summary.as_ref()); - apply_proactive_brief_failure_counts(&mut counts, proactive_brief.as_ref()); - apply_scheduled_memory_failure_counts(&mut counts, scheduled_memory.as_ref()); - apply_work_continuity_failure_counts(&mut counts, work_continuity.as_ref()); - - let dimension_scores = dimension_scores(job, &counts); - let normalized_score = normalized_score(&dimension_scores); - let wrong_result_count = wrong_result_count(&counts); - let status = job_status( - normalized_score, - job.scoring_rubric.pass_threshold, - wrong_result_count, - unsupported_claims.len(), - counts.source_mutations, - counts.blocking_executable_gaps, - ); - let reason = job_reason(status, &counts, normalized_score); - - for claim in &mut unsupported_claims { - claim.suite_id = job.suite.clone(); - claim.job_id = job.job_id.clone(); - } - - JobScoring { - status, - normalized_score, - hard_fail_hits, - unsupported_claims, - wrong_result_count, - knowledge, - trap_ids_used, - dimension_scores, - reason, - evolution, - consolidation, - memory_summary, - proactive_brief, - scheduled_memory, - work_continuity, - } -} - -fn apply_memory_summary_failure_counts( - counts: &mut FailureCounts, - metrics: Option<&MemorySummaryJobMetrics>, -) { - let Some(metrics) = metrics else { - return; - }; - - counts.memory_summary_invalid_current_entries = metrics.invalid_top_of_mind_count; - counts.memory_summary_untraced_entries = metrics.untraced_entry_count; - counts.memory_summary_missing_freshness = - metrics.entry_count.saturating_sub(metrics.freshness_marker_count); - counts.memory_summary_missing_rationale = - metrics.entry_count.saturating_sub(metrics.rationale_count); - counts.memory_summary_missing_categories = metrics.missing_required_category_count; - counts.memory_summary_unsupported_current_entries = metrics.unsupported_current_entry_count; -} - -fn apply_proactive_brief_failure_counts( - counts: &mut FailureCounts, - metrics: Option<&ProactiveBriefJobMetrics>, -) { - let Some(metrics) = metrics else { - return; - }; - - counts.proactive_brief_invalid_current_suggestions = metrics.invalid_current_suggestion_count; - counts.proactive_brief_untraced_suggestions = metrics.untraced_suggestion_count; - counts.proactive_brief_missing_freshness = - metrics.suggestion_count.saturating_sub(metrics.freshness_marker_count); - counts.proactive_brief_missing_action_rationale = - metrics.suggestion_count.saturating_sub(metrics.action_rationale_count); - counts.proactive_brief_missing_kinds = metrics.missing_required_suggestion_kind_count; - counts.proactive_brief_unsupported_current_suggestions = - metrics.unsupported_current_suggestion_count; - counts.proactive_brief_tombstone_violations = metrics.tombstone_violation_count; -} - -fn apply_scheduled_memory_failure_counts( - counts: &mut FailureCounts, - metrics: Option<&ScheduledMemoryJobMetrics>, -) { - let Some(metrics) = metrics else { - return; - }; - - counts.scheduled_memory_invalid_current_outputs = metrics.invalid_current_output_count; - counts.scheduled_memory_untraced_outputs = metrics.untraced_output_count; - counts.scheduled_memory_missing_freshness = - metrics.output_count.saturating_sub(metrics.freshness_marker_count); - counts.scheduled_memory_missing_action_rationale = - metrics.output_count.saturating_sub(metrics.action_rationale_count); - counts.scheduled_memory_missing_task_kinds = metrics.missing_required_task_kind_count; - counts.scheduled_memory_unsupported_current_outputs = metrics.unsupported_current_output_count; - counts.scheduled_memory_tombstone_violations = metrics.tombstone_violation_count; - counts.scheduled_memory_missing_trace = - metrics.trace_required_count.saturating_sub(metrics.trace_complete_count); - counts.source_mutations += metrics.source_mutation_count; -} - -fn apply_work_continuity_failure_counts( - counts: &mut FailureCounts, - metrics: Option<&WorkContinuityJobMetrics>, -) { - let Some(metrics) = metrics else { - return; - }; - - counts.work_continuity_reset_resume_missing = - metrics.reset_resume_required_count.saturating_sub(metrics.reset_resume_success_count); - counts.work_continuity_decision_rationale_missing = metrics - .decision_rationale_required_count - .saturating_sub(metrics.decision_rationale_recalled_count); - counts.work_continuity_rejected_option_unsuppressed = metrics - .rejected_option_required_count - .saturating_sub(metrics.rejected_option_suppressed_count); - counts.work_continuity_rejected_option_resurrection = - metrics.rejected_option_resurrection_count; - counts.work_continuity_explicit_next_step_missing = metrics - .explicit_next_step_required_count - .saturating_sub(metrics.explicit_next_step_correct_count); - counts.work_continuity_explicit_next_step_extra = metrics - .explicit_next_step_returned_count - .saturating_sub(metrics.explicit_next_step_correct_count); - counts.work_continuity_inferred_step_unlabeled = metrics - .inferred_next_step_required_count - .saturating_sub(metrics.inferred_next_step_labeled_count); - counts.work_continuity_inferred_step_as_instruction = metrics.inferred_step_instruction_count; - counts.work_continuity_handoff_source_ref_missing = metrics - .handoff_source_ref_required_count - .saturating_sub(metrics.handoff_source_ref_covered_count); - counts.work_continuity_redaction_missing = - metrics.redaction_required_count.saturating_sub(metrics.redaction_applied_count); - counts.work_continuity_sensitive_marker_persistence = - metrics.sensitive_marker_persistence_count; - counts.work_continuity_janitor_false_promotion = metrics.janitor_false_promotion_count; - counts.work_continuity_journal_only_authority_claim = - metrics.journal_only_authority_claim_count; -} - -fn score_declared_job( - job: &RealWorldJob, - status: TypedStatus, - trap_ids_used: Vec, - evolution: Option, - consolidation: Option, -) -> JobScoring { - JobScoring { - status, - normalized_score: 0.0, - hard_fail_hits: Vec::new(), - unsupported_claims: Vec::new(), - wrong_result_count: 0, - knowledge: None, - trap_ids_used, - dimension_scores: declared_not_encoded_dimension_scores(job), - reason: job - .encoding - .reason - .clone() - .unwrap_or_else(|| "Job did not reach a runnable scoring state.".to_string()), - evolution, - consolidation, - memory_summary: None, - proactive_brief: None, - scheduled_memory: None, - work_continuity: None, - } -} - -fn wrong_result_count(counts: &FailureCounts) -> usize { - counts.missing_claims - + counts.forbidden_claims - + counts.missing_evidence - + counts.trap_uses - + counts.operator_debug_missing - + counts.operator_debug_raw_sql - + counts.operator_debug_trace_gaps - + counts.operator_debug_repair_unclear - + counts.conflict_detection_missing - + counts.update_rationale_missing - + counts.proposal_usefulness_failures - + counts.lineage_failures - + counts.review_action_failures - + counts.memory_summary_invalid_current_entries - + counts.memory_summary_untraced_entries - + counts.memory_summary_missing_freshness - + counts.memory_summary_missing_rationale - + counts.memory_summary_missing_categories - + counts.memory_summary_unsupported_current_entries - + counts.proactive_brief_invalid_current_suggestions - + counts.proactive_brief_untraced_suggestions - + counts.proactive_brief_missing_freshness - + counts.proactive_brief_missing_action_rationale - + counts.proactive_brief_missing_kinds - + counts.proactive_brief_unsupported_current_suggestions - + counts.proactive_brief_tombstone_violations - + counts.scheduled_memory_invalid_current_outputs - + counts.scheduled_memory_untraced_outputs - + counts.scheduled_memory_missing_freshness - + counts.scheduled_memory_missing_action_rationale - + counts.scheduled_memory_missing_task_kinds - + counts.scheduled_memory_unsupported_current_outputs - + counts.scheduled_memory_tombstone_violations - + counts.scheduled_memory_missing_trace - + work_continuity_wrong_result_count(counts) - + counts.untraced_page_sections - + counts.missed_stale_findings - + counts.rebuild_failures - + counts.page_usefulness_failures -} - -fn operator_debug_failure_counts(job: &RealWorldJob) -> FailureCounts { - let Some(debug) = &job.operator_debug else { - return FailureCounts { - operator_debug_missing: usize::from(job.suite == "operator_debugging_ux"), - ..FailureCounts::default() - }; - }; - - FailureCounts { - operator_debug_raw_sql: usize::from(debug.raw_sql_needed), - operator_debug_trace_gaps: usize::from(debug.trace_completeness != "complete"), - operator_debug_repair_unclear: usize::from(debug.repair_action_clarity != "clear"), - ..FailureCounts::default() - } -} - -fn declared_not_encoded_dimension_scores(job: &RealWorldJob) -> Vec { - job.scoring_rubric - .dimensions - .iter() - .map(|(dimension_id, dimension)| DimensionScoreReport { - dimension: dimension_id.clone(), - score: 0.0, - max_points: dimension.max_points, - weight: dimension.weight, - }) - .collect() -} - -fn produced_answer(job: &RealWorldJob) -> &ProducedAnswer { - job.corpus - .adapter_response - .as_ref() - .map(|response| &response.answer) - .unwrap_or_else(|| synthetic_answer(job)) -} - -fn synthetic_answer(job: &RealWorldJob) -> &ProducedAnswer { - let _ = job; - - static EMPTY_ANSWER: std::sync::OnceLock = std::sync::OnceLock::new(); - - EMPTY_ANSWER.get_or_init(|| ProducedAnswer { - content: String::new(), - claims: Vec::new(), - evidence_ids: Vec::new(), - pages: Vec::new(), - memory_summaries: Vec::new(), - proactive_briefs: Vec::new(), - scheduled_tasks: Vec::new(), - work_journal_readbacks: Vec::new(), - recovery_drills: Vec::new(), - latency_ms: None, - cost: None, - trace_explainability: None, - }) -} - -fn produced_evidence_ids(answer: &ProducedAnswer) -> BTreeSet { - ordered_produced_evidence_ids(answer).into_iter().collect() -} - -fn ordered_produced_evidence_ids(answer: &ProducedAnswer) -> Vec { - let mut seen = BTreeSet::new(); - let mut evidence = Vec::new(); - - for evidence_id in &answer.evidence_ids { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for claim in &answer.claims { - for evidence_id in &claim.evidence_ids { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - for brief in &answer.proactive_briefs { - for suggestion in &brief.suggestions { - for evidence_id in &suggestion.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - } - for task in &answer.scheduled_tasks { - for output in &task.outputs { - for evidence_id in &output.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - } - for readback in &answer.work_journal_readbacks { - for entry in &readback.items { - for evidence_id in &entry.source_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for step in entry.explicit_next_steps.iter().chain(entry.inferred_next_steps.iter()) { - for evidence_id in &step.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - for option in &entry.rejected_options { - for evidence_id in &option.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - } - - if let Some(where_stopped) = &readback.where_stopped { - for evidence_id in &where_stopped.decision_rationale_evidence_ids { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &where_stopped.handoff_source_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - - for candidate in &readback.janitor_candidates { - for evidence_id in &candidate.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - } - for drill in &answer.recovery_drills { - for evidence_id in &drill.backup_pitr.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.degraded_read.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.rpo.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.rto.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.outbox_replay.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.qdrant_rebuild.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.migration_repair.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for evidence_id in &drill.dead_letter.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - for injection in &drill.failure_injections { - for evidence_id in &injection.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - for count in &drill.authority_record_counts { - for evidence_id in &count.evidence_refs { - push_ordered_evidence(&mut evidence, &mut seen, evidence_id); - } - } - } - - evidence -} - -fn push_ordered_evidence( - evidence: &mut Vec, - seen: &mut BTreeSet, - evidence_id: &str, -) { - if seen.insert(evidence_id.to_string()) { - evidence.push(evidence_id.to_string()); - } -} - -fn missing_required_claims(job: &RealWorldJob, answer: &ProducedAnswer) -> Vec { - job.expected_answer - .must_include - .iter() - .filter(|claim| !claim_is_present(claim, answer)) - .map(|claim| claim.text().to_string()) - .collect() -} - -fn claim_is_present(claim: &ExpectedClaim, answer: &ProducedAnswer) -> bool { - if let Some(claim_id) = claim.claim_id() - && answer.claims.iter().any(|produced| produced.claim_id.as_deref() == Some(claim_id)) - { - return true; - } - - answer.content.contains(claim.text()) -} - -fn forbidden_claim_hits(job: &RealWorldJob, answer: &ProducedAnswer) -> Vec { - job.expected_answer - .must_not_include - .iter() - .filter(|claim| answer.content.contains(claim.as_str())) - .cloned() - .collect() -} - -fn missing_required_evidence( - job: &RealWorldJob, - produced_evidence: &BTreeSet, -) -> Vec { - job.required_evidence - .iter() - .filter(|evidence| { - is_required_use(evidence) && !produced_evidence.contains(&evidence.evidence_id) - }) - .map(|evidence| evidence.evidence_id.clone()) - .collect() -} - -fn is_required_use(evidence: &RequiredEvidence) -> bool { - matches!(evidence.requirement.as_str(), "cite" | "use" | "explain") -} - -fn trap_ids_used(job: &RealWorldJob, produced_evidence: &BTreeSet) -> Vec { - job.negative_traps - .iter() - .filter(|trap| trap.failure_if_used) - .filter(|trap| { - trap.evidence_ids.iter().any(|evidence_id| produced_evidence.contains(evidence_id)) - }) - .map(|trap| trap.trap_id.clone()) - .collect() -} - -fn evolution_job_report( - job: &RealWorldJob, - answer: &ProducedAnswer, - trap_ids_used: &[String], - forbidden_claim_count: usize, -) -> Option { - let evolution = job.memory_evolution.as_ref()?; - let produced = produced_evidence_ids(answer); - let stale_trap_ids_used = stale_trap_ids_used(job, evolution, trap_ids_used); - let stale_answer_count = - stale_answer_count(job, evolution, &stale_trap_ids_used, forbidden_claim_count); - let conflict_detection_count = evolution - .conflicts - .iter() - .filter(|conflict| conflict_is_detected(conflict, answer)) - .count(); - let update_rationale_available = evolution - .update_rationale - .as_ref() - .is_some_and(|rationale| update_rationale_is_available(rationale, answer)); - let temporal_validity_required = - evolution.temporal_validity.as_ref().is_some_and(|temporal| temporal.required); - let temporal_validity_encoded = - evolution.temporal_validity.as_ref().is_some_and(|temporal| temporal.encoded); - let temporal_validity_not_encoded = temporal_validity_required && !temporal_validity_encoded; - let history_readback_encoded = - evolution.history_readback.as_ref().is_some_and(|history| history.encoded); - let history_event_types = evolution - .history_readback - .as_ref() - .map_or_else(Vec::new, |history| history.required_event_types.clone()); - let history_requires_note_version_links = evolution - .history_readback - .as_ref() - .is_some_and(|history| history.requires_note_version_links); - let follow_up = evolution - .temporal_validity - .as_ref() - .and_then(|temporal| temporal.follow_up.clone()) - .or_else(|| job.encoding.follow_up.as_ref().map(|follow_up| follow_up.title.clone())); - - Some(EvolutionJobReport { - current_evidence: evolution.current_evidence_ids.clone(), - historical_evidence: evolution.historical_evidence_ids.clone(), - tombstone_evidence: evolution.tombstone_evidence_ids.clone(), - invalidation_evidence: evolution.invalidation_evidence_ids.clone(), - selected_current_evidence: selected_evolution_evidence( - &evolution.current_evidence_ids, - &produced, - ), - selected_historical_evidence: selected_evolution_evidence( - &evolution.historical_evidence_ids, - &produced, - ), - selected_rationale_evidence: selected_rationale_evidence(evolution, &produced), - selected_tombstone_evidence: selected_evolution_evidence( - &evolution.tombstone_evidence_ids, - &produced, - ), - selected_invalidation_evidence: selected_evolution_evidence( - &evolution.invalidation_evidence_ids, - &produced, - ), - conflict_candidate_evidence: selected_conflict_candidate_evidence(evolution, &produced), - retrieved_but_dropped_evidence: trace_dropped_evidence(answer), - selected_but_not_narrated_evidence: selected_but_not_narrated_evidence(answer), - stale_answer_count, - stale_trap_ids_used, - conflict_count: evolution.conflicts.len(), - conflict_detection_count, - update_rationale_available, - temporal_validity_required, - temporal_validity_encoded, - temporal_validity_not_encoded, - history_readback_encoded, - history_event_types, - history_requires_note_version_links, - follow_up, - }) -} - -fn stale_answer_count( - job: &RealWorldJob, - evolution: &MemoryEvolution, - stale_trap_ids_used: &[String], - forbidden_claim_count: usize, -) -> usize { - let stale_trap_count = if evolution.stale_trap_ids.is_empty() { - job.negative_traps.iter().filter(|trap| trap.trap_type == "stale_fact").count() - } else { - evolution.stale_trap_ids.len() - }; - let stale_forbidden_claims = if stale_trap_count > 0 { forbidden_claim_count } else { 0 }; - - stale_trap_ids_used.len().max(stale_forbidden_claims) -} - -fn selected_evolution_evidence( - evidence_ids: &[String], - produced: &BTreeSet, -) -> Vec { - evidence_ids.iter().filter(|evidence_id| produced.contains(*evidence_id)).cloned().collect() -} - -fn selected_rationale_evidence( - evolution: &MemoryEvolution, - produced: &BTreeSet, -) -> Vec { - evolution.update_rationale.as_ref().map_or_else(Vec::new, |rationale| { - selected_evolution_evidence(&rationale.evidence_ids, produced) - }) -} - -fn selected_conflict_candidate_evidence( - evolution: &MemoryEvolution, - produced: &BTreeSet, -) -> Vec { - let mut evidence_ids = Vec::new(); - - for conflict in &evolution.conflicts { - push_if_produced(&mut evidence_ids, conflict.current_evidence_id.as_str(), produced); - push_if_produced(&mut evidence_ids, conflict.historical_evidence_id.as_str(), produced); - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - push_if_produced(&mut evidence_ids, evidence_id.as_str(), produced); - } - } - - evidence_ids -} - -fn push_if_produced(out: &mut Vec, evidence_id: &str, produced: &BTreeSet) { - if produced.contains(evidence_id) && !out.iter().any(|id| id == evidence_id) { - out.push(evidence_id.to_string()); - } -} - -fn trace_dropped_evidence(answer: &ProducedAnswer) -> Vec { - let mut evidence = Vec::new(); - - if let Some(trace) = &answer.trace_explainability { - for stage in &trace.stages { - for evidence_id in &stage.dropped_evidence { - if !evidence.iter().any(|id| id == evidence_id) { - evidence.push(evidence_id.clone()); - } - } - } - } - - evidence -} - -fn selected_but_not_narrated_evidence(answer: &ProducedAnswer) -> Vec { - let narrated = answer - .claims - .iter() - .flat_map(|claim| claim.evidence_ids.iter().map(String::as_str)) - .collect::>(); - - answer - .evidence_ids - .iter() - .filter(|evidence_id| !narrated.contains(evidence_id.as_str())) - .cloned() - .collect() -} - -fn stale_trap_ids_used( - job: &RealWorldJob, - evolution: &MemoryEvolution, - trap_ids_used: &[String], -) -> Vec { - let declared_stale_traps = if evolution.stale_trap_ids.is_empty() { - job.negative_traps - .iter() - .filter(|trap| trap.trap_type == "stale_fact") - .map(|trap| trap.trap_id.as_str()) - .collect::>() - } else { - evolution.stale_trap_ids.iter().map(String::as_str).collect::>() - }; - - trap_ids_used - .iter() - .filter(|trap_id| declared_stale_traps.contains(trap_id.as_str())) - .cloned() - .collect() -} - -fn conflict_is_detected(conflict: &EvolutionConflict, answer: &ProducedAnswer) -> bool { - let mut required_evidence = - vec![conflict.current_evidence_id.as_str(), conflict.historical_evidence_id.as_str()]; - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - required_evidence.push(evidence_id.as_str()); - } - - answer.claims.iter().any(|claim| { - claim.claim_id.as_deref() == Some(conflict.claim_id.as_str()) - && required_evidence - .iter() - .all(|evidence_id| claim.evidence_ids.iter().any(|id| id == evidence_id)) - }) -} - -fn update_rationale_is_available(rationale: &UpdateRationale, answer: &ProducedAnswer) -> bool { - if !rationale.available { - return false; - } - - answer.claims.iter().any(|claim| { - claim.claim_id.as_deref() == Some(rationale.claim_id.as_str()) - && !claim.evidence_ids.is_empty() - && rationale.evidence_ids.iter().any(|evidence_id| { - claim.evidence_ids.iter().any(|produced| produced == evidence_id) - }) - }) -} - -fn update_rationale_missing_count(report: &EvolutionJobReport) -> usize { - if report.update_rationale_available || report.temporal_validity_not_encoded { - 0 - } else if report.conflict_count > 0 { - 1 - } else { - 0 - } -} - -fn unsupported_claims(job: &RealWorldJob, answer: &ProducedAnswer) -> Vec { - answer.claims.iter().filter_map(|claim| unsupported_claim(job, claim)).collect() -} - -fn unsupported_claim(job: &RealWorldJob, claim: &ProducedClaim) -> Option { - let Some(claim_id) = claim.claim_id.as_deref() else { - return Some(unsupported_claim_report(claim, "claim has no claim_id")); - }; - let Some(allowed) = job.expected_answer.evidence_links.get(claim_id).map(EvidenceLink::ids) - else { - return Some(unsupported_claim_report( - claim, - "claim_id is not present in expected_answer.evidence_links", - )); - }; - - if claim.evidence_ids.is_empty() { - return Some(unsupported_claim_report(claim, "claim has no produced evidence ids")); - } - if !claim.evidence_ids.iter().any(|evidence_id| allowed.contains(evidence_id)) { - return Some(unsupported_claim_report( - claim, - "claim evidence is not allowed for this claim_id", - )); - } - - None -} - -fn unsupported_claim_report(claim: &ProducedClaim, reason: &str) -> UnsupportedClaimReport { - UnsupportedClaimReport { - suite_id: String::new(), - job_id: String::new(), - claim_id: claim.claim_id.clone(), - claim_text: bounded_text(claim.text.as_str(), 240), - reason: reason.to_string(), - evidence_ids: claim.evidence_ids.clone(), - } -} - -fn unsupported_page_claims(answer: &ProducedAnswer) -> Vec { - answer - .pages - .iter() - .flat_map(|page| { - page.sections.iter().filter_map(|section| { - if section_is_traced(section) || section_is_flagged_unsupported(section) { - return None; - } - - Some(UnsupportedClaimReport { - suite_id: String::new(), - job_id: String::new(), - claim_id: Some(format!("{}:{}", page.page_id, section.section_id)), - claim_text: bounded_text(section.content.as_str(), 240), - reason: - "derived page section has no source evidence and is not flagged unsupported" - .to_string(), - evidence_ids: section.evidence_ids.clone(), - }) - }) - }) - .collect() -} - -fn knowledge_metrics(job: &RealWorldJob, answer: &ProducedAnswer) -> Option { - if answer.pages.is_empty() { - return None; - } - - let mut metrics = KnowledgeJobMetrics { - page_count: answer.pages.len(), - stale_trap_count: stale_traps(job).len(), - ..KnowledgeJobMetrics::default() - }; - - for page in &answer.pages { - accumulate_page_metrics(page, &mut metrics); - } - - metrics.stale_traps_detected = stale_traps(job) - .iter() - .filter(|trap| page_artifacts_detect_stale_trap(&answer.pages, trap)) - .count(); - metrics.citation_coverage = ratio(metrics.traced_section_count, metrics.section_count); - metrics.stale_claim_detection = - ratio_or_full(metrics.stale_traps_detected, metrics.stale_trap_count); - metrics.rebuild_determinism = ratio(metrics.deterministic_rebuild_count, metrics.page_count); - metrics.backlink_coverage = ratio(metrics.pages_with_backlinks, metrics.page_count); - metrics.version_diff_coverage = ratio(metrics.pages_with_version_diff, metrics.page_count); - metrics.page_usefulness = round3( - (metrics.citation_coverage - + metrics.stale_claim_detection - + metrics.rebuild_determinism - + metrics.backlink_coverage) - / 4.0, - ); - - Some(metrics) -} - -fn stale_traps(job: &RealWorldJob) -> Vec<&NegativeTrap> { - job.negative_traps - .iter() - .filter(|trap| trap.trap_type == "stale_fact" && trap.failure_if_used) - .collect() -} - -fn accumulate_page_metrics(page: &DerivedPageArtifact, metrics: &mut KnowledgeJobMetrics) { - if !page.backlinks.is_empty() { - metrics.pages_with_backlinks += 1; - } - if page_has_version_diff(page) { - metrics.pages_with_version_diff += 1; - } - - metrics.backlink_count += page.backlinks.len(); - - for section in &page.sections { - metrics.section_count += 1; - - if section_is_traced(section) { - metrics.traced_section_count += 1; - } else if section_is_flagged_unsupported(section) { - metrics.flagged_unsupported_section_count += 1; - - if section.role == "summary" { - metrics.unsupported_summary_count += 1; - } - } else { - metrics.untraced_section_count += 1; - } - } - - if let Some(rebuild) = &page.rebuild { - if !rebuild.allowed_variance.is_empty() { - metrics.allowed_variance_count += 1; - } - if rebuild_is_acceptable(rebuild) { - metrics.deterministic_rebuild_count += 1; - } else { - metrics.rebuild_failure_count += 1; - } - } else { - metrics.rebuild_failure_count += 1; - } - - metrics.rebuild_page_count += 1; -} - -fn page_has_version_diff(page: &DerivedPageArtifact) -> bool { - page.page_version_diff.as_ref().is_some_and(|diff| { - diff.get("schema").and_then(Value::as_str) == Some("elf.knowledge_page.version_diff/v1") - && diff.get("available").and_then(Value::as_bool).unwrap_or(false) - }) -} - -fn section_is_traced(section: &DerivedPageSection) -> bool { - !section.evidence_ids.is_empty() || !section.timeline_event_ids.is_empty() -} - -fn section_is_flagged_unsupported(section: &DerivedPageSection) -> bool { - section.unsupported_reason.as_ref().is_some_and(|reason| !reason.trim().is_empty()) -} - -fn rebuild_is_acceptable(rebuild: &DerivedPageRebuild) -> bool { - (rebuild.deterministic && rebuild.first_hash == rebuild.second_hash) - || !rebuild.allowed_variance.is_empty() -} - -fn page_artifacts_detect_stale_trap(pages: &[DerivedPageArtifact], trap: &NegativeTrap) -> bool { - pages.iter().any(|page| { - page.lint_findings.iter().any(|finding| { - finding.trap_id.as_deref() == Some(trap.trap_id.as_str()) - || finding - .evidence_ids - .iter() - .any(|evidence_id| trap.evidence_ids.contains(evidence_id)) - }) - }) -} - -fn missed_stale_finding_count(metrics: &KnowledgeJobMetrics) -> usize { - metrics.stale_trap_count.saturating_sub(metrics.stale_traps_detected) -} - -fn page_usefulness_failure_count(metrics: &KnowledgeJobMetrics) -> usize { - if metrics.page_usefulness < 0.8 { 1 } else { 0 } -} - -fn memory_summary_metrics( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Option { - if answer.memory_summaries.is_empty() { - return None; - } - - let mut metrics = MemorySummaryJobMetrics { - summary_count: answer.memory_summaries.len(), - required_category_count: job - .memory_summary - .as_ref() - .map_or(0, |summary| summary.required_categories.len()), - ..MemorySummaryJobMetrics::default() - }; - let mut categories = BTreeSet::new(); - - for summary in &answer.memory_summaries { - accumulate_memory_summary_metrics(summary, &mut metrics, &mut categories); - } - - let covered_required_category_count = job.memory_summary.as_ref().map_or(0, |summary| { - summary.required_categories.iter().filter(|category| categories.contains(*category)).count() - }); - - metrics.covered_required_category_count = covered_required_category_count; - metrics.missing_required_category_count = - metrics.required_category_count.saturating_sub(covered_required_category_count); - metrics.source_ref_coverage = - ratio(metrics.source_ref_entry_count, metrics.source_ref_required_count); - metrics.freshness_coverage = ratio(metrics.freshness_marker_count, metrics.entry_count); - metrics.rationale_coverage = ratio(metrics.rationale_count, metrics.entry_count); - - Some(metrics) -} - -fn accumulate_memory_summary_metrics( - summary: &MemorySummaryArtifact, - metrics: &mut MemorySummaryJobMetrics, - categories: &mut BTreeSet, -) { - metrics.source_trace_selected_count += summary.source_trace.selected_source_refs.len(); - metrics.source_trace_dropped_count += summary.source_trace.dropped_source_refs.len(); - metrics.source_trace_stale_count += summary.source_trace.stale_source_refs.len(); - metrics.source_trace_superseded_count += summary.source_trace.superseded_source_refs.len(); - metrics.source_trace_tombstone_count += summary.source_trace.tombstone_source_refs.len(); - - let non_current_source_refs = memory_summary_non_current_trace_refs(&summary.source_trace); - - for entry in &summary.entries { - metrics.entry_count += 1; - - categories.insert(entry.category.clone()); - - accumulate_memory_summary_category(entry.category.as_str(), metrics); - - if memory_summary_entry_requires_source_ref(entry) { - metrics.source_ref_required_count += 1; - - if entry.source_refs.is_empty() { - metrics.untraced_entry_count += 1; - } - } - if !entry.source_refs.is_empty() { - metrics.source_ref_entry_count += 1; - } - if memory_summary_entry_has_freshness(entry) { - metrics.freshness_marker_count += 1; - } - if memory_summary_entry_has_rationale(entry) { - metrics.rationale_count += 1; - } - if memory_summary_entry_is_invalid_top_of_mind(entry, &non_current_source_refs) { - metrics.invalid_top_of_mind_count += 1; - } - if entry.category == "derived_project_profile" { - let has_support = - !entry.source_refs.is_empty() || !entry.unsupported_claim_flags.is_empty(); - - if has_support { - metrics.derived_with_source_or_unsupported_count += 1; - } else { - metrics.derived_missing_source_or_unsupported_count += 1; - } - if !entry.unsupported_claim_flags.is_empty() { - metrics.unsupported_derived_entry_count += 1; - } - if memory_summary_entry_includes_unsupported_current_claim(entry) { - metrics.unsupported_current_entry_count += 1; - } - } - - metrics.tombstone_ref_count += entry.freshness.tombstone_refs.len(); - } -} - -fn memory_summary_non_current_trace_refs(trace: &MemorySummarySourceTrace) -> BTreeSet<&str> { - trace - .stale_source_refs - .iter() - .chain(trace.superseded_source_refs.iter()) - .chain(trace.tombstone_source_refs.iter()) - .map(|item| item.evidence_id.as_str()) - .collect() -} - -fn accumulate_memory_summary_category(category: &str, metrics: &mut MemorySummaryJobMetrics) { - match category { - "top_of_mind" => metrics.top_of_mind_count += 1, - "background" => metrics.background_count += 1, - "stale" => metrics.stale_count += 1, - "superseded" => metrics.superseded_count += 1, - "tombstone" => metrics.tombstone_count += 1, - "derived_project_profile" => metrics.derived_project_profile_count += 1, - _ => {}, - } -} - -fn memory_summary_entry_requires_source_ref(entry: &MemorySummaryEntry) -> bool { - !(entry.category == "derived_project_profile" - && entry.source_refs.is_empty() - && !entry.unsupported_claim_flags.is_empty() - && entry.rationale.decision == "excluded") -} - -fn memory_summary_entry_is_invalid_top_of_mind( - entry: &MemorySummaryEntry, - non_current_source_refs: &BTreeSet<&str>, -) -> bool { - entry.category == "top_of_mind" - && (entry.freshness.status != "current" - || entry.rationale.decision != "included" - || !entry.freshness.superseded_by.is_empty() - || !entry.freshness.tombstone_refs.is_empty() - || entry - .source_refs - .iter() - .any(|source_ref| non_current_source_refs.contains(source_ref.as_str()))) -} - -fn memory_summary_entry_has_freshness(entry: &MemorySummaryEntry) -> bool { - if entry.freshness.status.trim().is_empty() { - return false; - } - - match entry.category.as_str() { - "superseded" => !entry.freshness.superseded_by.is_empty(), - "tombstone" => - entry.freshness.status == "tombstoned" && !entry.freshness.tombstone_refs.is_empty(), - _ => true, - } -} - -fn memory_summary_entry_has_rationale(entry: &MemorySummaryEntry) -> bool { - !entry.rationale.decision.trim().is_empty() - && !entry.rationale.reason_code.trim().is_empty() - && !entry.rationale.reason.trim().is_empty() -} - -fn memory_summary_entry_includes_unsupported_current_claim(entry: &MemorySummaryEntry) -> bool { - !entry.unsupported_claim_flags.is_empty() - && (entry.rationale.decision != "excluded" || entry.freshness.status == "current") -} - -fn unsupported_memory_summary_claims( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Vec { - answer - .memory_summaries - .iter() - .flat_map(|summary| { - summary.entries.iter().filter_map(|entry| { - if entry.category != "derived_project_profile" - || !entry.source_refs.is_empty() - || !entry.unsupported_claim_flags.is_empty() - { - return None; - } - - Some(UnsupportedClaimReport { - suite_id: job.suite.clone(), - job_id: job.job_id.clone(), - claim_id: Some(format!("{}:{}", summary.summary_id, entry.entry_id)), - claim_text: bounded_text(entry.text.as_str(), 240), - reason: - "derived memory summary entry has no source refs and no unsupported-claim flags" - .to_string(), - evidence_ids: entry.source_refs.clone(), - }) - }) - }) - .collect() -} - -fn proactive_brief_metrics( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Option { - if answer.proactive_briefs.is_empty() { - return None; - } - - let mut metrics = ProactiveBriefJobMetrics { - brief_count: answer.proactive_briefs.len(), - required_suggestion_kind_count: job - .proactive_brief - .as_ref() - .map_or(0, |brief| brief.required_suggestion_kinds.len()), - ..ProactiveBriefJobMetrics::default() - }; - let mut suggestion_kinds = BTreeSet::new(); - - for brief in &answer.proactive_briefs { - accumulate_proactive_brief_metrics(brief, &mut metrics, &mut suggestion_kinds); - } - - let covered_required_suggestion_kind_count = job.proactive_brief.as_ref().map_or(0, |brief| { - brief - .required_suggestion_kinds - .iter() - .filter(|kind| suggestion_kinds.contains(*kind)) - .count() - }); - - metrics.covered_required_suggestion_kind_count = covered_required_suggestion_kind_count; - metrics.missing_required_suggestion_kind_count = metrics - .required_suggestion_kind_count - .saturating_sub(covered_required_suggestion_kind_count); - metrics.evidence_ref_coverage = - ratio(metrics.evidence_ref_suggestion_count, metrics.evidence_ref_required_count); - metrics.freshness_coverage = ratio(metrics.freshness_marker_count, metrics.suggestion_count); - metrics.action_rationale_coverage = - ratio(metrics.action_rationale_count, metrics.suggestion_count); - - Some(metrics) -} - -fn accumulate_proactive_brief_metrics( - brief: &ProactiveBriefArtifact, - metrics: &mut ProactiveBriefJobMetrics, - suggestion_kinds: &mut BTreeSet, -) { - metrics.source_trace_selected_count += brief.source_trace.selected_source_refs.len(); - metrics.source_trace_dropped_count += brief.source_trace.dropped_source_refs.len(); - metrics.source_trace_stale_count += brief.source_trace.stale_source_refs.len(); - metrics.source_trace_superseded_count += brief.source_trace.superseded_source_refs.len(); - metrics.source_trace_tombstone_count += brief.source_trace.tombstone_source_refs.len(); - - let non_current_refs = memory_summary_non_current_trace_refs(&brief.source_trace); - let tombstone_refs = proactive_tombstone_trace_refs(&brief.source_trace); - - for suggestion in &brief.suggestions { - metrics.suggestion_count += 1; - metrics.evidence_ref_required_count += 1; - - suggestion_kinds.insert(suggestion.suggestion_kind.clone()); - - if suggestion.evidence_refs.is_empty() { - metrics.untraced_suggestion_count += 1; - } else { - metrics.evidence_ref_suggestion_count += 1; - } - if proactive_suggestion_has_freshness(suggestion) { - metrics.freshness_marker_count += 1; - } - if proactive_suggestion_has_action_rationale(suggestion) { - metrics.action_rationale_count += 1; - } - - accumulate_proactive_action_decision(suggestion.action.decision.as_str(), metrics); - - if suggestion.freshness.status == "current" { - metrics.current_suggestion_count += 1; - } else { - metrics.non_current_suggestion_count += 1; - } - if proactive_suggestion_is_stale_warning(suggestion) { - metrics.stale_warning_count += 1; - } - if proactive_suggestion_is_invalid_current(suggestion, &non_current_refs) { - metrics.invalid_current_suggestion_count += 1; - } - if proactive_suggestion_is_unsupported_current(suggestion) { - metrics.unsupported_current_suggestion_count += 1; - } - if proactive_suggestion_is_tombstone_violation(suggestion, &tombstone_refs) { - metrics.tombstone_violation_count += 1; - } - } -} - -fn proactive_tombstone_trace_refs(trace: &MemorySummarySourceTrace) -> BTreeSet<&str> { - trace.tombstone_source_refs.iter().map(|item| item.evidence_id.as_str()).collect() -} - -fn accumulate_proactive_action_decision(decision: &str, metrics: &mut ProactiveBriefJobMetrics) { - match decision { - "recommend" => metrics.recommended_count += 1, - "defer" => metrics.deferred_count += 1, - "reject" => metrics.rejected_count += 1, - _ => {}, - } -} - -fn proactive_suggestion_has_freshness(suggestion: &ProactiveSuggestion) -> bool { - if suggestion.freshness.status.trim().is_empty() { - return false; - } - - match suggestion.freshness.status.as_str() { - "superseded" => !suggestion.freshness.superseded_by.is_empty(), - "tombstoned" => !suggestion.freshness.tombstone_refs.is_empty(), - _ => true, - } -} - -fn proactive_suggestion_has_action_rationale(suggestion: &ProactiveSuggestion) -> bool { - !suggestion.action.decision.trim().is_empty() - && !suggestion.action.reason_code.trim().is_empty() - && !suggestion.action.reason.trim().is_empty() -} - -fn proactive_suggestion_is_stale_warning(suggestion: &ProactiveSuggestion) -> bool { - matches!( - suggestion.suggestion_kind.as_str(), - "stale_decision_audit" | "stale_plan_preference_warning" - ) && suggestion.freshness.status != "current" -} - -fn proactive_suggestion_is_invalid_current( - suggestion: &ProactiveSuggestion, - non_current_refs: &BTreeSet<&str>, -) -> bool { - suggestion.freshness.status == "current" - && (!suggestion.freshness.superseded_by.is_empty() - || !suggestion.freshness.tombstone_refs.is_empty() - || suggestion - .evidence_refs - .iter() - .any(|evidence_id| non_current_refs.contains(evidence_id.as_str()))) -} - -fn proactive_suggestion_is_unsupported_current(suggestion: &ProactiveSuggestion) -> bool { - !suggestion.unsupported_claim_flags.is_empty() - && (suggestion.action.decision == "recommend" || suggestion.freshness.status == "current") -} - -fn proactive_suggestion_is_tombstone_violation( - suggestion: &ProactiveSuggestion, - tombstone_refs: &BTreeSet<&str>, -) -> bool { - suggestion.freshness.status == "current" - && (!suggestion.freshness.tombstone_refs.is_empty() - || suggestion - .evidence_refs - .iter() - .any(|evidence_id| tombstone_refs.contains(evidence_id.as_str()))) -} - -fn unsupported_proactive_suggestions( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Vec { - answer - .proactive_briefs - .iter() - .flat_map(|brief| { - brief.suggestions.iter().filter_map(|suggestion| { - if suggestion.evidence_refs.is_empty() { - return Some(proactive_unsupported_claim_report( - job, - brief, - suggestion, - "proactive suggestion has no evidence refs", - )); - } - if proactive_suggestion_is_unsupported_current(suggestion) { - return Some(proactive_unsupported_claim_report( - job, - brief, - suggestion, - "unsupported proactive claim is still recommended or marked current", - )); - } - - None - }) - }) - .collect() -} - -fn proactive_unsupported_claim_report( - job: &RealWorldJob, - brief: &ProactiveBriefArtifact, - suggestion: &ProactiveSuggestion, - reason: &str, -) -> UnsupportedClaimReport { - UnsupportedClaimReport { - suite_id: job.suite.clone(), - job_id: job.job_id.clone(), - claim_id: Some(format!("{}:{}", brief.brief_id, suggestion.suggestion_id)), - claim_text: bounded_text(suggestion.body.as_str(), 240), - reason: reason.to_string(), - evidence_ids: suggestion.evidence_refs.clone(), - } -} - -fn scheduled_memory_metrics( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Option { - if answer.scheduled_tasks.is_empty() { - return None; - } - - let mut metrics = ScheduledMemoryJobMetrics { - task_run_count: answer.scheduled_tasks.len(), - required_task_kind_count: job - .scheduled_memory - .as_ref() - .map_or(0, |scheduled| scheduled.required_task_kinds.len()), - ..ScheduledMemoryJobMetrics::default() - }; - let mut task_kinds = BTreeSet::new(); - - for task in &answer.scheduled_tasks { - accumulate_scheduled_memory_metrics(task, &mut metrics, &mut task_kinds); - } - - let covered_required_task_kind_count = job.scheduled_memory.as_ref().map_or(0, |scheduled| { - scheduled.required_task_kinds.iter().filter(|kind| task_kinds.contains(*kind)).count() - }); - - metrics.covered_required_task_kind_count = covered_required_task_kind_count; - metrics.missing_required_task_kind_count = - metrics.required_task_kind_count.saturating_sub(covered_required_task_kind_count); - metrics.evidence_ref_coverage = - ratio(metrics.evidence_ref_output_count, metrics.evidence_ref_required_count); - metrics.freshness_coverage = ratio(metrics.freshness_marker_count, metrics.output_count); - metrics.action_rationale_coverage = ratio(metrics.action_rationale_count, metrics.output_count); - metrics.trace_coverage = ratio(metrics.trace_complete_count, metrics.trace_required_count); - - Some(metrics) -} - -fn accumulate_scheduled_memory_metrics( - task: &ScheduledMemoryTaskArtifact, - metrics: &mut ScheduledMemoryJobMetrics, - task_kinds: &mut BTreeSet, -) { - metrics.source_trace_selected_count += task.source_trace.selected_source_refs.len(); - metrics.source_trace_dropped_count += task.source_trace.dropped_source_refs.len(); - metrics.source_trace_stale_count += task.source_trace.stale_source_refs.len(); - metrics.source_trace_superseded_count += task.source_trace.superseded_source_refs.len(); - metrics.source_trace_tombstone_count += task.source_trace.tombstone_source_refs.len(); - metrics.trace_required_count += 1; - metrics.source_mutation_count += task.source_mutations.len() - + task.source_mutations.iter().map(forbidden_diff_key_count).sum::(); - - task_kinds.insert(task.task_kind.clone()); - - if scheduled_trace_is_complete(task.execution_trace.as_ref()) { - metrics.trace_complete_count += 1; - } - - let non_current_refs = memory_summary_non_current_trace_refs(&task.source_trace); - let tombstone_refs = proactive_tombstone_trace_refs(&task.source_trace); - - for output in &task.outputs { - metrics.output_count += 1; - metrics.evidence_ref_required_count += 1; - - if output.evidence_refs.is_empty() { - metrics.untraced_output_count += 1; - } else { - metrics.evidence_ref_output_count += 1; - } - if scheduled_output_has_freshness(output) { - metrics.freshness_marker_count += 1; - } - if scheduled_output_has_action_rationale(output) { - metrics.action_rationale_count += 1; - } - if output.freshness.status == "current" { - metrics.current_output_count += 1; - } else { - metrics.non_current_output_count += 1; - } - if scheduled_output_is_invalid_current(output, &non_current_refs) { - metrics.invalid_current_output_count += 1; - } - if scheduled_output_is_unsupported_current(output) { - metrics.unsupported_current_output_count += 1; - } - if scheduled_output_is_tombstone_violation(output, &tombstone_refs) { - metrics.tombstone_violation_count += 1; - } - } -} - -fn scheduled_trace_is_complete(trace: Option<&ScheduledMemoryExecutionTrace>) -> bool { - let Some(trace) = trace else { - return false; - }; - - trace.status == "completed" - && !trace.trace_id.trim().is_empty() - && !trace.output_ref.trim().is_empty() - && !trace.stages.is_empty() - && trace - .stages - .iter() - .any(|stage| stage.stage_name == "output_readback" && !stage.evidence_refs.is_empty()) -} - -fn scheduled_output_has_freshness(output: &ScheduledMemoryOutput) -> bool { - if output.freshness.status.trim().is_empty() { - return false; - } - - match output.freshness.status.as_str() { - "superseded" => !output.freshness.superseded_by.is_empty(), - "tombstoned" => !output.freshness.tombstone_refs.is_empty(), - _ => true, - } -} - -fn scheduled_output_has_action_rationale(output: &ScheduledMemoryOutput) -> bool { - !output.action.decision.trim().is_empty() - && !output.action.reason_code.trim().is_empty() - && !output.action.reason.trim().is_empty() -} - -fn scheduled_output_is_invalid_current( - output: &ScheduledMemoryOutput, - non_current_refs: &BTreeSet<&str>, -) -> bool { - output.freshness.status == "current" - && (!output.freshness.superseded_by.is_empty() - || !output.freshness.tombstone_refs.is_empty() - || output - .evidence_refs - .iter() - .any(|evidence_id| non_current_refs.contains(evidence_id.as_str()))) -} - -fn scheduled_output_is_unsupported_current(output: &ScheduledMemoryOutput) -> bool { - !output.unsupported_claim_flags.is_empty() - && (output.action.decision == "recommend" || output.freshness.status == "current") -} - -fn scheduled_output_is_tombstone_violation( - output: &ScheduledMemoryOutput, - tombstone_refs: &BTreeSet<&str>, -) -> bool { - output.freshness.status == "current" - && (!output.freshness.tombstone_refs.is_empty() - || output - .evidence_refs - .iter() - .any(|evidence_id| tombstone_refs.contains(evidence_id.as_str()))) -} - -fn unsupported_scheduled_outputs( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Vec { - answer - .scheduled_tasks - .iter() - .flat_map(|task| { - task.outputs.iter().filter_map(|output| { - if output.evidence_refs.is_empty() { - return Some(scheduled_unsupported_claim_report( - job, - task, - output, - "scheduled task output has no evidence refs", - )); - } - if scheduled_output_is_unsupported_current(output) { - return Some(scheduled_unsupported_claim_report( - job, - task, - output, - "unsupported scheduled task claim is still recommended or marked current", - )); - } - - None - }) - }) - .collect() -} - -fn scheduled_unsupported_claim_report( - job: &RealWorldJob, - task: &ScheduledMemoryTaskArtifact, - output: &ScheduledMemoryOutput, - reason: &str, -) -> UnsupportedClaimReport { - UnsupportedClaimReport { - suite_id: job.suite.clone(), - job_id: job.job_id.clone(), - claim_id: Some(format!("{}:{}", task.task_run_id, output.output_id)), - claim_text: bounded_text(output.text.as_str(), 240), - reason: reason.to_string(), - evidence_ids: output.evidence_refs.clone(), - } -} - -fn work_continuity_metrics( - job: &RealWorldJob, - answer: &ProducedAnswer, -) -> Option { - if job.work_continuity.is_none() && answer.work_journal_readbacks.is_empty() { - return None; - } - - let expectation = job.work_continuity.as_ref(); - let observed = work_continuity_observed(answer); - let mut metrics = initial_work_continuity_metrics(expectation, answer); - - if let Some(expected) = expectation { - apply_expected_work_continuity_counts(&mut metrics, expected, &observed); - } - - apply_observed_work_continuity_counts(&mut metrics, answer, &observed); - apply_work_continuity_rates(&mut metrics); - - Some(metrics) -} - -fn work_continuity_observed(answer: &ProducedAnswer) -> WorkContinuityObserved<'_> { - WorkContinuityObserved { - reset_resume_entry_ids: work_journal_reset_resume_entry_ids(answer), - decision_rationale_evidence_ids: work_journal_decision_rationale_evidence_ids(answer), - rejected_options: work_journal_rejected_options(answer), - explicit_next_steps: work_journal_explicit_next_steps(answer), - inferred_next_steps: work_journal_inferred_next_steps(answer), - handoff_source_refs: work_journal_handoff_source_refs(answer), - redacted_marker_ids: work_journal_redacted_marker_ids(answer), - janitor_candidates: work_journal_janitor_candidates(answer), - } -} - -fn initial_work_continuity_metrics( - expectation: Option<&WorkContinuityExpectation>, - answer: &ProducedAnswer, -) -> WorkContinuityJobMetrics { - WorkContinuityJobMetrics { - readback_count: answer.work_journal_readbacks.len(), - entry_count: answer - .work_journal_readbacks - .iter() - .map(|readback| readback.items.len()) - .sum(), - reset_resume_required_count: expectation - .map_or(0, |expected| expected.required_reset_resume_entry_ids.len()), - decision_rationale_required_count: expectation - .map_or(0, |expected| expected.required_decision_rationale_evidence_ids.len()), - rejected_option_required_count: expectation - .map_or(0, |expected| expected.required_rejected_option_ids.len()), - explicit_next_step_required_count: expectation - .map_or(0, |expected| expected.required_explicit_next_step_ids.len()), - inferred_next_step_required_count: expectation - .map_or(0, |expected| expected.required_inferred_next_step_ids.len()), - handoff_source_ref_required_count: expectation - .map_or(0, |expected| expected.required_handoff_source_ref_ids.len()), - redaction_required_count: expectation - .map_or(0, |expected| expected.required_redaction_marker_ids.len()), - janitor_candidate_count: expectation - .map_or(0, |expected| expected.required_janitor_candidate_ids.len()), - ..WorkContinuityJobMetrics::default() - } -} - -fn apply_expected_work_continuity_counts( - metrics: &mut WorkContinuityJobMetrics, - expected: &WorkContinuityExpectation, - observed: &WorkContinuityObserved<'_>, -) { - metrics.reset_resume_success_count = expected - .required_reset_resume_entry_ids - .iter() - .filter(|entry_id| observed.reset_resume_entry_ids.contains(entry_id.as_str())) - .count(); - metrics.decision_rationale_recalled_count = expected - .required_decision_rationale_evidence_ids - .iter() - .filter(|evidence_id| { - observed.decision_rationale_evidence_ids.contains(evidence_id.as_str()) - }) - .count(); - metrics.rejected_option_suppressed_count = expected - .required_rejected_option_ids - .iter() - .filter(|option_id| { - observed - .rejected_options - .iter() - .any(|option| option.option_id == **option_id && !option.resurrected_as_current) - }) - .count(); - metrics.explicit_next_step_correct_count = expected - .required_explicit_next_step_ids - .iter() - .filter(|step_id| { - observed.explicit_next_steps.iter().any(|step| { - step.step_id == **step_id && step.label == "explicit" && step.instruction - }) - }) - .count(); - metrics.inferred_next_step_labeled_count = expected - .required_inferred_next_step_ids - .iter() - .filter(|step_id| { - observed.inferred_next_steps.iter().any(|step| { - step.step_id == **step_id && step.label == "inferred" && !step.instruction - }) - }) - .count(); - metrics.handoff_source_ref_covered_count = expected - .required_handoff_source_ref_ids - .iter() - .filter(|source_ref| observed.handoff_source_refs.contains(source_ref.as_str())) - .count(); - metrics.redaction_applied_count = expected - .required_redaction_marker_ids - .iter() - .filter(|marker_id| observed.redacted_marker_ids.contains(marker_id.as_str())) - .count(); -} - -fn apply_observed_work_continuity_counts( - metrics: &mut WorkContinuityJobMetrics, - answer: &ProducedAnswer, - observed: &WorkContinuityObserved<'_>, -) { - metrics.janitor_candidate_count = - metrics.janitor_candidate_count.max(observed.janitor_candidates.len()); - metrics.janitor_false_promotion_count = observed - .janitor_candidates - .iter() - .filter(|candidate| candidate.promoted_to_memory || !candidate.review_required) - .count(); - metrics.explicit_next_step_returned_count = observed.explicit_next_steps.len(); - metrics.rejected_option_resurrection_count = - observed.rejected_options.iter().filter(|option| option.resurrected_as_current).count(); - metrics.inferred_step_instruction_count = - observed.inferred_next_steps.iter().filter(|step| step.instruction).count(); - metrics.sensitive_marker_persistence_count = answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.items.iter()) - .map(|entry| entry.redaction_audit.persisted_sensitive_marker_ids.len()) - .sum(); - metrics.journal_only_authority_claim_count = - answer.work_journal_readbacks.iter().map(work_journal_authority_claim_count).sum(); -} - -fn apply_work_continuity_rates(metrics: &mut WorkContinuityJobMetrics) { - metrics.reset_resume_success_rate = - ratio(metrics.reset_resume_success_count, metrics.reset_resume_required_count); - metrics.decision_rationale_recall_rate = - ratio(metrics.decision_rationale_recalled_count, metrics.decision_rationale_required_count); - metrics.rejected_option_suppression_rate = - ratio(metrics.rejected_option_suppressed_count, metrics.rejected_option_required_count); - metrics.explicit_next_step_precision = ratio_or( - metrics.explicit_next_step_correct_count, - metrics.explicit_next_step_returned_count, - usize::from(metrics.explicit_next_step_required_count == 0) as f64, - ); - metrics.inferred_next_step_labeling_rate = - ratio(metrics.inferred_next_step_labeled_count, metrics.inferred_next_step_required_count); - metrics.handoff_source_ref_coverage = - ratio(metrics.handoff_source_ref_covered_count, metrics.handoff_source_ref_required_count); - metrics.redaction_rate = - ratio(metrics.redaction_applied_count, metrics.redaction_required_count); - metrics.janitor_false_promotion_rate = - ratio(metrics.janitor_false_promotion_count, metrics.janitor_candidate_count); -} - -fn work_journal_reset_resume_entry_ids(answer: &ProducedAnswer) -> BTreeSet<&str> { - answer - .work_journal_readbacks - .iter() - .filter_map(|readback| readback.where_stopped.as_ref()) - .flat_map(|where_stopped| where_stopped.reset_resume_entry_ids.iter().map(String::as_str)) - .collect() -} - -fn work_journal_decision_rationale_evidence_ids(answer: &ProducedAnswer) -> BTreeSet<&str> { - answer - .work_journal_readbacks - .iter() - .filter_map(|readback| readback.where_stopped.as_ref()) - .flat_map(|where_stopped| { - where_stopped.decision_rationale_evidence_ids.iter().map(String::as_str) - }) - .collect() -} - -fn work_journal_rejected_options( - answer: &ProducedAnswer, -) -> Vec<&WorkJournalRejectedOptionArtifact> { - answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.items.iter()) - .flat_map(|entry| entry.rejected_options.iter()) - .collect() -} - -fn work_journal_explicit_next_steps(answer: &ProducedAnswer) -> Vec<&WorkJournalNextStepArtifact> { - answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.items.iter()) - .flat_map(|entry| entry.explicit_next_steps.iter()) - .collect() -} - -fn work_journal_inferred_next_steps(answer: &ProducedAnswer) -> Vec<&WorkJournalNextStepArtifact> { - answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.items.iter()) - .flat_map(|entry| entry.inferred_next_steps.iter()) - .collect() -} - -fn work_journal_handoff_source_refs(answer: &ProducedAnswer) -> BTreeSet<&str> { - let mut refs = answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.items.iter()) - .flat_map(|entry| entry.source_refs.iter().map(String::as_str)) - .collect::>(); - - for source_ref in answer - .work_journal_readbacks - .iter() - .filter_map(|readback| readback.where_stopped.as_ref()) - .flat_map(|where_stopped| where_stopped.handoff_source_refs.iter().map(String::as_str)) - { - refs.insert(source_ref); - } - - refs -} - -fn work_journal_redacted_marker_ids(answer: &ProducedAnswer) -> BTreeSet<&str> { - answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.items.iter()) - .flat_map(|entry| entry.redaction_audit.redacted_marker_ids.iter().map(String::as_str)) - .collect() -} - -fn work_journal_janitor_candidates( - answer: &ProducedAnswer, -) -> Vec<&WorkJournalJanitorCandidateArtifact> { - answer - .work_journal_readbacks - .iter() - .flat_map(|readback| readback.janitor_candidates.iter()) - .collect() -} - -fn work_journal_authority_claim_count(readback: &WorkJournalReadbackArtifact) -> usize { - let boundary_claim_count = - usize::from(readback.promotion_boundary.journal_entry_authority != "source_adjacent_only"); - let missing_promotion_boundary_count = usize::from( - !readback.promotion_boundary.memory_promotion_required - && !readback.promotion_boundary.accepted_refs.is_empty(), - ); - let where_stopped_claim_count = readback - .where_stopped - .as_ref() - .map_or(0, |where_stopped| where_stopped.journal_only_authority_claims.len()); - - boundary_claim_count + missing_promotion_boundary_count + where_stopped_claim_count -} - -fn hard_fail_hits( - job: &RealWorldJob, - unsupported_claims: &[UnsupportedClaimReport], - trap_ids_used: &[String], -) -> Vec { - let mut hits = Vec::new(); - - if !unsupported_claims.is_empty() { - hits.push( - "unsupported high-confidence claim about a required decision or fact".to_string(), - ); - } - if !trap_ids_used.is_empty() { - hits.push("use of a negative trap marked failure_if_used = true".to_string()); - } - if job.expected_answer.requires_caveat && !answer_has_required_caveat(job, produced_answer(job)) - { - hits.push("missing required caveat".to_string()); - } - if job.expected_answer.requires_refusal && !answer_looks_like_refusal(produced_answer(job)) { - hits.push("missing required refusal".to_string()); - } - - if let Some(work_continuity) = work_continuity_metrics(job, produced_answer(job)) { - if work_continuity.sensitive_marker_persistence_count > 0 { - hits.push("sensitive-marker persistence in Work Journal output".to_string()); - } - if work_continuity.rejected_option_resurrection_count > 0 { - hits.push("rejected-option resurrection in Work Journal readback".to_string()); - } - if work_continuity.inferred_step_instruction_count > 0 { - hits.push("inferred Work Journal next step surfaced as an instruction".to_string()); - } - if work_continuity.journal_only_authority_claim_count > 0 { - hits.push("journal-only Work Journal content claimed as current authority".to_string()); - } - if work_continuity.janitor_false_promotion_count > 0 { - hits.push("janitor Work Journal candidate promoted without review".to_string()); - } - } - if let Some(consolidation) = consolidation_job_report(job) { - if consolidation.source_mutation_count > 0 { - hits.push( - "source mutation count must remain zero for proposal-only consolidation cases" - .to_string(), - ); - } - if consolidation.executable_gaps.iter().any(|gap| gap.blocks_fixture_pass) { - hits.push( - "missing consolidation primitive requires a precise follow-up issue".to_string(), - ); - } - } - - hits -} - -fn answer_has_required_caveat(job: &RealWorldJob, answer: &ProducedAnswer) -> bool { - job.allowed_uncertainty.acceptable_phrases.iter().any(|phrase| answer.content.contains(phrase)) -} - -fn answer_looks_like_refusal(answer: &ProducedAnswer) -> bool { - let lower = answer.content.to_ascii_lowercase(); - - lower.contains("cannot") || lower.contains("can't") || lower.contains("refuse") -} - -fn dimension_scores(job: &RealWorldJob, counts: &FailureCounts) -> Vec { - job.scoring_rubric - .dimensions - .iter() - .map(|(dimension_id, dimension)| DimensionScoreReport { - dimension: dimension_id.clone(), - score: dimension_score(dimension_id, dimension.max_points, counts), - max_points: dimension.max_points, - weight: dimension.weight, - }) - .collect() -} - -fn dimension_score(dimension_id: &str, max_points: f64, counts: &FailureCounts) -> f64 { - let failed = match dimension_id { - "answer_correctness" | "workflow_helpfulness" => - counts.missing_claims > 0 - || counts.forbidden_claims > 0 - || counts.operator_debug_repair_unclear > 0 - || counts.conflict_detection_missing > 0 - || counts.proposal_usefulness_failures > 0 - || counts.review_action_failures > 0 - || counts.memory_summary_invalid_current_entries > 0 - || counts.memory_summary_missing_categories > 0 - || counts.memory_summary_unsupported_current_entries > 0 - || counts.proactive_brief_invalid_current_suggestions > 0 - || counts.proactive_brief_missing_kinds > 0 - || counts.proactive_brief_unsupported_current_suggestions > 0 - || counts.proactive_brief_tombstone_violations > 0 - || counts.scheduled_memory_invalid_current_outputs > 0 - || counts.scheduled_memory_missing_task_kinds > 0 - || counts.scheduled_memory_unsupported_current_outputs > 0 - || counts.scheduled_memory_tombstone_violations > 0 - || counts.scheduled_memory_missing_trace > 0 - || counts.work_continuity_reset_resume_missing > 0 - || counts.work_continuity_decision_rationale_missing > 0 - || counts.work_continuity_rejected_option_unsuppressed > 0 - || counts.work_continuity_rejected_option_resurrection > 0 - || counts.work_continuity_explicit_next_step_missing > 0 - || counts.work_continuity_explicit_next_step_extra > 0 - || counts.work_continuity_inferred_step_unlabeled > 0 - || counts.work_continuity_inferred_step_as_instruction > 0 - || counts.work_continuity_janitor_false_promotion > 0 - || counts.work_continuity_journal_only_authority_claim > 0 - || counts.page_usefulness_failures > 0, - "evidence_grounding" => - counts.missing_evidence > 0 - || counts.unsupported_claims > 0 - || counts.lineage_failures > 0 - || counts.memory_summary_untraced_entries > 0 - || counts.proactive_brief_untraced_suggestions > 0 - || counts.scheduled_memory_untraced_outputs > 0 - || counts.scheduled_memory_missing_trace > 0 - || counts.work_continuity_decision_rationale_missing > 0 - || counts.work_continuity_handoff_source_ref_missing > 0 - || counts.work_continuity_redaction_missing > 0 - || counts.work_continuity_sensitive_marker_persistence > 0 - || counts.untraced_page_sections > 0, - "trap_avoidance" => - counts.trap_uses > 0 - || counts.memory_summary_invalid_current_entries > 0 - || counts.proactive_brief_invalid_current_suggestions > 0 - || counts.proactive_brief_tombstone_violations > 0 - || counts.scheduled_memory_invalid_current_outputs > 0 - || counts.scheduled_memory_tombstone_violations > 0 - || counts.work_continuity_rejected_option_resurrection > 0 - || counts.work_continuity_sensitive_marker_persistence > 0 - || counts.missed_stale_findings > 0, - "uncertainty_handling" => - counts.unsupported_claims > 0 - || counts.memory_summary_unsupported_current_entries > 0 - || counts.proactive_brief_unsupported_current_suggestions > 0 - || counts.scheduled_memory_unsupported_current_outputs > 0 - || counts.work_continuity_journal_only_authority_claim > 0, - "lifecycle_behavior" => - counts.stale_answers > 0 - || counts.conflict_detection_missing > 0 - || counts.update_rationale_missing > 0 - || counts.source_mutations > 0 - || counts.memory_summary_invalid_current_entries > 0 - || counts.memory_summary_missing_freshness > 0 - || counts.memory_summary_missing_rationale > 0 - || counts.memory_summary_unsupported_current_entries > 0 - || counts.proactive_brief_invalid_current_suggestions > 0 - || counts.proactive_brief_missing_freshness > 0 - || counts.proactive_brief_missing_action_rationale > 0 - || counts.proactive_brief_unsupported_current_suggestions > 0 - || counts.proactive_brief_tombstone_violations > 0 - || counts.scheduled_memory_invalid_current_outputs > 0 - || counts.scheduled_memory_missing_freshness > 0 - || counts.scheduled_memory_missing_action_rationale > 0 - || counts.scheduled_memory_unsupported_current_outputs > 0 - || counts.scheduled_memory_tombstone_violations > 0 - || counts.scheduled_memory_missing_trace > 0 - || counts.work_continuity_reset_resume_missing > 0 - || counts.work_continuity_inferred_step_as_instruction > 0 - || counts.work_continuity_janitor_false_promotion > 0 - || counts.work_continuity_journal_only_authority_claim > 0 - || counts.rebuild_failures > 0, - "source_immutability" => counts.source_mutations > 0, - "proposal_usefulness" => counts.proposal_usefulness_failures > 0, - "lineage_completeness" => counts.lineage_failures > 0, - "review_action_correctness" => counts.review_action_failures > 0, - "debuggability" => - counts.missing_claims > 0 - || counts.unsupported_claims > 0 - || counts.operator_debug_missing > 0 - || counts.operator_debug_raw_sql > 0 - || counts.operator_debug_trace_gaps > 0 - || counts.scheduled_memory_missing_trace > 0 - || counts.work_continuity_reset_resume_missing > 0, - "trace_readback" => counts.scheduled_memory_missing_trace > 0, - "latency_resource" => counts.latency_violations > 0, - "personalization_fit" | "ownership_correctness" => - counts.missing_claims > 0 || counts.unsupported_claims > 0, - _ => counts.missing_claims > 0 || counts.unsupported_claims > 0 || counts.trap_uses > 0, - }; - - if failed { 0.0 } else { max_points } -} - -fn latency_violations(job: &RealWorldJob, answer: &ProducedAnswer) -> usize { - let Some(max_latency_ms) = latency_threshold_ms(job) else { - return 0; - }; - let Some(latency_ms) = answer.latency_ms else { - return 1; - }; - - usize::from(latency_ms > max_latency_ms) -} - -fn latency_threshold_ms(job: &RealWorldJob) -> Option { - job.scoring_rubric - .dimensions - .get("latency_resource") - .and_then(|dimension| dimension.criteria.get("max_latency_ms")) - .and_then(Value::as_f64) -} - -fn normalized_score(scores: &[DimensionScoreReport]) -> f64 { - let total_weight = scores.iter().map(|score| score.weight).sum::(); - - if total_weight == 0.0 { - return 0.0; - } - - scores.iter().map(|score| (score.score / score.max_points) * score.weight).sum::() - / total_weight -} - -fn job_status( - normalized_score: f64, - pass_threshold: f64, - wrong_result_count: usize, - unsupported_claim_count: usize, - source_mutation_count: usize, - blocking_executable_gap_count: usize, -) -> TypedStatus { - if unsupported_claim_count > 0 { - TypedStatus::UnsupportedClaim - } else if source_mutation_count > 0 { - TypedStatus::LifecycleFail - } else if blocking_executable_gap_count > 0 { - TypedStatus::Blocked - } else if wrong_result_count > 0 { - TypedStatus::WrongResult - } else if normalized_score >= pass_threshold { - TypedStatus::Pass - } else { - TypedStatus::WrongResult - } -} - -fn job_reason(status: TypedStatus, counts: &FailureCounts, normalized_score: f64) -> String { - let wrong_result_signal_count = wrong_result_signal_count(counts); - - match status { - TypedStatus::Pass => format!("Job passed with normalized_score {normalized_score:.3}."), - TypedStatus::UnsupportedClaim => format!( - "Job produced {} unsupported claim(s), {} wrong-result signal(s), {} latency violation(s), and normalized_score {normalized_score:.3}.", - counts.unsupported_claims, wrong_result_signal_count, counts.latency_violations - ), - TypedStatus::WrongResult => format!( - "Job produced {} wrong-result signal(s), {} latency violation(s), and normalized_score {normalized_score:.3}.", - wrong_result_signal_count, counts.latency_violations - ), - TypedStatus::LifecycleFail => format!( - "Job produced {} source mutation(s) and normalized_score {normalized_score:.3}.", - counts.source_mutations - ), - TypedStatus::Blocked => format!( - "Job has {} blocking executable gap(s) and normalized_score {normalized_score:.3}.", - counts.blocking_executable_gaps - ), - _ => "Job did not reach a runnable scoring state.".to_string(), - } -} - -fn wrong_result_signal_count(counts: &FailureCounts) -> usize { - counts.missing_claims - + counts.forbidden_claims - + counts.missing_evidence - + counts.trap_uses - + counts.operator_debug_missing - + counts.operator_debug_raw_sql - + counts.operator_debug_trace_gaps - + counts.operator_debug_repair_unclear - + counts.conflict_detection_missing - + counts.update_rationale_missing - + counts.proposal_usefulness_failures - + counts.lineage_failures - + counts.review_action_failures - + counts.memory_summary_invalid_current_entries - + counts.memory_summary_untraced_entries - + counts.memory_summary_missing_freshness - + counts.memory_summary_missing_rationale - + counts.memory_summary_missing_categories - + counts.memory_summary_unsupported_current_entries - + counts.proactive_brief_invalid_current_suggestions - + counts.proactive_brief_untraced_suggestions - + counts.proactive_brief_missing_freshness - + counts.proactive_brief_missing_action_rationale - + counts.proactive_brief_missing_kinds - + counts.proactive_brief_unsupported_current_suggestions - + counts.proactive_brief_tombstone_violations - + counts.scheduled_memory_invalid_current_outputs - + counts.scheduled_memory_untraced_outputs - + counts.scheduled_memory_missing_freshness - + counts.scheduled_memory_missing_action_rationale - + counts.scheduled_memory_missing_task_kinds - + counts.scheduled_memory_unsupported_current_outputs - + counts.scheduled_memory_tombstone_violations - + counts.scheduled_memory_missing_trace - + work_continuity_wrong_result_count(counts) - + counts.untraced_page_sections - + counts.missed_stale_findings - + counts.rebuild_failures - + counts.page_usefulness_failures -} - -fn work_continuity_wrong_result_count(counts: &FailureCounts) -> usize { - counts.work_continuity_reset_resume_missing - + counts.work_continuity_decision_rationale_missing - + counts.work_continuity_rejected_option_unsuppressed - + counts.work_continuity_rejected_option_resurrection - + counts.work_continuity_explicit_next_step_missing - + counts.work_continuity_explicit_next_step_extra - + counts.work_continuity_inferred_step_unlabeled - + counts.work_continuity_inferred_step_as_instruction - + counts.work_continuity_handoff_source_ref_missing - + counts.work_continuity_redaction_missing - + counts.work_continuity_sensitive_marker_persistence - + counts.work_continuity_janitor_false_promotion - + counts.work_continuity_journal_only_authority_claim -} - -fn job_report(job: &RealWorldJob, scoring: JobScoring) -> JobReport { - let answer = produced_answer(job); - let metrics = job_metrics(job, answer); - let retrieval_quality = retrieval_quality_report(job, answer); - - JobReport { - suite_id: job.suite.clone(), - job_id: job.job_id.clone(), - title: job.title.clone(), - status: scoring.status, - operational_evidence_tier: operational_evidence_tier(job).to_string(), - answer_type: job.expected_answer.answer_type.clone(), - requires_caveat: job.expected_answer.requires_caveat, - requires_refusal: job.expected_answer.requires_refusal, - can_answer_unknown: job.allowed_uncertainty.can_answer_unknown, - normalized_score: round3(scoring.normalized_score), - hard_fail_hits: scoring.hard_fail_hits, - expected_evidence: expected_evidence_report(job), - produced_answer: answer.content.clone(), - produced_evidence: produced_evidence_ids(answer).into_iter().collect(), - unsupported_claim_count: scoring.unsupported_claims.len(), - wrong_result_count: scoring.wrong_result_count, - stale_answer_count: scoring - .evolution - .as_ref() - .map_or(0, |report| report.stale_answer_count), - conflict_detection_count: scoring - .evolution - .as_ref() - .map_or(0, |report| report.conflict_detection_count), - update_rationale_available: scoring - .evolution - .as_ref() - .is_some_and(|report| report.update_rationale_available), - temporal_validity_not_encoded: scoring - .evolution - .as_ref() - .is_some_and(|report| report.temporal_validity_not_encoded), - history_readback_encoded: scoring - .evolution - .as_ref() - .is_some_and(|report| report.history_readback_encoded), - retrieval_quality, - latency_ms: answer.latency_ms, - cost: answer.cost.clone(), - trace_explainability: answer.trace_explainability.clone(), - knowledge: scoring.knowledge, - memory_summary: scoring.memory_summary, - proactive_brief: scoring.proactive_brief, - scheduled_memory: scoring.scheduled_memory, - work_continuity: scoring.work_continuity, - recovery_drills: answer.recovery_drills.clone(), - trap_ids_used: scoring.trap_ids_used, - dimension_scores: scoring.dimension_scores, - reason: scoring.reason, - evidence_required_count: metrics.evidence_required_count, - evidence_covered_count: metrics.evidence_covered_count, - source_ref_required_count: metrics.source_ref_required_count, - source_ref_covered_count: metrics.source_ref_covered_count, - quote_required_count: metrics.quote_required_count, - quote_covered_count: metrics.quote_covered_count, - stale_retrieval_count: metrics.stale_retrieval_count, - scope_check_count: metrics.scope_check_count, - scope_correct_count: metrics.scope_correct_count, - scope_violation_count: metrics.scope_violation_count, - redaction_leak_count: metrics.redaction_leak_count, - qdrant_rebuild_case: metrics.qdrant_rebuild_case, - operator_debug: job.operator_debug.clone(), - evolution: scoring.evolution, - consolidation: scoring.consolidation, - } -} - -fn consolidation_job_report(job: &RealWorldJob) -> Option { - let fixture = job.corpus.adapter_response.as_ref()?.consolidation.as_ref()?; - let proposals = fixture.proposals.iter().map(consolidation_proposal_report).collect::>(); - let executable_gaps = fixture - .executable_gaps - .iter() - .map(|gap| ConsolidationExecutableGapReport { - primitive: gap.primitive.clone(), - follow_up_issue: gap.follow_up_issue.clone(), - reason: gap.reason.clone(), - blocks_fixture_pass: gap.blocks_fixture_pass, - }) - .collect::>(); - let proposal_count = proposals.len(); - let source_mutation_count = - proposals.iter().map(|proposal| proposal.source_mutation_count).sum(); - let proposal_unsupported_claim_count = - proposals.iter().map(|proposal| proposal.unsupported_claim_count).sum(); - - Some(ConsolidationJobReport { - proposal_count, - proposal_usefulness: mean_proposal_metric( - proposals.iter().map(|proposal| proposal.usefulness_score), - ), - lineage_completeness: mean_proposal_metric( - proposals.iter().map(|proposal| proposal.lineage_completeness), - ), - review_action_correctness: mean_proposal_metric( - proposals.iter().map(|proposal| if proposal.review_action_correct { 1.0 } else { 0.0 }), - ), - source_mutation_count, - proposal_unsupported_claim_count, - executable_gaps, - proposals, - }) -} - -fn consolidation_proposal_report( - proposal: &ConsolidationProposalFixture, -) -> ConsolidationProposalReport { - ConsolidationProposalReport { - proposal_id: proposal.proposal_id.clone(), - proposal_kind: proposal.proposal_kind.clone(), - usefulness_score: round3(proposal.usefulness_score), - min_usefulness_score: round3(proposal.min_usefulness_score), - lineage_completeness: round3(lineage_completeness(proposal)), - expected_review_action: proposal.expected_review_action, - actual_review_action: proposal.actual_review_action, - review_action_correct: proposal.expected_review_action == proposal.actual_review_action, - source_mutation_count: proposal.source_mutations.len() - + forbidden_diff_key_count(&proposal.diff), - unsupported_claim_count: proposal - .unsupported_claim_count - .max(proposal.unsupported_claim_flags.len()), - } -} - -fn lineage_completeness(proposal: &ConsolidationProposalFixture) -> f64 { - let expected = proposal.expected_source_refs.iter().collect::>(); - let actual = proposal.source_refs.iter().collect::>(); - let matched = expected.iter().filter(|source_ref| actual.contains(**source_ref)).count(); - - matched as f64 / expected.len() as f64 -} - -fn forbidden_diff_key_count(value: &Value) -> usize { - match value { - Value::Object(map) => map - .iter() - .map(|(key, nested)| { - usize::from(FORBIDDEN_SOURCE_MUTATION_KEYS.contains(&key.as_str())) - + forbidden_diff_key_count(nested) - }) - .sum(), - Value::Array(items) => items.iter().map(forbidden_diff_key_count).sum(), - _ => 0, - } -} - -fn proposal_usefulness_failures(consolidation: Option<&ConsolidationJobReport>) -> usize { - consolidation.map_or(0, |report| { - report - .proposals - .iter() - .filter(|proposal| proposal.usefulness_score < proposal.min_usefulness_score) - .count() - }) -} - -fn lineage_failures(consolidation: Option<&ConsolidationJobReport>) -> usize { - consolidation.map_or(0, |report| { - report.proposals.iter().filter(|proposal| proposal.lineage_completeness < 1.0).count() - }) -} - -fn review_action_failures(consolidation: Option<&ConsolidationJobReport>) -> usize { - consolidation.map_or(0, |report| { - report.proposals.iter().filter(|proposal| !proposal.review_action_correct).count() - }) -} - -fn blocking_executable_gaps(consolidation: Option<&ConsolidationJobReport>) -> usize { - consolidation.map_or(0, |report| { - report.executable_gaps.iter().filter(|gap| gap.blocks_fixture_pass).count() - }) -} - -fn mean_proposal_metric(values: impl Iterator) -> Option { - let values = values.collect::>(); - - if values.is_empty() { - None - } else { - Some(round3(values.iter().sum::() / values.len() as f64)) - } -} - -fn job_metrics(job: &RealWorldJob, answer: &ProducedAnswer) -> JobMetrics { - let produced_evidence = produced_evidence_ids(answer); - let source_ref_by_evidence = source_ref_by_evidence(job); - let evidence_required_count = - job.required_evidence.iter().filter(|evidence| is_required_use(evidence)).count(); - let evidence_covered_count = job - .required_evidence - .iter() - .filter(|evidence| is_required_use(evidence)) - .filter(|evidence| produced_evidence.contains(&evidence.evidence_id)) - .count(); - let source_ref_required_count = evidence_required_count; - let source_ref_covered_count = job - .required_evidence - .iter() - .filter(|evidence| is_required_use(evidence)) - .filter(|evidence| produced_evidence.contains(&evidence.evidence_id)) - .filter(|evidence| { - source_ref_by_evidence.get(evidence.evidence_id.as_str()).is_some_and(|source_ref| { - source_ref.as_object().is_some_and(|object| !object.is_empty()) - }) - }) - .count(); - let quote_required_count = job - .required_evidence - .iter() - .filter(|evidence| is_required_use(evidence) && evidence.quote.is_some()) - .count(); - let quote_covered_count = job - .required_evidence - .iter() - .filter(|evidence| is_required_use(evidence) && evidence.quote.is_some()) - .filter(|evidence| produced_evidence.contains(&evidence.evidence_id)) - .count(); - let stale_retrieval_count = trap_use_count(job, &produced_evidence, "stale_fact", answer); - let scope_violation_count = ["near_duplicate", "scope_leak"] - .into_iter() - .map(|trap_type| trap_use_count(job, &produced_evidence, trap_type, answer)) - .sum(); - let scope_check_count = job - .negative_traps - .iter() - .filter(|trap| is_scope_trap_type(trap.trap_type.as_str())) - .count(); - let redaction_leak_count = trap_use_count(job, &produced_evidence, "privacy_leak", answer); - let scope_correct_count = scope_check_count.saturating_sub(scope_violation_count); - let qdrant_rebuild_case = job.tags.iter().any(|tag| tag == "qdrant_rebuild"); - - JobMetrics { - evidence_required_count, - evidence_covered_count, - source_ref_required_count, - source_ref_covered_count, - quote_required_count, - quote_covered_count, - stale_retrieval_count, - scope_check_count, - scope_correct_count, - scope_violation_count, - redaction_leak_count, - qdrant_rebuild_case, - } -} - -fn source_ref_by_evidence(job: &RealWorldJob) -> BTreeMap<&str, &Value> { - job.corpus.items.iter().map(|item| (item.evidence_id.as_str(), &item.source_ref)).collect() -} - -fn is_scope_trap_type(trap_type: &str) -> bool { - matches!(trap_type, "near_duplicate" | "scope_leak") -} - -fn trap_use_count( - job: &RealWorldJob, - produced_evidence: &BTreeSet, - trap_type: &str, - answer: &ProducedAnswer, -) -> usize { - job.negative_traps - .iter() - .filter(|trap| trap.failure_if_used && trap.trap_type == trap_type) - .filter(|trap| trap_was_used(job, trap, produced_evidence, answer)) - .count() -} - -fn trap_was_used( - job: &RealWorldJob, - trap: &NegativeTrap, - produced_evidence: &BTreeSet, - answer: &ProducedAnswer, -) -> bool { - trap.evidence_ids.iter().any(|evidence_id| { - produced_evidence.contains(evidence_id) - || answer_contains_corpus_item(job, evidence_id, answer) - }) -} - -fn answer_contains_corpus_item( - job: &RealWorldJob, - evidence_id: &str, - answer: &ProducedAnswer, -) -> bool { - job.corpus - .items - .iter() - .find(|item| item.evidence_id == evidence_id) - .and_then(|item| item.text.as_deref()) - .is_some_and(|text| !text.trim().is_empty() && answer.content.contains(text)) -} - -fn retrieval_quality_report(job: &RealWorldJob, answer: &ProducedAnswer) -> RetrievalQualityReport { - let expected = expected_evidence_ids(job); - let allowed = allowed_evidence_ids(job); - let produced = produced_evidence_ids(answer); - let trap_evidence = trap_evidence_ids(job); - let expected_evidence_matched = - expected.iter().filter(|evidence_id| produced.contains(evidence_id.as_str())).count(); - let irrelevant_context_count = - produced.iter().filter(|evidence_id| !allowed.contains(evidence_id.as_str())).count(); - let trap_context_count = - produced.iter().filter(|evidence_id| trap_evidence.contains(evidence_id.as_str())).count(); - - RetrievalQualityReport { - expected_evidence_total: expected.len(), - expected_evidence_matched, - expected_evidence_recall: ratio_or(expected_evidence_matched, expected.len(), 1.0), - produced_evidence_total: produced.len(), - irrelevant_context_count, - irrelevant_context_ratio: ratio_or(irrelevant_context_count, produced.len(), 0.0), - trap_context_count, - } -} - -fn expected_evidence_ids(job: &RealWorldJob) -> BTreeSet { - job.required_evidence - .iter() - .filter(|evidence| is_required_use(evidence)) - .map(|evidence| evidence.evidence_id.clone()) - .collect() -} - -fn allowed_evidence_ids(job: &RealWorldJob) -> BTreeSet { - let mut allowed = expected_evidence_ids(job); - - for link in job.expected_answer.evidence_links.values() { - allowed.extend(link.ids()); - } - - allowed -} - -fn trap_evidence_ids(job: &RealWorldJob) -> BTreeSet { - job.negative_traps.iter().flat_map(|trap| trap.evidence_ids.iter().cloned()).collect() -} - -fn expected_evidence_report(job: &RealWorldJob) -> Vec { - job.required_evidence - .iter() - .map(|evidence| ExpectedEvidenceReport { - evidence_id: evidence.evidence_id.clone(), - claim_id: evidence.claim_id.clone(), - requirement: evidence.requirement.clone(), - }) - .collect() -} - -fn suite_reports(jobs: &[JobReport]) -> Vec { - SUITES.iter().map(|suite_id| suite_report(suite_id, jobs)).collect() -} - -fn suite_report(suite_id: &str, jobs: &[JobReport]) -> SuiteReport { - let suite_jobs = jobs.iter().filter(|job| job.suite_id == suite_id).collect::>(); - - if suite_jobs.is_empty() { - return SuiteReport { - suite_id: suite_id.to_string(), - status: TypedStatus::NotEncoded, - encoded_job_count: 0, - score_mean: None, - unsupported_claim_count: 0, - wrong_result_count: 0, - stale_answer_count: 0, - conflict_detection_count: 0, - update_rationale_available_count: 0, - temporal_validity_not_encoded_count: 0, - history_readback_encoded_count: 0, - expected_evidence_recall: None, - irrelevant_context_ratio: None, - trace_explainability_count: 0, - reason: NOT_ENCODED_REASON.to_string(), - }; - } - - let status = aggregate_status(&suite_jobs); - let score_sum = suite_jobs.iter().map(|job| job.normalized_score).sum::(); - let unsupported_claim_count = suite_jobs.iter().map(|job| job.unsupported_claim_count).sum(); - let wrong_result_count = suite_jobs.iter().map(|job| job.wrong_result_count).sum(); - let stale_answer_count = suite_jobs.iter().map(|job| job.stale_answer_count).sum(); - let conflict_detection_count = suite_jobs.iter().map(|job| job.conflict_detection_count).sum(); - let update_rationale_available_count = - suite_jobs.iter().filter(|job| job.update_rationale_available).count(); - let temporal_validity_not_encoded_count = - suite_jobs.iter().filter(|job| job.temporal_validity_not_encoded).count(); - let history_readback_encoded_count = - suite_jobs.iter().filter(|job| job.history_readback_encoded).count(); - let trace_explainability_count = - suite_jobs.iter().filter(|job| job.trace_explainability.is_some()).count(); - - SuiteReport { - suite_id: suite_id.to_string(), - status, - encoded_job_count: suite_jobs.len(), - score_mean: Some(round3(score_sum / suite_jobs.len() as f64)), - unsupported_claim_count, - wrong_result_count, - stale_answer_count, - conflict_detection_count, - update_rationale_available_count, - temporal_validity_not_encoded_count, - history_readback_encoded_count, - expected_evidence_recall: Some(expected_evidence_recall_for_jobs(&suite_jobs)), - irrelevant_context_ratio: Some(irrelevant_context_ratio_for_jobs(&suite_jobs)), - trace_explainability_count, - reason: suite_reason(status, suite_jobs.len()), - } -} - -fn aggregate_status(jobs: &[&JobReport]) -> TypedStatus { - let statuses = jobs.iter().map(|job| job.status).collect::>(); - - if statuses.contains(&TypedStatus::UnsupportedClaim) { - TypedStatus::UnsupportedClaim - } else if statuses.contains(&TypedStatus::LifecycleFail) { - TypedStatus::LifecycleFail - } else if statuses.contains(&TypedStatus::WrongResult) { - TypedStatus::WrongResult - } else if statuses.contains(&TypedStatus::Incomplete) { - TypedStatus::Incomplete - } else if statuses.contains(&TypedStatus::Blocked) { - TypedStatus::Blocked - } else if statuses.contains(&TypedStatus::NotEncoded) { - TypedStatus::NotEncoded - } else if statuses.contains(&TypedStatus::Pass) { - TypedStatus::Pass - } else { - TypedStatus::NotEncoded - } -} - -fn suite_reason(status: TypedStatus, encoded_job_count: usize) -> String { - match status { - TypedStatus::Pass => format!("All {encoded_job_count} encoded job(s) passed."), - TypedStatus::UnsupportedClaim => - "At least one encoded job produced an unsupported claim.".to_string(), - TypedStatus::WrongResult => "At least one encoded job returned a wrong result.".to_string(), - TypedStatus::LifecycleFail => - "At least one encoded lifecycle-scored job failed lifecycle behavior.".to_string(), - TypedStatus::Incomplete => "At least one encoded job could not complete.".to_string(), - TypedStatus::Blocked => "At least one encoded job is blocked.".to_string(), - TypedStatus::NotEncoded => - if encoded_job_count == 0 { - NOT_ENCODED_REASON.to_string() - } else { - "At least one encoded fixture declares a not_encoded limitation.".to_string() - }, - } -} - -fn report_summary(jobs: &[JobReport], suites: &[SuiteReport]) -> ReportSummary { - let job_refs = jobs.iter().collect::>(); - let evidence_required_count = jobs.iter().map(|job| job.evidence_required_count).sum(); - let evidence_covered_count = jobs.iter().map(|job| job.evidence_covered_count).sum(); - let source_ref_required_count = jobs.iter().map(|job| job.source_ref_required_count).sum(); - let source_ref_covered_count = jobs.iter().map(|job| job.source_ref_covered_count).sum(); - let quote_required_count = jobs.iter().map(|job| job.quote_required_count).sum(); - let quote_covered_count = jobs.iter().map(|job| job.quote_covered_count).sum(); - let scope_check_count = jobs.iter().map(|job| job.scope_check_count).sum(); - let scope_correct_count = jobs.iter().map(|job| job.scope_correct_count).sum(); - let mut summary = ReportSummary { - job_count: jobs.len(), - encoded_suite_count: suites.iter().filter(|suite| suite.encoded_job_count > 0).count(), - not_encoded: 0, - unsupported_claim_count: jobs.iter().map(|job| job.unsupported_claim_count).sum(), - wrong_result_count: jobs.iter().map(|job| job.wrong_result_count).sum(), - stale_answer_count: jobs.iter().map(|job| job.stale_answer_count).sum(), - conflict_detection_count: jobs.iter().map(|job| job.conflict_detection_count).sum(), - update_rationale_available_count: jobs - .iter() - .filter(|job| job.update_rationale_available) - .count(), - temporal_validity_not_encoded_count: jobs - .iter() - .filter(|job| job.temporal_validity_not_encoded) - .count(), - history_readback_encoded_count: jobs - .iter() - .filter(|job| job.history_readback_encoded) - .count(), - expected_evidence_total: jobs - .iter() - .map(|job| job.retrieval_quality.expected_evidence_total) - .sum(), - expected_evidence_matched: jobs - .iter() - .map(|job| job.retrieval_quality.expected_evidence_matched) - .sum(), - expected_evidence_recall: expected_evidence_recall_for_jobs(&job_refs), - irrelevant_context_count: jobs - .iter() - .map(|job| job.retrieval_quality.irrelevant_context_count) - .sum(), - irrelevant_context_ratio: irrelevant_context_ratio_for_jobs(&job_refs), - trace_explainability_count: jobs - .iter() - .filter(|job| job.trace_explainability.is_some()) - .count(), - wrong_result_stage_attribution_count: jobs - .iter() - .filter(|job| { - job.status == TypedStatus::WrongResult - && trace_failure_stage(job.trace_explainability.as_ref()).is_some() - }) - .count(), - mean_score: mean_score(jobs), - mean_latency_ms: mean_latency(jobs), - total_cost: total_cost(jobs), - evidence_required_count, - evidence_covered_count, - evidence_coverage: ratio(evidence_covered_count, evidence_required_count), - source_ref_required_count, - source_ref_covered_count, - source_ref_coverage: ratio(source_ref_covered_count, source_ref_required_count), - quote_required_count, - quote_covered_count, - quote_coverage: ratio(quote_covered_count, quote_required_count), - stale_retrieval_count: jobs.iter().map(|job| job.stale_retrieval_count).sum(), - scope_check_count, - scope_correct_count, - scope_correctness: ratio(scope_correct_count, scope_check_count), - scope_violation_count: jobs.iter().map(|job| job.scope_violation_count).sum(), - redaction_leak_count: jobs.iter().map(|job| job.redaction_leak_count).sum(), - qdrant_rebuild_case_count: jobs.iter().filter(|job| job.qdrant_rebuild_case).count(), - qdrant_rebuild_pass_count: jobs - .iter() - .filter(|job| job.qdrant_rebuild_case && job.status == TypedStatus::Pass) - .count(), - operator_debug_job_count: jobs.iter().filter(|job| job.operator_debug.is_some()).count(), - raw_sql_needed_count: jobs - .iter() - .filter_map(|job| job.operator_debug.as_ref()) - .filter(|debug| debug.raw_sql_needed) - .count(), - trace_incomplete_count: jobs - .iter() - .filter_map(|job| job.operator_debug.as_ref()) - .filter(|debug| debug.trace_completeness != "complete") - .count(), - operator_ux_gap_count: jobs - .iter() - .filter_map(|job| job.operator_debug.as_ref()) - .map(|debug| debug.ux_gaps.len()) - .sum(), - consolidation: consolidation_summary(jobs), - memory_summary: memory_summary_summary(jobs), - proactive_brief: proactive_brief_summary(jobs), - scheduled_memory: scheduled_memory_summary(jobs), - work_continuity: work_continuity_summary(jobs), - knowledge: knowledge_summary(jobs), - ..ReportSummary::default() - }; - - for job in jobs { - match job.status { - TypedStatus::Pass => summary.pass += 1, - TypedStatus::WrongResult => summary.wrong_result += 1, - TypedStatus::LifecycleFail => summary.lifecycle_fail += 1, - TypedStatus::Incomplete => summary.incomplete += 1, - TypedStatus::Blocked => summary.blocked += 1, - TypedStatus::NotEncoded => summary.not_encoded += 1, - TypedStatus::UnsupportedClaim => summary.unsupported_claim += 1, - } - } - - summary -} - -fn scoreboard_report( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], - summary: &ReportSummary, - external_adapters: &ExternalAdapterSection, -) -> ScoreboardReport { - let job_typed_non_pass_count = - job_reports.iter().filter(|job| job.status != TypedStatus::Pass).count(); - let external_typed_non_pass_count = external_typed_non_pass_count(&external_adapters.summary); - let job_typed_non_pass_states_present = typed_non_pass_states_present(job_reports); - let external_adapter_typed_non_pass_states_present = - external_typed_non_pass_states_present(&external_adapters.summary); - let mut typed_non_pass_states_present = job_typed_non_pass_states_present.clone(); - - typed_non_pass_states_present.extend(external_adapter_typed_non_pass_states_present.clone()); - typed_non_pass_states_present.sort(); - typed_non_pass_states_present.dedup(); - - let typed_non_pass_count = job_typed_non_pass_count + external_typed_non_pass_count; - - ScoreboardReport { - schema: SCOREBOARD_SCHEMA.to_string(), - result_states: SCOREBOARD_RESULT_STATES.iter().map(ToString::to_string).collect(), - evidence_classes: SCOREBOARD_EVIDENCE_CLASSES.iter().map(ToString::to_string).collect(), - metric_basis: "produced_evidence_order".to_string(), - retrieval_k: SCOREBOARD_RETRIEVAL_K, - job_typed_non_pass_count, - job_typed_non_pass_states_present, - job_summary_claim: scoreboard_summary_claim(job_reports, job_typed_non_pass_count).to_string(), - external_adapter_typed_non_pass_count: external_typed_non_pass_count, - external_adapter_typed_non_pass_states_present, - typed_non_pass_count, - typed_non_pass_states_present, - evidence_class_counts: scoreboard_evidence_class_counts(external_adapters), - summary_claim: scoreboard_summary_claim(job_reports, typed_non_pass_count).to_string(), - unqualified_win_claim_allowed: false, - claim_boundary: "Typed non-pass states and non-live evidence classes must remain visible; reports must not collapse them into unqualified wins.".to_string(), - rows: scoreboard_rows(raw_jobs, job_reports, summary, external_adapters), - optimization_roadmap: scoreboard_optimization_roadmap(), - } -} - -fn scoreboard_rows( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], - summary: &ReportSummary, - external_adapters: &ExternalAdapterSection, -) -> Vec { - let mut rows = vec![elf_scoreboard_row(raw_jobs, job_reports, summary)]; - - rows.extend(external_project_scoreboard_rows(&external_adapters.adapters)); - - rows -} - -fn elf_scoreboard_row( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], - summary: &ReportSummary, -) -> ScoreboardRow { - let source_id_mapped = - summary.source_ref_required_count > 0 && summary.source_ref_coverage >= 1.0; - let result_state = aggregate_job_report_state(job_reports); - let metrics = scoreboard_metrics_for_reports(raw_jobs, job_reports, summary); - let typed_non_pass_count = - job_reports.iter().filter(|job| job.status != TypedStatus::Pass).count(); - let mut row = ScoreboardRow { - product_id: "elf_current_report".to_string(), - product_name: "ELF".to_string(), - row_source: "current_real_world_job_report".to_string(), - evidence_class: "fixture_backed".to_string(), - result_state, - comparable: false, - same_corpus: true, - source_id_mapped, - held_out: jobs_have_tag(raw_jobs, "held_out"), - leakage_audited: jobs_have_tag(raw_jobs, "leakage_audited"), - product_runtime: false, - container_digest_identified: false, - metrics, - strengths: elf_scoreboard_strengths(summary), - weaknesses: Vec::new(), - next_evidence: Vec::new(), - source_provenance: vec![ - "apps/elf-eval/fixtures/real_world_memory/".to_string(), - "apps/elf-eval/src/bin/real_world_job_benchmark.rs".to_string(), - ], - }; - - if typed_non_pass_count > 0 { - row.weaknesses - .push(format!("{typed_non_pass_count} encoded job row(s) are typed non-pass.")); - } - - scoreboard_apply_comparability_gaps(&mut row); - - row -} - -fn aggregate_job_report_state(job_reports: &[JobReport]) -> String { - if job_reports.is_empty() { - return "not_tested".to_string(); - } - - let refs = job_reports.iter().collect::>(); - - scoreboard_result_state(aggregate_status(&refs)).to_string() -} - -fn jobs_have_tag(jobs: &[RealWorldJob], tag: &str) -> bool { - !jobs.is_empty() && jobs.iter().all(|job| job.tags.iter().any(|candidate| candidate == tag)) -} - -fn scoreboard_metrics_for_reports( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], - summary: &ReportSummary, -) -> ScoreboardMetrics { - ScoreboardMetrics { - retrieval: scoreboard_retrieval_metrics(job_reports, summary), - lifecycle: scoreboard_lifecycle_metrics(raw_jobs, job_reports), - answer_safety: scoreboard_answer_safety_metrics(summary), - operations: scoreboard_operational_metrics(raw_jobs, job_reports, summary), - coverage: ScoreboardCoverageMetrics { - job_count: summary.job_count, - encoded_suite_count: summary.encoded_suite_count, - pass_count: summary.pass, - typed_non_pass_count: job_reports - .iter() - .filter(|job| job.status != TypedStatus::Pass) - .count(), - source_ref_coverage: Some(summary.source_ref_coverage), - evidence_coverage: Some(summary.evidence_coverage), - evidence_class: "fixture_backed".to_string(), - }, - } -} - -fn scoreboard_retrieval_metrics( - job_reports: &[JobReport], - summary: &ReportSummary, -) -> ScoreboardRetrievalMetrics { - let produced_evidence_total = - job_reports.iter().map(|job| job.retrieval_quality.produced_evidence_total).sum(); - let mut relevant_at_k = 0; - let mut precision_denominator_at_k = 0; - let mut reciprocal_rank_sum = 0.0; - let mut ndcg_sum = 0.0; - let mut ranked_job_count = 0; - - for job in job_reports { - let expected = job - .expected_evidence - .iter() - .map(|evidence| evidence.evidence_id.as_str()) - .collect::>(); - let ranked = scoreboard_ranked_metrics_for_job(job, &expected); - - relevant_at_k += ranked.relevant_at_k; - precision_denominator_at_k += ranked.precision_denominator_at_k; - reciprocal_rank_sum += ranked.reciprocal_rank; - ndcg_sum += ranked.ndcg; - ranked_job_count += 1; - } - - ScoreboardRetrievalMetrics { - k: SCOREBOARD_RETRIEVAL_K, - metric_basis: "produced_evidence_order".to_string(), - recall_at_k: Some(ratio_or(relevant_at_k, summary.expected_evidence_total, 1.0)), - precision_at_k: Some(ratio_or(relevant_at_k, precision_denominator_at_k, 1.0)), - mrr: Some(scoreboard_mean_metric(reciprocal_rank_sum, ranked_job_count)), - ndcg: Some(scoreboard_mean_metric(ndcg_sum, ranked_job_count)), - expected_evidence_recall: Some(summary.expected_evidence_recall), - citation_source_ref_coverage: Some(summary.source_ref_coverage), - expected_evidence_matched: summary.expected_evidence_matched, - expected_evidence_total: summary.expected_evidence_total, - produced_evidence_total, - } -} - -fn scoreboard_ranked_metrics_for_job( - job: &JobReport, - expected: &BTreeSet<&str>, -) -> ScoreboardRankedMetrics { - let precision_denominator_at_k = SCOREBOARD_RETRIEVAL_K; - let relevant_at_k = job - .produced_evidence - .iter() - .take(SCOREBOARD_RETRIEVAL_K) - .filter(|evidence_id| expected.contains(evidence_id.as_str())) - .count(); - let reciprocal_rank = job - .produced_evidence - .iter() - .position(|evidence_id| expected.contains(evidence_id.as_str())) - .map_or_else(|| f64::from(expected.is_empty()), |index| 1.0 / (index + 1) as f64); - let ndcg = scoreboard_ndcg(job.produced_evidence.as_slice(), expected); - - ScoreboardRankedMetrics { relevant_at_k, precision_denominator_at_k, reciprocal_rank, ndcg } -} - -fn scoreboard_ndcg(produced_evidence: &[String], expected: &BTreeSet<&str>) -> f64 { - if expected.is_empty() { - return 1.0; - } - - let dcg = produced_evidence - .iter() - .take(SCOREBOARD_RETRIEVAL_K) - .enumerate() - .filter(|(_, evidence_id)| expected.contains(evidence_id.as_str())) - .map(|(index, _)| 1.0 / ((index + 2) as f64).log2()) - .sum::(); - let ideal_hits = expected.len().min(SCOREBOARD_RETRIEVAL_K); - let idcg = (0..ideal_hits).map(|index| 1.0 / ((index + 2) as f64).log2()).sum::(); - - if idcg > 0.0 { dcg / idcg } else { 0.0 } -} - -fn scoreboard_mean_metric(sum: f64, count: usize) -> f64 { - if count == 0 { 1.0 } else { round3(sum / count as f64) } -} - -fn scoreboard_lifecycle_metrics( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], -) -> ScoreboardLifecycleMetrics { - let stale_check_count: usize = raw_jobs - .iter() - .map(|job| { - job.negative_traps - .iter() - .filter(|trap| trap.failure_if_used && trap.trap_type == "stale_fact") - .count() - }) - .sum(); - let stale_failure_count = job_reports - .iter() - .map(|job| job.stale_answer_count + job.stale_retrieval_count) - .sum::(); - let update_check_count = scoreboard_lifecycle_check_count(raw_jobs, scoreboard_is_update_job); - let update_correct_count = - scoreboard_lifecycle_correct_count(raw_jobs, job_reports, scoreboard_is_update_job); - let delete_check_count = scoreboard_lifecycle_check_count(raw_jobs, scoreboard_is_delete_job); - let delete_correct_count = - scoreboard_lifecycle_correct_count(raw_jobs, job_reports, scoreboard_is_delete_job); - let rollback_history_check_count = - scoreboard_lifecycle_check_count(raw_jobs, scoreboard_is_rollback_history_job); - let rollback_history_readback_count = raw_jobs - .iter() - .zip(job_reports.iter()) - .filter(|(job, report)| { - scoreboard_is_rollback_history_job(job) && report.status == TypedStatus::Pass - }) - .count(); - - ScoreboardLifecycleMetrics { - stale_suppression: Some(ratio_or( - stale_check_count.saturating_sub(stale_failure_count), - stale_check_count, - 1.0, - )), - stale_suppressed_count: stale_check_count.saturating_sub(stale_failure_count), - stale_check_count, - update_correctness: Some(ratio_or(update_correct_count, update_check_count, 1.0)), - update_correct_count, - update_check_count, - delete_correctness: Some(ratio_or(delete_correct_count, delete_check_count, 1.0)), - delete_correct_count, - delete_check_count, - rollback_history_readback_rate: Some(ratio_or( - rollback_history_readback_count, - rollback_history_check_count, - 1.0, - )), - rollback_history_readback_count, - rollback_history_check_count, - } -} - -fn scoreboard_lifecycle_check_count( - jobs: &[RealWorldJob], - predicate: fn(&RealWorldJob) -> bool, -) -> usize { - jobs.iter().filter(|job| predicate(job)).count() -} - -fn scoreboard_lifecycle_correct_count( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], - predicate: fn(&RealWorldJob) -> bool, -) -> usize { - raw_jobs - .iter() - .zip(job_reports.iter()) - .filter(|(job, report)| predicate(job) && report.status == TypedStatus::Pass) - .count() -} - -fn scoreboard_is_update_job(job: &RealWorldJob) -> bool { - scoreboard_has_any_tag( - job, - &["update", "correction_persistence", "current_authority", "conflicting_source_authority"], - ) -} - -fn scoreboard_is_delete_job(job: &RealWorldJob) -> bool { - scoreboard_has_any_tag(job, &["delete", "ttl", "tombstone"]) -} - -fn scoreboard_is_rollback_history_job(job: &RealWorldJob) -> bool { - scoreboard_has_any_tag(job, &["rollback", "correction_persistence"]) -} - -fn scoreboard_has_any_tag(job: &RealWorldJob, tags: &[&str]) -> bool { - job.tags.iter().any(|tag| tags.contains(&tag.as_str())) -} - -fn scoreboard_answer_safety_metrics(summary: &ReportSummary) -> ScoreboardAnswerSafetyMetrics { - ScoreboardAnswerSafetyMetrics { - unsupported_claim_rate: Some(ratio(summary.unsupported_claim_count, summary.job_count)), - unsupported_claim_count: summary.unsupported_claim_count, - stale_answer_rate: Some(ratio(summary.stale_answer_count, summary.job_count)), - stale_answer_count: summary.stale_answer_count, - hallucinated_evidence_rate: Some(summary.irrelevant_context_ratio), - redaction_leak_count: summary.redaction_leak_count, - irrelevant_context_ratio: Some(summary.irrelevant_context_ratio), - } -} - -fn scoreboard_operational_metrics( - raw_jobs: &[RealWorldJob], - job_reports: &[JobReport], - summary: &ReportSummary, -) -> ScoreboardOperationalMetrics { - let resource_envelope_job_count = - raw_jobs.iter().filter(|job| scoreboard_has_any_tag(job, &["resource_envelope"])).count(); - let resource_envelope_pass_count = raw_jobs - .iter() - .zip(job_reports.iter()) - .filter(|(job, report)| { - scoreboard_has_any_tag(job, &["resource_envelope"]) - && report.status == TypedStatus::Pass - }) - .count(); - - ScoreboardOperationalMetrics { - mean_latency_ms: summary.mean_latency_ms, - total_cost: summary.total_cost.clone(), - resource_envelope_status: if resource_envelope_job_count == resource_envelope_pass_count { - "pass".to_string() - } else { - "typed_non_pass_present".to_string() - }, - resource_envelope_job_count, - resource_envelope_pass_count, - } -} - -fn elf_scoreboard_strengths(summary: &ReportSummary) -> Vec { - let mut strengths = Vec::new(); - - if summary.expected_evidence_recall >= 1.0 { - strengths.push("Expected evidence recall is complete for encoded jobs.".to_string()); - } - if summary.source_ref_coverage >= 1.0 { - strengths - .push("Source-ref coverage is complete for encoded required evidence.".to_string()); - } - if summary.stale_answer_count == 0 && summary.stale_retrieval_count == 0 { - strengths.push("Encoded stale-answer and stale-retrieval counters are zero.".to_string()); - } - if summary.redaction_leak_count == 0 { - strengths.push("Encoded redaction leak count is zero.".to_string()); - } - if summary.work_continuity.is_some() { - strengths.push("Work Continuity readback metrics are encoded in the report.".to_string()); - } - - strengths -} - -fn external_project_scoreboard_rows(adapters: &[ExternalAdapterReport]) -> Vec { - let mut by_project: BTreeMap> = BTreeMap::new(); - - for adapter in adapters.iter().filter(|adapter| adapter.project != "ELF") { - by_project.entry(adapter.project.clone()).or_default().push(adapter); - } - - by_project - .into_iter() - .map(|(project, adapters)| external_project_scoreboard_row(project, adapters.as_slice())) - .collect() -} - -fn external_project_scoreboard_row( - project: String, - adapters: &[&ExternalAdapterReport], -) -> ScoreboardRow { - let evidence_class = strongest_scoreboard_evidence_class(adapters); - let result_state = external_project_result_state(adapters); - let source_id_mapped = external_project_source_id_mapped(adapters); - let same_corpus = external_project_same_corpus(adapters); - let product_runtime = - adapters.iter().any(|adapter| adapter.evidence_class == "live_real_world"); - let container_digest_identified = - adapters.iter().any(|adapter| adapter_has_container_digest(adapter)); - let typed_non_pass_count = - adapters.iter().map(|adapter| adapter_typed_non_pass_count(adapter)).sum(); - let mut row = ScoreboardRow { - product_id: scoreboard_project_id(project.as_str()), - product_name: project, - row_source: "external_adapter_manifest".to_string(), - evidence_class: evidence_class.clone(), - result_state, - comparable: false, - same_corpus, - source_id_mapped, - held_out: false, - leakage_audited: false, - product_runtime, - container_digest_identified, - metrics: external_project_scoreboard_metrics( - adapters, - evidence_class.as_str(), - typed_non_pass_count, - ), - strengths: external_project_strengths(adapters), - weaknesses: external_project_weaknesses(adapters), - next_evidence: Vec::new(), - source_provenance: external_project_source_provenance(adapters), - }; - - scoreboard_apply_comparability_gaps(&mut row); - - row -} - -fn external_project_scoreboard_metrics( - adapters: &[&ExternalAdapterReport], - evidence_class: &str, - typed_non_pass_count: usize, -) -> ScoreboardMetrics { - let pass_count = adapters - .iter() - .flat_map(|adapter| adapter.suites.iter()) - .filter(|suite| suite.status == AdapterCoverageStatus::Pass) - .count(); - let suite_count = adapters.iter().map(|adapter| adapter.suites.len()).sum(); - - ScoreboardMetrics { - retrieval: ScoreboardRetrievalMetrics { - k: SCOREBOARD_RETRIEVAL_K, - metric_basis: "external_adapter_manifest_no_ordered_evidence".to_string(), - ..ScoreboardRetrievalMetrics::default() - }, - coverage: ScoreboardCoverageMetrics { - job_count: 0, - encoded_suite_count: suite_count, - pass_count, - typed_non_pass_count, - source_ref_coverage: None, - evidence_coverage: None, - evidence_class: evidence_class.to_string(), - }, - ..ScoreboardMetrics::default() - } -} - -fn strongest_scoreboard_evidence_class(adapters: &[&ExternalAdapterReport]) -> String { - for evidence_class in ["live_real_world", "live_baseline", "fixture_backed", "research_gate"] { - if adapters.iter().any(|adapter| { - scoreboard_evidence_class(adapter.evidence_class.as_str()) == evidence_class - }) { - return evidence_class.to_string(); - } - } - - "research_gate".to_string() -} - -fn external_project_result_state(adapters: &[&ExternalAdapterReport]) -> String { - for status in [ - AdapterCoverageStatus::WrongResult, - AdapterCoverageStatus::Blocked, - AdapterCoverageStatus::Incomplete, - AdapterCoverageStatus::LifecycleFail, - AdapterCoverageStatus::NotEncoded, - AdapterCoverageStatus::Unsupported, - ] { - if adapters.iter().any(|adapter| adapter_has_status(adapter, status)) { - return adapter_status_to_scoreboard_state(status).to_string(); - } - } - - "not_comparable".to_string() -} - -fn adapter_has_status(adapter: &ExternalAdapterReport, status: AdapterCoverageStatus) -> bool { - adapter.overall_status == status - || adapter.setup.status == status - || adapter.run.status == status - || adapter.result.status == status - || adapter.capabilities.iter().any(|capability| capability.status == status) - || adapter.suites.iter().any(|suite| suite.status == status) - || adapter.scenarios.iter().any(|scenario| scenario.status == status) -} - -fn external_project_same_corpus(adapters: &[&ExternalAdapterReport]) -> bool { - let needles = &["same-corpus", "same corpus", "same_corpus", "shared corpus"]; - - adapters.iter().any(|adapter| { - text_mentions_any(adapter.adapter_kind.as_str(), needles) - || adapter_has_reported_same_corpus_text(adapter, needles) - }) -} - -fn external_project_source_id_mapped(adapters: &[&ExternalAdapterReport]) -> bool { - let needles = &[ - "source-id mapped", - "source ids mapped", - "maps to source ids", - "mapped to source ids", - "maps back to source ids", - "map to generated evidence ids", - "mapped to generated evidence ids", - "evidence ids match", - ]; - - adapters.iter().any(|adapter| adapter_has_passing_text(adapter, needles)) -} - -fn adapter_has_passing_text(adapter: &ExternalAdapterReport, needles: &[&str]) -> bool { - adapter_status_mentions_any(adapter.setup.status, adapter.setup.evidence.as_str(), needles) - || adapter_status_mentions_any(adapter.run.status, adapter.run.evidence.as_str(), needles) - || adapter_status_mentions_any( - adapter.result.status, - adapter.result.evidence.as_str(), - needles, - ) || adapter.capabilities.iter().any(|capability| { - adapter_status_mentions_any(capability.status, capability.capability.as_str(), needles) - || adapter_status_mentions_any(capability.status, capability.evidence.as_str(), needles) - }) || adapter.suites.iter().any(|suite| { - adapter_status_mentions_any(suite.status, suite.suite_id.as_str(), needles) - || adapter_status_mentions_any(suite.status, suite.evidence.as_str(), needles) - }) || adapter.scenarios.iter().any(|scenario| { - adapter_status_mentions_any(scenario.status, scenario.scenario_id.as_str(), needles) - || adapter_status_mentions_any(scenario.status, scenario.evidence.as_str(), needles) - }) -} - -fn adapter_has_reported_same_corpus_text( - adapter: &ExternalAdapterReport, - needles: &[&str], -) -> bool { - adapter_status_reports_same_corpus( - adapter.setup.status, - adapter.setup.evidence.as_str(), - needles, - ) || adapter_status_reports_same_corpus( - adapter.run.status, - adapter.run.evidence.as_str(), - needles, - ) || adapter_status_reports_same_corpus( - adapter.result.status, - adapter.result.evidence.as_str(), - needles, - ) || adapter.capabilities.iter().any(|capability| { - adapter_status_reports_same_corpus( - capability.status, - capability.capability.as_str(), - needles, - ) || adapter_status_reports_same_corpus( - capability.status, - capability.evidence.as_str(), - needles, - ) - }) || adapter.suites.iter().any(|suite| { - adapter_status_reports_same_corpus(suite.status, suite.suite_id.as_str(), needles) - || adapter_status_reports_same_corpus(suite.status, suite.evidence.as_str(), needles) - }) || adapter.scenarios.iter().any(|scenario| { - adapter_status_reports_same_corpus(scenario.status, scenario.scenario_id.as_str(), needles) - || adapter_status_reports_same_corpus( - scenario.status, - scenario.evidence.as_str(), - needles, - ) - }) -} - -fn adapter_status_reports_same_corpus( - status: AdapterCoverageStatus, - text: &str, - needles: &[&str], -) -> bool { - matches!( - status, - AdapterCoverageStatus::Pass - | AdapterCoverageStatus::Real - | AdapterCoverageStatus::WrongResult - | AdapterCoverageStatus::LifecycleFail - ) && text_mentions_any(text, needles) -} - -fn adapter_status_mentions_any( - status: AdapterCoverageStatus, - text: &str, - needles: &[&str], -) -> bool { - matches!(status, AdapterCoverageStatus::Pass | AdapterCoverageStatus::Real) - && text_mentions_any(text, needles) -} - -fn text_mentions_any(text: &str, needles: &[&str]) -> bool { - let text = text.to_ascii_lowercase(); - - needles.iter().any(|needle| text.contains(&needle.to_ascii_lowercase())) -} - -fn adapter_status_to_scoreboard_state(status: AdapterCoverageStatus) -> &'static str { - match status { - AdapterCoverageStatus::WrongResult | AdapterCoverageStatus::LifecycleFail => "wrong_result", - AdapterCoverageStatus::Blocked => "blocked", - AdapterCoverageStatus::Incomplete => "incomplete", - AdapterCoverageStatus::NotEncoded | AdapterCoverageStatus::Unsupported => "not_encoded", - AdapterCoverageStatus::Real - | AdapterCoverageStatus::Mocked - | AdapterCoverageStatus::Pass => "not_comparable", - } -} - -fn adapter_typed_non_pass_count(adapter: &ExternalAdapterReport) -> usize { - let direct_statuses = - [adapter.overall_status, adapter.setup.status, adapter.run.status, adapter.result.status]; - let direct = direct_statuses - .into_iter() - .filter(|status| adapter_status_is_typed_non_pass(*status)) - .count(); - let capability = adapter - .capabilities - .iter() - .filter(|capability| adapter_status_is_typed_non_pass(capability.status)) - .count(); - let suites = adapter - .suites - .iter() - .filter(|suite| adapter_status_is_typed_non_pass(suite.status)) - .count(); - let scenarios = adapter - .scenarios - .iter() - .filter(|scenario| adapter_status_is_typed_non_pass(scenario.status)) - .count(); - - direct + capability + suites + scenarios -} - -fn adapter_status_is_typed_non_pass(status: AdapterCoverageStatus) -> bool { - matches!( - status, - AdapterCoverageStatus::Unsupported - | AdapterCoverageStatus::Blocked - | AdapterCoverageStatus::Incomplete - | AdapterCoverageStatus::WrongResult - | AdapterCoverageStatus::LifecycleFail - | AdapterCoverageStatus::NotEncoded - ) -} - -fn adapter_has_container_digest(adapter: &ExternalAdapterReport) -> bool { - adapter.setup.evidence.contains("sha256:") - || adapter.run.evidence.contains("sha256:") - || adapter.result.evidence.contains("sha256:") - || adapter.evidence.iter().any(|evidence| { - evidence.reference.contains("sha256:") || evidence.reference.contains("digest") - }) -} - -fn external_project_strengths(adapters: &[&ExternalAdapterReport]) -> Vec { - let mut strengths = BTreeSet::new(); - - for adapter in adapters { - for capability in &adapter.capabilities { - if matches!( - capability.status, - AdapterCoverageStatus::Pass | AdapterCoverageStatus::Real - ) { - strengths.insert(format!( - "{} capability is {}.", - capability.capability, - adapter_status_str(capability.status) - )); - } - } - for scenario in &adapter.scenarios { - if scenario_comparison_outcome(scenario) == ScenarioComparisonOutcome::Loss { - strengths.insert(format!( - "Scenario {} is recorded as a competitor strength.", - scenario.scenario_id - )); - } - } - } - - strengths.into_iter().take(6).collect() -} - -fn external_project_weaknesses(adapters: &[&ExternalAdapterReport]) -> Vec { - let mut weaknesses = BTreeSet::new(); - - for adapter in adapters { - if adapter.overall_status != AdapterCoverageStatus::Pass { - weaknesses.insert(format!( - "Adapter {} overall status is {}.", - adapter.adapter_id, - adapter_status_str(adapter.overall_status) - )); - } - - for suite in &adapter.suites { - if adapter_status_is_typed_non_pass(suite.status) { - weaknesses.insert(format!( - "Suite {} is {}.", - suite.suite_id, - adapter_status_str(suite.status) - )); - } - } - } - - weaknesses.into_iter().take(8).collect() -} - -fn external_project_source_provenance(adapters: &[&ExternalAdapterReport]) -> Vec { - let mut provenance = BTreeSet::new(); - - for adapter in adapters { - for evidence in &adapter.evidence { - provenance.insert(evidence.reference.clone()); - } - for artifact in [&adapter.setup.artifact, &adapter.run.artifact, &adapter.result.artifact] - .into_iter() - .flatten() - { - provenance.insert(artifact.clone()); - } - } - - provenance.into_iter().take(12).collect() -} - -fn scoreboard_project_id(project: &str) -> String { - project - .chars() - .map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '_' }) - .collect::() - .split('_') - .filter(|part| !part.is_empty()) - .collect::>() - .join("_") -} - -fn scoreboard_apply_comparability_gaps(row: &mut ScoreboardRow) { - if !row.same_corpus { - row.next_evidence.push("Map this product to the same corpus.".to_string()); - } - if !row.source_id_mapped { - row.next_evidence.push("Map returned evidence to stable source ids.".to_string()); - } - if !row.held_out { - row.next_evidence.push("Publish a held-out split for this row.".to_string()); - } - if !row.leakage_audited { - row.next_evidence.push("Publish leakage-audit evidence for this row.".to_string()); - } - if !row.product_runtime { - row.next_evidence - .push("Run a Docker-contained product-runtime adapter for this row.".to_string()); - } - if !row.container_digest_identified { - row.next_evidence.push("Record container image digest evidence.".to_string()); - } - if row.result_state != "pass" { - row.next_evidence - .push("Resolve typed non-pass state before claiming a comparable pass.".to_string()); - } - - row.comparable = row.same_corpus - && row.source_id_mapped - && row.held_out - && row.leakage_audited - && row.product_runtime - && row.container_digest_identified - && row.result_state == "pass" - && row.metrics.retrieval.recall_at_k.is_some() - && row.metrics.retrieval.precision_at_k.is_some() - && row.metrics.retrieval.mrr.is_some() - && row.metrics.retrieval.ndcg.is_some(); - - if !row.comparable && row.result_state == "pass" { - row.result_state = "not_comparable".to_string(); - } - if !row.comparable { - row.weaknesses - .push("This row is not a comparable product-runtime scoreboard pass.".to_string()); - } -} - -fn scoreboard_optimization_roadmap() -> Vec { - vec![ - "Capture Docker image digests and runtime metadata for product-runtime rows.".to_string(), - "Add held-out and leakage-audit manifests before broad competitor comparisons.".to_string(), - "Promote external adapters from typed blockers to same-corpus source-id-mapped runtime rows only after they emit comparable evidence.".to_string(), - "Use row-level metrics for optimization direction; do not claim a universal leaderboard.".to_string(), - ] -} - -fn typed_non_pass_states_present(jobs: &[JobReport]) -> Vec { - let mut states = BTreeSet::new(); - - for job in jobs.iter().filter(|job| job.status != TypedStatus::Pass) { - states.insert(scoreboard_result_state(job.status).to_string()); - } - - states.into_iter().collect() -} - -fn external_typed_non_pass_count(summary: &ExternalAdapterSummary) -> usize { - [ - &summary.overall_status_counts, - &summary.capability_status_counts, - &summary.suite_status_counts, - &summary.scenario_status_counts, - ] - .into_iter() - .map(scoreboard_adapter_typed_non_pass_count) - .sum::() - + summary.scenario_outcome_counts.not_tested -} - -fn scoreboard_adapter_typed_non_pass_count(counts: &AdapterStatusCounts) -> usize { - counts.blocked - + counts.incomplete - + counts.wrong_result - + counts.lifecycle_fail - + counts.not_encoded - + counts.unsupported -} - -fn external_typed_non_pass_states_present(summary: &ExternalAdapterSummary) -> Vec { - let mut states = BTreeSet::new(); - - for counts in [ - &summary.overall_status_counts, - &summary.capability_status_counts, - &summary.suite_status_counts, - &summary.scenario_status_counts, - ] { - if counts.blocked > 0 { - states.insert("blocked".to_string()); - } - if counts.incomplete > 0 { - states.insert("incomplete".to_string()); - } - if counts.wrong_result + counts.lifecycle_fail > 0 { - states.insert("wrong_result".to_string()); - } - if counts.not_encoded + counts.unsupported > 0 { - states.insert("not_encoded".to_string()); - } - } - - if summary.scenario_outcome_counts.not_tested > 0 { - states.insert("not_tested".to_string()); - } - - states.into_iter().collect() -} - -fn scoreboard_result_state(status: TypedStatus) -> &'static str { - match status { - TypedStatus::Pass => "pass", - TypedStatus::WrongResult | TypedStatus::LifecycleFail => "wrong_result", - TypedStatus::Incomplete => "incomplete", - TypedStatus::Blocked => "blocked", - TypedStatus::NotEncoded => "not_encoded", - TypedStatus::UnsupportedClaim => "unsupported_claim", - } -} - -fn scoreboard_evidence_class_counts( - external_adapters: &ExternalAdapterSection, -) -> BTreeMap { - let mut counts = SCOREBOARD_EVIDENCE_CLASSES - .iter() - .map(|state| (state.to_string(), 0)) - .collect::>(); - - for adapter in &external_adapters.adapters { - let state = scoreboard_evidence_class(adapter.evidence_class.as_str()); - - *counts.entry(state.to_string()).or_insert(0) += 1; - } - - counts -} - -fn scoreboard_evidence_class(evidence_class: &str) -> &str { - match evidence_class { - "live_baseline_only" => "live_baseline", - other => other, - } -} - -fn scoreboard_summary_claim(jobs: &[JobReport], typed_non_pass_count: usize) -> &'static str { - if jobs.is_empty() { - "not_tested" - } else if typed_non_pass_count > 0 { - "typed_non_pass_present" - } else { - "all_encoded_jobs_passed" - } -} - -fn operational_evidence_report( - jobs: &[RealWorldJob], - reports: &[JobReport], -) -> OperationalEvidenceReport { - let paired = jobs.iter().zip(reports.iter()).collect::>(); - let tiers = OPERATIONAL_EVIDENCE_TIERS - .iter() - .map(|tier| operational_evidence_tier_report(tier, paired.as_slice())) - .collect::>(); - let private_tier = tiers.iter().find(|tier| tier.tier == "private_corpus"); - let provider_tier = tiers.iter().find(|tier| tier.tier == "provider_backed"); - let private_corpus_pass_claim_allowed = - private_tier.is_some_and(|tier| tier.pass_claim_allowed); - let provider_backed_pass_claim_allowed = - provider_tier.is_some_and(|tier| tier.pass_claim_allowed); - let missing_private_provider_inputs_are_typed_blockers = private_tier - .is_some_and(operational_tier_has_typed_blocker) - && provider_tier.is_some_and(operational_tier_has_typed_blocker); - - OperationalEvidenceReport { - schema: OPERATIONAL_EVIDENCE_SCHEMA.to_string(), - tiers, - latency: operational_latency_report(reports), - cost: operational_cost_summary(reports), - resource: operational_resource_summary(paired.as_slice()), - cold_start_restore_rebuild: operational_cold_start_restore_rebuild(paired.as_slice()), - authority_recovery: operational_authority_recovery(reports), - missing_private_provider_inputs_are_typed_blockers, - private_corpus_pass_claim_allowed, - provider_backed_pass_claim_allowed, - claim_boundary: "Operational evidence tiers are separate: local fixture and public-proxy passes do not prove private-corpus or provider-backed production quality.".to_string(), - } -} - -fn operational_evidence_tier_report( - tier: &str, - paired: &[(&RealWorldJob, &JobReport)], -) -> OperationalEvidenceTierReport { - let tier_jobs = paired - .iter() - .filter(|(job, _)| operational_evidence_tier(job) == tier) - .copied() - .collect::>(); - let reports = tier_jobs.iter().map(|(_, report)| *report).collect::>(); - let status = if reports.is_empty() { - TypedStatus::NotEncoded - } else { - aggregate_status(reports.as_slice()) - }; - let job_count = reports.len(); - let pass = reports.iter().filter(|report| report.status == TypedStatus::Pass).count(); - let wrong_result = - reports.iter().filter(|report| report.status == TypedStatus::WrongResult).count(); - let lifecycle_fail = - reports.iter().filter(|report| report.status == TypedStatus::LifecycleFail).count(); - let incomplete = - reports.iter().filter(|report| report.status == TypedStatus::Incomplete).count(); - let blocked = reports.iter().filter(|report| report.status == TypedStatus::Blocked).count(); - let not_encoded = usize::from(reports.is_empty()) - + reports.iter().filter(|report| report.status == TypedStatus::NotEncoded).count(); - let unsupported_claim = - reports.iter().filter(|report| report.status == TypedStatus::UnsupportedClaim).count(); - - OperationalEvidenceTierReport { - tier: tier.to_string(), - status, - job_count, - pass, - wrong_result, - lifecycle_fail, - incomplete, - blocked, - not_encoded, - unsupported_claim, - mean_latency_ms: mean_latency_for_reports(reports.as_slice()), - total_cost: total_cost_for_reports(reports.as_slice()), - resource_evidence_count: tier_jobs - .iter() - .filter(|(job, _)| job_has_tag(job, "resource_envelope")) - .count(), - cold_start_evidence_count: tier_jobs - .iter() - .filter(|(job, _)| job_has_tag(job, "cold_start")) - .count(), - restore_evidence_count: tier_jobs - .iter() - .filter(|(job, _)| job_has_tag(job, "restore")) - .count(), - qdrant_rebuild_evidence_count: tier_jobs - .iter() - .filter(|(job, report)| { - job_has_tag(job, "qdrant_rebuild") || report.qdrant_rebuild_case - }) - .count(), - pass_claim_allowed: job_count > 0 && status == TypedStatus::Pass, - blocker_reasons: reports - .iter() - .filter(|report| report.status != TypedStatus::Pass) - .map(|report| report.reason.clone()) - .collect(), - job_ids: reports.iter().map(|report| report.job_id.clone()).collect(), - } -} - -fn operational_tier_has_typed_blocker(tier: &OperationalEvidenceTierReport) -> bool { - tier.blocked + tier.incomplete + tier.not_encoded > 0 && !tier.pass_claim_allowed -} - -fn operational_latency_report(reports: &[JobReport]) -> OperationalLatencyReport { - let latencies = reports.iter().filter_map(|report| report.latency_ms).collect::>(); - - OperationalLatencyReport { - measured_job_count: latencies.len(), - missing_latency_job_count: reports.len().saturating_sub(latencies.len()), - mean_ms: mean_latency_for_values(latencies.as_slice()), - max_ms: latencies.iter().copied().reduce(f64::max).map(round3), - } -} - -fn operational_cost_summary(reports: &[JobReport]) -> OperationalCostSummary { - let costs = reports.iter().filter_map(|report| report.cost.as_ref()).collect::>(); - let zero_cost_job_count = - costs.iter().filter(|cost| cost.amount.is_some_and(|amount| amount == 0.0)).count(); - - OperationalCostSummary { - jobs_with_cost_report: costs.len(), - missing_cost_job_count: reports.len().saturating_sub(costs.len()), - zero_cost_job_count, - total: total_cost(reports), - claim_boundary: "Fixture and local-provider zero-cost reports are execution-accounting evidence only; they do not prove hosted provider spend.".to_string(), - } -} - -fn operational_resource_summary( - paired: &[(&RealWorldJob, &JobReport)], -) -> OperationalResourceSummary { - let resource_jobs = - paired.iter().filter(|(job, _)| job_has_tag(job, "resource_envelope")).collect::>(); - let latency_resource_dimension_job_count = paired - .iter() - .filter(|(_, report)| { - report.dimension_scores.iter().any(|score| score.dimension == "latency_resource") - }) - .count(); - - OperationalResourceSummary { - resource_envelope_job_count: resource_jobs.len(), - resource_envelope_pass_count: resource_jobs - .iter() - .filter(|(_, report)| report.status == TypedStatus::Pass) - .count(), - latency_resource_dimension_job_count, - job_ids: resource_jobs.iter().map(|(_, report)| report.job_id.clone()).collect(), - } -} - -fn operational_cold_start_restore_rebuild( - paired: &[(&RealWorldJob, &JobReport)], -) -> OperationalColdStartRestoreRebuild { - let cold_start_jobs = - paired.iter().filter(|(job, _)| job_has_tag(job, "cold_start")).collect::>(); - let restore_jobs = - paired.iter().filter(|(job, _)| job_has_tag(job, "restore")).collect::>(); - let qdrant_rebuild_jobs = paired - .iter() - .filter(|(job, report)| job_has_tag(job, "qdrant_rebuild") || report.qdrant_rebuild_case) - .collect::>(); - let mut job_ids = cold_start_jobs - .iter() - .chain(restore_jobs.iter()) - .chain(qdrant_rebuild_jobs.iter()) - .map(|(_, report)| report.job_id.clone()) - .collect::>() - .into_iter() - .collect::>(); - - job_ids.sort(); - OperationalColdStartRestoreRebuild { - cold_start_job_count: cold_start_jobs.len(), - cold_start_pass_count: cold_start_jobs - .iter() - .filter(|(_, report)| report.status == TypedStatus::Pass) - .count(), - restore_job_count: restore_jobs.len(), - restore_pass_count: restore_jobs - .iter() - .filter(|(_, report)| report.status == TypedStatus::Pass) - .count(), - qdrant_rebuild_job_count: qdrant_rebuild_jobs.len(), - qdrant_rebuild_pass_count: qdrant_rebuild_jobs - .iter() - .filter(|(_, report)| report.status == TypedStatus::Pass) - .count(), - job_ids, - } -} - -fn operational_authority_recovery(reports: &[JobReport]) -> OperationalAuthorityRecoveryReport { - let recovery_jobs = - reports.iter().filter(|report| !report.recovery_drills.is_empty()).collect::>(); - let drills = - recovery_jobs.iter().flat_map(|report| report.recovery_drills.iter()).collect::>(); - let authority_counts = - drills.iter().flat_map(|drill| drill.authority_record_counts.iter()).collect::>(); - let mut job_ids = recovery_jobs - .iter() - .map(|report| report.job_id.clone()) - .collect::>() - .into_iter() - .collect::>(); - - job_ids.sort(); - OperationalAuthorityRecoveryReport { - drill_count: drills.len(), - drill_pass_count: recovery_jobs - .iter() - .filter(|report| report.status == TypedStatus::Pass) - .flat_map(|report| report.recovery_drills.iter()) - .filter(|drill| recovery_drill_succeeded(drill)) - .count(), - topology_reported_count: drills - .iter() - .filter(|drill| !drill.topology.authority_store.trim().is_empty()) - .count(), - failure_injection_count: drills.iter().map(|drill| drill.failure_injections.len()).sum(), - degraded_read_labeled_count: drills - .iter() - .filter(|drill| !drill.degraded_read.unavailable_labels.is_empty()) - .count(), - source_of_truth_visible_count: drills - .iter() - .filter(|drill| drill.degraded_read.source_of_truth_visible) - .count(), - backup_pitr_restored_count: drills - .iter() - .filter(|drill| drill.backup_pitr.restored) - .count(), - rpo_target_count: drills.len(), - rpo_met_count: drills.iter().filter(|drill| recovery_measurement_met(&drill.rpo)).count(), - rto_target_count: drills.len(), - rto_met_count: drills.iter().filter(|drill| recovery_measurement_met(&drill.rto)).count(), - authority_plane_count: authority_counts.len(), - record_count_preserved_count: authority_counts - .iter() - .filter(|count| authority_record_count_balanced(count)) - .count(), - source_ref_preserved_count: authority_counts - .iter() - .filter(|count| count.source_refs_preserved) - .count(), - lifecycle_history_preserved_count: authority_counts - .iter() - .filter(|count| count.lifecycle_history_preserved) - .count(), - idempotent_outbox_replay_count: drills - .iter() - .filter(|drill| recovery_outbox_replay_succeeded(&drill.outbox_replay)) - .count(), - qdrant_rebuild_complete_count: drills - .iter() - .filter(|drill| recovery_qdrant_rebuild_succeeded(&drill.qdrant_rebuild)) - .count(), - migration_repair_count: drills - .iter() - .filter(|drill| recovery_migration_repair_succeeded(&drill.migration_repair)) - .count(), - dead_letter_handled_count: drills - .iter() - .filter(|drill| recovery_dead_letter_succeeded(&drill.dead_letter)) - .count(), - job_ids, - } -} - -fn operational_evidence_tier(job: &RealWorldJob) -> &'static str { - if job_has_tag(job, "provider_backed") { - "provider_backed" - } else if job_has_tag(job, "private_corpus") - || matches!(job.corpus.profile, CorpusProfile::PrivateSanitized) - { - "private_corpus" - } else if job_has_tag(job, "public_proxy") { - "public_proxy" - } else { - "local_fixture" - } -} - -fn job_has_tag(job: &RealWorldJob, tag: &str) -> bool { - job.tags.iter().any(|candidate| candidate == tag) -} - -fn evolution_summary(jobs: &[JobReport]) -> EvolutionSummary { - EvolutionSummary { - stale_answer_count: jobs.iter().map(|job| job.stale_answer_count).sum(), - conflict_detection_count: jobs.iter().map(|job| job.conflict_detection_count).sum(), - update_rationale_available_count: jobs - .iter() - .filter(|job| job.update_rationale_available) - .count(), - temporal_validity_not_encoded_count: jobs - .iter() - .filter(|job| job.temporal_validity_not_encoded) - .count(), - history_readback_encoded_count: jobs - .iter() - .filter(|job| job.history_readback_encoded) - .count(), - } -} - -fn follow_up_reports(jobs: &[RealWorldJob]) -> Vec { - jobs.iter() - .filter_map(|job| { - job.encoding.follow_up.as_ref().map(|follow_up| FollowUpReport { - suite_id: job.suite.clone(), - job_id: job.job_id.clone(), - title: follow_up.title.clone(), - reason: follow_up.reason.clone(), - }) - }) - .collect() -} - -fn ratio(numerator: usize, denominator: usize) -> f64 { - if denominator == 0 { - return 0.0; - } - - round3(numerator as f64 / denominator as f64) -} - -fn expected_evidence_recall_for_jobs(jobs: &[&JobReport]) -> f64 { - let total = jobs.iter().map(|job| job.retrieval_quality.expected_evidence_total).sum::(); - let matched = - jobs.iter().map(|job| job.retrieval_quality.expected_evidence_matched).sum::(); - - ratio_or(matched, total, 1.0) -} - -fn irrelevant_context_ratio_for_jobs(jobs: &[&JobReport]) -> f64 { - let total = jobs.iter().map(|job| job.retrieval_quality.produced_evidence_total).sum::(); - let irrelevant = - jobs.iter().map(|job| job.retrieval_quality.irrelevant_context_count).sum::(); - - ratio_or(irrelevant, total, 0.0) -} - -fn ratio_or(numerator: usize, denominator: usize, empty_value: f64) -> f64 { - if denominator == 0 { empty_value } else { round3(numerator as f64 / denominator as f64) } -} - -fn ratio_or_full(numerator: usize, denominator: usize) -> f64 { - ratio_or(numerator, denominator, 1.0) -} - -fn consolidation_summary(jobs: &[JobReport]) -> ConsolidationSummaryReport { - let reports = jobs.iter().filter_map(|job| job.consolidation.as_ref()).collect::>(); - - if reports.is_empty() { - return ConsolidationSummaryReport::default(); - } - - let proposals = reports.iter().flat_map(|report| report.proposals.iter()).collect::>(); - let executable_gap_count = reports.iter().map(|report| report.executable_gaps.len()).sum(); - - ConsolidationSummaryReport { - proposal_count: proposals.len(), - proposal_usefulness: mean_proposal_metric( - proposals.iter().map(|proposal| proposal.usefulness_score), - ), - lineage_completeness: mean_proposal_metric( - proposals.iter().map(|proposal| proposal.lineage_completeness), - ), - review_action_correctness: mean_proposal_metric( - proposals.iter().map(|proposal| if proposal.review_action_correct { 1.0 } else { 0.0 }), - ), - source_mutation_count: proposals - .iter() - .map(|proposal| proposal.source_mutation_count) - .sum(), - proposal_unsupported_claim_count: proposals - .iter() - .map(|proposal| proposal.unsupported_claim_count) - .sum(), - executable_gap_count, - } -} - -fn memory_summary_summary(jobs: &[JobReport]) -> Option { - let memory_jobs = jobs.iter().filter_map(|job| job.memory_summary.as_ref()).collect::>(); - - if memory_jobs.is_empty() { - return None; - } - - let job_count = memory_jobs.len(); - let summary_count = memory_jobs.iter().map(|metrics| metrics.summary_count).sum(); - let entry_count = memory_jobs.iter().map(|metrics| metrics.entry_count).sum(); - let required_category_count = - memory_jobs.iter().map(|metrics| metrics.required_category_count).sum(); - let covered_required_category_count = - memory_jobs.iter().map(|metrics| metrics.covered_required_category_count).sum(); - let source_ref_required_count = - memory_jobs.iter().map(|metrics| metrics.source_ref_required_count).sum(); - let source_ref_entry_count = - memory_jobs.iter().map(|metrics| metrics.source_ref_entry_count).sum(); - let freshness_marker_count = - memory_jobs.iter().map(|metrics| metrics.freshness_marker_count).sum(); - let rationale_count = memory_jobs.iter().map(|metrics| metrics.rationale_count).sum(); - - Some(MemorySummaryReport { - job_count, - summary_count, - entry_count, - required_category_count, - covered_required_category_count, - missing_required_category_count: memory_jobs - .iter() - .map(|metrics| metrics.missing_required_category_count) - .sum(), - top_of_mind_count: memory_jobs.iter().map(|metrics| metrics.top_of_mind_count).sum(), - background_count: memory_jobs.iter().map(|metrics| metrics.background_count).sum(), - stale_count: memory_jobs.iter().map(|metrics| metrics.stale_count).sum(), - superseded_count: memory_jobs.iter().map(|metrics| metrics.superseded_count).sum(), - tombstone_count: memory_jobs.iter().map(|metrics| metrics.tombstone_count).sum(), - derived_project_profile_count: memory_jobs - .iter() - .map(|metrics| metrics.derived_project_profile_count) - .sum(), - source_ref_required_count, - source_ref_entry_count, - source_ref_coverage: ratio(source_ref_entry_count, source_ref_required_count), - freshness_marker_count, - freshness_coverage: ratio(freshness_marker_count, entry_count), - rationale_count, - rationale_coverage: ratio(rationale_count, entry_count), - invalid_top_of_mind_count: memory_jobs - .iter() - .map(|metrics| metrics.invalid_top_of_mind_count) - .sum(), - untraced_entry_count: memory_jobs.iter().map(|metrics| metrics.untraced_entry_count).sum(), - derived_with_source_or_unsupported_count: memory_jobs - .iter() - .map(|metrics| metrics.derived_with_source_or_unsupported_count) - .sum(), - derived_missing_source_or_unsupported_count: memory_jobs - .iter() - .map(|metrics| metrics.derived_missing_source_or_unsupported_count) - .sum(), - unsupported_derived_entry_count: memory_jobs - .iter() - .map(|metrics| metrics.unsupported_derived_entry_count) - .sum(), - unsupported_current_entry_count: memory_jobs - .iter() - .map(|metrics| metrics.unsupported_current_entry_count) - .sum(), - tombstone_ref_count: memory_jobs.iter().map(|metrics| metrics.tombstone_ref_count).sum(), - source_trace_selected_count: memory_jobs - .iter() - .map(|metrics| metrics.source_trace_selected_count) - .sum(), - source_trace_dropped_count: memory_jobs - .iter() - .map(|metrics| metrics.source_trace_dropped_count) - .sum(), - source_trace_stale_count: memory_jobs - .iter() - .map(|metrics| metrics.source_trace_stale_count) - .sum(), - source_trace_superseded_count: memory_jobs - .iter() - .map(|metrics| metrics.source_trace_superseded_count) - .sum(), - source_trace_tombstone_count: memory_jobs - .iter() - .map(|metrics| metrics.source_trace_tombstone_count) - .sum(), - }) -} - -fn proactive_brief_summary(jobs: &[JobReport]) -> Option { - let proactive_jobs = - jobs.iter().filter_map(|job| job.proactive_brief.as_ref()).collect::>(); - - if proactive_jobs.is_empty() { - return None; - } - - let job_count = proactive_jobs.len(); - let suggestion_count = - proactive_jobs.iter().map(|metrics| metrics.suggestion_count).sum::(); - let evidence_ref_required_count = - proactive_jobs.iter().map(|metrics| metrics.evidence_ref_required_count).sum(); - let evidence_ref_suggestion_count = - proactive_jobs.iter().map(|metrics| metrics.evidence_ref_suggestion_count).sum(); - let freshness_marker_count = - proactive_jobs.iter().map(|metrics| metrics.freshness_marker_count).sum(); - let action_rationale_count = - proactive_jobs.iter().map(|metrics| metrics.action_rationale_count).sum(); - - Some(ProactiveBriefSummaryReport { - job_count, - brief_count: proactive_jobs.iter().map(|metrics| metrics.brief_count).sum(), - suggestion_count, - required_suggestion_kind_count: proactive_jobs - .iter() - .map(|metrics| metrics.required_suggestion_kind_count) - .sum(), - covered_required_suggestion_kind_count: proactive_jobs - .iter() - .map(|metrics| metrics.covered_required_suggestion_kind_count) - .sum(), - missing_required_suggestion_kind_count: proactive_jobs - .iter() - .map(|metrics| metrics.missing_required_suggestion_kind_count) - .sum(), - evidence_ref_required_count, - evidence_ref_suggestion_count, - evidence_ref_coverage: ratio(evidence_ref_suggestion_count, evidence_ref_required_count), - freshness_marker_count, - freshness_coverage: ratio(freshness_marker_count, suggestion_count), - action_rationale_count, - action_rationale_coverage: ratio(action_rationale_count, suggestion_count), - recommended_count: proactive_jobs.iter().map(|metrics| metrics.recommended_count).sum(), - deferred_count: proactive_jobs.iter().map(|metrics| metrics.deferred_count).sum(), - rejected_count: proactive_jobs.iter().map(|metrics| metrics.rejected_count).sum(), - current_suggestion_count: proactive_jobs - .iter() - .map(|metrics| metrics.current_suggestion_count) - .sum(), - non_current_suggestion_count: proactive_jobs - .iter() - .map(|metrics| metrics.non_current_suggestion_count) - .sum(), - stale_warning_count: proactive_jobs.iter().map(|metrics| metrics.stale_warning_count).sum(), - invalid_current_suggestion_count: proactive_jobs - .iter() - .map(|metrics| metrics.invalid_current_suggestion_count) - .sum(), - untraced_suggestion_count: proactive_jobs - .iter() - .map(|metrics| metrics.untraced_suggestion_count) - .sum(), - unsupported_current_suggestion_count: proactive_jobs - .iter() - .map(|metrics| metrics.unsupported_current_suggestion_count) - .sum(), - tombstone_violation_count: proactive_jobs - .iter() - .map(|metrics| metrics.tombstone_violation_count) - .sum(), - source_trace_selected_count: proactive_jobs - .iter() - .map(|metrics| metrics.source_trace_selected_count) - .sum(), - source_trace_dropped_count: proactive_jobs - .iter() - .map(|metrics| metrics.source_trace_dropped_count) - .sum(), - source_trace_stale_count: proactive_jobs - .iter() - .map(|metrics| metrics.source_trace_stale_count) - .sum(), - source_trace_superseded_count: proactive_jobs - .iter() - .map(|metrics| metrics.source_trace_superseded_count) - .sum(), - source_trace_tombstone_count: proactive_jobs - .iter() - .map(|metrics| metrics.source_trace_tombstone_count) - .sum(), - }) -} - -fn scheduled_memory_summary(jobs: &[JobReport]) -> Option { - let scheduled_jobs = - jobs.iter().filter_map(|job| job.scheduled_memory.as_ref()).collect::>(); - - if scheduled_jobs.is_empty() { - return None; - } - - let job_count = scheduled_jobs.len(); - let output_count = scheduled_jobs.iter().map(|metrics| metrics.output_count).sum::(); - let evidence_ref_required_count = - scheduled_jobs.iter().map(|metrics| metrics.evidence_ref_required_count).sum(); - let evidence_ref_output_count = - scheduled_jobs.iter().map(|metrics| metrics.evidence_ref_output_count).sum(); - let freshness_marker_count = - scheduled_jobs.iter().map(|metrics| metrics.freshness_marker_count).sum(); - let action_rationale_count = - scheduled_jobs.iter().map(|metrics| metrics.action_rationale_count).sum(); - let trace_required_count = - scheduled_jobs.iter().map(|metrics| metrics.trace_required_count).sum(); - let trace_complete_count = - scheduled_jobs.iter().map(|metrics| metrics.trace_complete_count).sum(); - - Some(ScheduledMemorySummaryReport { - job_count, - task_run_count: scheduled_jobs.iter().map(|metrics| metrics.task_run_count).sum(), - output_count, - required_task_kind_count: scheduled_jobs - .iter() - .map(|metrics| metrics.required_task_kind_count) - .sum(), - covered_required_task_kind_count: scheduled_jobs - .iter() - .map(|metrics| metrics.covered_required_task_kind_count) - .sum(), - missing_required_task_kind_count: scheduled_jobs - .iter() - .map(|metrics| metrics.missing_required_task_kind_count) - .sum(), - evidence_ref_required_count, - evidence_ref_output_count, - evidence_ref_coverage: ratio(evidence_ref_output_count, evidence_ref_required_count), - freshness_marker_count, - freshness_coverage: ratio(freshness_marker_count, output_count), - action_rationale_count, - action_rationale_coverage: ratio(action_rationale_count, output_count), - trace_required_count, - trace_complete_count, - trace_coverage: ratio(trace_complete_count, trace_required_count), - source_mutation_count: scheduled_jobs - .iter() - .map(|metrics| metrics.source_mutation_count) - .sum(), - current_output_count: scheduled_jobs - .iter() - .map(|metrics| metrics.current_output_count) - .sum(), - non_current_output_count: scheduled_jobs - .iter() - .map(|metrics| metrics.non_current_output_count) - .sum(), - invalid_current_output_count: scheduled_jobs - .iter() - .map(|metrics| metrics.invalid_current_output_count) - .sum(), - untraced_output_count: scheduled_jobs - .iter() - .map(|metrics| metrics.untraced_output_count) - .sum(), - unsupported_current_output_count: scheduled_jobs - .iter() - .map(|metrics| metrics.unsupported_current_output_count) - .sum(), - tombstone_violation_count: scheduled_jobs - .iter() - .map(|metrics| metrics.tombstone_violation_count) - .sum(), - source_trace_selected_count: scheduled_jobs - .iter() - .map(|metrics| metrics.source_trace_selected_count) - .sum(), - source_trace_dropped_count: scheduled_jobs - .iter() - .map(|metrics| metrics.source_trace_dropped_count) - .sum(), - source_trace_stale_count: scheduled_jobs - .iter() - .map(|metrics| metrics.source_trace_stale_count) - .sum(), - source_trace_superseded_count: scheduled_jobs - .iter() - .map(|metrics| metrics.source_trace_superseded_count) - .sum(), - source_trace_tombstone_count: scheduled_jobs - .iter() - .map(|metrics| metrics.source_trace_tombstone_count) - .sum(), - }) -} - -fn work_continuity_summary(jobs: &[JobReport]) -> Option { - let work_jobs = jobs.iter().filter_map(|job| job.work_continuity.as_ref()).collect::>(); - - if work_jobs.is_empty() { - return None; - } - - let reset_resume_required_count = - work_jobs.iter().map(|metrics| metrics.reset_resume_required_count).sum(); - let reset_resume_success_count = - work_jobs.iter().map(|metrics| metrics.reset_resume_success_count).sum(); - let decision_rationale_required_count = - work_jobs.iter().map(|metrics| metrics.decision_rationale_required_count).sum(); - let decision_rationale_recalled_count = - work_jobs.iter().map(|metrics| metrics.decision_rationale_recalled_count).sum(); - let rejected_option_required_count = - work_jobs.iter().map(|metrics| metrics.rejected_option_required_count).sum(); - let rejected_option_suppressed_count = - work_jobs.iter().map(|metrics| metrics.rejected_option_suppressed_count).sum(); - let explicit_next_step_returned_count = - work_jobs.iter().map(|metrics| metrics.explicit_next_step_returned_count).sum(); - let explicit_next_step_correct_count = - work_jobs.iter().map(|metrics| metrics.explicit_next_step_correct_count).sum(); - let inferred_next_step_required_count = - work_jobs.iter().map(|metrics| metrics.inferred_next_step_required_count).sum(); - let inferred_next_step_labeled_count = - work_jobs.iter().map(|metrics| metrics.inferred_next_step_labeled_count).sum(); - let handoff_source_ref_required_count = - work_jobs.iter().map(|metrics| metrics.handoff_source_ref_required_count).sum(); - let handoff_source_ref_covered_count = - work_jobs.iter().map(|metrics| metrics.handoff_source_ref_covered_count).sum(); - let redaction_required_count = - work_jobs.iter().map(|metrics| metrics.redaction_required_count).sum(); - let redaction_applied_count = - work_jobs.iter().map(|metrics| metrics.redaction_applied_count).sum(); - let janitor_candidate_count = - work_jobs.iter().map(|metrics| metrics.janitor_candidate_count).sum(); - let janitor_false_promotion_count = - work_jobs.iter().map(|metrics| metrics.janitor_false_promotion_count).sum(); - - Some(WorkContinuitySummaryReport { - job_count: work_jobs.len(), - readback_count: work_jobs.iter().map(|metrics| metrics.readback_count).sum(), - entry_count: work_jobs.iter().map(|metrics| metrics.entry_count).sum(), - reset_resume_required_count, - reset_resume_success_count, - reset_resume_success_rate: ratio(reset_resume_success_count, reset_resume_required_count), - decision_rationale_required_count, - decision_rationale_recalled_count, - decision_rationale_recall_rate: ratio( - decision_rationale_recalled_count, - decision_rationale_required_count, - ), - rejected_option_required_count, - rejected_option_suppressed_count, - rejected_option_resurrection_count: work_jobs - .iter() - .map(|metrics| metrics.rejected_option_resurrection_count) - .sum(), - rejected_option_suppression_rate: ratio( - rejected_option_suppressed_count, - rejected_option_required_count, - ), - explicit_next_step_required_count: work_jobs - .iter() - .map(|metrics| metrics.explicit_next_step_required_count) - .sum(), - explicit_next_step_returned_count, - explicit_next_step_correct_count, - explicit_next_step_precision: ratio_or( - explicit_next_step_correct_count, - explicit_next_step_returned_count, - 1.0, - ), - inferred_next_step_required_count, - inferred_next_step_labeled_count, - inferred_step_instruction_count: work_jobs - .iter() - .map(|metrics| metrics.inferred_step_instruction_count) - .sum(), - inferred_next_step_labeling_rate: ratio( - inferred_next_step_labeled_count, - inferred_next_step_required_count, - ), - handoff_source_ref_required_count, - handoff_source_ref_covered_count, - handoff_source_ref_coverage: ratio( - handoff_source_ref_covered_count, - handoff_source_ref_required_count, - ), - redaction_required_count, - redaction_applied_count, - sensitive_marker_persistence_count: work_jobs - .iter() - .map(|metrics| metrics.sensitive_marker_persistence_count) - .sum(), - redaction_rate: ratio(redaction_applied_count, redaction_required_count), - janitor_candidate_count, - janitor_false_promotion_count, - janitor_false_promotion_rate: ratio(janitor_false_promotion_count, janitor_candidate_count), - journal_only_authority_claim_count: work_jobs - .iter() - .map(|metrics| metrics.journal_only_authority_claim_count) - .sum(), - }) -} - -fn knowledge_summary(jobs: &[JobReport]) -> Option { - let knowledge_jobs = jobs.iter().filter_map(|job| job.knowledge.as_ref()).collect::>(); - - if knowledge_jobs.is_empty() { - return None; - } - - let job_count = knowledge_jobs.len(); - let page_count = knowledge_jobs.iter().map(|metrics| metrics.page_count).sum::(); - let section_count = knowledge_jobs.iter().map(|metrics| metrics.section_count).sum::(); - let traced_section_count = - knowledge_jobs.iter().map(|metrics| metrics.traced_section_count).sum::(); - let stale_trap_count = - knowledge_jobs.iter().map(|metrics| metrics.stale_trap_count).sum::(); - let stale_traps_detected = - knowledge_jobs.iter().map(|metrics| metrics.stale_traps_detected).sum::(); - let deterministic_rebuild_count = - knowledge_jobs.iter().map(|metrics| metrics.deterministic_rebuild_count).sum::(); - let rebuild_page_count = - knowledge_jobs.iter().map(|metrics| metrics.rebuild_page_count).sum::(); - let backlink_count = knowledge_jobs.iter().map(|metrics| metrics.backlink_count).sum::(); - let pages_with_backlinks = - knowledge_jobs.iter().map(|metrics| metrics.pages_with_backlinks).sum::(); - let pages_with_version_diff = - knowledge_jobs.iter().map(|metrics| metrics.pages_with_version_diff).sum::(); - let page_usefulness = round3( - knowledge_jobs.iter().map(|metrics| metrics.page_usefulness).sum::() - / job_count as f64, - ); - - Some(KnowledgeSummary { - job_count, - page_count, - section_count, - backlink_count, - pages_with_backlinks, - pages_with_version_diff, - citation_coverage: ratio(traced_section_count, section_count), - stale_claim_detection: ratio_or_full(stale_traps_detected, stale_trap_count), - rebuild_determinism: ratio(deterministic_rebuild_count, rebuild_page_count), - backlink_coverage: ratio(pages_with_backlinks, page_count), - version_diff_coverage: ratio(pages_with_version_diff, page_count), - page_usefulness, - unsupported_summary_count: knowledge_jobs - .iter() - .map(|metrics| metrics.unsupported_summary_count) - .sum(), - untraced_section_count: knowledge_jobs - .iter() - .map(|metrics| metrics.untraced_section_count) - .sum(), - allowed_variance_count: knowledge_jobs - .iter() - .map(|metrics| metrics.allowed_variance_count) - .sum(), - }) -} - -fn mean_score(jobs: &[JobReport]) -> f64 { - if jobs.is_empty() { - return 0.0; - } - - round3(jobs.iter().map(|job| job.normalized_score).sum::() / jobs.len() as f64) -} - -fn mean_latency(jobs: &[JobReport]) -> Option { - let latencies = jobs.iter().filter_map(|job| job.latency_ms).collect::>(); - - mean_latency_for_values(latencies.as_slice()) -} - -fn mean_latency_for_reports(jobs: &[&JobReport]) -> Option { - let latencies = jobs.iter().filter_map(|job| job.latency_ms).collect::>(); - - mean_latency_for_values(latencies.as_slice()) -} - -fn mean_latency_for_values(latencies: &[f64]) -> Option { - if latencies.is_empty() { - None - } else { - Some(round3(latencies.iter().sum::() / latencies.len() as f64)) - } -} - -fn total_cost(jobs: &[JobReport]) -> Option { - let costs = jobs.iter().filter_map(|job| job.cost.as_ref()).collect::>(); - - total_cost_for_values(costs.as_slice()) -} - -fn total_cost_for_reports(jobs: &[&JobReport]) -> Option { - let costs = jobs.iter().filter_map(|job| job.cost.as_ref()).collect::>(); - - total_cost_for_values(costs.as_slice()) -} - -fn total_cost_for_values(costs: &[&CostReport]) -> Option { - if costs.is_empty() { - return None; - } - - let currency = costs.iter().find_map(|cost| cost.currency.clone()); - let amount = sum_optional_f64(costs.iter().filter_map(|cost| cost.amount)); - let input_tokens = sum_optional_u64(costs.iter().filter_map(|cost| cost.input_tokens)); - let output_tokens = sum_optional_u64(costs.iter().filter_map(|cost| cost.output_tokens)); - - Some(CostReport { currency, amount, input_tokens, output_tokens }) -} - -fn sum_optional_f64(values: impl Iterator) -> Option { - let values = values.collect::>(); - - if values.is_empty() { None } else { Some(round3(values.iter().sum())) } -} - -fn sum_optional_u64(values: impl Iterator) -> Option { - let values = values.collect::>(); - - if values.is_empty() { None } else { Some(values.iter().sum()) } -} - -fn corpus_profile(jobs: &[RealWorldJob]) -> String { - let profiles = jobs.iter().map(|job| job.corpus.profile.as_str()).collect::>(); - - if profiles.len() == 1 { - profiles.into_iter().next().unwrap_or("unknown").to_string() - } else { - "mixed".to_string() - } -} - -fn adapter_report(args: &RunArgs) -> Result { - Ok(AdapterReport { - adapter_id: args.adapter_id.clone(), - name: args.adapter_name.clone(), - behavior: args.adapter_behavior.clone(), - storage: typed_status_from_arg( - args.adapter_storage_status.as_str(), - "--adapter-storage-status", - )?, - runtime: typed_status_from_arg( - args.adapter_runtime_status.as_str(), - "--adapter-runtime-status", - )?, - notes: args.adapter_notes.clone(), - }) -} - -fn typed_status_from_arg(raw: &str, flag: &str) -> Result { - match raw { - "pass" => Ok(TypedStatus::Pass), - "wrong_result" => Ok(TypedStatus::WrongResult), - "lifecycle_fail" => Ok(TypedStatus::LifecycleFail), - "incomplete" => Ok(TypedStatus::Incomplete), - "blocked" => Ok(TypedStatus::Blocked), - "not_encoded" => Ok(TypedStatus::NotEncoded), - "unsupported_claim" => Ok(TypedStatus::UnsupportedClaim), - _ => Err(eyre::eyre!( - "{flag} must be one of pass, wrong_result, lifecycle_fail, incomplete, blocked, not_encoded, or unsupported_claim." - )), - } -} - -fn external_adapter_section( - manifest_path: &Path, - skip_manifest: bool, -) -> Result { - if skip_manifest { - return Ok(empty_external_adapter_section("skipped")); - } - - let manifest_path = resolve_external_adapter_manifest_path(manifest_path); - - if !manifest_path.exists() { - return Ok(empty_external_adapter_section("missing")); - } - - let raw = fs::read_to_string(&manifest_path)?; - let manifest = serde_json::from_str::(&raw).map_err(|err| { - eyre::eyre!("Failed to parse external adapter manifest {}: {err}", manifest_path.display()) - })?; - - validate_external_adapter_manifest(&manifest, &manifest_path)?; - - let summary = external_adapter_summary(&manifest.adapters); - - Ok(ExternalAdapterSection { - schema: EXTERNAL_ADAPTER_REPORT_SCHEMA.to_string(), - manifest_id: manifest.manifest_id, - docker_isolation: manifest.docker_isolation, - summary, - adapters: manifest.adapters, - }) -} - -fn empty_external_adapter_section(reason: &str) -> ExternalAdapterSection { - ExternalAdapterSection { - schema: EXTERNAL_ADAPTER_REPORT_SCHEMA.to_string(), - manifest_id: reason.to_string(), - docker_isolation: ExternalDockerIsolation::default(), - summary: ExternalAdapterSummary::default(), - adapters: Vec::new(), - } -} - -fn resolve_external_adapter_manifest_path(path: &Path) -> PathBuf { - if path.exists() || path.is_absolute() { - return path.to_path_buf(); - } - - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let Some(workspace_root) = manifest_dir.parent().and_then(Path::parent) else { - return path.to_path_buf(); - }; - let workspace_candidate = workspace_root.join(path); - - if workspace_candidate.exists() { workspace_candidate } else { path.to_path_buf() } -} - -fn validate_external_adapter_manifest( - manifest: &ExternalAdapterManifest, - path: &Path, -) -> Result<()> { - if manifest.schema != EXTERNAL_ADAPTER_MANIFEST_SCHEMA { - return Err(eyre::eyre!( - "{} has schema {}, expected {EXTERNAL_ADAPTER_MANIFEST_SCHEMA}.", - path.display(), - manifest.schema - )); - } - if manifest.manifest_id.trim().is_empty() { - return Err(eyre::eyre!("{} has an empty manifest_id.", path.display())); - } - - validate_external_docker_isolation(path, &manifest.docker_isolation)?; - - validate_external_adapters(path, &manifest.adapters) -} - -fn validate_external_docker_isolation(path: &Path, docker: &ExternalDockerIsolation) -> Result<()> { - if docker.compose_file.trim().is_empty() - || docker.runner.trim().is_empty() - || docker.artifact_dir.trim().is_empty() - { - return Err(eyre::eyre!("{} has incomplete docker_isolation metadata.", path.display())); - } - if !docker.default { - return Err(eyre::eyre!( - "{} external adapter manifest must default to Docker isolation.", - path.display() - )); - } - if docker.host_global_installs_required { - return Err(eyre::eyre!( - "{} external adapter manifest must not require host-global installs by default.", - path.display() - )); - } - - Ok(()) -} - -fn validate_external_adapters(path: &Path, adapters: &[ExternalAdapterReport]) -> Result<()> { - if adapters.is_empty() { - return Err(eyre::eyre!("{} declares no external adapters.", path.display())); - } - - let mut seen = BTreeSet::new(); - - for adapter in adapters { - validate_external_adapter(path, adapter)?; - - if !seen.insert(adapter.adapter_id.as_str()) { - return Err(eyre::eyre!( - "{} declares duplicate adapter_id {}.", - path.display(), - adapter.adapter_id - )); - } - } - - Ok(()) -} - -fn validate_external_adapter(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - if adapter.adapter_id.trim().is_empty() - || adapter.project.trim().is_empty() - || adapter.adapter_kind.trim().is_empty() - || adapter.evidence_class.trim().is_empty() - { - return Err(eyre::eyre!("{} has an incomplete external adapter.", path.display())); - } - if !matches!( - adapter.evidence_class.as_str(), - "fixture_backed" | "live_baseline_only" | "live_real_world" | "research_gate" - ) { - return Err(eyre::eyre!( - "{} adapter {} has unsupported evidence_class {}.", - path.display(), - adapter.adapter_id, - adapter.evidence_class - )); - } - if adapter.docker_default && adapter.host_global_installs_required { - return Err(eyre::eyre!( - "{} adapter {} is Docker-default but requires host-global installs.", - path.display(), - adapter.adapter_id - )); - } - - validate_adapter_execution(path, adapter)?; - validate_adapter_capabilities(path, adapter)?; - validate_adapter_suites(path, adapter)?; - validate_adapter_scenarios(path, adapter)?; - validate_adapter_evidence(path, adapter)?; - validate_adapter_execution_metadata(path, adapter)?; - - if let Some(follow_up) = &adapter.follow_up - && (follow_up.title.trim().is_empty() || follow_up.reason.trim().is_empty()) - { - return Err(eyre::eyre!( - "{} adapter {} has an incomplete follow_up.", - path.display(), - adapter.adapter_id - )); - } - - Ok(()) -} - -fn validate_adapter_execution(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - for evidence in [&adapter.setup, &adapter.run, &adapter.result] { - if evidence.evidence.trim().is_empty() - || evidence.command.as_deref().is_some_and(str::is_empty) - || evidence.artifact.as_deref().is_some_and(str::is_empty) - { - return Err(eyre::eyre!( - "{} adapter {} has incomplete setup/run/result evidence.", - path.display(), - adapter.adapter_id - )); - } - } - - Ok(()) -} - -fn validate_adapter_capabilities(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - for capability in &adapter.capabilities { - if capability.capability.trim().is_empty() || capability.evidence.trim().is_empty() { - return Err(eyre::eyre!( - "{} adapter {} has incomplete capability coverage.", - path.display(), - adapter.adapter_id - )); - } - } - - Ok(()) -} - -fn validate_adapter_suites(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - for suite in &adapter.suites { - if !SUITES.contains(&suite.suite_id.as_str()) { - return Err(eyre::eyre!( - "{} adapter {} references unknown suite {}.", - path.display(), - adapter.adapter_id, - suite.suite_id - )); - } - if suite.evidence.trim().is_empty() { - return Err(eyre::eyre!( - "{} adapter {} has suite {} without evidence.", - path.display(), - adapter.adapter_id, - suite.suite_id - )); - } - } - - Ok(()) -} - -fn validate_adapter_scenarios(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - for scenario in &adapter.scenarios { - if scenario.scenario_id.trim().is_empty() - || scenario.evidence.trim().is_empty() - || scenario.command.as_deref().is_some_and(str::is_empty) - || scenario.artifact.as_deref().is_some_and(str::is_empty) - { - return Err(eyre::eyre!( - "{} adapter {} has incomplete scenario judgment.", - path.display(), - adapter.adapter_id - )); - } - - if let Some(suite_id) = &scenario.suite_id - && !SUITES.contains(&suite_id.as_str()) - { - return Err(eyre::eyre!( - "{} adapter {} scenario {} references unknown suite {}.", - path.display(), - adapter.adapter_id, - scenario.scenario_id, - suite_id - )); - } - - let outcome = scenario_comparison_outcome(scenario); - - if blocked_status_missing_blocked_outcome(scenario.status, scenario.comparison_outcome) { - return Err(eyre::eyre!( - "{} adapter {} scenario {} uses blocked status without blocked comparison outcome.", - path.display(), - adapter.adapter_id, - scenario.scenario_id - )); - } - if unmeasured_status_has_measured_outcome(scenario.status, outcome) { - return Err(eyre::eyre!( - "{} adapter {} scenario {} uses {} status with {} outcome.", - path.display(), - adapter.adapter_id, - scenario.scenario_id, - adapter_status_str(scenario.status), - scenario_comparison_outcome_str(outcome) - )); - } - if unmeasured_status_has_measured_position(scenario.status, scenario.elf_position) { - return Err(eyre::eyre!( - "{} adapter {} scenario {} uses {} status with {} position.", - path.display(), - adapter.adapter_id, - scenario.scenario_id, - adapter_status_str(scenario.status), - scenario_position_str(scenario.elf_position) - )); - } - if explicit_outcome_conflicts_with_position(scenario) { - return Err(eyre::eyre!( - "{} adapter {} scenario {} uses {} position with {} outcome.", - path.display(), - adapter.adapter_id, - scenario.scenario_id, - scenario_position_str(scenario.elf_position), - scenario_comparison_outcome_str(outcome) - )); - } - } - - Ok(()) -} - -fn blocked_status_missing_blocked_outcome( - status: AdapterCoverageStatus, - outcome: Option, -) -> bool { - status == AdapterCoverageStatus::Blocked && outcome != Some(ScenarioComparisonOutcome::Blocked) -} - -fn unmeasured_status_has_measured_outcome( - status: AdapterCoverageStatus, - outcome: ScenarioComparisonOutcome, -) -> bool { - matches!( - status, - AdapterCoverageStatus::Blocked - | AdapterCoverageStatus::Incomplete - | AdapterCoverageStatus::NotEncoded - | AdapterCoverageStatus::Unsupported - ) && matches!( - outcome, - ScenarioComparisonOutcome::Win - | ScenarioComparisonOutcome::Tie - | ScenarioComparisonOutcome::Loss - ) -} - -fn unmeasured_status_has_measured_position( - status: AdapterCoverageStatus, - position: ElfScenarioPosition, -) -> bool { - matches!( - status, - AdapterCoverageStatus::Blocked - | AdapterCoverageStatus::Incomplete - | AdapterCoverageStatus::NotEncoded - | AdapterCoverageStatus::Unsupported - ) && matches!( - position, - ElfScenarioPosition::Wins | ElfScenarioPosition::Ties | ElfScenarioPosition::Loses - ) -} - -fn explicit_outcome_conflicts_with_position(scenario: &AdapterScenarioJudgment) -> bool { - let Some(outcome) = scenario.comparison_outcome else { - return false; - }; - - !position_supports_outcome(scenario.elf_position, outcome) -} - -fn position_supports_outcome( - position: ElfScenarioPosition, - outcome: ScenarioComparisonOutcome, -) -> bool { - matches!( - (position, outcome), - (ElfScenarioPosition::Wins, ScenarioComparisonOutcome::Win) - | (ElfScenarioPosition::Ties, ScenarioComparisonOutcome::Tie) - | (ElfScenarioPosition::Loses, ScenarioComparisonOutcome::Loss) - | (ElfScenarioPosition::Untested, ScenarioComparisonOutcome::NotTested) - | (ElfScenarioPosition::Untested, ScenarioComparisonOutcome::Blocked) - | (ElfScenarioPosition::Untested, ScenarioComparisonOutcome::NonGoal) - ) -} - -fn validate_adapter_evidence(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - for evidence in &adapter.evidence { - if evidence.kind.trim().is_empty() || evidence.reference.trim().is_empty() { - return Err(eyre::eyre!( - "{} adapter {} has incomplete evidence pointers.", - path.display(), - adapter.adapter_id - )); - } - } - - Ok(()) -} - -fn validate_adapter_execution_metadata(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { - let Some(metadata) = &adapter.execution_metadata else { - return Ok(()); - }; - - if metadata.setup_path.trim().is_empty() - || metadata.runtime_boundary.trim().is_empty() - || metadata.resource_expectation.trim().is_empty() - || metadata.retry_guidance.iter().any(|guidance| guidance.trim().is_empty()) - || metadata.sources.is_empty() - { - return Err(eyre::eyre!( - "{} adapter {} has incomplete execution metadata.", - path.display(), - adapter.adapter_id - )); - } - - for source in &metadata.sources { - if source.label.trim().is_empty() - || source.url.trim().is_empty() - || source.evidence.trim().is_empty() - { - return Err(eyre::eyre!( - "{} adapter {} has incomplete source metadata.", - path.display(), - adapter.adapter_id - )); - } - } - - Ok(()) -} - -fn external_adapter_summary(adapters: &[ExternalAdapterReport]) -> ExternalAdapterSummary { - let external_projects = adapters - .iter() - .filter_map(|adapter| (adapter.project != "ELF").then_some(adapter.project.as_str())) - .collect::>(); - let mut summary = ExternalAdapterSummary { - adapter_count: adapters.len(), - external_project_count: external_projects.len(), - ..ExternalAdapterSummary::default() - }; - - for adapter in adapters { - accumulate_adapter_summary(&mut summary, adapter); - } - - summary -} - -fn accumulate_adapter_summary( - summary: &mut ExternalAdapterSummary, - adapter: &ExternalAdapterReport, -) { - summary.docker_default_count += usize::from(adapter.docker_default); - summary.host_global_install_required_count += - usize::from(adapter.host_global_installs_required); - summary.fixture_backed_count += usize::from(adapter.evidence_class == "fixture_backed"); - summary.live_baseline_only_count += usize::from(adapter.evidence_class == "live_baseline_only"); - summary.live_real_world_count += usize::from(adapter.evidence_class == "live_real_world"); - summary.research_gate_count += usize::from(adapter.evidence_class == "research_gate"); - - increment_adapter_status_count(&mut summary.overall_status_counts, adapter.overall_status); - - for capability in &adapter.capabilities { - increment_adapter_status_count(&mut summary.capability_status_counts, capability.status); - } - for suite in &adapter.suites { - increment_adapter_status_count(&mut summary.suite_status_counts, suite.status); - } - for scenario in &adapter.scenarios { - increment_adapter_status_count(&mut summary.scenario_status_counts, scenario.status); - increment_scenario_position_count( - &mut summary.scenario_position_counts, - scenario.elf_position, - ); - increment_scenario_outcome_count( - &mut summary.scenario_outcome_counts, - scenario_comparison_outcome(scenario), - ); - } -} - -fn increment_adapter_status_count(counts: &mut AdapterStatusCounts, status: AdapterCoverageStatus) { - match status { - AdapterCoverageStatus::Real => counts.real += 1, - AdapterCoverageStatus::Mocked => counts.mocked += 1, - AdapterCoverageStatus::Unsupported => counts.unsupported += 1, - AdapterCoverageStatus::Blocked => counts.blocked += 1, - AdapterCoverageStatus::Incomplete => counts.incomplete += 1, - AdapterCoverageStatus::WrongResult => counts.wrong_result += 1, - AdapterCoverageStatus::LifecycleFail => counts.lifecycle_fail += 1, - AdapterCoverageStatus::Pass => counts.pass += 1, - AdapterCoverageStatus::NotEncoded => counts.not_encoded += 1, - } -} - -fn increment_scenario_position_count( - counts: &mut ScenarioPositionCounts, - position: ElfScenarioPosition, -) { - match position { - ElfScenarioPosition::Wins => counts.wins += 1, - ElfScenarioPosition::Ties => counts.ties += 1, - ElfScenarioPosition::Loses => counts.loses += 1, - ElfScenarioPosition::Untested => counts.untested += 1, - } -} - -fn scenario_comparison_outcome(scenario: &AdapterScenarioJudgment) -> ScenarioComparisonOutcome { - scenario.comparison_outcome.unwrap_or(match scenario.elf_position { - ElfScenarioPosition::Wins => ScenarioComparisonOutcome::Win, - ElfScenarioPosition::Ties => ScenarioComparisonOutcome::Tie, - ElfScenarioPosition::Loses => ScenarioComparisonOutcome::Loss, - ElfScenarioPosition::Untested => ScenarioComparisonOutcome::NotTested, - }) -} - -fn increment_scenario_outcome_count( - counts: &mut ScenarioOutcomeCounts, - outcome: ScenarioComparisonOutcome, -) { - match outcome { - ScenarioComparisonOutcome::Win => counts.win += 1, - ScenarioComparisonOutcome::Tie => counts.tie += 1, - ScenarioComparisonOutcome::Loss => counts.loss += 1, - ScenarioComparisonOutcome::NotTested => counts.not_tested += 1, - ScenarioComparisonOutcome::Blocked => counts.blocked += 1, - ScenarioComparisonOutcome::NonGoal => counts.non_goal += 1, - } -} - -fn capture_integration_report(jobs: &[RealWorldJob]) -> CaptureIntegrationReport { - let mut report = CaptureIntegrationReport::default(); - - for job in jobs { - extend_unique(&mut report.real, &job.corpus.capture_behaviors.real); - extend_unique(&mut report.fixture_backed, &job.corpus.capture_behaviors.fixture_backed); - extend_unique(&mut report.mocked, &job.corpus.capture_behaviors.mocked); - extend_unique(&mut report.blocked, &job.corpus.capture_behaviors.blocked); - extend_unique(&mut report.not_encoded, &job.corpus.capture_behaviors.not_encoded); - extend_unique(&mut report.notes, &job.corpus.capture_behaviors.notes); - } - - if report.real.is_empty() - && report.fixture_backed.is_empty() - && report.mocked.is_empty() - && report.blocked.is_empty() - && report.not_encoded.is_empty() - { - report - .not_encoded - .push("No capture/integration behavior was declared by encoded fixtures.".to_string()); - } - - report -} - -fn extend_unique(target: &mut Vec, values: &[String]) { - let mut seen = target.iter().cloned().collect::>(); - - for value in values { - if seen.insert(value.clone()) { - target.push(value.clone()); - } - } -} - -fn private_corpus_redaction(jobs: &[RealWorldJob]) -> PrivateCorpusRedaction { - let private_fixture_count = jobs - .iter() - .filter(|job| matches!(job.corpus.profile, CorpusProfile::PrivateSanitized)) - .count(); - let policy = if private_fixture_count == 0 { - "no_private_corpus".to_string() - } else { - "publish evidence ids and bounded score summaries only; do not publish private text" - .to_string() - }; - - PrivateCorpusRedaction { policy, private_fixture_count } -} - -fn render_markdown(report: &RealWorldReport, report_path: &Path) -> String { - let report_path = report_path.display().to_string(); - let mut out = String::new(); - - render_markdown_header(&mut out, report, report_path.as_str()); - render_markdown_scoreboard(&mut out, report); - render_markdown_operational_evidence(&mut out, report); - render_markdown_external_adapters(&mut out, report); - render_markdown_capture_integration(&mut out, report); - render_markdown_suites(&mut out, report); - render_markdown_jobs(&mut out, report); - render_markdown_operator_debugging(&mut out, report); - render_markdown_evolution(&mut out, report); - render_markdown_trace_explainability(&mut out, report); - render_markdown_consolidation(&mut out, report); - render_markdown_memory_summary(&mut out, report); - render_markdown_proactive_brief(&mut out, report); - render_markdown_scheduled_memory(&mut out, report); - render_markdown_work_continuity(&mut out, report); - render_markdown_knowledge(&mut out, report); - render_markdown_unsupported_claims(&mut out, report); - render_markdown_follow_ups(&mut out, report); - render_markdown_semantics(&mut out, report); - - out -} - -fn render_markdown_scoreboard(out: &mut String, report: &RealWorldReport) { - out.push_str("## Quality Scoreboard Grammar\n\n"); - out.push_str("The scoreboard is a claim grammar, not a leaderboard. A report may claim only the statuses and evidence classes represented by its source JSON.\n\n"); - out.push_str(&format!("- Schema: `{}`\n", md_inline(report.scoreboard.schema.as_str()))); - out.push_str(&format!( - "- Result states: `{}`\n", - md_inline(report.scoreboard.result_states.join(", ").as_str()) - )); - out.push_str(&format!( - "- Evidence classes: `{}`\n", - md_inline(report.scoreboard.evidence_classes.join(", ").as_str()) - )); - out.push_str(&format!( - "- Metric basis: `{}` at k=`{}`\n", - md_inline(report.scoreboard.metric_basis.as_str()), - report.scoreboard.retrieval_k - )); - out.push_str(&format!( - "- Summary claim: `{}`\n", - md_inline(report.scoreboard.summary_claim.as_str()) - )); - out.push_str(&format!( - "- Job summary claim: `{}`\n", - md_inline(report.scoreboard.job_summary_claim.as_str()) - )); - out.push_str(&format!( - "- Job typed non-pass rows: `{}` ({})\n", - report.scoreboard.job_typed_non_pass_count, - md_inline( - scoreboard_state_list(&report.scoreboard.job_typed_non_pass_states_present).as_str() - ) - )); - out.push_str(&format!( - "- External-adapter typed non-pass rows: `{}` ({})\n", - report.scoreboard.external_adapter_typed_non_pass_count, - md_inline( - scoreboard_state_list( - &report.scoreboard.external_adapter_typed_non_pass_states_present - ) - .as_str() - ) - )); - out.push_str(&format!( - "- Typed non-pass rows: `{}` ({})\n", - report.scoreboard.typed_non_pass_count, - md_inline(scoreboard_state_list(&report.scoreboard.typed_non_pass_states_present).as_str()) - )); - out.push_str(&format!( - "- Evidence class counts: `{}`\n", - md_inline(scoreboard_evidence_class_count_display(&report.scoreboard).as_str()) - )); - out.push_str(&format!( - "- Unqualified win claim allowed: `{}`\n", - report.scoreboard.unqualified_win_claim_allowed - )); - out.push_str(&format!( - "- Claim boundary: {}\n\n", - md_cell(report.scoreboard.claim_boundary.as_str()) - )); - out.push_str("| Product | State | Evidence | Comparable | Runtime Gates | Recall@k | Precision@k | MRR | nDCG | Stale Suppression | Update/Delete | Source Refs | Latency | Next Evidence |\n"); - out.push_str( - "| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | ---: | --- | --- |\n", - ); - - for row in &report.scoreboard.rows { - out.push_str(&format!( - "| {} | `{}` | `{}` | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", - md_cell(row.product_name.as_str()), - md_inline(row.result_state.as_str()), - md_inline(row.evidence_class.as_str()), - row.comparable, - scoreboard_runtime_gate_cell(row), - scoreboard_optional_f64(row.metrics.retrieval.recall_at_k), - scoreboard_optional_f64(row.metrics.retrieval.precision_at_k), - scoreboard_optional_f64(row.metrics.retrieval.mrr), - scoreboard_optional_f64(row.metrics.retrieval.ndcg), - scoreboard_optional_f64(row.metrics.lifecycle.stale_suppression), - scoreboard_update_delete_cell(row), - scoreboard_optional_f64(row.metrics.coverage.source_ref_coverage), - scoreboard_latency_cell(row), - md_cell(scoreboard_list_cell(&row.next_evidence).as_str()) - )); - } - - if !report.scoreboard.optimization_roadmap.is_empty() { - out.push_str("\nOptimization direction:\n"); - - for item in &report.scoreboard.optimization_roadmap { - out.push_str(&format!("- {}\n", md_cell(item.as_str()))); - } - - out.push('\n'); - } -} - -fn render_markdown_operational_evidence(out: &mut String, report: &RealWorldReport) { - let evidence = &report.operational_evidence; - - if evidence.schema.is_empty() { - return; - } - - out.push_str("## Operational Evidence Gates\n\n"); - out.push_str("This section separates operational evidence tiers so local fixture or public-proxy passes do not become private-corpus or provider-backed proof.\n\n"); - out.push_str(&format!("- Schema: `{}`\n", md_inline(evidence.schema.as_str()))); - out.push_str(&format!("- Claim boundary: {}\n", md_cell(evidence.claim_boundary.as_str()))); - out.push_str(&format!( - "- Missing private/provider inputs are typed blockers: `{}`\n", - evidence.missing_private_provider_inputs_are_typed_blockers - )); - out.push_str(&format!( - "- Private-corpus pass claim allowed: `{}`\n", - evidence.private_corpus_pass_claim_allowed - )); - out.push_str(&format!( - "- Provider-backed pass claim allowed: `{}`\n", - evidence.provider_backed_pass_claim_allowed - )); - out.push_str(&format!( - "- Latency: `{}` measured job(s), `{}` missing, mean `{}`, max `{}`\n", - evidence.latency.measured_job_count, - evidence.latency.missing_latency_job_count, - optional_f64(evidence.latency.mean_ms, " ms"), - optional_f64(evidence.latency.max_ms, " ms") - )); - out.push_str(&format!( - "- Cost: `{}` job(s) reported cost, `{}` missing, `{}` zero-cost; total `{}`\n", - evidence.cost.jobs_with_cost_report, - evidence.cost.missing_cost_job_count, - evidence.cost.zero_cost_job_count, - cost_display(evidence.cost.total.as_ref()) - )); - out.push_str(&format!("- Cost boundary: {}\n", md_cell(evidence.cost.claim_boundary.as_str()))); - out.push_str(&format!( - "- Resource envelope jobs: `{}` total, `{}` pass; latency/resource dimensions `{}`\n", - evidence.resource.resource_envelope_job_count, - evidence.resource.resource_envelope_pass_count, - evidence.resource.latency_resource_dimension_job_count - )); - out.push_str(&format!( - "- Cold-start/restore/rebuild: cold-start `{}`/`{}` pass, restore `{}`/`{}` pass, Qdrant rebuild `{}`/`{}` pass\n\n", - evidence.cold_start_restore_rebuild.cold_start_pass_count, - evidence.cold_start_restore_rebuild.cold_start_job_count, - evidence.cold_start_restore_rebuild.restore_pass_count, - evidence.cold_start_restore_rebuild.restore_job_count, - evidence.cold_start_restore_rebuild.qdrant_rebuild_pass_count, - evidence.cold_start_restore_rebuild.qdrant_rebuild_job_count - )); - out.push_str(&format!( - "- Authority recovery drills: `{}`/`{}` pass, topology `{}`, failure injections `{}`, backup/PITR restored `{}`, degraded reads labeled `{}`, source-of-truth visible `{}`, RPO `{}`/`{}` met, RTO `{}`/`{}` met, record counts `{}`/`{}` preserved, source refs `{}`/`{}` preserved, lifecycle histories `{}`/`{}` preserved, idempotent replay `{}`, complete Qdrant rebuild `{}`, migration repair `{}`, dead-letter handled `{}`\n\n", - evidence.authority_recovery.drill_pass_count, - evidence.authority_recovery.drill_count, - evidence.authority_recovery.topology_reported_count, - evidence.authority_recovery.failure_injection_count, - evidence.authority_recovery.backup_pitr_restored_count, - evidence.authority_recovery.degraded_read_labeled_count, - evidence.authority_recovery.source_of_truth_visible_count, - evidence.authority_recovery.rpo_met_count, - evidence.authority_recovery.rpo_target_count, - evidence.authority_recovery.rto_met_count, - evidence.authority_recovery.rto_target_count, - evidence.authority_recovery.record_count_preserved_count, - evidence.authority_recovery.authority_plane_count, - evidence.authority_recovery.source_ref_preserved_count, - evidence.authority_recovery.authority_plane_count, - evidence.authority_recovery.lifecycle_history_preserved_count, - evidence.authority_recovery.authority_plane_count, - evidence.authority_recovery.idempotent_outbox_replay_count, - evidence.authority_recovery.qdrant_rebuild_complete_count, - evidence.authority_recovery.migration_repair_count, - evidence.authority_recovery.dead_letter_handled_count - )); - out.push_str("| Evidence Tier | Status | Jobs | Pass | Blocked | Incomplete | Not Encoded | Mean Latency | Cost | Resource | Cold Start | Restore | Qdrant Rebuild | Pass Claim |\n"); - out.push_str("| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: | ---: | --- |\n"); - - for tier in &evidence.tiers { - out.push_str(&format!( - "| `{}` | `{}` | {} | {} | {} | {} | {} | `{}` | `{}` | {} | {} | {} | {} | `{}` |\n", - md_inline(tier.tier.as_str()), - status_str(tier.status), - tier.job_count, - tier.pass, - tier.blocked, - tier.incomplete, - tier.not_encoded, - optional_f64(tier.mean_latency_ms, " ms"), - cost_display(tier.total_cost.as_ref()), - tier.resource_evidence_count, - tier.cold_start_evidence_count, - tier.restore_evidence_count, - tier.qdrant_rebuild_evidence_count, - tier.pass_claim_allowed - )); - } - - if evidence.tiers.iter().any(|tier| !tier.blocker_reasons.is_empty()) { - out.push_str("\nTyped blocker reasons:\n"); - - for tier in &evidence.tiers { - for reason in &tier.blocker_reasons { - out.push_str(&format!( - "- `{}`: {}\n", - md_inline(tier.tier.as_str()), - md_cell(reason) - )); - } - } - } - - out.push('\n'); -} - -fn render_markdown_capture_integration(out: &mut String, report: &RealWorldReport) { - out.push_str("## Capture And Integration Coverage\n\n"); - - if report.adapter.behavior == DEFAULT_ADAPTER_BEHAVIOR { - out.push_str("The real-world job runner is fixture-backed. This section separates encoded evidence from live adapter claims.\n\n"); - } else { - out.push_str("This report scores materialized adapter responses. Capture and integration classes still describe the job corpus, not broad external adapter coverage.\n\n"); - } - - out.push_str("| Class | Behaviors |\n"); - out.push_str("| --- | --- |\n"); - out.push_str(&format!("| real | {} |\n", md_list(report.capture_integration.real.as_slice()))); - out.push_str(&format!( - "| fixture-backed | {} |\n", - md_list(report.capture_integration.fixture_backed.as_slice()) - )); - out.push_str(&format!( - "| mocked | {} |\n", - md_list(report.capture_integration.mocked.as_slice()) - )); - out.push_str(&format!( - "| blocked | {} |\n", - md_list(report.capture_integration.blocked.as_slice()) - )); - out.push_str(&format!( - "| not encoded | {} |\n", - md_list(report.capture_integration.not_encoded.as_slice()) - )); - - if !report.capture_integration.notes.is_empty() { - out.push_str("\nNotes:\n"); - - for note in &report.capture_integration.notes { - out.push_str(&format!("- {}\n", md_cell(note.as_str()))); - } - } - - out.push('\n'); -} - -fn render_markdown_external_adapters(out: &mut String, report: &RealWorldReport) { - out.push_str("## External Adapter Coverage\n\n"); - - if report.external_adapters.adapters.is_empty() { - out.push_str("No external adapter coverage manifest was loaded for this report.\n\n"); - - return; - } - - let summary = &report.external_adapters.summary; - - out.push_str("This section is manifest-backed. It records external adapter coverage and blockers, but it does not convert live-baseline retrieval results into real-world suite wins.\n\n"); - out.push_str(&format!( - "- Manifest: `{}`\n", - md_inline(report.external_adapters.manifest_id.as_str()) - )); - out.push_str(&format!( - "- Docker default: `{}` via `{}`; artifact dir `{}`\n", - report.external_adapters.docker_isolation.default, - md_inline(report.external_adapters.docker_isolation.compose_file.as_str()), - md_inline(report.external_adapters.docker_isolation.artifact_dir.as_str()) - )); - out.push_str(&format!( - "- Adapter records: `{}` total, `{}` external project(s), `{}` Docker-default, `{}` requiring host-global installs\n", - summary.adapter_count, - summary.external_project_count, - summary.docker_default_count, - summary.host_global_install_required_count - )); - out.push_str(&format!( - "- Evidence classes: `{}` fixture-backed, `{}` live-baseline-only, `{}` live real-world, `{}` research-gate\n", - summary.fixture_backed_count, - summary.live_baseline_only_count, - summary.live_real_world_count, - summary.research_gate_count - )); - out.push_str(&format!( - "- Overall statuses: `{}`\n", - adapter_status_counts_display(&summary.overall_status_counts) - )); - out.push_str(&format!( - "- Capability coverage statuses: `{}`\n", - adapter_status_counts_display(&summary.capability_status_counts) - )); - out.push_str(&format!( - "- Real-world suite statuses: `{}`\n", - adapter_status_counts_display(&summary.suite_status_counts) - )); - - if has_adapter_scenarios(report.external_adapters.adapters.as_slice()) { - out.push_str(&format!( - "- Scenario coverage statuses: `{}`\n", - adapter_status_counts_display(&summary.scenario_status_counts) - )); - out.push_str(&format!( - "- ELF scenario positions: `{}`\n", - scenario_position_counts_display(&summary.scenario_position_counts) - )); - out.push_str(&format!( - "- Scenario comparison outcomes: `{}`\n", - scenario_outcome_counts_display(&summary.scenario_outcome_counts) - )); - } - - out.push('\n'); - out.push_str("| Project | Adapter | Evidence Class | Overall | Setup | Run | Result | Docker | Suites | Evidence |\n"); - out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n"); - - for adapter in &report.external_adapters.adapters { - out.push_str(&format!( - "| {} | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | {} | {} |\n", - md_cell(adapter.project.as_str()), - md_inline(adapter.adapter_id.as_str()), - md_inline(adapter.evidence_class.as_str()), - adapter_status_str(adapter.overall_status), - adapter_status_str(adapter.setup.status), - adapter_status_str(adapter.run.status), - adapter_status_str(adapter.result.status), - adapter.docker_default, - adapter_suite_cell(adapter.suites.as_slice()), - adapter_evidence_cell(adapter) - )); - } - - out.push_str("\n### Adapter Capability Details\n\n"); - out.push_str("| Adapter | Capability | Status | Evidence |\n"); - out.push_str("| --- | --- | --- | --- |\n"); - - for adapter in &report.external_adapters.adapters { - for capability in &adapter.capabilities { - out.push_str(&format!( - "| `{}` | {} | `{}` | {} |\n", - md_inline(adapter.adapter_id.as_str()), - md_cell(capability.capability.as_str()), - adapter_status_str(capability.status), - md_cell(capability.evidence.as_str()) - )); - } - } - - render_markdown_adapter_scenarios(out, report.external_adapters.adapters.as_slice()); - render_markdown_adapter_execution_metadata(out, report.external_adapters.adapters.as_slice()); - - out.push('\n'); -} - -fn render_markdown_adapter_scenarios(out: &mut String, adapters: &[ExternalAdapterReport]) { - if !has_adapter_scenarios(adapters) { - return; - } - - out.push_str("\n### Adapter Scenario Judgments\n\n"); - out.push_str("| Adapter | Scenario | Suite | Status | Outcome | Evidence |\n"); - out.push_str("| --- | --- | --- | --- | --- | --- |\n"); - - for adapter in adapters { - for scenario in &adapter.scenarios { - out.push_str(&format!( - "| `{}` | `{}` | {} | `{}` | `{}` | {} |\n", - md_inline(adapter.adapter_id.as_str()), - md_inline(scenario.scenario_id.as_str()), - scenario - .suite_id - .as_deref() - .map(|suite| format!("`{}`", md_inline(suite))) - .unwrap_or_else(|| "`none`".to_string()), - adapter_status_str(scenario.status), - scenario_comparison_outcome_str(scenario_comparison_outcome(scenario)), - adapter_scenario_evidence_cell(scenario) - )); - } - } -} - -fn has_adapter_scenarios(adapters: &[ExternalAdapterReport]) -> bool { - adapters.iter().any(|adapter| !adapter.scenarios.is_empty()) -} - -fn render_markdown_adapter_execution_metadata( - out: &mut String, - adapters: &[ExternalAdapterReport], -) { - let mut wrote_header = false; - - for adapter in adapters { - let Some(metadata) = &adapter.execution_metadata else { - continue; - }; - - if !wrote_header { - out.push_str("\n### Adapter Execution Metadata\n\n"); - out.push_str("| Adapter | Sources | Setup Path | Runtime Boundary | Resource Expectation | Retry Guidance | Research Depth |\n"); - out.push_str("| --- | --- | --- | --- | --- | --- | --- |\n"); - - wrote_header = true; - } - - out.push_str(&format!( - "| `{}` | {} | {} | {} | {} | {} | {} |\n", - md_inline(adapter.adapter_id.as_str()), - adapter_sources_cell(metadata.sources.as_slice()), - md_cell(metadata.setup_path.as_str()), - md_cell(metadata.runtime_boundary.as_str()), - md_cell(metadata.resource_expectation.as_str()), - md_list(metadata.retry_guidance.as_slice()), - md_cell(metadata.research_depth.as_deref().unwrap_or("not recorded")) - )); - } -} - -fn render_markdown_header(out: &mut String, report: &RealWorldReport, report_path: &str) { - out.push_str("# Real-World Job Benchmark Report\n\n"); - out.push_str( - "Goal: Publish a Markdown summary for one generated real_world_job benchmark report.\n", - ); - out.push_str( - "Read this when: You need a durable smoke report for real-world agent memory job fixtures.\n", - ); - out.push_str(&format!("Inputs: `{}`.\n", md_inline(report_path))); - out.push_str("Depends on: `apps/elf-eval/fixtures/`, `docs/spec/real_world_agent_memory_benchmark_v1.md`, and `Makefile.toml`.\n"); - out.push_str( - "Verification: Compare this Markdown summary with the source JSON before committing.\n\n", - ); - out.push_str("## Summary\n\n"); - out.push_str(&format!("- Run ID: `{}`\n", md_inline(report.run_id.as_str()))); - out.push_str(&format!("- Generated at: `{}`\n", md_inline(report.generated_at.as_str()))); - out.push_str(&format!("- Runner version: `{}`\n", md_inline(report.runner_version.as_str()))); - out.push_str(&format!("- Corpus profile: `{}`\n", md_inline(report.corpus_profile.as_str()))); - out.push_str(&format!( - "- Adapter: `{}` ({})\n", - md_inline(report.adapter.adapter_id.as_str()), - md_inline(report.adapter.behavior.as_str()) - )); - out.push_str(&format!("- Jobs: `{}`\n", report.summary.job_count)); - out.push_str(&format!( - "- Suites with encoded jobs: `{}`\n", - report.summary.encoded_suite_count - )); - out.push_str(&format!( - "- Suites with `not_encoded` status: `{}`\n", - report.not_encoded_suites.len() - )); - out.push_str(&format!("- Status summary: `{}` pass, `{}` wrong_result, `{}` lifecycle_fail, `{}` incomplete, `{}` blocked, `{}` not_encoded, `{}` unsupported_claim\n", report.summary.pass, report.summary.wrong_result, report.summary.lifecycle_fail, report.summary.incomplete, report.summary.blocked, report.summary.not_encoded, report.summary.unsupported_claim)); - out.push_str(&format!( - "- Unsupported claim count: `{}`\n", - report.summary.unsupported_claim_count - )); - out.push_str(&format!("- Wrong-result count: `{}`\n", report.summary.wrong_result_count)); - out.push_str(&format!("- Stale-answer count: `{}`\n", report.summary.stale_answer_count)); - out.push_str(&format!( - "- Conflict detections: `{}`\n", - report.summary.conflict_detection_count - )); - out.push_str(&format!( - "- Update rationales available: `{}`\n", - report.summary.update_rationale_available_count - )); - out.push_str(&format!( - "- Temporal validity not encoded: `{}`\n", - report.summary.temporal_validity_not_encoded_count - )); - out.push_str(&format!( - "- History readback encoded: `{}`\n", - report.summary.history_readback_encoded_count - )); - - render_markdown_quality_summary(out, report); - - out.push_str(&format!("- Mean score: `{:.3}`\n", report.summary.mean_score)); - out.push_str(&format!( - "- Mean latency: `{}`\n", - optional_f64(report.summary.mean_latency_ms, " ms") - )); - out.push_str(&format!("- Cost: `{}`\n", cost_display(report.summary.total_cost.as_ref()))); - out.push_str(&format!( - "- Operator-debug jobs: `{}`\n", - report.summary.operator_debug_job_count - )); - out.push_str(&format!("- Raw SQL needed: `{}`\n", report.summary.raw_sql_needed_count)); - out.push_str(&format!( - "- Trace-incomplete debug jobs: `{}`\n", - report.summary.trace_incomplete_count - )); - out.push_str(&format!("- Operator UX gaps: `{}`\n", report.summary.operator_ux_gap_count)); - - render_markdown_optional_summary_metrics(out, &report.summary); - - out.push_str(&format!( - "- Private corpus redaction: `{}`\n\n", - md_inline(report.private_corpus_redaction.policy.as_str()) - )); -} - -fn render_markdown_optional_summary_metrics(out: &mut String, summary: &ReportSummary) { - if let Some(knowledge) = &summary.knowledge { - render_markdown_knowledge_summary_metrics(out, knowledge); - } - if let Some(memory_summary) = &summary.memory_summary { - render_markdown_memory_summary_metrics(out, memory_summary); - } - if let Some(proactive) = &summary.proactive_brief { - render_markdown_proactive_summary_metrics(out, proactive); - } - if let Some(scheduled) = &summary.scheduled_memory { - render_markdown_scheduled_summary_metrics(out, scheduled); - } - if let Some(work_continuity) = &summary.work_continuity { - render_markdown_work_continuity_summary_metrics(out, work_continuity); - } -} - -fn render_markdown_knowledge_summary_metrics(out: &mut String, knowledge: &KnowledgeSummary) { - out.push_str(&format!("- Knowledge citation coverage: `{:.3}`\n", knowledge.citation_coverage)); - out.push_str(&format!("- Stale claim detection: `{:.3}`\n", knowledge.stale_claim_detection)); - out.push_str(&format!("- Rebuild determinism: `{:.3}`\n", knowledge.rebuild_determinism)); - out.push_str(&format!( - "- Backlinks: `{}` total, `{:.3}` page coverage\n", - knowledge.backlink_count, knowledge.backlink_coverage - )); - out.push_str(&format!("- Version diff coverage: `{:.3}`\n", knowledge.version_diff_coverage)); - out.push_str(&format!("- Page usefulness: `{:.3}`\n", knowledge.page_usefulness)); - out.push_str(&format!( - "- Unsupported summary count: `{}`\n", - knowledge.unsupported_summary_count - )); -} - -fn render_markdown_memory_summary_metrics(out: &mut String, memory_summary: &MemorySummaryReport) { - out.push_str(&format!( - "- Memory summary entries: `{}` across `{}` artifact(s)\n", - memory_summary.entry_count, memory_summary.summary_count - )); - out.push_str(&format!( - "- Memory summary source-ref coverage: `{}/{}` (`{:.3}`)\n", - memory_summary.source_ref_entry_count, - memory_summary.source_ref_required_count, - memory_summary.source_ref_coverage - )); - out.push_str(&format!( - "- Memory summary invalid top-of-mind count: `{}`\n", - memory_summary.invalid_top_of_mind_count - )); - out.push_str(&format!( - "- Memory summary unsupported derived entries: `{}`\n", - memory_summary.unsupported_derived_entry_count - )); - out.push_str(&format!( - "- Memory summary unsupported current entries: `{}`\n", - memory_summary.unsupported_current_entry_count - )); -} - -fn render_markdown_proactive_summary_metrics( - out: &mut String, - proactive: &ProactiveBriefSummaryReport, -) { - out.push_str(&format!( - "- Proactive brief suggestions: `{}` across `{}` artifact(s)\n", - proactive.suggestion_count, proactive.brief_count - )); - out.push_str(&format!( - "- Proactive evidence-ref coverage: `{}/{}` (`{:.3}`)\n", - proactive.evidence_ref_suggestion_count, - proactive.evidence_ref_required_count, - proactive.evidence_ref_coverage - )); - out.push_str(&format!( - "- Proactive freshness/action rationale coverage: `{:.3}` / `{:.3}`\n", - proactive.freshness_coverage, proactive.action_rationale_coverage - )); - out.push_str(&format!( - "- Proactive stale/currentness violations: `{}` invalid current, `{}` tombstone violation(s)\n", - proactive.invalid_current_suggestion_count, proactive.tombstone_violation_count - )); - out.push_str(&format!( - "- Proactive rejected/deferred suggestions: `{}` rejected, `{}` deferred\n", - proactive.rejected_count, proactive.deferred_count - )); -} - -fn render_markdown_scheduled_summary_metrics( - out: &mut String, - scheduled: &ScheduledMemorySummaryReport, -) { - out.push_str(&format!( - "- Scheduled memory outputs: `{}` across `{}` task run(s)\n", - scheduled.output_count, scheduled.task_run_count - )); - out.push_str(&format!( - "- Scheduled memory evidence-ref coverage: `{}/{}` (`{:.3}`)\n", - scheduled.evidence_ref_output_count, - scheduled.evidence_ref_required_count, - scheduled.evidence_ref_coverage - )); - out.push_str(&format!( - "- Scheduled memory freshness/action/trace coverage: `{:.3}` / `{:.3}` / `{:.3}`\n", - scheduled.freshness_coverage, scheduled.action_rationale_coverage, scheduled.trace_coverage - )); - out.push_str(&format!( - "- Scheduled memory stale/currentness violations: `{}` invalid current, `{}` tombstone violation(s)\n", - scheduled.invalid_current_output_count, scheduled.tombstone_violation_count - )); - out.push_str(&format!( - "- Scheduled memory source mutations: `{}`\n", - scheduled.source_mutation_count - )); -} - -fn render_markdown_work_continuity_summary_metrics( - out: &mut String, - work_continuity: &WorkContinuitySummaryReport, -) { - out.push_str(&format!( - "- Work continuity readbacks: `{}` entries across `{}` artifact(s)\n", - work_continuity.entry_count, work_continuity.readback_count - )); - out.push_str(&format!( - "- Work continuity reset/resume and rationale recall: `{:.3}` / `{:.3}`\n", - work_continuity.reset_resume_success_rate, work_continuity.decision_rationale_recall_rate - )); - out.push_str(&format!( - "- Work continuity rejected-option suppression and explicit next-step precision: `{:.3}` / `{:.3}`\n", - work_continuity.rejected_option_suppression_rate, - work_continuity.explicit_next_step_precision - )); - out.push_str(&format!( - "- Work continuity inferred-step labeling and handoff source-ref coverage: `{:.3}` / `{:.3}`\n", - work_continuity.inferred_next_step_labeling_rate, - work_continuity.handoff_source_ref_coverage - )); - out.push_str(&format!( - "- Work continuity redaction and janitor false-promotion rates: `{:.3}` / `{:.3}`\n", - work_continuity.redaction_rate, work_continuity.janitor_false_promotion_rate - )); - out.push_str(&format!( - "- Work continuity hard-fail markers: `{}` sensitive persistence, `{}` rejected resurrection, `{}` inferred instructions, `{}` journal-only authority claim(s)\n", - work_continuity.sensitive_marker_persistence_count, - work_continuity.rejected_option_resurrection_count, - work_continuity.inferred_step_instruction_count, - work_continuity.journal_only_authority_claim_count - )); -} - -fn render_markdown_quality_summary(out: &mut String, report: &RealWorldReport) { - out.push_str(&format!( - "- Evidence coverage: `{}/{}` (`{:.3}`)\n", - report.summary.evidence_covered_count, - report.summary.evidence_required_count, - report.summary.evidence_coverage - )); - out.push_str(&format!( - "- Source-ref coverage: `{}/{}` (`{:.3}`)\n", - report.summary.source_ref_covered_count, - report.summary.source_ref_required_count, - report.summary.source_ref_coverage - )); - out.push_str(&format!( - "- Quote coverage: `{}/{}` (`{:.3}`)\n", - report.summary.quote_covered_count, - report.summary.quote_required_count, - report.summary.quote_coverage - )); - out.push_str(&format!("- Stale retrieval count: `{}`\n", report.summary.stale_retrieval_count)); - out.push_str(&format!( - "- Scope correctness: `{}/{}` (`{:.3}`), violations `{}`\n", - report.summary.scope_correct_count, - report.summary.scope_check_count, - report.summary.scope_correctness, - report.summary.scope_violation_count - )); - out.push_str(&format!("- Redaction leak count: `{}`\n", report.summary.redaction_leak_count)); - out.push_str(&format!( - "- Qdrant rebuild cases: `{}` encoded, `{}` pass\n", - report.summary.qdrant_rebuild_case_count, report.summary.qdrant_rebuild_pass_count - )); - out.push_str(&format!( - "- Expected evidence recall: `{:.3}` ({}/{})\n", - report.summary.expected_evidence_recall, - report.summary.expected_evidence_matched, - report.summary.expected_evidence_total - )); - out.push_str(&format!( - "- Irrelevant context ratio: `{:.3}` ({} irrelevant)\n", - report.summary.irrelevant_context_ratio, report.summary.irrelevant_context_count - )); - out.push_str(&format!( - "- Trace explainability: `{}` job(s), `{}` wrong-result stage attribution(s)\n", - report.summary.trace_explainability_count, - report.summary.wrong_result_stage_attribution_count - )); - out.push_str(&format!( - "- Consolidation source mutation count: `{}`\n", - report.summary.consolidation.source_mutation_count - )); -} - -fn render_markdown_suites(out: &mut String, report: &RealWorldReport) { - out.push_str("## Suites\n\n"); - out.push_str( - "| Suite | Status | Jobs | Score | Evidence Recall | Irrelevant Context | Trace Explain | Stale Answers | Conflicts | Update Rationales | Temporal Gaps | History Readback | Unsupported Claims | Wrong Results | Reason |\n", - ); - out.push_str("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n"); - - for suite in &report.suites { - out.push_str(&format!( - "| {} | `{}` | {} | `{}` | `{}` | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", - md_cell(suite.suite_id.as_str()), - status_str(suite.status), - suite.encoded_job_count, - optional_f64(suite.score_mean, ""), - optional_f64(suite.expected_evidence_recall, ""), - optional_f64(suite.irrelevant_context_ratio, ""), - suite.trace_explainability_count, - suite.stale_answer_count, - suite.conflict_detection_count, - suite.update_rationale_available_count, - suite.temporal_validity_not_encoded_count, - suite.history_readback_encoded_count, - suite.unsupported_claim_count, - suite.wrong_result_count, - md_cell(suite.reason.as_str()) - )); - } - - out.push('\n'); -} - -fn render_markdown_jobs(out: &mut String, report: &RealWorldReport) { - out.push_str("## Jobs\n\n"); - out.push_str("| Suite | Job | Status | Answer Type | Caveat Required | Refusal Required | Unknown Allowed | Score | Evidence Recall | Irrelevant Context | Expected Evidence | Produced Evidence | Trace Failure Stage | Stale Answers | Conflicts | Update Rationale | Temporal Gap | Unsupported Claims | Wrong Results | Latency | Cost |\n"); - out.push_str( - "| --- | --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | --- | --- | --- | ---: | ---: | --- | --- | ---: | ---: | ---: | --- |\n", - ); - - for job in &report.jobs { - let expected = job - .expected_evidence - .iter() - .map(|evidence| evidence.evidence_id.as_str()) - .collect::>() - .join(", "); - let produced = job.produced_evidence.join(", "); - - out.push_str(&format!( - "| {} | {} | `{}` | `{}` | `{}` | `{}` | `{}` | `{:.3}` | `{:.3}` | `{:.3}` | `{}` | `{}` | `{}` | {} | {} | `{}` | `{}` | {} | {} | `{}` | `{}` |\n", - md_cell(job.suite_id.as_str()), - md_cell(job.job_id.as_str()), - status_str(job.status), - md_inline(job.answer_type.as_str()), - bool_display(job.requires_caveat), - bool_display(job.requires_refusal), - bool_display(job.can_answer_unknown), - job.normalized_score, - job.retrieval_quality.expected_evidence_recall, - job.retrieval_quality.irrelevant_context_ratio, - md_inline(expected.as_str()), - md_inline(produced.as_str()), - md_inline(trace_failure_stage(job.trace_explainability.as_ref()).unwrap_or("-")), - job.stale_answer_count, - job.conflict_detection_count, - bool_display(job.update_rationale_available), - bool_display(job.temporal_validity_not_encoded), - job.unsupported_claim_count, - job.wrong_result_count, - optional_f64(job.latency_ms, " ms"), - cost_display(job.cost.as_ref()) - )); - } - - out.push('\n'); -} - -fn render_markdown_operator_debugging(out: &mut String, report: &RealWorldReport) { - let jobs = report.jobs.iter().filter(|job| job.operator_debug.is_some()).collect::>(); - - out.push_str("## Operator Debugging UX\n\n"); - - if jobs.is_empty() { - out.push_str("No encoded job reported operator debugging evidence.\n\n"); - - return; - } - - out.push_str("| Job | Failure Mode | Trace Evidence | Trace Available | Replay Command | Steps | Raw SQL | Dropped Candidate Visibility | Trace Completeness | Repair Clarity | UX Gaps |\n"); - out.push_str("| --- | --- | --- | --- | --- | ---: | --- | --- | --- | --- | --- |\n"); - - for job in jobs { - if let Some(debug) = &job.operator_debug { - out.push_str(&format!( - "| {} | {} | {} | `{}` | `{}` | {} | `{}` | {} | `{}` | `{}` | {} |\n", - md_cell(job.job_id.as_str()), - md_cell(debug.failure_mode.as_str()), - debug_trace_cell(debug), - debug.trace_available.unwrap_or(debug.trace_id.is_some()), - debug.replay_command_available.unwrap_or(debug.replay_command.is_some()), - debug.steps_to_root_cause, - debug.raw_sql_needed, - md_cell(debug.dropped_candidate_visibility.as_str()), - md_inline(debug.trace_completeness.as_str()), - md_inline(debug.repair_action_clarity.as_str()), - ux_gap_cell(debug.ux_gaps.as_slice()) - )); - } - } - - out.push_str("\n### Operator Debug Details\n\n"); - - for job in report.jobs.iter().filter(|job| job.operator_debug.is_some()) { - if let Some(debug) = &job.operator_debug { - out.push_str(&format!("#### `{}`\n\n", md_inline(job.job_id.as_str()))); - out.push_str(&format!("- Root cause: {}\n", md_cell(debug.root_cause.as_str()))); - out.push_str(&format!( - "- Viewer panels: `{}`\n", - md_inline(debug.viewer_panels.join(", ").as_str()) - )); - out.push_str(&format!( - "- CLI steps: `{}`\n", - md_inline(debug.cli_steps.join(" -> ").as_str()) - )); - - if let Some(command) = &debug.replay_command { - out.push_str(&format!("- Replay command: `{}`\n", md_inline(command.as_str()))); - } - if let Some(artifact) = &debug.replay_artifact { - out.push_str(&format!("- Replay artifact: `{}`\n", md_inline(artifact.as_str()))); - } - - out.push_str(&format!( - "- Trace evidence: `{}`\n", - md_inline(debug.trace_evidence.join(", ").as_str()) - )); - out.push('\n'); - } - } -} - -fn debug_trace_cell(debug: &OperatorDebugEvidence) -> String { - let trace = debug.trace_id.as_deref().unwrap_or("-"); - let viewer = debug - .viewer_url - .as_deref() - .map(|url| format!("[viewer]({})", md_url(url))) - .unwrap_or_else(|| "viewer: -".to_string()); - let bundle = debug - .admin_trace_bundle_url - .as_deref() - .map(|url| format!("[bundle]({})", md_url(url))) - .unwrap_or_else(|| "bundle: -".to_string()); - - format!("`{}`
{}
{}", md_inline(trace), viewer, bundle) -} - -fn ux_gap_cell(gaps: &[OperatorUxGap]) -> String { - if gaps.is_empty() { - return "`none`".to_string(); - } - - gaps.iter() - .map(|gap| { - format!( - "`{}`: {} ({})", - md_inline(gap.gap_id.as_str()), - md_cell(gap.description.as_str()), - md_inline(gap.follow_up_issue.as_str()) - ) - }) - .collect::>() - .join("
") -} - -fn render_markdown_evolution(out: &mut String, report: &RealWorldReport) { - out.push_str("## Memory Evolution\n\n"); - out.push_str(&format!("- Stale answers: `{}`\n", report.evolution.stale_answer_count)); - out.push_str(&format!( - "- Conflict detections: `{}`\n", - report.evolution.conflict_detection_count - )); - out.push_str(&format!( - "- Update rationales available: `{}`\n", - report.evolution.update_rationale_available_count - )); - out.push_str(&format!( - "- Temporal validity not encoded: `{}`\n\n", - report.evolution.temporal_validity_not_encoded_count - )); - out.push_str(&format!( - "- History readback encoded: `{}`\n\n", - report.evolution.history_readback_encoded_count - )); - out.push_str("| Suite | Job | Current Evidence | Historical Evidence | Tombstone/Invalidation | Selected Current | Selected Historical | Selected Rationale | Selected Tombstone/Invalidation | Selected But Not Narrated | Stale Traps Used | Conflict Count | Detected | Update Rationale | Temporal Validity | History Readback | Follow-up |\n"); - out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | ---: | ---: | --- | --- | --- | --- |\n"); - - for job in &report.jobs { - let Some(evolution) = &job.evolution else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | {} | {} | `{}` | `{}` | `{}` | {} |\n", - md_cell(job.suite_id.as_str()), - md_cell(job.job_id.as_str()), - md_inline(evolution.current_evidence.join(", ").as_str()), - md_inline(evolution.historical_evidence.join(", ").as_str()), - md_inline( - evolution - .tombstone_evidence - .iter() - .chain(evolution.invalidation_evidence.iter()) - .cloned() - .collect::>() - .join(", ") - .as_str() - ), - md_inline(evolution.selected_current_evidence.join(", ").as_str()), - md_inline(evolution.selected_historical_evidence.join(", ").as_str()), - md_inline(evolution.selected_rationale_evidence.join(", ").as_str()), - md_inline( - evolution - .selected_tombstone_evidence - .iter() - .chain(evolution.selected_invalidation_evidence.iter()) - .cloned() - .collect::>() - .join(", ") - .as_str() - ), - md_inline(evolution.selected_but_not_narrated_evidence.join(", ").as_str()), - md_inline(evolution.stale_trap_ids_used.join(", ").as_str()), - evolution.conflict_count, - evolution.conflict_detection_count, - bool_display(evolution.update_rationale_available), - temporal_display(evolution), - history_display(evolution), - md_cell(evolution.follow_up.as_deref().unwrap_or("-")) - )); - } - - out.push('\n'); -} - -fn render_markdown_trace_explainability(out: &mut String, report: &RealWorldReport) { - out.push_str("## Trace Explainability\n\n"); - - let jobs = - report.jobs.iter().filter(|job| job.trace_explainability.is_some()).collect::>(); - - if jobs.is_empty() { - out.push_str("No encoded job reported trace explainability metadata.\n\n"); - - return; - } - - out.push_str("| Suite | Job | Trace | Failure Stage | Reason | Stage Evidence |\n"); - out.push_str("| --- | --- | --- | --- | --- | --- |\n"); - - for job in jobs { - let trace = job.trace_explainability.as_ref(); - - out.push_str(&format!( - "| {} | {} | `{}` | `{}` | {} | {} |\n", - md_cell(job.suite_id.as_str()), - md_cell(job.job_id.as_str()), - md_inline(trace.and_then(|trace| trace.trace_id.as_deref()).unwrap_or("-")), - md_inline(trace_failure_stage(trace).unwrap_or("-")), - md_cell(trace_failure_reason(trace).unwrap_or("-")), - md_cell(trace_stage_summary(trace).as_str()) - )); - } - - out.push('\n'); -} - -fn render_markdown_consolidation(out: &mut String, report: &RealWorldReport) { - if report.summary.consolidation.proposal_count == 0 { - return; - } - - out.push_str("## Consolidation\n\n"); - out.push_str("| Job | Proposals | Usefulness | Lineage | Review Actions | Source Mutations | Proposal Unsupported Claims | Executable Gaps |\n"); - out.push_str("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n"); - - for job in &report.jobs { - let Some(consolidation) = &job.consolidation else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | `{}` | `{}` | `{}` | {} | {} | {} |\n", - md_cell(job.job_id.as_str()), - consolidation.proposal_count, - optional_f64(consolidation.proposal_usefulness, ""), - optional_f64(consolidation.lineage_completeness, ""), - optional_f64(consolidation.review_action_correctness, ""), - consolidation.source_mutation_count, - consolidation.proposal_unsupported_claim_count, - consolidation.executable_gaps.len() - )); - } - - out.push_str( - "\nSource mutation count must remain `0` for proposal-only consolidation cases.\n\n", - ); - - render_markdown_consolidation_gaps(out, report); -} - -fn render_markdown_consolidation_gaps(out: &mut String, report: &RealWorldReport) { - let gaps = report - .jobs - .iter() - .filter_map(|job| job.consolidation.as_ref().map(|consolidation| (job, consolidation))) - .flat_map(|(job, consolidation)| { - consolidation.executable_gaps.iter().map(move |gap| (job.job_id.as_str(), gap)) - }) - .collect::>(); - - if gaps.is_empty() { - return; - } - - out.push_str("### Executable Gaps\n\n"); - out.push_str("| Job | Primitive | Follow-Up Issue | Blocks Fixture Pass | Reason |\n"); - out.push_str("| --- | --- | --- | --- | --- |\n"); - - for (job_id, gap) in gaps { - out.push_str(&format!( - "| {} | {} | {} | `{}` | {} |\n", - md_cell(job_id), - md_cell(gap.primitive.as_str()), - md_cell(gap.follow_up_issue.as_str()), - gap.blocks_fixture_pass, - md_cell(gap.reason.as_str()) - )); - } - - out.push('\n'); -} - -fn render_markdown_knowledge(out: &mut String, report: &RealWorldReport) { - let knowledge_jobs = - report.jobs.iter().filter(|job| job.knowledge.is_some()).collect::>(); - - if knowledge_jobs.is_empty() { - return; - } - - out.push_str("## Knowledge Page Metrics\n\n"); - out.push_str("| Job | Pages | Sections | Citation Coverage | Stale Claim Detection | Rebuild Determinism | Version Diff Coverage | Page Usefulness | Backlinks | Unsupported Summaries | Untraced Sections | Allowed Variance |\n"); - out.push_str( - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", - ); - - for job in knowledge_jobs { - let Some(knowledge) = &job.knowledge else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | {} | `{:.3}` | `{:.3}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} |\n", - md_cell(job.job_id.as_str()), - knowledge.page_count, - knowledge.section_count, - knowledge.citation_coverage, - knowledge.stale_claim_detection, - knowledge.rebuild_determinism, - knowledge.version_diff_coverage, - knowledge.page_usefulness, - knowledge.backlink_count, - knowledge.unsupported_summary_count, - knowledge.untraced_section_count, - knowledge.allowed_variance_count - )); - } - - out.push('\n'); -} - -fn render_markdown_memory_summary(out: &mut String, report: &RealWorldReport) { - let memory_jobs = - report.jobs.iter().filter(|job| job.memory_summary.is_some()).collect::>(); - - if memory_jobs.is_empty() { - return; - } - - out.push_str("## Memory Summary Metrics\n\n"); - out.push_str("| Job | Summaries | Entries | Categories | Source Coverage | Freshness | Rationale | Invalid Top-of-Mind | Untraced | Derived Unsupported | Unsupported Current | Tombstone Refs |\n"); - out.push_str( - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", - ); - - for job in memory_jobs { - let Some(metrics) = &job.memory_summary else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | {} | `{}/{}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} | {} |\n", - md_cell(job.job_id.as_str()), - metrics.summary_count, - metrics.entry_count, - metrics.covered_required_category_count, - metrics.required_category_count, - metrics.source_ref_coverage, - metrics.freshness_coverage, - metrics.rationale_coverage, - metrics.invalid_top_of_mind_count, - metrics.untraced_entry_count, - metrics.unsupported_derived_entry_count, - metrics.unsupported_current_entry_count, - metrics.tombstone_ref_count - )); - } - - out.push('\n'); -} - -fn render_markdown_proactive_brief(out: &mut String, report: &RealWorldReport) { - let proactive_jobs = - report.jobs.iter().filter(|job| job.proactive_brief.is_some()).collect::>(); - - if proactive_jobs.is_empty() { - return; - } - - out.push_str("## Proactive Brief Metrics\n\n"); - out.push_str("| Job | Briefs | Suggestions | Kinds | Evidence Coverage | Freshness | Action Rationale | Invalid Current | Untraced | Unsupported Current | Tombstone Violations | Rejected | Deferred |\n"); - out.push_str( - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", - ); - - for job in proactive_jobs { - let Some(metrics) = &job.proactive_brief else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | {} | `{}/{}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} | {} | {} |\n", - md_cell(job.job_id.as_str()), - metrics.brief_count, - metrics.suggestion_count, - metrics.covered_required_suggestion_kind_count, - metrics.required_suggestion_kind_count, - metrics.evidence_ref_coverage, - metrics.freshness_coverage, - metrics.action_rationale_coverage, - metrics.invalid_current_suggestion_count, - metrics.untraced_suggestion_count, - metrics.unsupported_current_suggestion_count, - metrics.tombstone_violation_count, - metrics.rejected_count, - metrics.deferred_count - )); - } - - out.push('\n'); -} - -fn render_markdown_scheduled_memory(out: &mut String, report: &RealWorldReport) { - let scheduled_jobs = - report.jobs.iter().filter(|job| job.scheduled_memory.is_some()).collect::>(); - - if scheduled_jobs.is_empty() { - return; - } - - out.push_str("## Scheduled Memory Metrics\n\n"); - out.push_str("| Job | Task Runs | Outputs | Kinds | Evidence Coverage | Freshness | Action Rationale | Trace Coverage | Invalid Current | Untraced | Unsupported Current | Tombstone Violations | Source Mutations |\n"); - out.push_str( - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", - ); - - for job in scheduled_jobs { - let Some(metrics) = &job.scheduled_memory else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | {} | `{}/{}` | `{:.3}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} | {} |\n", - md_cell(job.job_id.as_str()), - metrics.task_run_count, - metrics.output_count, - metrics.covered_required_task_kind_count, - metrics.required_task_kind_count, - metrics.evidence_ref_coverage, - metrics.freshness_coverage, - metrics.action_rationale_coverage, - metrics.trace_coverage, - metrics.invalid_current_output_count, - metrics.untraced_output_count, - metrics.unsupported_current_output_count, - metrics.tombstone_violation_count, - metrics.source_mutation_count - )); - } - - out.push('\n'); -} - -fn render_markdown_work_continuity(out: &mut String, report: &RealWorldReport) { - let work_jobs = - report.jobs.iter().filter(|job| job.work_continuity.is_some()).collect::>(); - - if work_jobs.is_empty() { - return; - } - - out.push_str("## Work Continuity Metrics\n\n"); - out.push_str("| Job | Readbacks | Entries | Reset/Resume | Decision Rationale | Rejected Suppression | Explicit Precision | Inferred Labeling | Handoff Sources | Redaction | Janitor False Promotion | Sensitive Persistence | Journal Authority Claims |\n"); - out.push_str( - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", - ); - - for job in work_jobs { - let Some(metrics) = &job.work_continuity else { - continue; - }; - - out.push_str(&format!( - "| {} | {} | {} | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | {} | {} |\n", - md_cell(job.job_id.as_str()), - metrics.readback_count, - metrics.entry_count, - metrics.reset_resume_success_count, - metrics.reset_resume_required_count, - metrics.reset_resume_success_rate, - metrics.decision_rationale_recalled_count, - metrics.decision_rationale_required_count, - metrics.decision_rationale_recall_rate, - metrics.rejected_option_suppressed_count, - metrics.rejected_option_required_count, - metrics.rejected_option_suppression_rate, - metrics.explicit_next_step_correct_count, - metrics.explicit_next_step_returned_count, - metrics.explicit_next_step_precision, - metrics.inferred_next_step_labeled_count, - metrics.inferred_next_step_required_count, - metrics.inferred_next_step_labeling_rate, - metrics.handoff_source_ref_covered_count, - metrics.handoff_source_ref_required_count, - metrics.handoff_source_ref_coverage, - metrics.redaction_applied_count, - metrics.redaction_required_count, - metrics.redaction_rate, - metrics.janitor_false_promotion_count, - metrics.janitor_candidate_count, - metrics.janitor_false_promotion_rate, - metrics.sensitive_marker_persistence_count, - metrics.journal_only_authority_claim_count - )); - } - - out.push('\n'); -} - -fn render_markdown_unsupported_claims(out: &mut String, report: &RealWorldReport) { - out.push_str("## Unsupported Claims\n\n"); - - if report.unsupported_claims.is_empty() { - out.push_str("No unsupported claims were produced by encoded jobs.\n\n"); - - return; - } - - out.push_str("| Suite | Job | Claim | Evidence | Reason |\n"); - out.push_str("| --- | --- | --- | --- | --- |\n"); - - for claim in &report.unsupported_claims { - out.push_str(&format!( - "| {} | {} | {} | `{}` | {} |\n", - md_cell(claim.suite_id.as_str()), - md_cell(claim.job_id.as_str()), - md_cell(claim.claim_text.as_str()), - md_inline(claim.evidence_ids.join(", ").as_str()), - md_cell(claim.reason.as_str()) - )); - } - - out.push('\n'); -} - -fn render_markdown_follow_ups(out: &mut String, report: &RealWorldReport) { - out.push_str("## Follow-Ups\n\n"); - - if report.follow_ups.is_empty() { - out.push_str("No benchmark follow-ups were declared by encoded jobs.\n\n"); - - return; - } - - out.push_str("| Suite | Job | Follow-up | Reason |\n"); - out.push_str("| --- | --- | --- | --- |\n"); - - for follow_up in &report.follow_ups { - out.push_str(&format!( - "| {} | {} | {} | {} |\n", - md_cell(follow_up.suite_id.as_str()), - md_cell(follow_up.job_id.as_str()), - md_cell(follow_up.title.as_str()), - md_cell(follow_up.reason.as_str()) - )); - } - - out.push('\n'); -} - -fn render_markdown_semantics(out: &mut String, report: &RealWorldReport) { - out.push_str("## Result Semantics\n\n"); - out.push_str( - "This report uses `docs/spec/real_world_agent_memory_benchmark_v1.md` status terms.\n", - ); - out.push_str("It is a real-world job fixture report, not a Docker live-baseline report.\n"); - out.push_str("Existing live-baseline reports remain valid for their encoded retrieval and lifecycle checks and are not reinterpreted as real-world suite wins.\n\n"); - out.push_str( - "The summary counters report required evidence coverage, source-ref coverage, quote coverage, expected evidence recall, irrelevant context ratio, trace explainability, stale retrievals, scope violations, redaction leaks, Qdrant rebuild case coverage, stale answers, conflict detections, update rationale availability, and temporal validity gaps across encoded jobs.\n\n", - ); - out.push_str( - "- `pass`: encoded jobs met their pass threshold with required evidence and no hard-fail rule.\n", - ); - out.push_str( - "- `wrong_result`: a job completed but missed required answer or evidence expectations.\n", - ); - out.push_str("- `incomplete`: the runner or adapter did not reach the behavioral check.\n"); - out.push_str("- `blocked`: required credentials, private input, product runtime, or host integration is outside the run scope.\n"); - out.push_str( - "- `not_tested`: a comparison row or report slice has no executed benchmark evidence.\n", - ); - out.push_str("- `unsupported_claim`: a job produced a substantive claim not supported by the fixture evidence links.\n"); - out.push_str("- `not_encoded`: a suite has no checked-in fixture, or an encoded fixture declares a capability gap so no pass/fail claim is allowed.\n"); - out.push_str( - "- `fixture_backed`: checked-in fixtures were scored; no live product execution is implied.\n", - ); - out.push_str("- `live_baseline`: Docker live-baseline retrieval or lifecycle evidence exists, but it is not a real-world suite pass by itself.\n"); - out.push_str("- `live_real_world`: a live adapter ran the real-world job contract and reported typed outcomes.\n"); - out.push_str("- `research_gate`: research, setup, source mapping, or resource gates are recorded before a fair benchmark can run.\n\n"); - out.push_str("Any `wrong_result`, `incomplete`, `blocked`, `not_tested`, `not_encoded`, `unsupported_claim`, or non-live evidence class must remain visible and must not be counted as a win.\n\n"); - out.push_str("For `knowledge_compilation` jobs, generated pages are benchmark artifacts. Page sections must cite source evidence or timeline events, or be explicitly flagged as unsupported. Flagged unsupported summaries are counted separately from hidden unsupported claims.\n\n"); - out.push_str("For `source_library` jobs, saved long-form material and social/thread captures are source records, not durable Memory Notes. Source records must preserve canonical source metadata, source_ref hydration pointers, and explicit promotion boundaries before any memory write is claimed.\n\n"); - out.push_str("For `memory_summary` jobs, summary artifacts are derived review surfaces. Top-of-mind entries must be current, included or downgraded entries must carry source refs, and derived project-profile entries must either cite sources or be explicitly flagged as unsupported.\n\n"); - out.push_str("For `proactive_brief` jobs, brief artifacts are fixture-scored derived outputs, not scheduled UI behavior. Every suggestion must carry evidence refs, freshness/currentness metadata, and an action rationale; stale, superseded, or tombstoned sources must not be presented as current recommendations.\n\n"); - out.push_str("For `scheduled_memory` jobs, task artifacts are deterministic fixture-scored stand-ins for asynchronous work. Every output must carry evidence refs, freshness/currentness metadata, action rationale, and execution trace/readback evidence; scheduled tasks must not mutate source notes silently or claim hosted scheduler/private-provider parity from fixture-only output.\n\n"); - out.push_str("For `work_continuity` jobs, Work Journal entries are source-adjacent readback artifacts, not current fact authority. Reset/resume, decisions, rejected options, next steps, handoff refs, redactions, and janitor candidates must preserve source refs and promotion boundaries; sensitive marker persistence, rejected-option resurrection, inferred next steps treated as instructions, and journal-only authority claims are hard fails.\n\n"); - out.push_str("## Suites With `not_encoded` Status\n\n"); - - if report.not_encoded_suites.is_empty() { - out.push_str("All declared suites have at least one encoded job.\n"); - } else { - for suite in &report.not_encoded_suites { - out.push_str(&format!("- `{}`\n", md_inline(suite.as_str()))); - } - } -} - -fn scoreboard_state_list(states: &[String]) -> String { - if states.is_empty() { "none".to_string() } else { states.join(", ") } -} - -fn scoreboard_evidence_class_count_display(scoreboard: &ScoreboardReport) -> String { - SCOREBOARD_EVIDENCE_CLASSES - .iter() - .map(|state| { - let count = scoreboard.evidence_class_counts.get(*state).copied().unwrap_or_default(); - - format!("{state}={count}") - }) - .collect::>() - .join(", ") -} - -fn scoreboard_optional_f64(value: Option) -> String { - value.map_or_else(|| "`n/a`".to_string(), |value| format!("`{}`", round3(value))) -} - -fn scoreboard_optional_f64_plain(value: Option) -> String { - value.map_or_else(|| "n/a".to_string(), |value| round3(value).to_string()) -} - -fn scoreboard_runtime_gate_cell(row: &ScoreboardRow) -> String { - format!( - "`same_corpus={}`
`source_ids={}`
`held_out={}`
`leakage={}`
`runtime={}`
`digest={}`", - row.same_corpus, - row.source_id_mapped, - row.held_out, - row.leakage_audited, - row.product_runtime, - row.container_digest_identified - ) -} - -fn scoreboard_update_delete_cell(row: &ScoreboardRow) -> String { - format!( - "`update={}`
`delete={}`", - scoreboard_optional_f64_plain(row.metrics.lifecycle.update_correctness), - scoreboard_optional_f64_plain(row.metrics.lifecycle.delete_correctness) - ) -} - -fn scoreboard_latency_cell(row: &ScoreboardRow) -> String { - row.metrics - .operations - .mean_latency_ms - .map_or_else(|| "`n/a`".to_string(), |latency| format!("`{} ms`", round3(latency))) -} - -fn scoreboard_list_cell(values: &[String]) -> String { - if values.is_empty() { "none".to_string() } else { values.join("; ") } -} - -fn status_str(status: TypedStatus) -> &'static str { - match status { - TypedStatus::Pass => "pass", - TypedStatus::WrongResult => "wrong_result", - TypedStatus::LifecycleFail => "lifecycle_fail", - TypedStatus::Incomplete => "incomplete", - TypedStatus::Blocked => "blocked", - TypedStatus::NotEncoded => "not_encoded", - TypedStatus::UnsupportedClaim => "unsupported_claim", - } -} - -fn adapter_status_str(status: AdapterCoverageStatus) -> &'static str { - match status { - AdapterCoverageStatus::Real => "real", - AdapterCoverageStatus::Mocked => "mocked", - AdapterCoverageStatus::Unsupported => "unsupported", - AdapterCoverageStatus::Blocked => "blocked", - AdapterCoverageStatus::Incomplete => "incomplete", - AdapterCoverageStatus::WrongResult => "wrong_result", - AdapterCoverageStatus::LifecycleFail => "lifecycle_fail", - AdapterCoverageStatus::Pass => "pass", - AdapterCoverageStatus::NotEncoded => "not_encoded", - } -} - -fn scenario_comparison_outcome_str(outcome: ScenarioComparisonOutcome) -> &'static str { - match outcome { - ScenarioComparisonOutcome::Win => "win", - ScenarioComparisonOutcome::Tie => "tie", - ScenarioComparisonOutcome::Loss => "loss", - ScenarioComparisonOutcome::NotTested => "not_tested", - ScenarioComparisonOutcome::Blocked => "blocked", - ScenarioComparisonOutcome::NonGoal => "non_goal", - } -} - -fn scenario_position_str(position: ElfScenarioPosition) -> &'static str { - match position { - ElfScenarioPosition::Wins => "wins", - ElfScenarioPosition::Ties => "ties", - ElfScenarioPosition::Loses => "loses", - ElfScenarioPosition::Untested => "untested", - } -} - -fn adapter_status_counts_display(counts: &AdapterStatusCounts) -> String { - [ - ("real", counts.real), - ("mocked", counts.mocked), - ("unsupported", counts.unsupported), - ("blocked", counts.blocked), - ("incomplete", counts.incomplete), - ("wrong_result", counts.wrong_result), - ("lifecycle_fail", counts.lifecycle_fail), - ("pass", counts.pass), - ("not_encoded", counts.not_encoded), - ] - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(status, count)| format!("{status}={count}")) - .collect::>() - .join(", ") -} - -fn scenario_position_counts_display(counts: &ScenarioPositionCounts) -> String { - [ - ("wins", counts.wins), - ("ties", counts.ties), - ("loses", counts.loses), - ("untested", counts.untested), - ] - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(position, count)| format!("{position}={count}")) - .collect::>() - .join(", ") -} - -fn scenario_outcome_counts_display(counts: &ScenarioOutcomeCounts) -> String { - [ - ("win", counts.win), - ("tie", counts.tie), - ("loss", counts.loss), - ("not_tested", counts.not_tested), - ("blocked", counts.blocked), - ("non_goal", counts.non_goal), - ] - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(outcome, count)| format!("{outcome}={count}")) - .collect::>() - .join(", ") -} - -fn adapter_suite_cell(suites: &[AdapterSuiteCoverage]) -> String { - if suites.is_empty() { - return "`none`".to_string(); - } - - suites - .iter() - .map(|suite| { - format!( - "`{}`: `{}`", - md_inline(suite.suite_id.as_str()), - adapter_status_str(suite.status) - ) - }) - .collect::>() - .join("
") -} - -fn adapter_evidence_cell(adapter: &ExternalAdapterReport) -> String { - let setup = adapter - .setup - .command - .as_deref() - .or(adapter.setup.artifact.as_deref()) - .unwrap_or(adapter.setup.evidence.as_str()); - let result = adapter - .result - .artifact - .as_deref() - .or(adapter.result.command.as_deref()) - .unwrap_or(adapter.result.evidence.as_str()); - - format!("setup: `{}`
result: `{}`", md_inline(setup), md_inline(result)) -} - -fn adapter_scenario_evidence_cell(scenario: &AdapterScenarioJudgment) -> String { - let evidence = md_cell(scenario.evidence.as_str()); - let command = scenario - .command - .as_deref() - .map(|command| format!("
command: `{}`", md_inline(command))) - .unwrap_or_default(); - let artifact = scenario - .artifact - .as_deref() - .map(|artifact| format!("
artifact: `{}`", md_inline(artifact))) - .unwrap_or_default(); - - format!("{evidence}{command}{artifact}") -} - -fn adapter_sources_cell(sources: &[AdapterSource]) -> String { - if sources.is_empty() { - return "`none`".to_string(); - } - - sources - .iter() - .map(|source| { - format!( - "[{}]({}): {}", - md_cell(source.label.as_str()), - md_url(source.url.as_str()), - md_cell(source.evidence.as_str()) - ) - }) - .collect::>() - .join("
") -} - -fn trace_failure_stage(trace: Option<&TraceExplainability>) -> Option<&str> { - trace.and_then(|trace| trace.failure_stage.as_deref()) -} - -fn trace_failure_reason(trace: Option<&TraceExplainability>) -> Option<&str> { - trace.and_then(|trace| trace.failure_reason.as_deref()) -} - -fn trace_stage_summary(trace: Option<&TraceExplainability>) -> String { - let Some(trace) = trace else { - return "-".to_string(); - }; - let stages = trace - .stages - .iter() - .map(|stage| { - format!( - "{} kept={} demoted={} dropped={} distractors={}", - stage.stage_name, - stage.kept_evidence.join("+"), - stage.demoted_evidence.join("+"), - stage.dropped_evidence.join("+"), - stage.distractor_evidence.join("+") - ) - }) - .collect::>(); - - if stages.is_empty() { "-".to_string() } else { stages.join("; ") } -} - -fn write_or_print(path: Option<&Path>, content: &str) -> Result<()> { - if let Some(path) = path { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent)?; - } - - fs::write(path, content)?; - - println!("Wrote {}", path.display()); - } else { - println!("{content}"); - } - - Ok(()) -} - -fn optional_f64(value: Option, suffix: &str) -> String { - value.map(|value| format!("{value:.3}{suffix}")).unwrap_or_else(|| "-".to_string()) -} - -fn bool_display(value: bool) -> &'static str { - if value { "true" } else { "false" } -} - -fn temporal_display(evolution: &EvolutionJobReport) -> &'static str { - if evolution.temporal_validity_not_encoded { - "not_encoded" - } else if evolution.temporal_validity_encoded { - "encoded" - } else if evolution.temporal_validity_required { - "required" - } else { - "-" - } -} - -fn history_display(evolution: &EvolutionJobReport) -> String { - if !evolution.history_readback_encoded { - return "-".to_string(); - } - - let mut parts = vec![format!("events={}", evolution.history_event_types.join(","))]; - - if evolution.history_requires_note_version_links { - parts.push("note_version_links=true".to_string()); - } - - parts.join(";") -} - -fn cost_display(cost: Option<&CostReport>) -> String { - let Some(cost) = cost else { - return "-".to_string(); - }; - - match (cost.amount, cost.currency.as_deref()) { - (Some(amount), Some(currency)) => format!("{amount:.3} {currency}"), - (Some(amount), None) => format!("{amount:.3}"), - (None, _) => "-".to_string(), - } -} - -fn bounded_text(value: &str, max_chars: usize) -> String { - let mut chars = value.chars(); - let text = chars.by_ref().take(max_chars).collect::(); - - if chars.next().is_some() { format!("{text}...") } else { text } -} - -fn md_inline(value: &str) -> String { - value.replace('`', "'").replace('\n', " ") -} - -fn md_cell(value: &str) -> String { - md_inline(value).replace('|', "\\|") -} - -fn md_url(value: &str) -> String { - value.replace(')', "%29").replace(' ', "%20") -} - -fn md_list(values: &[String]) -> String { - if values.is_empty() { - return "-".to_string(); - } - - md_cell(values.join("; ").as_str()) -} - -fn round3(value: f64) -> f64 { - (value * 1_000.0).round() / 1_000.0 } diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts.rs new file mode 100644 index 00000000..0670e688 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts.rs @@ -0,0 +1,31 @@ +#[path = "artifacts/answer.rs"] mod answer; +#[path = "artifacts/consolidation.rs"] mod consolidation; +#[path = "artifacts/cost.rs"] mod cost; +#[path = "artifacts/knowledge.rs"] mod knowledge; +#[path = "artifacts/memory.rs"] mod memory; +#[path = "artifacts/proactive.rs"] mod proactive; +#[path = "artifacts/recovery.rs"] mod recovery; +#[path = "artifacts/scheduled.rs"] mod scheduled; +#[path = "artifacts/work.rs"] mod work; + +pub(super) use self::{ + answer::{ProducedAnswer, ProducedClaim}, + consolidation::{ConsolidationFixture, ConsolidationProposalFixture}, + cost::CostReport, + knowledge::{DerivedPageArtifact, DerivedPageRebuild, DerivedPageSection}, + memory::{MemorySummaryArtifact, MemorySummaryEntry, MemorySummarySourceTrace}, + proactive::{ProactiveBriefArtifact, ProactiveSuggestion}, + recovery::{ + AuthorityRecordCount, AuthorityRecoveryDrillArtifact, RecoveryBackupPitr, + RecoveryDeadLetterHandling, RecoveryDegradedRead, RecoveryDrillTopology, + RecoveryMeasurement, RecoveryMigrationRepair, RecoveryOutboxReplay, RecoveryQdrantRebuild, + }, + scheduled::{ + ScheduledMemoryExecutionTrace, ScheduledMemoryOutput, ScheduledMemoryTaskArtifact, + }, + work::{ + WorkContinuityObserved, WorkJournalEntryArtifact, WorkJournalJanitorCandidateArtifact, + WorkJournalNextStepArtifact, WorkJournalReadbackArtifact, + WorkJournalRejectedOptionArtifact, WorkJournalWhereStoppedArtifact, + }, +}; diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/answer.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/answer.rs new file mode 100644 index 00000000..d6b291ad --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/answer.rs @@ -0,0 +1,46 @@ +use crate::{ + Deserialize, Serialize, TraceExplainability, + artifacts::{ + cost::CostReport, knowledge::DerivedPageArtifact, memory::MemorySummaryArtifact, + proactive::ProactiveBriefArtifact, recovery::AuthorityRecoveryDrillArtifact, + scheduled::ScheduledMemoryTaskArtifact, work::WorkJournalReadbackArtifact, + }, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ProducedAnswer { + pub(crate) content: String, + #[serde(default)] + pub(crate) claims: Vec, + #[serde(default)] + pub(crate) evidence_ids: Vec, + #[serde(default)] + pub(crate) pages: Vec, + #[serde(default)] + pub(crate) memory_summaries: Vec, + #[serde(default)] + pub(crate) proactive_briefs: Vec, + #[serde(default)] + pub(crate) scheduled_tasks: Vec, + #[serde(default)] + pub(crate) work_journal_readbacks: Vec, + #[serde(default)] + pub(crate) recovery_drills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latency_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) trace_explainability: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ProducedClaim { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) claim_id: Option, + pub(crate) text: String, + #[serde(default)] + pub(crate) evidence_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) confidence: Option, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/consolidation.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/consolidation.rs new file mode 100644 index 00000000..0cf482d7 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/consolidation.rs @@ -0,0 +1,40 @@ +use crate::{ConsolidationReviewAction, Deserialize, Value}; + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct ConsolidationFixture { + #[serde(default)] + pub(crate) proposals: Vec, + #[serde(default)] + pub(crate) executable_gaps: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct ConsolidationProposalFixture { + pub(crate) proposal_id: String, + pub(crate) proposal_kind: String, + #[serde(default)] + pub(crate) source_refs: Vec, + #[serde(default)] + pub(crate) expected_source_refs: Vec, + pub(crate) usefulness_score: f64, + pub(crate) min_usefulness_score: f64, + pub(crate) expected_review_action: ConsolidationReviewAction, + pub(crate) actual_review_action: ConsolidationReviewAction, + #[serde(default)] + pub(crate) source_mutations: Vec, + #[serde(default)] + pub(crate) unsupported_claim_count: usize, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, + #[serde(default)] + pub(crate) diff: Value, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct ConsolidationExecutableGap { + pub(crate) primitive: String, + pub(crate) follow_up_issue: String, + pub(crate) reason: String, + #[serde(default)] + pub(crate) blocks_fixture_pass: bool, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/cost.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/cost.rs new file mode 100644 index 00000000..660a83be --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/cost.rs @@ -0,0 +1,13 @@ +use crate::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct CostReport { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output_tokens: Option, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/knowledge.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/knowledge.rs new file mode 100644 index 00000000..6783e333 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/knowledge.rs @@ -0,0 +1,55 @@ +use crate::{Deserialize, Serialize, Value}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct DerivedPageArtifact { + pub(crate) page_id: String, + pub(crate) page_type: String, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + #[serde(default)] + pub(crate) sections: Vec, + #[serde(default)] + pub(crate) backlinks: Vec, + #[serde(default)] + pub(crate) lint_findings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) rebuild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) page_version_diff: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct DerivedPageSection { + pub(crate) section_id: String, + pub(crate) heading: String, + pub(crate) role: String, + pub(crate) content: String, + #[serde(default)] + pub(crate) evidence_ids: Vec, + #[serde(default)] + pub(crate) timeline_event_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unsupported_reason: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct DerivedPageLintFinding { + pub(crate) finding_id: String, + pub(crate) finding_type: String, + pub(crate) severity: String, + pub(crate) text: String, + #[serde(default)] + pub(crate) evidence_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) trap_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct DerivedPageRebuild { + pub(crate) first_hash: String, + pub(crate) second_hash: String, + pub(crate) deterministic: bool, + #[serde(default)] + pub(crate) allowed_variance: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/memory.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/memory.rs new file mode 100644 index 00000000..7a368bdf --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/memory.rs @@ -0,0 +1,79 @@ +use crate::{Deserialize, Serialize, Value}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct MemorySummaryArtifact { + pub(crate) summary_id: String, + pub(crate) contract_schema: String, + pub(crate) generated_at: String, + pub(crate) tenant_id: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) read_profile: String, + #[serde(default)] + pub(crate) entries: Vec, + pub(crate) source_trace: MemorySummarySourceTrace, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct MemorySummaryEntry { + pub(crate) entry_id: String, + pub(crate) category: String, + pub(crate) text: String, + #[serde(default)] + pub(crate) source_refs: Vec, + pub(crate) freshness: MemorySummaryFreshness, + pub(crate) rationale: MemorySummaryRationale, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct MemorySummaryFreshness { + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) observed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) valid_from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) valid_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) last_confirmed_at: Option, + #[serde(default)] + pub(crate) superseded_by: Vec, + #[serde(default)] + pub(crate) tombstone_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct MemorySummaryRationale { + pub(crate) decision: String, + pub(crate) reason_code: String, + pub(crate) reason: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct MemorySummarySourceTrace { + #[serde(default)] + pub(crate) selected_source_refs: Vec, + #[serde(default)] + pub(crate) dropped_source_refs: Vec, + #[serde(default)] + pub(crate) stale_source_refs: Vec, + #[serde(default)] + pub(crate) superseded_source_refs: Vec, + #[serde(default)] + pub(crate) tombstone_source_refs: Vec, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct MemorySummarySourceTraceItem { + pub(crate) evidence_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) superseded_by: Option, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/proactive.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/proactive.rs new file mode 100644 index 00000000..6a37a9cc --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/proactive.rs @@ -0,0 +1,40 @@ +use crate::{ + Deserialize, Serialize, Value, + artifacts::memory::{MemorySummaryFreshness, MemorySummarySourceTrace}, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ProactiveBriefArtifact { + pub(crate) brief_id: String, + pub(crate) contract_schema: String, + pub(crate) generated_at: String, + pub(crate) tenant_id: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) read_profile: String, + pub(crate) brief_kind: String, + #[serde(default)] + pub(crate) suggestions: Vec, + pub(crate) source_trace: MemorySummarySourceTrace, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ProactiveSuggestion { + pub(crate) suggestion_id: String, + pub(crate) suggestion_kind: String, + pub(crate) title: String, + pub(crate) body: String, + #[serde(default)] + pub(crate) evidence_refs: Vec, + pub(crate) freshness: MemorySummaryFreshness, + pub(crate) action: ProactiveSuggestionAction, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ProactiveSuggestionAction { + pub(crate) decision: String, + pub(crate) reason_code: String, + pub(crate) reason: String, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/recovery.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/recovery.rs new file mode 100644 index 00000000..5c2c31f4 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/recovery.rs @@ -0,0 +1,118 @@ +use crate::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct AuthorityRecoveryDrillArtifact { + pub(crate) drill_id: String, + pub(crate) contract_schema: String, + pub(crate) generated_at: String, + pub(crate) topology: RecoveryDrillTopology, + #[serde(default)] + pub(crate) failure_injections: Vec, + pub(crate) backup_pitr: RecoveryBackupPitr, + pub(crate) degraded_read: RecoveryDegradedRead, + pub(crate) rpo: RecoveryMeasurement, + pub(crate) rto: RecoveryMeasurement, + #[serde(default)] + pub(crate) authority_record_counts: Vec, + pub(crate) outbox_replay: RecoveryOutboxReplay, + pub(crate) qdrant_rebuild: RecoveryQdrantRebuild, + pub(crate) migration_repair: RecoveryMigrationRepair, + pub(crate) dead_letter: RecoveryDeadLetterHandling, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryDrillTopology { + pub(crate) authority_store: String, + #[serde(default)] + pub(crate) derived_indexes: Vec, + #[serde(default)] + pub(crate) adapters: Vec, + pub(crate) failover: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryFailureInjection { + pub(crate) injection_id: String, + pub(crate) target: String, + pub(crate) fault: String, + pub(crate) started_at: String, + pub(crate) completed_at: String, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryBackupPitr { + pub(crate) backup_ref: String, + pub(crate) pitr_target: String, + pub(crate) restored: bool, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryDegradedRead { + pub(crate) source_of_truth_visible: bool, + #[serde(default)] + pub(crate) unavailable_derived_indexes: Vec, + #[serde(default)] + pub(crate) unavailable_adapters: Vec, + #[serde(default)] + pub(crate) unavailable_labels: Vec, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryMeasurement { + pub(crate) target_seconds: f64, + pub(crate) measured_seconds: f64, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct AuthorityRecordCount { + pub(crate) plane: String, + pub(crate) before_count: u64, + pub(crate) after_count: u64, + pub(crate) source_refs_preserved: bool, + pub(crate) lifecycle_history_preserved: bool, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryOutboxReplay { + pub(crate) idempotent: bool, + pub(crate) replayed_count: u64, + pub(crate) duplicate_write_count: u64, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryQdrantRebuild { + pub(crate) complete: bool, + pub(crate) rebuilt_count: u64, + pub(crate) missing_vector_count: u64, + pub(crate) error_count: u64, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryMigrationRepair { + pub(crate) applied: bool, + pub(crate) repaired_count: u64, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RecoveryDeadLetterHandling { + pub(crate) dead_letter_count: u64, + pub(crate) handled_count: u64, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/scheduled.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/scheduled.rs new file mode 100644 index 00000000..9efdb5e0 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/scheduled.rs @@ -0,0 +1,62 @@ +use crate::{ + Deserialize, Serialize, Value, + artifacts::{ + memory::{MemorySummaryFreshness, MemorySummarySourceTrace}, + proactive::ProactiveSuggestionAction, + }, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ScheduledMemoryTaskArtifact { + pub(crate) task_run_id: String, + pub(crate) contract_schema: String, + pub(crate) generated_at: String, + pub(crate) scheduled_for: String, + pub(crate) tenant_id: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) read_profile: String, + pub(crate) task_kind: String, + #[serde(default)] + pub(crate) outputs: Vec, + pub(crate) source_trace: MemorySummarySourceTrace, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) execution_trace: Option, + #[serde(default)] + pub(crate) source_mutations: Vec, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ScheduledMemoryOutput { + pub(crate) output_id: String, + pub(crate) output_kind: String, + pub(crate) text: String, + #[serde(default)] + pub(crate) evidence_refs: Vec, + pub(crate) freshness: MemorySummaryFreshness, + pub(crate) action: ProactiveSuggestionAction, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ScheduledMemoryExecutionTrace { + pub(crate) trace_id: String, + pub(crate) trigger_kind: String, + pub(crate) status: String, + pub(crate) started_at: String, + pub(crate) completed_at: String, + pub(crate) output_ref: String, + #[serde(default)] + pub(crate) stages: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ScheduledMemoryTraceStage { + pub(crate) stage_name: String, + pub(crate) summary: String, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/work.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/work.rs new file mode 100644 index 00000000..80229f9d --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/artifacts/work.rs @@ -0,0 +1,111 @@ +use crate::{BTreeSet, Deserialize, Serialize}; + +pub(crate) struct WorkContinuityObserved<'a> { + pub(crate) reset_resume_entry_ids: BTreeSet<&'a str>, + pub(crate) decision_rationale_evidence_ids: BTreeSet<&'a str>, + pub(crate) rejected_options: Vec<&'a WorkJournalRejectedOptionArtifact>, + pub(crate) explicit_next_steps: Vec<&'a WorkJournalNextStepArtifact>, + pub(crate) inferred_next_steps: Vec<&'a WorkJournalNextStepArtifact>, + pub(crate) handoff_source_refs: BTreeSet<&'a str>, + pub(crate) redacted_marker_ids: BTreeSet<&'a str>, + pub(crate) janitor_candidates: Vec<&'a WorkJournalJanitorCandidateArtifact>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WorkJournalReadbackArtifact { + pub(crate) readback_id: String, + pub(crate) contract_schema: String, + pub(crate) generated_at: String, + pub(crate) session_id: String, + pub(crate) tenant_id: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) read_profile: String, + #[serde(default)] + pub(crate) items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) where_stopped: Option, + pub(crate) promotion_boundary: WorkJournalPromotionBoundaryArtifact, + #[serde(default)] + pub(crate) janitor_candidates: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct WorkJournalEntryArtifact { + pub(crate) entry_id: String, + pub(crate) family: String, + pub(crate) title: String, + pub(crate) body: String, + #[serde(default)] + pub(crate) source_refs: Vec, + #[serde(default)] + pub(crate) redaction_audit: WorkJournalRedactionAuditArtifact, + #[serde(default)] + pub(crate) explicit_next_steps: Vec, + #[serde(default)] + pub(crate) inferred_next_steps: Vec, + #[serde(default)] + pub(crate) rejected_options: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct WorkJournalRedactionAuditArtifact { + #[serde(default)] + pub(crate) required_marker_ids: Vec, + #[serde(default)] + pub(crate) redacted_marker_ids: Vec, + #[serde(default)] + pub(crate) persisted_sensitive_marker_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WorkJournalNextStepArtifact { + pub(crate) step_id: String, + pub(crate) text: String, + pub(crate) label: String, + pub(crate) instruction: bool, + #[serde(default)] + pub(crate) evidence_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WorkJournalRejectedOptionArtifact { + pub(crate) option_id: String, + pub(crate) text: String, + #[serde(default)] + pub(crate) evidence_refs: Vec, + pub(crate) resurrected_as_current: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct WorkJournalWhereStoppedArtifact { + #[serde(default)] + pub(crate) reset_resume_entry_ids: Vec, + #[serde(default)] + pub(crate) decision_rationale_evidence_ids: Vec, + #[serde(default)] + pub(crate) current_explicit_next_step_ids: Vec, + #[serde(default)] + pub(crate) labeled_inferred_next_step_ids: Vec, + #[serde(default)] + pub(crate) handoff_source_refs: Vec, + #[serde(default)] + pub(crate) journal_only_authority_claims: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WorkJournalPromotionBoundaryArtifact { + pub(crate) journal_entry_authority: String, + pub(crate) memory_promotion_required: bool, + #[serde(default)] + pub(crate) accepted_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WorkJournalJanitorCandidateArtifact { + pub(crate) candidate_id: String, + #[serde(default)] + pub(crate) evidence_refs: Vec, + pub(crate) review_required: bool, + pub(crate) promoted_to_memory: bool, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/cli.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/cli.rs new file mode 100644 index 00000000..e1bc6f32 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/cli.rs @@ -0,0 +1,73 @@ +use crate::{ + DEFAULT_ADAPTER_BEHAVIOR, DEFAULT_ADAPTER_ID, DEFAULT_ADAPTER_NAME, DEFAULT_ADAPTER_NOTES, + DEFAULT_ADAPTER_RUNTIME_STATUS, DEFAULT_ADAPTER_STORAGE_STATUS, + DEFAULT_EXTERNAL_ADAPTER_MANIFEST_PATH, DEFAULT_FIXTURE_PATH, DEFAULT_MARKDOWN_PATH, + DEFAULT_REPORT_PATH, DEFAULT_RUN_ID, Parser, PathBuf, Subcommand, +}; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), +)] +pub(super) struct Args { + #[command(subcommand)] + pub(super) command: Command, +} + +#[derive(Debug, Parser)] +pub(super) struct RunArgs { + /// Fixture file or directory containing real_world_job JSON fixtures. + #[arg(long, value_name = "PATH", default_value = DEFAULT_FIXTURE_PATH)] + pub(super) fixtures: PathBuf, + /// Write report JSON to this file. Omit to print to stdout. + #[arg(long, value_name = "FILE")] + pub(super) out: Option, + /// Stable run id recorded in the generated report. + #[arg(long, default_value = DEFAULT_RUN_ID)] + pub(super) run_id: String, + /// Adapter id recorded for the offline smoke response. + #[arg(long, default_value = DEFAULT_ADAPTER_ID)] + pub(super) adapter_id: String, + /// Human-readable adapter name recorded in the generated report. + #[arg(long, default_value = DEFAULT_ADAPTER_NAME)] + pub(super) adapter_name: String, + /// Adapter behavior label recorded in the generated report. + #[arg(long, default_value = DEFAULT_ADAPTER_BEHAVIOR)] + pub(super) adapter_behavior: String, + /// Adapter storage typed status recorded in the generated report. + #[arg(long, default_value = DEFAULT_ADAPTER_STORAGE_STATUS)] + pub(super) adapter_storage_status: String, + /// Adapter runtime typed status recorded in the generated report. + #[arg(long, default_value = DEFAULT_ADAPTER_RUNTIME_STATUS)] + pub(super) adapter_runtime_status: String, + /// Adapter notes recorded in the generated report. + #[arg(long, default_value = DEFAULT_ADAPTER_NOTES)] + pub(super) adapter_notes: String, + /// Real-world external adapter manifest to include in report coverage. + #[arg(long, value_name = "FILE", default_value = DEFAULT_EXTERNAL_ADAPTER_MANIFEST_PATH)] + pub(super) external_adapter_manifest: PathBuf, + /// Skip loading the real-world external adapter coverage manifest. + #[arg(long)] + pub(super) skip_external_adapter_manifest: bool, +} + +#[derive(Debug, Parser)] +pub(super) struct PublishArgs { + /// Generated real_world_job JSON report. + #[arg(long, value_name = "FILE", default_value = DEFAULT_REPORT_PATH)] + pub(super) report: PathBuf, + /// Write Markdown to this file. Omit to print to stdout. + #[arg(long, value_name = "FILE", default_value = DEFAULT_MARKDOWN_PATH)] + pub(super) out: Option, +} + +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab")] +pub(super) enum Command { + /// Parse and score real_world_job fixtures, then emit a JSON report. + Run(RunArgs), + /// Render Markdown from a generated real_world_job JSON report. + Publish(PublishArgs), +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/commands.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/commands.rs new file mode 100644 index 00000000..91dc476f --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/commands.rs @@ -0,0 +1,238 @@ +use crate::{ + AdapterReport, BTreeSet, CaptureIntegrationReport, CorpusProfile, OffsetDateTime, Path, + PathBuf, PrivateCorpusRedaction, PublishArgs, REPORT_SCHEMA, RealWorldJob, RealWorldReport, + Result, Rfc3339, RunArgs, TypedStatus, VERSION, eyre, fs, +}; + +pub(super) fn run_command(args: RunArgs) -> Result<()> { + let jobs = load_jobs(&args.fixtures)?; + let report = build_report(&jobs, &args)?; + let json = serde_json::to_string_pretty(&report)?; + + write_or_print(args.out.as_deref(), json.as_str()) +} + +pub(super) fn publish_command(args: PublishArgs) -> Result<()> { + let raw = fs::read_to_string(&args.report)?; + let report = serde_json::from_str::(&raw)?; + let markdown = crate::render_markdown(&report, &args.report); + + write_or_print(args.out.as_deref(), markdown.as_str()) +} + +fn load_jobs(path: &Path) -> Result> { + let paths = fixture_paths(path)?; + let mut jobs = Vec::with_capacity(paths.len()); + + for fixture in paths { + let raw = fs::read_to_string(&fixture)?; + let job = serde_json::from_str::(&raw) + .map_err(|err| eyre::eyre!("Failed to parse {}: {err}", fixture.display()))?; + + crate::validate_job(&job, &fixture)?; + + jobs.push(job); + } + + Ok(jobs) +} + +fn fixture_paths(path: &Path) -> Result> { + if path.is_file() { + return Ok(vec![path.to_path_buf()]); + } + if !path.is_dir() { + return Err(eyre::eyre!("Fixture path does not exist: {}", path.display())); + } + + let mut paths = Vec::new(); + + collect_fixture_paths(path, &mut paths)?; + + paths.sort(); + + if paths.is_empty() { + return Err(eyre::eyre!("No JSON fixtures found in {}.", path.display())); + } + + Ok(paths) +} + +fn collect_fixture_paths(path: &Path, paths: &mut Vec) -> Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + collect_fixture_paths(entry_path.as_path(), paths)?; + } else if entry_path.extension().and_then(|ext| ext.to_str()) == Some("json") { + paths.push(entry_path); + } + } + + Ok(()) +} + +fn build_report(jobs: &[RealWorldJob], args: &RunArgs) -> Result { + if jobs.is_empty() { + return Err(eyre::eyre!("At least one real_world_job fixture is required.")); + } + + let mut job_reports = Vec::with_capacity(jobs.len()); + let mut unsupported_claims = Vec::new(); + + for job in jobs { + let scoring = crate::score_job(job); + + unsupported_claims.extend(scoring.unsupported_claims.clone()); + job_reports.push(crate::job_report(job, scoring)); + } + + let suites = crate::suite_reports(&job_reports); + let not_encoded_suites = suites + .iter() + .filter(|suite| suite.status == TypedStatus::NotEncoded) + .map(|suite| suite.suite_id.clone()) + .collect::>(); + let summary = crate::report_summary(&job_reports, &suites); + let evolution = crate::evolution_summary(&job_reports); + let follow_ups = crate::follow_up_reports(jobs); + let external_adapters = crate::external_adapter_section( + &args.external_adapter_manifest, + args.skip_external_adapter_manifest, + )?; + let scoreboard = crate::scoreboard_report(jobs, &job_reports, &summary, &external_adapters); + let operational_evidence = crate::operational_evidence_report(jobs, &job_reports); + + Ok(RealWorldReport { + schema: REPORT_SCHEMA.to_string(), + run_id: args.run_id.clone(), + generated_at: OffsetDateTime::now_utc().format(&Rfc3339)?, + runner_version: VERSION.to_string(), + corpus_profile: corpus_profile(jobs), + adapter: adapter_report(args)?, + scoreboard, + operational_evidence, + external_adapters, + capture_integration: capture_integration_report(jobs), + summary, + suites, + jobs: job_reports, + unsupported_claims, + not_encoded_suites, + private_corpus_redaction: private_corpus_redaction(jobs), + evolution, + follow_ups, + }) +} + +fn corpus_profile(jobs: &[RealWorldJob]) -> String { + let profiles = jobs.iter().map(|job| job.corpus.profile.as_str()).collect::>(); + + if profiles.len() == 1 { + profiles.into_iter().next().unwrap_or("unknown").to_string() + } else { + "mixed".to_string() + } +} + +fn adapter_report(args: &RunArgs) -> Result { + Ok(AdapterReport { + adapter_id: args.adapter_id.clone(), + name: args.adapter_name.clone(), + behavior: args.adapter_behavior.clone(), + storage: typed_status_from_arg( + args.adapter_storage_status.as_str(), + "--adapter-storage-status", + )?, + runtime: typed_status_from_arg( + args.adapter_runtime_status.as_str(), + "--adapter-runtime-status", + )?, + notes: args.adapter_notes.clone(), + }) +} + +fn typed_status_from_arg(raw: &str, flag: &str) -> Result { + match raw { + "pass" => Ok(TypedStatus::Pass), + "wrong_result" => Ok(TypedStatus::WrongResult), + "lifecycle_fail" => Ok(TypedStatus::LifecycleFail), + "incomplete" => Ok(TypedStatus::Incomplete), + "blocked" => Ok(TypedStatus::Blocked), + "not_encoded" => Ok(TypedStatus::NotEncoded), + "unsupported_claim" => Ok(TypedStatus::UnsupportedClaim), + _ => Err(eyre::eyre!( + "{flag} must be one of pass, wrong_result, lifecycle_fail, incomplete, blocked, not_encoded, or unsupported_claim." + )), + } +} + +fn capture_integration_report(jobs: &[RealWorldJob]) -> CaptureIntegrationReport { + let mut report = CaptureIntegrationReport::default(); + + for job in jobs { + extend_unique(&mut report.real, &job.corpus.capture_behaviors.real); + extend_unique(&mut report.fixture_backed, &job.corpus.capture_behaviors.fixture_backed); + extend_unique(&mut report.mocked, &job.corpus.capture_behaviors.mocked); + extend_unique(&mut report.blocked, &job.corpus.capture_behaviors.blocked); + extend_unique(&mut report.not_encoded, &job.corpus.capture_behaviors.not_encoded); + extend_unique(&mut report.notes, &job.corpus.capture_behaviors.notes); + } + + if report.real.is_empty() + && report.fixture_backed.is_empty() + && report.mocked.is_empty() + && report.blocked.is_empty() + && report.not_encoded.is_empty() + { + report + .not_encoded + .push("No capture/integration behavior was declared by encoded fixtures.".to_string()); + } + + report +} + +fn extend_unique(target: &mut Vec, values: &[String]) { + let mut seen = target.iter().cloned().collect::>(); + + for value in values { + if seen.insert(value.clone()) { + target.push(value.clone()); + } + } +} + +fn private_corpus_redaction(jobs: &[RealWorldJob]) -> PrivateCorpusRedaction { + let private_fixture_count = jobs + .iter() + .filter(|job| matches!(job.corpus.profile, CorpusProfile::PrivateSanitized)) + .count(); + let policy = if private_fixture_count == 0 { + "no_private_corpus".to_string() + } else { + "publish evidence ids and bounded score summaries only; do not publish private text" + .to_string() + }; + + PrivateCorpusRedaction { policy, private_fixture_count } +} + +fn write_or_print(path: Option<&Path>, content: &str) -> Result<()> { + if let Some(path) = path { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + + fs::write(path, content)?; + + println!("Wrote {}", path.display()); + } else { + println!("{content}"); + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/diagnostic_reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/diagnostic_reports.rs new file mode 100644 index 00000000..49a77c51 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/diagnostic_reports.rs @@ -0,0 +1,69 @@ +use crate::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct OperatorDebugEvidence { + pub(super) failure_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) trace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) viewer_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) admin_trace_bundle_url: Option, + pub(super) root_cause: String, + pub(super) steps_to_root_cause: u32, + pub(super) raw_sql_needed: bool, + pub(super) dropped_candidate_visibility: String, + pub(super) trace_completeness: String, + pub(super) repair_action_clarity: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) trace_available: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) replay_command_available: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) replay_command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) replay_artifact: Option, + #[serde(default)] + pub(super) viewer_panels: Vec, + #[serde(default)] + pub(super) cli_steps: Vec, + #[serde(default)] + pub(super) trace_evidence: Vec, + #[serde(default)] + pub(super) ux_gaps: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct OperatorUxGap { + pub(super) gap_id: String, + pub(super) severity: String, + pub(super) description: String, + pub(super) follow_up_issue: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct TraceExplainability { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) trace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) failure_stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) failure_reason: Option, + #[serde(default)] + pub(super) stages: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct TraceStageExplainability { + pub(super) stage_name: String, + #[serde(default)] + pub(super) kept_evidence: Vec, + #[serde(default)] + pub(super) dropped_evidence: Vec, + #[serde(default)] + pub(super) demoted_evidence: Vec, + #[serde(default)] + pub(super) distractor_evidence: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) notes: Option, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/enums.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/enums.rs new file mode 100644 index 00000000..a4b34226 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/enums.rs @@ -0,0 +1,111 @@ +use crate::{BTreeSet, Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum CorpusProfile { + Synthetic, + PrivateSanitized, + GeneratedPublic, + ExternalAdapter, +} +impl CorpusProfile { + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::Synthetic => "synthetic", + Self::PrivateSanitized => "private_sanitized", + Self::GeneratedPublic => "generated_public", + Self::ExternalAdapter => "external_adapter", + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(super) enum ExpectedClaim { + Text(String), + Object { claim_id: Option, text: String }, +} +impl ExpectedClaim { + pub(super) fn claim_id(&self) -> Option<&str> { + match self { + Self::Text(_) => None, + Self::Object { claim_id, .. } => claim_id.as_deref(), + } + } + + pub(super) fn text(&self) -> &str { + match self { + Self::Text(text) => text, + Self::Object { text, .. } => text, + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(super) enum EvidenceLink { + One(String), + Many(Vec), +} +impl EvidenceLink { + pub(super) fn ids(&self) -> BTreeSet { + match self { + Self::One(id) => BTreeSet::from([id.clone()]), + Self::Many(ids) => ids.iter().cloned().collect(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ConsolidationReviewAction { + Apply, + Discard, + Defer, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum TypedStatus { + Pass, + WrongResult, + LifecycleFail, + Incomplete, + Blocked, + NotEncoded, + UnsupportedClaim, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum AdapterCoverageStatus { + Real, + Mocked, + Unsupported, + Blocked, + Incomplete, + WrongResult, + LifecycleFail, + Pass, + NotEncoded, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ElfScenarioPosition { + Wins, + Ties, + Loses, + Untested, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ScenarioComparisonOutcome { + Win, + Tie, + Loss, + NotTested, + Blocked, + NonGoal, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapter_reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapter_reports.rs new file mode 100644 index 00000000..3366383e --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapter_reports.rs @@ -0,0 +1,209 @@ +use crate::{ + AdapterCoverageStatus, Deserialize, ElfScenarioPosition, FollowUpInput, + ScenarioComparisonOutcome, Serialize, TypedStatus, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct AdapterReport { + pub(super) adapter_id: String, + pub(super) name: String, + pub(super) behavior: String, + pub(super) storage: TypedStatus, + pub(super) runtime: TypedStatus, + pub(super) notes: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ExternalAdapterManifest { + pub(super) schema: String, + pub(super) manifest_id: String, + pub(super) docker_isolation: ExternalDockerIsolation, + #[serde(default)] + pub(super) adapters: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ExternalAdapterSection { + pub(super) schema: String, + pub(super) manifest_id: String, + pub(super) docker_isolation: ExternalDockerIsolation, + pub(super) summary: ExternalAdapterSummary, + #[serde(default)] + pub(super) adapters: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ExternalDockerIsolation { + pub(super) default: bool, + pub(super) compose_file: String, + pub(super) runner: String, + pub(super) artifact_dir: String, + pub(super) host_global_installs_required: bool, + #[serde(default)] + pub(super) notes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ExternalAdapterReport { + pub(super) adapter_id: String, + pub(super) project: String, + pub(super) adapter_kind: String, + pub(super) evidence_class: String, + pub(super) docker_default: bool, + pub(super) host_global_installs_required: bool, + pub(super) overall_status: AdapterCoverageStatus, + pub(super) setup: AdapterExecutionEvidence, + pub(super) run: AdapterExecutionEvidence, + pub(super) result: AdapterExecutionEvidence, + #[serde(default)] + pub(super) capabilities: Vec, + #[serde(default)] + pub(super) suites: Vec, + #[serde(default)] + pub(super) scenarios: Vec, + #[serde(default)] + pub(super) evidence: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) execution_metadata: Option, + #[serde(default)] + pub(super) notes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) follow_up: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterExecutionEvidence { + pub(super) status: AdapterCoverageStatus, + pub(super) evidence: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) artifact: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterCapabilityCoverage { + pub(super) capability: String, + pub(super) status: AdapterCoverageStatus, + pub(super) evidence: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterSuiteCoverage { + pub(super) suite_id: String, + pub(super) status: AdapterCoverageStatus, + pub(super) evidence: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterScenarioJudgment { + pub(super) scenario_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) suite_id: Option, + pub(super) status: AdapterCoverageStatus, + pub(super) elf_position: ElfScenarioPosition, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) comparison_outcome: Option, + pub(super) evidence: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) artifact: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterEvidencePointer { + pub(super) kind: String, + #[serde(rename = "ref")] + pub(super) reference: String, + pub(super) status: AdapterCoverageStatus, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterExecutionMetadata { + #[serde(default)] + pub(super) sources: Vec, + pub(super) setup_path: String, + pub(super) runtime_boundary: String, + pub(super) resource_expectation: String, + #[serde(default)] + pub(super) retry_guidance: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) research_depth: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AdapterSource { + pub(super) label: String, + pub(super) url: String, + pub(super) evidence: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ExternalAdapterSummary { + pub(super) adapter_count: usize, + pub(super) external_project_count: usize, + pub(super) docker_default_count: usize, + pub(super) host_global_install_required_count: usize, + pub(super) fixture_backed_count: usize, + pub(super) live_baseline_only_count: usize, + pub(super) live_real_world_count: usize, + #[serde(default)] + pub(super) research_gate_count: usize, + pub(super) overall_status_counts: AdapterStatusCounts, + pub(super) capability_status_counts: AdapterStatusCounts, + pub(super) suite_status_counts: AdapterStatusCounts, + #[serde(default)] + pub(super) scenario_status_counts: AdapterStatusCounts, + #[serde(default)] + pub(super) scenario_position_counts: ScenarioPositionCounts, + #[serde(default)] + pub(super) scenario_outcome_counts: ScenarioOutcomeCounts, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct AdapterStatusCounts { + pub(super) real: usize, + pub(super) mocked: usize, + pub(super) unsupported: usize, + pub(super) blocked: usize, + pub(super) incomplete: usize, + pub(super) wrong_result: usize, + pub(super) lifecycle_fail: usize, + pub(super) pass: usize, + pub(super) not_encoded: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScenarioPositionCounts { + pub(super) wins: usize, + pub(super) ties: usize, + pub(super) loses: usize, + pub(super) untested: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScenarioOutcomeCounts { + pub(super) win: usize, + pub(super) tie: usize, + pub(super) loss: usize, + pub(super) not_tested: usize, + pub(super) blocked: usize, + pub(super) non_goal: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct CaptureIntegrationReport { + #[serde(default)] + pub(super) real: Vec, + #[serde(default)] + pub(super) fixture_backed: Vec, + #[serde(default)] + pub(super) mocked: Vec, + #[serde(default)] + pub(super) blocked: Vec, + #[serde(default)] + pub(super) not_encoded: Vec, + #[serde(default)] + pub(super) notes: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters.rs new file mode 100644 index 00000000..99ca6823 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters.rs @@ -0,0 +1,45 @@ +#[path = "external_adapters/manifest.rs"] mod manifest; +#[path = "external_adapters/outcome.rs"] mod outcome; +#[path = "external_adapters/summary.rs"] mod summary; +#[path = "external_adapters/validation.rs"] mod validation; + +pub(super) use outcome::scenario_comparison_outcome; + +use std::fs; + +use crate::{ + EXTERNAL_ADAPTER_REPORT_SCHEMA, ExternalAdapterManifest, ExternalAdapterSection, Path, Result, + eyre, +}; + +pub(super) fn external_adapter_section( + manifest_path: &Path, + skip_manifest: bool, +) -> Result { + if skip_manifest { + return Ok(manifest::empty_external_adapter_section("skipped")); + } + + let manifest_path = manifest::resolve_external_adapter_manifest_path(manifest_path); + + if !manifest_path.exists() { + return Ok(manifest::empty_external_adapter_section("missing")); + } + + let raw = fs::read_to_string(&manifest_path)?; + let manifest = serde_json::from_str::(&raw).map_err(|err| { + eyre::eyre!("Failed to parse external adapter manifest {}: {err}", manifest_path.display()) + })?; + + validation::validate_external_adapter_manifest(&manifest, &manifest_path)?; + + let summary = summary::external_adapter_summary(&manifest.adapters); + + Ok(ExternalAdapterSection { + schema: EXTERNAL_ADAPTER_REPORT_SCHEMA.to_string(), + manifest_id: manifest.manifest_id, + docker_isolation: manifest.docker_isolation, + summary, + adapters: manifest.adapters, + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/manifest.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/manifest.rs new file mode 100644 index 00000000..18c00fc6 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/manifest.rs @@ -0,0 +1,30 @@ +use std::path::{Path, PathBuf}; + +use crate::{ + EXTERNAL_ADAPTER_REPORT_SCHEMA, ExternalAdapterSection, ExternalAdapterSummary, + ExternalDockerIsolation, +}; + +pub(super) fn empty_external_adapter_section(reason: &str) -> ExternalAdapterSection { + ExternalAdapterSection { + schema: EXTERNAL_ADAPTER_REPORT_SCHEMA.to_string(), + manifest_id: reason.to_string(), + docker_isolation: ExternalDockerIsolation::default(), + summary: ExternalAdapterSummary::default(), + adapters: Vec::new(), + } +} + +pub(super) fn resolve_external_adapter_manifest_path(path: &Path) -> PathBuf { + if path.exists() || path.is_absolute() { + return path.to_path_buf(); + } + + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let Some(workspace_root) = manifest_dir.parent().and_then(Path::parent) else { + return path.to_path_buf(); + }; + let workspace_candidate = workspace_root.join(path); + + if workspace_candidate.exists() { workspace_candidate } else { path.to_path_buf() } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/outcome.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/outcome.rs new file mode 100644 index 00000000..2e9ac366 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/outcome.rs @@ -0,0 +1,27 @@ +use crate::{AdapterScenarioJudgment, ElfScenarioPosition, ScenarioComparisonOutcome}; + +pub(in super::super) fn scenario_comparison_outcome( + scenario: &AdapterScenarioJudgment, +) -> ScenarioComparisonOutcome { + scenario.comparison_outcome.unwrap_or(match scenario.elf_position { + ElfScenarioPosition::Wins => ScenarioComparisonOutcome::Win, + ElfScenarioPosition::Ties => ScenarioComparisonOutcome::Tie, + ElfScenarioPosition::Loses => ScenarioComparisonOutcome::Loss, + ElfScenarioPosition::Untested => ScenarioComparisonOutcome::NotTested, + }) +} + +pub(super) fn position_supports_outcome( + position: ElfScenarioPosition, + outcome: ScenarioComparisonOutcome, +) -> bool { + matches!( + (position, outcome), + (ElfScenarioPosition::Wins, ScenarioComparisonOutcome::Win) + | (ElfScenarioPosition::Ties, ScenarioComparisonOutcome::Tie) + | (ElfScenarioPosition::Loses, ScenarioComparisonOutcome::Loss) + | (ElfScenarioPosition::Untested, ScenarioComparisonOutcome::NotTested) + | (ElfScenarioPosition::Untested, ScenarioComparisonOutcome::Blocked) + | (ElfScenarioPosition::Untested, ScenarioComparisonOutcome::NonGoal) + ) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/summary.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/summary.rs new file mode 100644 index 00000000..35bef2e0 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/summary.rs @@ -0,0 +1,100 @@ +use std::collections::BTreeSet; + +use crate::{ + AdapterCoverageStatus, AdapterStatusCounts, ElfScenarioPosition, ExternalAdapterReport, + ExternalAdapterSummary, ScenarioComparisonOutcome, ScenarioOutcomeCounts, + ScenarioPositionCounts, external_adapters::outcome, +}; + +pub(super) fn external_adapter_summary( + adapters: &[ExternalAdapterReport], +) -> ExternalAdapterSummary { + let external_projects = adapters + .iter() + .filter_map(|adapter| (adapter.project != "ELF").then_some(adapter.project.as_str())) + .collect::>(); + let mut summary = ExternalAdapterSummary { + adapter_count: adapters.len(), + external_project_count: external_projects.len(), + ..ExternalAdapterSummary::default() + }; + + for adapter in adapters { + accumulate_adapter_summary(&mut summary, adapter); + } + + summary +} + +fn accumulate_adapter_summary( + summary: &mut ExternalAdapterSummary, + adapter: &ExternalAdapterReport, +) { + summary.docker_default_count += usize::from(adapter.docker_default); + summary.host_global_install_required_count += + usize::from(adapter.host_global_installs_required); + summary.fixture_backed_count += usize::from(adapter.evidence_class == "fixture_backed"); + summary.live_baseline_only_count += usize::from(adapter.evidence_class == "live_baseline_only"); + summary.live_real_world_count += usize::from(adapter.evidence_class == "live_real_world"); + summary.research_gate_count += usize::from(adapter.evidence_class == "research_gate"); + + increment_adapter_status_count(&mut summary.overall_status_counts, adapter.overall_status); + + for capability in &adapter.capabilities { + increment_adapter_status_count(&mut summary.capability_status_counts, capability.status); + } + for suite in &adapter.suites { + increment_adapter_status_count(&mut summary.suite_status_counts, suite.status); + } + for scenario in &adapter.scenarios { + increment_adapter_status_count(&mut summary.scenario_status_counts, scenario.status); + increment_scenario_position_count( + &mut summary.scenario_position_counts, + scenario.elf_position, + ); + increment_scenario_outcome_count( + &mut summary.scenario_outcome_counts, + outcome::scenario_comparison_outcome(scenario), + ); + } +} + +fn increment_adapter_status_count(counts: &mut AdapterStatusCounts, status: AdapterCoverageStatus) { + match status { + AdapterCoverageStatus::Real => counts.real += 1, + AdapterCoverageStatus::Mocked => counts.mocked += 1, + AdapterCoverageStatus::Unsupported => counts.unsupported += 1, + AdapterCoverageStatus::Blocked => counts.blocked += 1, + AdapterCoverageStatus::Incomplete => counts.incomplete += 1, + AdapterCoverageStatus::WrongResult => counts.wrong_result += 1, + AdapterCoverageStatus::LifecycleFail => counts.lifecycle_fail += 1, + AdapterCoverageStatus::Pass => counts.pass += 1, + AdapterCoverageStatus::NotEncoded => counts.not_encoded += 1, + } +} + +fn increment_scenario_position_count( + counts: &mut ScenarioPositionCounts, + position: ElfScenarioPosition, +) { + match position { + ElfScenarioPosition::Wins => counts.wins += 1, + ElfScenarioPosition::Ties => counts.ties += 1, + ElfScenarioPosition::Loses => counts.loses += 1, + ElfScenarioPosition::Untested => counts.untested += 1, + } +} + +fn increment_scenario_outcome_count( + counts: &mut ScenarioOutcomeCounts, + outcome: ScenarioComparisonOutcome, +) { + match outcome { + ScenarioComparisonOutcome::Win => counts.win += 1, + ScenarioComparisonOutcome::Tie => counts.tie += 1, + ScenarioComparisonOutcome::Loss => counts.loss += 1, + ScenarioComparisonOutcome::NotTested => counts.not_tested += 1, + ScenarioComparisonOutcome::Blocked => counts.blocked += 1, + ScenarioComparisonOutcome::NonGoal => counts.non_goal += 1, + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/validation.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/validation.rs new file mode 100644 index 00000000..b0b59883 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/external_adapters/validation.rs @@ -0,0 +1,343 @@ +use std::{collections::BTreeSet, path::Path}; + +use color_eyre::{Result, eyre}; + +use crate::{ + AdapterCoverageStatus, AdapterScenarioJudgment, EXTERNAL_ADAPTER_MANIFEST_SCHEMA, + ElfScenarioPosition, ExternalAdapterManifest, ExternalAdapterReport, ExternalDockerIsolation, + SUITES, ScenarioComparisonOutcome, external_adapters::outcome, formatting, +}; + +pub(super) fn validate_external_adapter_manifest( + manifest: &ExternalAdapterManifest, + path: &Path, +) -> Result<()> { + if manifest.schema != EXTERNAL_ADAPTER_MANIFEST_SCHEMA { + return Err(eyre::eyre!( + "{} has schema {}, expected {EXTERNAL_ADAPTER_MANIFEST_SCHEMA}.", + path.display(), + manifest.schema + )); + } + if manifest.manifest_id.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty manifest_id.", path.display())); + } + + validate_external_docker_isolation(path, &manifest.docker_isolation)?; + + validate_external_adapters(path, &manifest.adapters) +} + +fn validate_external_docker_isolation(path: &Path, docker: &ExternalDockerIsolation) -> Result<()> { + if docker.compose_file.trim().is_empty() + || docker.runner.trim().is_empty() + || docker.artifact_dir.trim().is_empty() + { + return Err(eyre::eyre!("{} has incomplete docker_isolation metadata.", path.display())); + } + if !docker.default { + return Err(eyre::eyre!( + "{} external adapter manifest must default to Docker isolation.", + path.display() + )); + } + if docker.host_global_installs_required { + return Err(eyre::eyre!( + "{} external adapter manifest must not require host-global installs by default.", + path.display() + )); + } + + Ok(()) +} + +fn validate_external_adapters(path: &Path, adapters: &[ExternalAdapterReport]) -> Result<()> { + if adapters.is_empty() { + return Err(eyre::eyre!("{} declares no external adapters.", path.display())); + } + + let mut seen = BTreeSet::new(); + + for adapter in adapters { + validate_external_adapter(path, adapter)?; + + if !seen.insert(adapter.adapter_id.as_str()) { + return Err(eyre::eyre!( + "{} declares duplicate adapter_id {}.", + path.display(), + adapter.adapter_id + )); + } + } + + Ok(()) +} + +fn validate_external_adapter(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + if adapter.adapter_id.trim().is_empty() + || adapter.project.trim().is_empty() + || adapter.adapter_kind.trim().is_empty() + || adapter.evidence_class.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete external adapter.", path.display())); + } + if !matches!( + adapter.evidence_class.as_str(), + "fixture_backed" | "live_baseline_only" | "live_real_world" | "research_gate" + ) { + return Err(eyre::eyre!( + "{} adapter {} has unsupported evidence_class {}.", + path.display(), + adapter.adapter_id, + adapter.evidence_class + )); + } + if adapter.docker_default && adapter.host_global_installs_required { + return Err(eyre::eyre!( + "{} adapter {} is Docker-default but requires host-global installs.", + path.display(), + adapter.adapter_id + )); + } + + validate_adapter_execution(path, adapter)?; + validate_adapter_capabilities(path, adapter)?; + validate_adapter_suites(path, adapter)?; + validate_adapter_scenarios(path, adapter)?; + validate_adapter_evidence(path, adapter)?; + validate_adapter_execution_metadata(path, adapter)?; + + if let Some(follow_up) = &adapter.follow_up + && (follow_up.title.trim().is_empty() || follow_up.reason.trim().is_empty()) + { + return Err(eyre::eyre!( + "{} adapter {} has an incomplete follow_up.", + path.display(), + adapter.adapter_id + )); + } + + Ok(()) +} + +fn validate_adapter_execution(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + for evidence in [&adapter.setup, &adapter.run, &adapter.result] { + if evidence.evidence.trim().is_empty() + || evidence.command.as_deref().is_some_and(str::is_empty) + || evidence.artifact.as_deref().is_some_and(str::is_empty) + { + return Err(eyre::eyre!( + "{} adapter {} has incomplete setup/run/result evidence.", + path.display(), + adapter.adapter_id + )); + } + } + + Ok(()) +} + +fn validate_adapter_capabilities(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + for capability in &adapter.capabilities { + if capability.capability.trim().is_empty() || capability.evidence.trim().is_empty() { + return Err(eyre::eyre!( + "{} adapter {} has incomplete capability coverage.", + path.display(), + adapter.adapter_id + )); + } + } + + Ok(()) +} + +fn validate_adapter_suites(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + for suite in &adapter.suites { + if !SUITES.contains(&suite.suite_id.as_str()) { + return Err(eyre::eyre!( + "{} adapter {} references unknown suite {}.", + path.display(), + adapter.adapter_id, + suite.suite_id + )); + } + if suite.evidence.trim().is_empty() { + return Err(eyre::eyre!( + "{} adapter {} has suite {} without evidence.", + path.display(), + adapter.adapter_id, + suite.suite_id + )); + } + } + + Ok(()) +} + +fn validate_adapter_scenarios(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + for scenario in &adapter.scenarios { + if scenario.scenario_id.trim().is_empty() + || scenario.evidence.trim().is_empty() + || scenario.command.as_deref().is_some_and(str::is_empty) + || scenario.artifact.as_deref().is_some_and(str::is_empty) + { + return Err(eyre::eyre!( + "{} adapter {} has incomplete scenario judgment.", + path.display(), + adapter.adapter_id + )); + } + + if let Some(suite_id) = &scenario.suite_id + && !SUITES.contains(&suite_id.as_str()) + { + return Err(eyre::eyre!( + "{} adapter {} scenario {} references unknown suite {}.", + path.display(), + adapter.adapter_id, + scenario.scenario_id, + suite_id + )); + } + + let outcome = outcome::scenario_comparison_outcome(scenario); + + if blocked_status_missing_blocked_outcome(scenario.status, scenario.comparison_outcome) { + return Err(eyre::eyre!( + "{} adapter {} scenario {} uses blocked status without blocked comparison outcome.", + path.display(), + adapter.adapter_id, + scenario.scenario_id + )); + } + if unmeasured_status_has_measured_outcome(scenario.status, outcome) { + return Err(eyre::eyre!( + "{} adapter {} scenario {} uses {} status with {} outcome.", + path.display(), + adapter.adapter_id, + scenario.scenario_id, + formatting::adapter_status_str(scenario.status), + formatting::scenario_comparison_outcome_str(outcome) + )); + } + if unmeasured_status_has_measured_position(scenario.status, scenario.elf_position) { + return Err(eyre::eyre!( + "{} adapter {} scenario {} uses {} status with {} position.", + path.display(), + adapter.adapter_id, + scenario.scenario_id, + formatting::adapter_status_str(scenario.status), + formatting::scenario_position_str(scenario.elf_position) + )); + } + if explicit_outcome_conflicts_with_position(scenario) { + return Err(eyre::eyre!( + "{} adapter {} scenario {} uses {} position with {} outcome.", + path.display(), + adapter.adapter_id, + scenario.scenario_id, + formatting::scenario_position_str(scenario.elf_position), + formatting::scenario_comparison_outcome_str(outcome) + )); + } + } + + Ok(()) +} + +fn blocked_status_missing_blocked_outcome( + status: AdapterCoverageStatus, + outcome: Option, +) -> bool { + status == AdapterCoverageStatus::Blocked && outcome != Some(ScenarioComparisonOutcome::Blocked) +} + +fn unmeasured_status_has_measured_outcome( + status: AdapterCoverageStatus, + outcome: ScenarioComparisonOutcome, +) -> bool { + matches!( + status, + AdapterCoverageStatus::Blocked + | AdapterCoverageStatus::Incomplete + | AdapterCoverageStatus::NotEncoded + | AdapterCoverageStatus::Unsupported + ) && matches!( + outcome, + ScenarioComparisonOutcome::Win + | ScenarioComparisonOutcome::Tie + | ScenarioComparisonOutcome::Loss + ) +} + +fn unmeasured_status_has_measured_position( + status: AdapterCoverageStatus, + position: ElfScenarioPosition, +) -> bool { + matches!( + status, + AdapterCoverageStatus::Blocked + | AdapterCoverageStatus::Incomplete + | AdapterCoverageStatus::NotEncoded + | AdapterCoverageStatus::Unsupported + ) && matches!( + position, + ElfScenarioPosition::Wins | ElfScenarioPosition::Ties | ElfScenarioPosition::Loses + ) +} + +fn explicit_outcome_conflicts_with_position(scenario: &AdapterScenarioJudgment) -> bool { + let Some(outcome) = scenario.comparison_outcome else { + return false; + }; + + !outcome::position_supports_outcome(scenario.elf_position, outcome) +} + +fn validate_adapter_evidence(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + for evidence in &adapter.evidence { + if evidence.kind.trim().is_empty() || evidence.reference.trim().is_empty() { + return Err(eyre::eyre!( + "{} adapter {} has incomplete evidence pointers.", + path.display(), + adapter.adapter_id + )); + } + } + + Ok(()) +} + +fn validate_adapter_execution_metadata(path: &Path, adapter: &ExternalAdapterReport) -> Result<()> { + let Some(metadata) = &adapter.execution_metadata else { + return Ok(()); + }; + + if metadata.setup_path.trim().is_empty() + || metadata.runtime_boundary.trim().is_empty() + || metadata.resource_expectation.trim().is_empty() + || metadata.retry_guidance.iter().any(|guidance| guidance.trim().is_empty()) + || metadata.sources.is_empty() + { + return Err(eyre::eyre!( + "{} adapter {} has incomplete execution metadata.", + path.display(), + adapter.adapter_id + )); + } + + for source in &metadata.sources { + if source.label.trim().is_empty() + || source.url.trim().is_empty() + || source.evidence.trim().is_empty() + { + return Err(eyre::eyre!( + "{} adapter {} has incomplete source metadata.", + path.display(), + adapter.adapter_id + )); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics.rs new file mode 100644 index 00000000..5c47c8b4 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics.rs @@ -0,0 +1,99 @@ +#[path = "feature_metrics/common.rs"] mod common; +#[path = "feature_metrics/knowledge.rs"] mod knowledge; +#[path = "feature_metrics/memory_summary.rs"] mod memory_summary; +#[path = "feature_metrics/proactive.rs"] mod proactive; +#[path = "feature_metrics/scheduled.rs"] mod scheduled; +#[path = "feature_metrics/work_continuity.rs"] mod work_continuity; + +use crate::{ + BTreeSet, DerivedPageArtifact, DerivedPageRebuild, DerivedPageSection, + FORBIDDEN_SOURCE_MUTATION_KEYS, KnowledgeJobMetrics, MemorySummaryArtifact, MemorySummaryEntry, + MemorySummaryJobMetrics, MemorySummarySourceTrace, NegativeTrap, ProactiveBriefArtifact, + ProactiveBriefJobMetrics, ProactiveSuggestion, ProducedAnswer, RealWorldJob, + ScheduledMemoryExecutionTrace, ScheduledMemoryJobMetrics, ScheduledMemoryOutput, + ScheduledMemoryTaskArtifact, UnsupportedClaimReport, Value, WorkContinuityExpectation, + WorkContinuityJobMetrics, WorkContinuityObserved, WorkJournalJanitorCandidateArtifact, + WorkJournalNextStepArtifact, WorkJournalReadbackArtifact, WorkJournalRejectedOptionArtifact, + formatting::{bounded_text, round3}, + summary::{ratio, ratio_or, ratio_or_full}, +}; + +pub(super) fn unsupported_page_claims(answer: &ProducedAnswer) -> Vec { + knowledge::unsupported_page_claims_impl(answer) +} + +pub(super) fn knowledge_metrics( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + knowledge::knowledge_metrics_impl(job, answer) +} + +pub(super) fn missed_stale_finding_count(metrics: &KnowledgeJobMetrics) -> usize { + knowledge::missed_stale_finding_count_impl(metrics) +} + +pub(super) fn page_usefulness_failure_count(metrics: &KnowledgeJobMetrics) -> usize { + knowledge::page_usefulness_failure_count_impl(metrics) +} + +pub(super) fn memory_summary_metrics( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + memory_summary::memory_summary_metrics_impl(job, answer) +} + +pub(super) fn unsupported_memory_summary_claims( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + memory_summary::unsupported_memory_summary_claims_impl(job, answer) +} + +pub(super) fn proactive_brief_metrics( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + proactive::proactive_brief_metrics_impl(job, answer) +} + +pub(super) fn unsupported_proactive_suggestions( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + proactive::unsupported_proactive_suggestions_impl(job, answer) +} + +pub(super) fn scheduled_memory_metrics( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + scheduled::scheduled_memory_metrics_impl(job, answer) +} + +pub(super) fn unsupported_scheduled_outputs( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + scheduled::unsupported_scheduled_outputs_impl(job, answer) +} + +pub(super) fn work_continuity_metrics( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + work_continuity::work_continuity_metrics_impl(job, answer) +} + +pub(super) fn forbidden_diff_key_count(value: &Value) -> usize { + common::forbidden_diff_key_count_impl(value) +} + +fn memory_summary_non_current_trace_refs(trace: &MemorySummarySourceTrace) -> BTreeSet<&str> { + memory_summary::memory_summary_non_current_trace_refs_impl(trace) +} + +fn proactive_tombstone_trace_refs(trace: &MemorySummarySourceTrace) -> BTreeSet<&str> { + proactive::proactive_tombstone_trace_refs_impl(trace) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/common.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/common.rs new file mode 100644 index 00000000..d2f64eab --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/common.rs @@ -0,0 +1,15 @@ +use crate::feature_metrics::{FORBIDDEN_SOURCE_MUTATION_KEYS, Value}; + +pub(super) fn forbidden_diff_key_count_impl(value: &Value) -> usize { + match value { + Value::Object(map) => map + .iter() + .map(|(key, nested)| { + usize::from(FORBIDDEN_SOURCE_MUTATION_KEYS.contains(&key.as_str())) + + forbidden_diff_key_count_impl(nested) + }) + .sum(), + Value::Array(items) => items.iter().map(forbidden_diff_key_count_impl).sum(), + _ => 0, + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/knowledge.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/knowledge.rs new file mode 100644 index 00000000..d45fe55a --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/knowledge.rs @@ -0,0 +1,161 @@ +use crate::feature_metrics::{ + self, DerivedPageArtifact, DerivedPageRebuild, DerivedPageSection, KnowledgeJobMetrics, + NegativeTrap, ProducedAnswer, RealWorldJob, UnsupportedClaimReport, Value, +}; + +pub(super) fn unsupported_page_claims_impl(answer: &ProducedAnswer) -> Vec { + answer + .pages + .iter() + .flat_map(|page| { + page.sections.iter().filter_map(|section| { + if section_is_traced(section) || section_is_flagged_unsupported(section) { + return None; + } + + Some(UnsupportedClaimReport { + suite_id: String::new(), + job_id: String::new(), + claim_id: Some(format!("{}:{}", page.page_id, section.section_id)), + claim_text: feature_metrics::bounded_text(section.content.as_str(), 240), + reason: + "derived page section has no source evidence and is not flagged unsupported" + .to_string(), + evidence_ids: section.evidence_ids.clone(), + }) + }) + }) + .collect() +} + +pub(super) fn knowledge_metrics_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + if answer.pages.is_empty() { + return None; + } + + let mut metrics = KnowledgeJobMetrics { + page_count: answer.pages.len(), + stale_trap_count: stale_traps(job).len(), + ..KnowledgeJobMetrics::default() + }; + + for page in &answer.pages { + accumulate_page_metrics(page, &mut metrics); + } + + metrics.stale_traps_detected = stale_traps(job) + .iter() + .filter(|trap| page_artifacts_detect_stale_trap(&answer.pages, trap)) + .count(); + metrics.citation_coverage = + feature_metrics::ratio(metrics.traced_section_count, metrics.section_count); + metrics.stale_claim_detection = + feature_metrics::ratio_or_full(metrics.stale_traps_detected, metrics.stale_trap_count); + metrics.rebuild_determinism = + feature_metrics::ratio(metrics.deterministic_rebuild_count, metrics.page_count); + metrics.backlink_coverage = + feature_metrics::ratio(metrics.pages_with_backlinks, metrics.page_count); + metrics.version_diff_coverage = + feature_metrics::ratio(metrics.pages_with_version_diff, metrics.page_count); + metrics.page_usefulness = feature_metrics::round3( + (metrics.citation_coverage + + metrics.stale_claim_detection + + metrics.rebuild_determinism + + metrics.backlink_coverage) + / 4.0, + ); + + Some(metrics) +} + +pub(super) fn missed_stale_finding_count_impl(metrics: &KnowledgeJobMetrics) -> usize { + metrics.stale_trap_count.saturating_sub(metrics.stale_traps_detected) +} + +pub(super) fn page_usefulness_failure_count_impl(metrics: &KnowledgeJobMetrics) -> usize { + if metrics.page_usefulness < 0.8 { 1 } else { 0 } +} + +fn stale_traps(job: &RealWorldJob) -> Vec<&NegativeTrap> { + job.negative_traps + .iter() + .filter(|trap| trap.trap_type == "stale_fact" && trap.failure_if_used) + .collect() +} + +fn accumulate_page_metrics(page: &DerivedPageArtifact, metrics: &mut KnowledgeJobMetrics) { + if !page.backlinks.is_empty() { + metrics.pages_with_backlinks += 1; + } + if page_has_version_diff(page) { + metrics.pages_with_version_diff += 1; + } + + metrics.backlink_count += page.backlinks.len(); + + for section in &page.sections { + metrics.section_count += 1; + + if section_is_traced(section) { + metrics.traced_section_count += 1; + } else if section_is_flagged_unsupported(section) { + metrics.flagged_unsupported_section_count += 1; + + if section.role == "summary" { + metrics.unsupported_summary_count += 1; + } + } else { + metrics.untraced_section_count += 1; + } + } + + if let Some(rebuild) = &page.rebuild { + if !rebuild.allowed_variance.is_empty() { + metrics.allowed_variance_count += 1; + } + if rebuild_is_acceptable(rebuild) { + metrics.deterministic_rebuild_count += 1; + } else { + metrics.rebuild_failure_count += 1; + } + } else { + metrics.rebuild_failure_count += 1; + } + + metrics.rebuild_page_count += 1; +} + +fn page_has_version_diff(page: &DerivedPageArtifact) -> bool { + page.page_version_diff.as_ref().is_some_and(|diff| { + diff.get("schema").and_then(Value::as_str) == Some("elf.knowledge_page.version_diff/v1") + && diff.get("available").and_then(Value::as_bool).unwrap_or(false) + }) +} + +fn section_is_traced(section: &DerivedPageSection) -> bool { + !section.evidence_ids.is_empty() || !section.timeline_event_ids.is_empty() +} + +fn section_is_flagged_unsupported(section: &DerivedPageSection) -> bool { + section.unsupported_reason.as_ref().is_some_and(|reason| !reason.trim().is_empty()) +} + +fn rebuild_is_acceptable(rebuild: &DerivedPageRebuild) -> bool { + (rebuild.deterministic && rebuild.first_hash == rebuild.second_hash) + || !rebuild.allowed_variance.is_empty() +} + +fn page_artifacts_detect_stale_trap(pages: &[DerivedPageArtifact], trap: &NegativeTrap) -> bool { + pages.iter().any(|page| { + page.lint_findings.iter().any(|finding| { + finding.trap_id.as_deref() == Some(trap.trap_id.as_str()) + || finding + .evidence_ids + .iter() + .any(|evidence_id| trap.evidence_ids.contains(evidence_id)) + }) + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/memory_summary.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/memory_summary.rs new file mode 100644 index 00000000..19584b10 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/memory_summary.rs @@ -0,0 +1,204 @@ +use crate::feature_metrics::{ + self, BTreeSet, MemorySummaryArtifact, MemorySummaryEntry, MemorySummaryJobMetrics, + MemorySummarySourceTrace, ProducedAnswer, RealWorldJob, UnsupportedClaimReport, +}; + +pub(super) fn memory_summary_metrics_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + if answer.memory_summaries.is_empty() { + return None; + } + + let mut metrics = MemorySummaryJobMetrics { + summary_count: answer.memory_summaries.len(), + required_category_count: job + .memory_summary + .as_ref() + .map_or(0, |summary| summary.required_categories.len()), + ..MemorySummaryJobMetrics::default() + }; + let mut categories = BTreeSet::new(); + + for summary in &answer.memory_summaries { + accumulate_memory_summary_metrics(summary, &mut metrics, &mut categories); + } + + let covered_required_category_count = job.memory_summary.as_ref().map_or(0, |summary| { + summary.required_categories.iter().filter(|category| categories.contains(*category)).count() + }); + + metrics.covered_required_category_count = covered_required_category_count; + metrics.missing_required_category_count = + metrics.required_category_count.saturating_sub(covered_required_category_count); + metrics.source_ref_coverage = + feature_metrics::ratio(metrics.source_ref_entry_count, metrics.source_ref_required_count); + metrics.freshness_coverage = + feature_metrics::ratio(metrics.freshness_marker_count, metrics.entry_count); + metrics.rationale_coverage = + feature_metrics::ratio(metrics.rationale_count, metrics.entry_count); + + Some(metrics) +} + +pub(super) fn memory_summary_non_current_trace_refs_impl( + trace: &MemorySummarySourceTrace, +) -> BTreeSet<&str> { + trace + .stale_source_refs + .iter() + .chain(trace.superseded_source_refs.iter()) + .chain(trace.tombstone_source_refs.iter()) + .map(|item| item.evidence_id.as_str()) + .collect() +} + +pub(super) fn unsupported_memory_summary_claims_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + answer + .memory_summaries + .iter() + .flat_map(|summary| { + summary.entries.iter().filter_map(|entry| { + if entry.category != "derived_project_profile" + || !entry.source_refs.is_empty() + || !entry.unsupported_claim_flags.is_empty() + { + return None; + } + + Some(UnsupportedClaimReport { + suite_id: job.suite.clone(), + job_id: job.job_id.clone(), + claim_id: Some(format!("{}:{}", summary.summary_id, entry.entry_id)), + claim_text: feature_metrics::bounded_text(entry.text.as_str(), 240), + reason: + "derived memory summary entry has no source refs and no unsupported-claim flags" + .to_string(), + evidence_ids: entry.source_refs.clone(), + }) + }) + }) + .collect() +} + +fn accumulate_memory_summary_metrics( + summary: &MemorySummaryArtifact, + metrics: &mut MemorySummaryJobMetrics, + categories: &mut BTreeSet, +) { + metrics.source_trace_selected_count += summary.source_trace.selected_source_refs.len(); + metrics.source_trace_dropped_count += summary.source_trace.dropped_source_refs.len(); + metrics.source_trace_stale_count += summary.source_trace.stale_source_refs.len(); + metrics.source_trace_superseded_count += summary.source_trace.superseded_source_refs.len(); + metrics.source_trace_tombstone_count += summary.source_trace.tombstone_source_refs.len(); + + let non_current_source_refs = memory_summary_non_current_trace_refs_impl(&summary.source_trace); + + for entry in &summary.entries { + metrics.entry_count += 1; + + categories.insert(entry.category.clone()); + + accumulate_memory_summary_category(entry.category.as_str(), metrics); + + if memory_summary_entry_requires_source_ref(entry) { + metrics.source_ref_required_count += 1; + + if entry.source_refs.is_empty() { + metrics.untraced_entry_count += 1; + } + } + if !entry.source_refs.is_empty() { + metrics.source_ref_entry_count += 1; + } + if memory_summary_entry_has_freshness(entry) { + metrics.freshness_marker_count += 1; + } + if memory_summary_entry_has_rationale(entry) { + metrics.rationale_count += 1; + } + if memory_summary_entry_is_invalid_top_of_mind(entry, &non_current_source_refs) { + metrics.invalid_top_of_mind_count += 1; + } + if entry.category == "derived_project_profile" { + let has_support = + !entry.source_refs.is_empty() || !entry.unsupported_claim_flags.is_empty(); + + if has_support { + metrics.derived_with_source_or_unsupported_count += 1; + } else { + metrics.derived_missing_source_or_unsupported_count += 1; + } + if !entry.unsupported_claim_flags.is_empty() { + metrics.unsupported_derived_entry_count += 1; + } + if memory_summary_entry_includes_unsupported_current_claim(entry) { + metrics.unsupported_current_entry_count += 1; + } + } + + metrics.tombstone_ref_count += entry.freshness.tombstone_refs.len(); + } +} + +fn accumulate_memory_summary_category(category: &str, metrics: &mut MemorySummaryJobMetrics) { + match category { + "top_of_mind" => metrics.top_of_mind_count += 1, + "background" => metrics.background_count += 1, + "stale" => metrics.stale_count += 1, + "superseded" => metrics.superseded_count += 1, + "tombstone" => metrics.tombstone_count += 1, + "derived_project_profile" => metrics.derived_project_profile_count += 1, + _ => {}, + } +} + +fn memory_summary_entry_requires_source_ref(entry: &MemorySummaryEntry) -> bool { + !(entry.category == "derived_project_profile" + && entry.source_refs.is_empty() + && !entry.unsupported_claim_flags.is_empty() + && entry.rationale.decision == "excluded") +} + +fn memory_summary_entry_is_invalid_top_of_mind( + entry: &MemorySummaryEntry, + non_current_source_refs: &BTreeSet<&str>, +) -> bool { + entry.category == "top_of_mind" + && (entry.freshness.status != "current" + || entry.rationale.decision != "included" + || !entry.freshness.superseded_by.is_empty() + || !entry.freshness.tombstone_refs.is_empty() + || entry + .source_refs + .iter() + .any(|source_ref| non_current_source_refs.contains(source_ref.as_str()))) +} + +fn memory_summary_entry_has_freshness(entry: &MemorySummaryEntry) -> bool { + if entry.freshness.status.trim().is_empty() { + return false; + } + + match entry.category.as_str() { + "superseded" => !entry.freshness.superseded_by.is_empty(), + "tombstone" => + entry.freshness.status == "tombstoned" && !entry.freshness.tombstone_refs.is_empty(), + _ => true, + } +} + +fn memory_summary_entry_has_rationale(entry: &MemorySummaryEntry) -> bool { + !entry.rationale.decision.trim().is_empty() + && !entry.rationale.reason_code.trim().is_empty() + && !entry.rationale.reason.trim().is_empty() +} + +fn memory_summary_entry_includes_unsupported_current_claim(entry: &MemorySummaryEntry) -> bool { + !entry.unsupported_claim_flags.is_empty() + && (entry.rationale.decision != "excluded" || entry.freshness.status == "current") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/proactive.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/proactive.rs new file mode 100644 index 00000000..a8337ef3 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/proactive.rs @@ -0,0 +1,223 @@ +use crate::feature_metrics::{ + self, BTreeSet, MemorySummarySourceTrace, ProactiveBriefArtifact, ProactiveBriefJobMetrics, + ProactiveSuggestion, ProducedAnswer, RealWorldJob, UnsupportedClaimReport, +}; + +pub(super) fn proactive_brief_metrics_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + if answer.proactive_briefs.is_empty() { + return None; + } + + let mut metrics = ProactiveBriefJobMetrics { + brief_count: answer.proactive_briefs.len(), + required_suggestion_kind_count: job + .proactive_brief + .as_ref() + .map_or(0, |brief| brief.required_suggestion_kinds.len()), + ..ProactiveBriefJobMetrics::default() + }; + let mut suggestion_kinds = BTreeSet::new(); + + for brief in &answer.proactive_briefs { + accumulate_proactive_brief_metrics(brief, &mut metrics, &mut suggestion_kinds); + } + + let covered_required_suggestion_kind_count = job.proactive_brief.as_ref().map_or(0, |brief| { + brief + .required_suggestion_kinds + .iter() + .filter(|kind| suggestion_kinds.contains(*kind)) + .count() + }); + + metrics.covered_required_suggestion_kind_count = covered_required_suggestion_kind_count; + metrics.missing_required_suggestion_kind_count = metrics + .required_suggestion_kind_count + .saturating_sub(covered_required_suggestion_kind_count); + metrics.evidence_ref_coverage = feature_metrics::ratio( + metrics.evidence_ref_suggestion_count, + metrics.evidence_ref_required_count, + ); + metrics.freshness_coverage = + feature_metrics::ratio(metrics.freshness_marker_count, metrics.suggestion_count); + metrics.action_rationale_coverage = + feature_metrics::ratio(metrics.action_rationale_count, metrics.suggestion_count); + + Some(metrics) +} + +pub(super) fn proactive_tombstone_trace_refs_impl( + trace: &MemorySummarySourceTrace, +) -> BTreeSet<&str> { + trace.tombstone_source_refs.iter().map(|item| item.evidence_id.as_str()).collect() +} + +pub(super) fn unsupported_proactive_suggestions_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + answer + .proactive_briefs + .iter() + .flat_map(|brief| { + brief.suggestions.iter().filter_map(|suggestion| { + if suggestion.evidence_refs.is_empty() { + return Some(proactive_unsupported_claim_report( + job, + brief, + suggestion, + "proactive suggestion has no evidence refs", + )); + } + if proactive_suggestion_is_unsupported_current(suggestion) { + return Some(proactive_unsupported_claim_report( + job, + brief, + suggestion, + "unsupported proactive claim is still recommended or marked current", + )); + } + + None + }) + }) + .collect() +} + +fn accumulate_proactive_brief_metrics( + brief: &ProactiveBriefArtifact, + metrics: &mut ProactiveBriefJobMetrics, + suggestion_kinds: &mut BTreeSet, +) { + metrics.source_trace_selected_count += brief.source_trace.selected_source_refs.len(); + metrics.source_trace_dropped_count += brief.source_trace.dropped_source_refs.len(); + metrics.source_trace_stale_count += brief.source_trace.stale_source_refs.len(); + metrics.source_trace_superseded_count += brief.source_trace.superseded_source_refs.len(); + metrics.source_trace_tombstone_count += brief.source_trace.tombstone_source_refs.len(); + + let non_current_refs = + feature_metrics::memory_summary_non_current_trace_refs(&brief.source_trace); + let tombstone_refs = proactive_tombstone_trace_refs_impl(&brief.source_trace); + + for suggestion in &brief.suggestions { + metrics.suggestion_count += 1; + metrics.evidence_ref_required_count += 1; + + suggestion_kinds.insert(suggestion.suggestion_kind.clone()); + + if suggestion.evidence_refs.is_empty() { + metrics.untraced_suggestion_count += 1; + } else { + metrics.evidence_ref_suggestion_count += 1; + } + if proactive_suggestion_has_freshness(suggestion) { + metrics.freshness_marker_count += 1; + } + if proactive_suggestion_has_action_rationale(suggestion) { + metrics.action_rationale_count += 1; + } + + accumulate_proactive_action_decision(suggestion.action.decision.as_str(), metrics); + + if suggestion.freshness.status == "current" { + metrics.current_suggestion_count += 1; + } else { + metrics.non_current_suggestion_count += 1; + } + if proactive_suggestion_is_stale_warning(suggestion) { + metrics.stale_warning_count += 1; + } + if proactive_suggestion_is_invalid_current(suggestion, &non_current_refs) { + metrics.invalid_current_suggestion_count += 1; + } + if proactive_suggestion_is_unsupported_current(suggestion) { + metrics.unsupported_current_suggestion_count += 1; + } + if proactive_suggestion_is_tombstone_violation(suggestion, &tombstone_refs) { + metrics.tombstone_violation_count += 1; + } + } +} + +fn accumulate_proactive_action_decision(decision: &str, metrics: &mut ProactiveBriefJobMetrics) { + match decision { + "recommend" => metrics.recommended_count += 1, + "defer" => metrics.deferred_count += 1, + "reject" => metrics.rejected_count += 1, + _ => {}, + } +} + +fn proactive_suggestion_has_freshness(suggestion: &ProactiveSuggestion) -> bool { + if suggestion.freshness.status.trim().is_empty() { + return false; + } + + match suggestion.freshness.status.as_str() { + "superseded" => !suggestion.freshness.superseded_by.is_empty(), + "tombstoned" => !suggestion.freshness.tombstone_refs.is_empty(), + _ => true, + } +} + +fn proactive_suggestion_has_action_rationale(suggestion: &ProactiveSuggestion) -> bool { + !suggestion.action.decision.trim().is_empty() + && !suggestion.action.reason_code.trim().is_empty() + && !suggestion.action.reason.trim().is_empty() +} + +fn proactive_suggestion_is_stale_warning(suggestion: &ProactiveSuggestion) -> bool { + matches!( + suggestion.suggestion_kind.as_str(), + "stale_decision_audit" | "stale_plan_preference_warning" + ) && suggestion.freshness.status != "current" +} + +fn proactive_suggestion_is_invalid_current( + suggestion: &ProactiveSuggestion, + non_current_refs: &BTreeSet<&str>, +) -> bool { + suggestion.freshness.status == "current" + && (!suggestion.freshness.superseded_by.is_empty() + || !suggestion.freshness.tombstone_refs.is_empty() + || suggestion + .evidence_refs + .iter() + .any(|evidence_id| non_current_refs.contains(evidence_id.as_str()))) +} + +fn proactive_suggestion_is_unsupported_current(suggestion: &ProactiveSuggestion) -> bool { + !suggestion.unsupported_claim_flags.is_empty() + && (suggestion.action.decision == "recommend" || suggestion.freshness.status == "current") +} + +fn proactive_suggestion_is_tombstone_violation( + suggestion: &ProactiveSuggestion, + tombstone_refs: &BTreeSet<&str>, +) -> bool { + suggestion.freshness.status == "current" + && (!suggestion.freshness.tombstone_refs.is_empty() + || suggestion + .evidence_refs + .iter() + .any(|evidence_id| tombstone_refs.contains(evidence_id.as_str()))) +} + +fn proactive_unsupported_claim_report( + job: &RealWorldJob, + brief: &ProactiveBriefArtifact, + suggestion: &ProactiveSuggestion, + reason: &str, +) -> UnsupportedClaimReport { + UnsupportedClaimReport { + suite_id: job.suite.clone(), + job_id: job.job_id.clone(), + claim_id: Some(format!("{}:{}", brief.brief_id, suggestion.suggestion_id)), + claim_text: feature_metrics::bounded_text(suggestion.body.as_str(), 240), + reason: reason.to_string(), + evidence_ids: suggestion.evidence_refs.clone(), + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/scheduled.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/scheduled.rs new file mode 100644 index 00000000..be5034ad --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/scheduled.rs @@ -0,0 +1,215 @@ +use crate::feature_metrics::{ + self, BTreeSet, ProducedAnswer, RealWorldJob, ScheduledMemoryExecutionTrace, + ScheduledMemoryJobMetrics, ScheduledMemoryOutput, ScheduledMemoryTaskArtifact, + UnsupportedClaimReport, forbidden_diff_key_count, +}; + +pub(super) fn scheduled_memory_metrics_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + if answer.scheduled_tasks.is_empty() { + return None; + } + + let mut metrics = ScheduledMemoryJobMetrics { + task_run_count: answer.scheduled_tasks.len(), + required_task_kind_count: job + .scheduled_memory + .as_ref() + .map_or(0, |scheduled| scheduled.required_task_kinds.len()), + ..ScheduledMemoryJobMetrics::default() + }; + let mut task_kinds = BTreeSet::new(); + + for task in &answer.scheduled_tasks { + accumulate_scheduled_memory_metrics(task, &mut metrics, &mut task_kinds); + } + + let covered_required_task_kind_count = job.scheduled_memory.as_ref().map_or(0, |scheduled| { + scheduled.required_task_kinds.iter().filter(|kind| task_kinds.contains(*kind)).count() + }); + + metrics.covered_required_task_kind_count = covered_required_task_kind_count; + metrics.missing_required_task_kind_count = + metrics.required_task_kind_count.saturating_sub(covered_required_task_kind_count); + metrics.evidence_ref_coverage = feature_metrics::ratio( + metrics.evidence_ref_output_count, + metrics.evidence_ref_required_count, + ); + metrics.freshness_coverage = + feature_metrics::ratio(metrics.freshness_marker_count, metrics.output_count); + metrics.action_rationale_coverage = + feature_metrics::ratio(metrics.action_rationale_count, metrics.output_count); + metrics.trace_coverage = + feature_metrics::ratio(metrics.trace_complete_count, metrics.trace_required_count); + + Some(metrics) +} + +pub(super) fn unsupported_scheduled_outputs_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + answer + .scheduled_tasks + .iter() + .flat_map(|task| { + task.outputs.iter().filter_map(|output| { + if output.evidence_refs.is_empty() { + return Some(scheduled_unsupported_claim_report( + job, + task, + output, + "scheduled task output has no evidence refs", + )); + } + if scheduled_output_is_unsupported_current(output) { + return Some(scheduled_unsupported_claim_report( + job, + task, + output, + "unsupported scheduled task claim is still recommended or marked current", + )); + } + + None + }) + }) + .collect() +} + +fn accumulate_scheduled_memory_metrics( + task: &ScheduledMemoryTaskArtifact, + metrics: &mut ScheduledMemoryJobMetrics, + task_kinds: &mut BTreeSet, +) { + metrics.source_trace_selected_count += task.source_trace.selected_source_refs.len(); + metrics.source_trace_dropped_count += task.source_trace.dropped_source_refs.len(); + metrics.source_trace_stale_count += task.source_trace.stale_source_refs.len(); + metrics.source_trace_superseded_count += task.source_trace.superseded_source_refs.len(); + metrics.source_trace_tombstone_count += task.source_trace.tombstone_source_refs.len(); + metrics.trace_required_count += 1; + metrics.source_mutation_count += task.source_mutations.len() + + task.source_mutations.iter().map(forbidden_diff_key_count).sum::(); + + task_kinds.insert(task.task_kind.clone()); + + if scheduled_trace_is_complete(task.execution_trace.as_ref()) { + metrics.trace_complete_count += 1; + } + + let non_current_refs = + feature_metrics::memory_summary_non_current_trace_refs(&task.source_trace); + let tombstone_refs = feature_metrics::proactive_tombstone_trace_refs(&task.source_trace); + + for output in &task.outputs { + metrics.output_count += 1; + metrics.evidence_ref_required_count += 1; + + if output.evidence_refs.is_empty() { + metrics.untraced_output_count += 1; + } else { + metrics.evidence_ref_output_count += 1; + } + if scheduled_output_has_freshness(output) { + metrics.freshness_marker_count += 1; + } + if scheduled_output_has_action_rationale(output) { + metrics.action_rationale_count += 1; + } + if output.freshness.status == "current" { + metrics.current_output_count += 1; + } else { + metrics.non_current_output_count += 1; + } + if scheduled_output_is_invalid_current(output, &non_current_refs) { + metrics.invalid_current_output_count += 1; + } + if scheduled_output_is_unsupported_current(output) { + metrics.unsupported_current_output_count += 1; + } + if scheduled_output_is_tombstone_violation(output, &tombstone_refs) { + metrics.tombstone_violation_count += 1; + } + } +} + +fn scheduled_trace_is_complete(trace: Option<&ScheduledMemoryExecutionTrace>) -> bool { + let Some(trace) = trace else { + return false; + }; + + trace.status == "completed" + && !trace.trace_id.trim().is_empty() + && !trace.output_ref.trim().is_empty() + && !trace.stages.is_empty() + && trace + .stages + .iter() + .any(|stage| stage.stage_name == "output_readback" && !stage.evidence_refs.is_empty()) +} + +fn scheduled_output_has_freshness(output: &ScheduledMemoryOutput) -> bool { + if output.freshness.status.trim().is_empty() { + return false; + } + + match output.freshness.status.as_str() { + "superseded" => !output.freshness.superseded_by.is_empty(), + "tombstoned" => !output.freshness.tombstone_refs.is_empty(), + _ => true, + } +} + +fn scheduled_output_has_action_rationale(output: &ScheduledMemoryOutput) -> bool { + !output.action.decision.trim().is_empty() + && !output.action.reason_code.trim().is_empty() + && !output.action.reason.trim().is_empty() +} + +fn scheduled_output_is_invalid_current( + output: &ScheduledMemoryOutput, + non_current_refs: &BTreeSet<&str>, +) -> bool { + output.freshness.status == "current" + && (!output.freshness.superseded_by.is_empty() + || !output.freshness.tombstone_refs.is_empty() + || output + .evidence_refs + .iter() + .any(|evidence_id| non_current_refs.contains(evidence_id.as_str()))) +} + +fn scheduled_output_is_unsupported_current(output: &ScheduledMemoryOutput) -> bool { + !output.unsupported_claim_flags.is_empty() + && (output.action.decision == "recommend" || output.freshness.status == "current") +} + +fn scheduled_output_is_tombstone_violation( + output: &ScheduledMemoryOutput, + tombstone_refs: &BTreeSet<&str>, +) -> bool { + output.freshness.status == "current" + && (!output.freshness.tombstone_refs.is_empty() + || output + .evidence_refs + .iter() + .any(|evidence_id| tombstone_refs.contains(evidence_id.as_str()))) +} + +fn scheduled_unsupported_claim_report( + job: &RealWorldJob, + task: &ScheduledMemoryTaskArtifact, + output: &ScheduledMemoryOutput, + reason: &str, +) -> UnsupportedClaimReport { + UnsupportedClaimReport { + suite_id: job.suite.clone(), + job_id: job.job_id.clone(), + claim_id: Some(format!("{}:{}", task.task_run_id, output.output_id)), + claim_text: feature_metrics::bounded_text(output.text.as_str(), 240), + reason: reason.to_string(), + evidence_ids: output.evidence_refs.clone(), + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/work_continuity.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/work_continuity.rs new file mode 100644 index 00000000..d294de83 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/feature_metrics/work_continuity.rs @@ -0,0 +1,292 @@ +use crate::feature_metrics::{ + self, BTreeSet, ProducedAnswer, RealWorldJob, WorkContinuityExpectation, + WorkContinuityJobMetrics, WorkContinuityObserved, WorkJournalJanitorCandidateArtifact, + WorkJournalNextStepArtifact, WorkJournalReadbackArtifact, WorkJournalRejectedOptionArtifact, +}; + +pub(super) fn work_continuity_metrics_impl( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Option { + if job.work_continuity.is_none() && answer.work_journal_readbacks.is_empty() { + return None; + } + + let expectation = job.work_continuity.as_ref(); + let observed = work_continuity_observed(answer); + let mut metrics = initial_work_continuity_metrics(expectation, answer); + + if let Some(expected) = expectation { + apply_expected_work_continuity_counts(&mut metrics, expected, &observed); + } + + apply_observed_work_continuity_counts(&mut metrics, answer, &observed); + apply_work_continuity_rates(&mut metrics); + + Some(metrics) +} + +fn work_continuity_observed(answer: &ProducedAnswer) -> WorkContinuityObserved<'_> { + WorkContinuityObserved { + reset_resume_entry_ids: work_journal_reset_resume_entry_ids(answer), + decision_rationale_evidence_ids: work_journal_decision_rationale_evidence_ids(answer), + rejected_options: work_journal_rejected_options(answer), + explicit_next_steps: work_journal_explicit_next_steps(answer), + inferred_next_steps: work_journal_inferred_next_steps(answer), + handoff_source_refs: work_journal_handoff_source_refs(answer), + redacted_marker_ids: work_journal_redacted_marker_ids(answer), + janitor_candidates: work_journal_janitor_candidates(answer), + } +} + +fn initial_work_continuity_metrics( + expectation: Option<&WorkContinuityExpectation>, + answer: &ProducedAnswer, +) -> WorkContinuityJobMetrics { + WorkContinuityJobMetrics { + readback_count: answer.work_journal_readbacks.len(), + entry_count: answer + .work_journal_readbacks + .iter() + .map(|readback| readback.items.len()) + .sum(), + reset_resume_required_count: expectation + .map_or(0, |expected| expected.required_reset_resume_entry_ids.len()), + decision_rationale_required_count: expectation + .map_or(0, |expected| expected.required_decision_rationale_evidence_ids.len()), + rejected_option_required_count: expectation + .map_or(0, |expected| expected.required_rejected_option_ids.len()), + explicit_next_step_required_count: expectation + .map_or(0, |expected| expected.required_explicit_next_step_ids.len()), + inferred_next_step_required_count: expectation + .map_or(0, |expected| expected.required_inferred_next_step_ids.len()), + handoff_source_ref_required_count: expectation + .map_or(0, |expected| expected.required_handoff_source_ref_ids.len()), + redaction_required_count: expectation + .map_or(0, |expected| expected.required_redaction_marker_ids.len()), + janitor_candidate_count: expectation + .map_or(0, |expected| expected.required_janitor_candidate_ids.len()), + ..WorkContinuityJobMetrics::default() + } +} + +fn apply_expected_work_continuity_counts( + metrics: &mut WorkContinuityJobMetrics, + expected: &WorkContinuityExpectation, + observed: &WorkContinuityObserved<'_>, +) { + metrics.reset_resume_success_count = expected + .required_reset_resume_entry_ids + .iter() + .filter(|entry_id| observed.reset_resume_entry_ids.contains(entry_id.as_str())) + .count(); + metrics.decision_rationale_recalled_count = expected + .required_decision_rationale_evidence_ids + .iter() + .filter(|evidence_id| { + observed.decision_rationale_evidence_ids.contains(evidence_id.as_str()) + }) + .count(); + metrics.rejected_option_suppressed_count = expected + .required_rejected_option_ids + .iter() + .filter(|option_id| { + observed + .rejected_options + .iter() + .any(|option| option.option_id == **option_id && !option.resurrected_as_current) + }) + .count(); + metrics.explicit_next_step_correct_count = expected + .required_explicit_next_step_ids + .iter() + .filter(|step_id| { + observed.explicit_next_steps.iter().any(|step| { + step.step_id == **step_id && step.label == "explicit" && step.instruction + }) + }) + .count(); + metrics.inferred_next_step_labeled_count = expected + .required_inferred_next_step_ids + .iter() + .filter(|step_id| { + observed.inferred_next_steps.iter().any(|step| { + step.step_id == **step_id && step.label == "inferred" && !step.instruction + }) + }) + .count(); + metrics.handoff_source_ref_covered_count = expected + .required_handoff_source_ref_ids + .iter() + .filter(|source_ref| observed.handoff_source_refs.contains(source_ref.as_str())) + .count(); + metrics.redaction_applied_count = expected + .required_redaction_marker_ids + .iter() + .filter(|marker_id| observed.redacted_marker_ids.contains(marker_id.as_str())) + .count(); +} + +fn apply_observed_work_continuity_counts( + metrics: &mut WorkContinuityJobMetrics, + answer: &ProducedAnswer, + observed: &WorkContinuityObserved<'_>, +) { + metrics.janitor_candidate_count = + metrics.janitor_candidate_count.max(observed.janitor_candidates.len()); + metrics.janitor_false_promotion_count = observed + .janitor_candidates + .iter() + .filter(|candidate| candidate.promoted_to_memory || !candidate.review_required) + .count(); + metrics.explicit_next_step_returned_count = observed.explicit_next_steps.len(); + metrics.rejected_option_resurrection_count = + observed.rejected_options.iter().filter(|option| option.resurrected_as_current).count(); + metrics.inferred_step_instruction_count = + observed.inferred_next_steps.iter().filter(|step| step.instruction).count(); + metrics.sensitive_marker_persistence_count = answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.items.iter()) + .map(|entry| entry.redaction_audit.persisted_sensitive_marker_ids.len()) + .sum(); + metrics.journal_only_authority_claim_count = + answer.work_journal_readbacks.iter().map(work_journal_authority_claim_count).sum(); +} + +fn apply_work_continuity_rates(metrics: &mut WorkContinuityJobMetrics) { + metrics.reset_resume_success_rate = feature_metrics::ratio( + metrics.reset_resume_success_count, + metrics.reset_resume_required_count, + ); + metrics.decision_rationale_recall_rate = feature_metrics::ratio( + metrics.decision_rationale_recalled_count, + metrics.decision_rationale_required_count, + ); + metrics.rejected_option_suppression_rate = feature_metrics::ratio( + metrics.rejected_option_suppressed_count, + metrics.rejected_option_required_count, + ); + metrics.explicit_next_step_precision = feature_metrics::ratio_or( + metrics.explicit_next_step_correct_count, + metrics.explicit_next_step_returned_count, + usize::from(metrics.explicit_next_step_required_count == 0) as f64, + ); + metrics.inferred_next_step_labeling_rate = feature_metrics::ratio( + metrics.inferred_next_step_labeled_count, + metrics.inferred_next_step_required_count, + ); + metrics.handoff_source_ref_coverage = feature_metrics::ratio( + metrics.handoff_source_ref_covered_count, + metrics.handoff_source_ref_required_count, + ); + metrics.redaction_rate = + feature_metrics::ratio(metrics.redaction_applied_count, metrics.redaction_required_count); + metrics.janitor_false_promotion_rate = feature_metrics::ratio( + metrics.janitor_false_promotion_count, + metrics.janitor_candidate_count, + ); +} + +fn work_journal_reset_resume_entry_ids(answer: &ProducedAnswer) -> BTreeSet<&str> { + answer + .work_journal_readbacks + .iter() + .filter_map(|readback| readback.where_stopped.as_ref()) + .flat_map(|where_stopped| where_stopped.reset_resume_entry_ids.iter().map(String::as_str)) + .collect() +} + +fn work_journal_decision_rationale_evidence_ids(answer: &ProducedAnswer) -> BTreeSet<&str> { + answer + .work_journal_readbacks + .iter() + .filter_map(|readback| readback.where_stopped.as_ref()) + .flat_map(|where_stopped| { + where_stopped.decision_rationale_evidence_ids.iter().map(String::as_str) + }) + .collect() +} + +fn work_journal_rejected_options( + answer: &ProducedAnswer, +) -> Vec<&WorkJournalRejectedOptionArtifact> { + answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.items.iter()) + .flat_map(|entry| entry.rejected_options.iter()) + .collect() +} + +fn work_journal_explicit_next_steps(answer: &ProducedAnswer) -> Vec<&WorkJournalNextStepArtifact> { + answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.items.iter()) + .flat_map(|entry| entry.explicit_next_steps.iter()) + .collect() +} + +fn work_journal_inferred_next_steps(answer: &ProducedAnswer) -> Vec<&WorkJournalNextStepArtifact> { + answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.items.iter()) + .flat_map(|entry| entry.inferred_next_steps.iter()) + .collect() +} + +fn work_journal_handoff_source_refs(answer: &ProducedAnswer) -> BTreeSet<&str> { + let mut refs = answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.items.iter()) + .flat_map(|entry| entry.source_refs.iter().map(String::as_str)) + .collect::>(); + + for source_ref in answer + .work_journal_readbacks + .iter() + .filter_map(|readback| readback.where_stopped.as_ref()) + .flat_map(|where_stopped| where_stopped.handoff_source_refs.iter().map(String::as_str)) + { + refs.insert(source_ref); + } + + refs +} + +fn work_journal_redacted_marker_ids(answer: &ProducedAnswer) -> BTreeSet<&str> { + answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.items.iter()) + .flat_map(|entry| entry.redaction_audit.redacted_marker_ids.iter().map(String::as_str)) + .collect() +} + +fn work_journal_janitor_candidates( + answer: &ProducedAnswer, +) -> Vec<&WorkJournalJanitorCandidateArtifact> { + answer + .work_journal_readbacks + .iter() + .flat_map(|readback| readback.janitor_candidates.iter()) + .collect() +} + +fn work_journal_authority_claim_count(readback: &WorkJournalReadbackArtifact) -> usize { + let boundary_claim_count = + usize::from(readback.promotion_boundary.journal_entry_authority != "source_adjacent_only"); + let missing_promotion_boundary_count = usize::from( + !readback.promotion_boundary.memory_promotion_required + && !readback.promotion_boundary.accepted_refs.is_empty(), + ); + let where_stopped_claim_count = readback + .where_stopped + .as_ref() + .map_or(0, |where_stopped| where_stopped.journal_only_authority_claims.len()); + + boundary_claim_count + missing_promotion_boundary_count + where_stopped_claim_count +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/fixtures.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/fixtures.rs new file mode 100644 index 00000000..32a5eb13 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/fixtures.rs @@ -0,0 +1,252 @@ +use crate::{ + BTreeMap, CaptureIntegrationReport, ConsolidationFixture, CorpusProfile, Deserialize, + EvidenceLink, ExpectedClaim, OperatorDebugEvidence, ProducedAnswer, Serialize, TypedStatus, + Value, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct RealWorldJob { + pub(super) schema: String, + pub(super) job_id: String, + pub(super) suite: String, + pub(super) title: String, + pub(super) corpus: Corpus, + #[serde(default)] + pub(super) timeline: Vec, + pub(super) prompt: Prompt, + pub(super) expected_answer: ExpectedAnswer, + #[serde(default)] + pub(super) required_evidence: Vec, + #[serde(default)] + pub(super) negative_traps: Vec, + pub(super) scoring_rubric: ScoringRubric, + pub(super) allowed_uncertainty: AllowedUncertainty, + pub(super) operator_debug: Option, + #[serde(default)] + pub(super) tags: Vec, + #[serde(default)] + pub(super) encoding: JobEncoding, + pub(super) memory_evolution: Option, + pub(super) memory_summary: Option, + pub(super) proactive_brief: Option, + pub(super) scheduled_memory: Option, + pub(super) work_continuity: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Corpus { + pub(super) corpus_id: String, + pub(super) profile: CorpusProfile, + #[serde(default)] + pub(super) items: Vec, + #[serde(default)] + pub(super) capture_behaviors: CaptureIntegrationReport, + + pub(super) adapter_response: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct CorpusItem { + pub(super) evidence_id: String, + pub(super) kind: String, + + pub(super) text: Option, + + pub(super) local_ref: Option, + #[serde(default)] + pub(super) source_ref: Value, + + pub(super) created_at: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TimelineEvent { + pub(super) event_id: String, + pub(super) ts: String, + pub(super) actor: String, + pub(super) action: String, + #[serde(default)] + pub(super) evidence_ids: Vec, + pub(super) summary: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Prompt { + pub(super) role: String, + pub(super) content: String, + pub(super) job_mode: String, + #[serde(default)] + pub(super) constraints: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ExpectedAnswer { + #[serde(default)] + pub(super) must_include: Vec, + #[serde(default)] + pub(super) must_not_include: Vec, + #[serde(default)] + pub(super) evidence_links: BTreeMap, + pub(super) answer_type: String, + #[serde(default)] + pub(super) accepted_alternates: Vec, + #[serde(default)] + pub(super) requires_caveat: bool, + #[serde(default)] + pub(super) requires_refusal: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RequiredEvidence { + pub(super) evidence_id: String, + pub(super) claim_id: String, + pub(super) requirement: String, + + pub(super) quote: Option, + + pub(super) selector: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct NegativeTrap { + pub(super) trap_id: String, + #[serde(rename = "type")] + pub(super) trap_type: String, + #[serde(default)] + pub(super) evidence_ids: Vec, + #[serde(default)] + pub(super) failure_if_used: bool, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct JobEncoding { + pub(super) status: Option, + pub(super) reason: Option, + pub(super) follow_up: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct FollowUpInput { + pub(super) title: String, + pub(super) reason: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct MemoryEvolution { + #[serde(default)] + pub(super) current_evidence_ids: Vec, + #[serde(default)] + pub(super) historical_evidence_ids: Vec, + #[serde(default)] + pub(super) tombstone_evidence_ids: Vec, + #[serde(default)] + pub(super) invalidation_evidence_ids: Vec, + #[serde(default)] + pub(super) stale_trap_ids: Vec, + #[serde(default)] + pub(super) conflicts: Vec, + pub(super) update_rationale: Option, + pub(super) temporal_validity: Option, + pub(super) history_readback: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct EvolutionConflict { + pub(super) conflict_id: String, + pub(super) claim_id: String, + pub(super) current_evidence_id: String, + pub(super) historical_evidence_id: String, + pub(super) resolved_by_evidence_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct UpdateRationale { + pub(super) claim_id: String, + #[serde(default)] + pub(super) evidence_ids: Vec, + pub(super) available: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TemporalValidity { + pub(super) required: bool, + pub(super) encoded: bool, + pub(super) follow_up: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HistoryReadback { + pub(super) encoded: bool, + #[serde(default)] + pub(super) required_event_types: Vec, + pub(super) requires_note_version_links: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct MemorySummaryExpectation { + #[serde(default)] + pub(super) required_categories: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ProactiveBriefExpectation { + #[serde(default)] + pub(super) required_suggestion_kinds: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ScheduledMemoryExpectation { + #[serde(default)] + pub(super) required_task_kinds: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct WorkContinuityExpectation { + #[serde(default)] + pub(super) required_reset_resume_entry_ids: Vec, + #[serde(default)] + pub(super) required_decision_rationale_evidence_ids: Vec, + #[serde(default)] + pub(super) required_rejected_option_ids: Vec, + #[serde(default)] + pub(super) required_explicit_next_step_ids: Vec, + #[serde(default)] + pub(super) required_inferred_next_step_ids: Vec, + #[serde(default)] + pub(super) required_handoff_source_ref_ids: Vec, + #[serde(default)] + pub(super) required_redaction_marker_ids: Vec, + #[serde(default)] + pub(super) required_janitor_candidate_ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ScoringRubric { + #[serde(default)] + pub(super) dimensions: BTreeMap, + pub(super) pass_threshold: f64, + #[serde(default)] + pub(super) hard_fail_rules: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RubricDimension { + pub(super) weight: f64, + pub(super) max_points: f64, + pub(super) criteria: Value, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AllowedUncertainty { + pub(super) can_answer_unknown: bool, + #[serde(default)] + pub(super) acceptable_phrases: Vec, + pub(super) fallback_action: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct AdapterResponse { + pub(super) adapter_id: Option, + pub(super) answer: ProducedAnswer, + pub(super) consolidation: Option, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/formatting.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/formatting.rs new file mode 100644 index 00000000..ff7c7aa3 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/formatting.rs @@ -0,0 +1,65 @@ +use crate::{ + AdapterCoverageStatus, ElfScenarioPosition, ScenarioComparisonOutcome, TraceExplainability, + TypedStatus, +}; + +pub(super) fn status_str(status: TypedStatus) -> &'static str { + match status { + TypedStatus::Pass => "pass", + TypedStatus::WrongResult => "wrong_result", + TypedStatus::LifecycleFail => "lifecycle_fail", + TypedStatus::Incomplete => "incomplete", + TypedStatus::Blocked => "blocked", + TypedStatus::NotEncoded => "not_encoded", + TypedStatus::UnsupportedClaim => "unsupported_claim", + } +} + +pub(super) fn adapter_status_str(status: AdapterCoverageStatus) -> &'static str { + match status { + AdapterCoverageStatus::Real => "real", + AdapterCoverageStatus::Mocked => "mocked", + AdapterCoverageStatus::Unsupported => "unsupported", + AdapterCoverageStatus::Blocked => "blocked", + AdapterCoverageStatus::Incomplete => "incomplete", + AdapterCoverageStatus::WrongResult => "wrong_result", + AdapterCoverageStatus::LifecycleFail => "lifecycle_fail", + AdapterCoverageStatus::Pass => "pass", + AdapterCoverageStatus::NotEncoded => "not_encoded", + } +} + +pub(super) fn scenario_comparison_outcome_str(outcome: ScenarioComparisonOutcome) -> &'static str { + match outcome { + ScenarioComparisonOutcome::Win => "win", + ScenarioComparisonOutcome::Tie => "tie", + ScenarioComparisonOutcome::Loss => "loss", + ScenarioComparisonOutcome::NotTested => "not_tested", + ScenarioComparisonOutcome::Blocked => "blocked", + ScenarioComparisonOutcome::NonGoal => "non_goal", + } +} + +pub(super) fn scenario_position_str(position: ElfScenarioPosition) -> &'static str { + match position { + ElfScenarioPosition::Wins => "wins", + ElfScenarioPosition::Ties => "ties", + ElfScenarioPosition::Loses => "loses", + ElfScenarioPosition::Untested => "untested", + } +} + +pub(super) fn trace_failure_stage(trace: Option<&TraceExplainability>) -> Option<&str> { + trace.and_then(|trace| trace.failure_stage.as_deref()) +} + +pub(super) fn bounded_text(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let text = chars.by_ref().take(max_chars).collect::(); + + if chars.next().is_some() { format!("{text}...") } else { text } +} + +pub(super) fn round3(value: f64) -> f64 { + (value * 1_000.0).round() / 1_000.0 +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/job_reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/job_reports.rs new file mode 100644 index 00000000..9e15a0f8 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/job_reports.rs @@ -0,0 +1,463 @@ +use crate::{ + AuthorityRecoveryDrillArtifact, ConsolidationReviewAction, CostReport, Deserialize, + OperatorDebugEvidence, Serialize, TraceExplainability, TypedStatus, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct JobReport { + pub(super) suite_id: String, + pub(super) job_id: String, + pub(super) title: String, + pub(super) status: TypedStatus, + pub(super) operational_evidence_tier: String, + pub(super) answer_type: String, + pub(super) requires_caveat: bool, + pub(super) requires_refusal: bool, + pub(super) can_answer_unknown: bool, + pub(super) normalized_score: f64, + pub(super) hard_fail_hits: Vec, + pub(super) expected_evidence: Vec, + pub(super) produced_answer: String, + pub(super) produced_evidence: Vec, + pub(super) unsupported_claim_count: usize, + pub(super) wrong_result_count: usize, + #[serde(default)] + pub(super) stale_answer_count: usize, + #[serde(default)] + pub(super) conflict_detection_count: usize, + #[serde(default)] + pub(super) update_rationale_available: bool, + #[serde(default)] + pub(super) temporal_validity_not_encoded: bool, + #[serde(default)] + pub(super) history_readback_encoded: bool, + pub(super) retrieval_quality: RetrievalQualityReport, + pub(super) latency_ms: Option, + pub(super) cost: Option, + pub(super) trace_explainability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) knowledge: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) memory_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) proactive_brief: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) scheduled_memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) work_continuity: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) recovery_drills: Vec, + pub(super) trap_ids_used: Vec, + pub(super) dimension_scores: Vec, + pub(super) reason: String, + #[serde(default)] + pub(super) evidence_required_count: usize, + #[serde(default)] + pub(super) evidence_covered_count: usize, + #[serde(default)] + pub(super) source_ref_required_count: usize, + #[serde(default)] + pub(super) source_ref_covered_count: usize, + #[serde(default)] + pub(super) quote_required_count: usize, + #[serde(default)] + pub(super) quote_covered_count: usize, + #[serde(default)] + pub(super) stale_retrieval_count: usize, + #[serde(default)] + pub(super) scope_check_count: usize, + #[serde(default)] + pub(super) scope_correct_count: usize, + #[serde(default)] + pub(super) scope_violation_count: usize, + #[serde(default)] + pub(super) redaction_leak_count: usize, + #[serde(default)] + pub(super) qdrant_rebuild_case: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) operator_debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) evolution: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) consolidation: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct ExpectedEvidenceReport { + pub(super) evidence_id: String, + pub(super) claim_id: String, + pub(super) requirement: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct DimensionScoreReport { + pub(super) dimension: String, + pub(super) score: f64, + pub(super) max_points: f64, + pub(super) weight: f64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct RetrievalQualityReport { + pub(super) expected_evidence_total: usize, + pub(super) expected_evidence_matched: usize, + pub(super) expected_evidence_recall: f64, + pub(super) produced_evidence_total: usize, + pub(super) irrelevant_context_count: usize, + pub(super) irrelevant_context_ratio: f64, + pub(super) trap_context_count: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ConsolidationJobReport { + pub(super) proposal_count: usize, + pub(super) proposal_usefulness: Option, + pub(super) lineage_completeness: Option, + pub(super) review_action_correctness: Option, + pub(super) source_mutation_count: usize, + pub(super) proposal_unsupported_claim_count: usize, + pub(super) executable_gaps: Vec, + pub(super) proposals: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ConsolidationProposalReport { + pub(super) proposal_id: String, + pub(super) proposal_kind: String, + pub(super) usefulness_score: f64, + pub(super) min_usefulness_score: f64, + pub(super) lineage_completeness: f64, + pub(super) expected_review_action: ConsolidationReviewAction, + pub(super) actual_review_action: ConsolidationReviewAction, + pub(super) review_action_correct: bool, + pub(super) source_mutation_count: usize, + pub(super) unsupported_claim_count: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ConsolidationExecutableGapReport { + pub(super) primitive: String, + pub(super) follow_up_issue: String, + pub(super) reason: String, + pub(super) blocks_fixture_pass: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct UnsupportedClaimReport { + pub(super) suite_id: String, + pub(super) job_id: String, + pub(super) claim_id: Option, + pub(super) claim_text: String, + pub(super) reason: String, + pub(super) evidence_ids: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct KnowledgeJobMetrics { + pub(super) page_count: usize, + pub(super) section_count: usize, + pub(super) traced_section_count: usize, + pub(super) flagged_unsupported_section_count: usize, + pub(super) untraced_section_count: usize, + pub(super) unsupported_summary_count: usize, + pub(super) backlink_count: usize, + pub(super) pages_with_backlinks: usize, + pub(super) pages_with_version_diff: usize, + pub(super) stale_trap_count: usize, + pub(super) stale_traps_detected: usize, + pub(super) rebuild_page_count: usize, + pub(super) deterministic_rebuild_count: usize, + pub(super) rebuild_failure_count: usize, + pub(super) allowed_variance_count: usize, + pub(super) citation_coverage: f64, + pub(super) stale_claim_detection: f64, + pub(super) rebuild_determinism: f64, + pub(super) backlink_coverage: f64, + pub(super) version_diff_coverage: f64, + pub(super) page_usefulness: f64, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct MemorySummaryJobMetrics { + pub(super) summary_count: usize, + pub(super) entry_count: usize, + pub(super) required_category_count: usize, + pub(super) covered_required_category_count: usize, + pub(super) missing_required_category_count: usize, + pub(super) top_of_mind_count: usize, + pub(super) background_count: usize, + pub(super) stale_count: usize, + pub(super) superseded_count: usize, + pub(super) tombstone_count: usize, + pub(super) derived_project_profile_count: usize, + pub(super) source_ref_required_count: usize, + pub(super) source_ref_entry_count: usize, + pub(super) source_ref_coverage: f64, + pub(super) freshness_marker_count: usize, + pub(super) freshness_coverage: f64, + pub(super) rationale_count: usize, + pub(super) rationale_coverage: f64, + pub(super) invalid_top_of_mind_count: usize, + pub(super) untraced_entry_count: usize, + pub(super) derived_with_source_or_unsupported_count: usize, + pub(super) derived_missing_source_or_unsupported_count: usize, + pub(super) unsupported_derived_entry_count: usize, + pub(super) unsupported_current_entry_count: usize, + pub(super) tombstone_ref_count: usize, + pub(super) source_trace_selected_count: usize, + pub(super) source_trace_dropped_count: usize, + pub(super) source_trace_stale_count: usize, + pub(super) source_trace_superseded_count: usize, + pub(super) source_trace_tombstone_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ProactiveBriefJobMetrics { + pub(super) brief_count: usize, + pub(super) suggestion_count: usize, + pub(super) required_suggestion_kind_count: usize, + pub(super) covered_required_suggestion_kind_count: usize, + pub(super) missing_required_suggestion_kind_count: usize, + pub(super) evidence_ref_required_count: usize, + pub(super) evidence_ref_suggestion_count: usize, + pub(super) evidence_ref_coverage: f64, + pub(super) freshness_marker_count: usize, + pub(super) freshness_coverage: f64, + pub(super) action_rationale_count: usize, + pub(super) action_rationale_coverage: f64, + pub(super) recommended_count: usize, + pub(super) deferred_count: usize, + pub(super) rejected_count: usize, + pub(super) current_suggestion_count: usize, + pub(super) non_current_suggestion_count: usize, + pub(super) stale_warning_count: usize, + pub(super) invalid_current_suggestion_count: usize, + pub(super) untraced_suggestion_count: usize, + pub(super) unsupported_current_suggestion_count: usize, + pub(super) tombstone_violation_count: usize, + pub(super) source_trace_selected_count: usize, + pub(super) source_trace_dropped_count: usize, + pub(super) source_trace_stale_count: usize, + pub(super) source_trace_superseded_count: usize, + pub(super) source_trace_tombstone_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScheduledMemoryJobMetrics { + pub(super) task_run_count: usize, + pub(super) output_count: usize, + pub(super) required_task_kind_count: usize, + pub(super) covered_required_task_kind_count: usize, + pub(super) missing_required_task_kind_count: usize, + pub(super) evidence_ref_required_count: usize, + pub(super) evidence_ref_output_count: usize, + pub(super) evidence_ref_coverage: f64, + pub(super) freshness_marker_count: usize, + pub(super) freshness_coverage: f64, + pub(super) action_rationale_count: usize, + pub(super) action_rationale_coverage: f64, + pub(super) trace_required_count: usize, + pub(super) trace_complete_count: usize, + pub(super) trace_coverage: f64, + pub(super) source_mutation_count: usize, + pub(super) current_output_count: usize, + pub(super) non_current_output_count: usize, + pub(super) invalid_current_output_count: usize, + pub(super) untraced_output_count: usize, + pub(super) unsupported_current_output_count: usize, + pub(super) tombstone_violation_count: usize, + pub(super) source_trace_selected_count: usize, + pub(super) source_trace_dropped_count: usize, + pub(super) source_trace_stale_count: usize, + pub(super) source_trace_superseded_count: usize, + pub(super) source_trace_tombstone_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct WorkContinuityJobMetrics { + pub(super) readback_count: usize, + pub(super) entry_count: usize, + pub(super) reset_resume_required_count: usize, + pub(super) reset_resume_success_count: usize, + pub(super) reset_resume_success_rate: f64, + pub(super) decision_rationale_required_count: usize, + pub(super) decision_rationale_recalled_count: usize, + pub(super) decision_rationale_recall_rate: f64, + pub(super) rejected_option_required_count: usize, + pub(super) rejected_option_suppressed_count: usize, + pub(super) rejected_option_resurrection_count: usize, + pub(super) rejected_option_suppression_rate: f64, + pub(super) explicit_next_step_required_count: usize, + pub(super) explicit_next_step_returned_count: usize, + pub(super) explicit_next_step_correct_count: usize, + pub(super) explicit_next_step_precision: f64, + pub(super) inferred_next_step_required_count: usize, + pub(super) inferred_next_step_labeled_count: usize, + pub(super) inferred_step_instruction_count: usize, + pub(super) inferred_next_step_labeling_rate: f64, + pub(super) handoff_source_ref_required_count: usize, + pub(super) handoff_source_ref_covered_count: usize, + pub(super) handoff_source_ref_coverage: f64, + pub(super) redaction_required_count: usize, + pub(super) redaction_applied_count: usize, + pub(super) sensitive_marker_persistence_count: usize, + pub(super) redaction_rate: f64, + pub(super) janitor_candidate_count: usize, + pub(super) janitor_false_promotion_count: usize, + pub(super) janitor_false_promotion_rate: f64, + pub(super) journal_only_authority_claim_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct EvolutionSummary { + pub(super) stale_answer_count: usize, + pub(super) conflict_detection_count: usize, + pub(super) update_rationale_available_count: usize, + pub(super) temporal_validity_not_encoded_count: usize, + pub(super) history_readback_encoded_count: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct EvolutionJobReport { + pub(super) current_evidence: Vec, + pub(super) historical_evidence: Vec, + pub(super) tombstone_evidence: Vec, + pub(super) invalidation_evidence: Vec, + pub(super) selected_current_evidence: Vec, + pub(super) selected_historical_evidence: Vec, + pub(super) selected_rationale_evidence: Vec, + pub(super) selected_tombstone_evidence: Vec, + pub(super) selected_invalidation_evidence: Vec, + pub(super) conflict_candidate_evidence: Vec, + pub(super) retrieved_but_dropped_evidence: Vec, + pub(super) selected_but_not_narrated_evidence: Vec, + pub(super) stale_trap_ids_used: Vec, + pub(super) stale_answer_count: usize, + pub(super) conflict_count: usize, + pub(super) conflict_detection_count: usize, + pub(super) update_rationale_available: bool, + pub(super) temporal_validity_required: bool, + pub(super) temporal_validity_encoded: bool, + pub(super) temporal_validity_not_encoded: bool, + pub(super) history_readback_encoded: bool, + pub(super) history_event_types: Vec, + pub(super) history_requires_note_version_links: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) follow_up: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct FollowUpReport { + pub(super) suite_id: String, + pub(super) job_id: String, + pub(super) title: String, + pub(super) reason: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct PrivateCorpusRedaction { + pub(super) policy: String, + pub(super) private_fixture_count: usize, +} + +#[derive(Debug)] +pub(super) struct JobScoring { + pub(super) status: TypedStatus, + pub(super) normalized_score: f64, + pub(super) hard_fail_hits: Vec, + pub(super) unsupported_claims: Vec, + pub(super) wrong_result_count: usize, + pub(super) knowledge: Option, + pub(super) trap_ids_used: Vec, + pub(super) dimension_scores: Vec, + pub(super) reason: String, + pub(super) evolution: Option, + pub(super) consolidation: Option, + pub(super) memory_summary: Option, + pub(super) proactive_brief: Option, + pub(super) scheduled_memory: Option, + pub(super) work_continuity: Option, +} + +#[derive(Debug, Default)] +pub(super) struct FailureCounts { + pub(super) missing_claims: usize, + pub(super) forbidden_claims: usize, + pub(super) missing_evidence: usize, + pub(super) trap_uses: usize, + pub(super) unsupported_claims: usize, + pub(super) operator_debug_missing: usize, + pub(super) operator_debug_raw_sql: usize, + pub(super) operator_debug_trace_gaps: usize, + pub(super) operator_debug_repair_unclear: usize, + pub(super) stale_answers: usize, + pub(super) conflict_detection_missing: usize, + pub(super) update_rationale_missing: usize, + pub(super) latency_violations: usize, + pub(super) proposal_usefulness_failures: usize, + pub(super) lineage_failures: usize, + pub(super) review_action_failures: usize, + pub(super) source_mutations: usize, + pub(super) blocking_executable_gaps: usize, + pub(super) memory_summary_invalid_current_entries: usize, + pub(super) memory_summary_untraced_entries: usize, + pub(super) memory_summary_missing_freshness: usize, + pub(super) memory_summary_missing_rationale: usize, + pub(super) memory_summary_missing_categories: usize, + pub(super) memory_summary_unsupported_current_entries: usize, + pub(super) proactive_brief_invalid_current_suggestions: usize, + pub(super) proactive_brief_untraced_suggestions: usize, + pub(super) proactive_brief_missing_freshness: usize, + pub(super) proactive_brief_missing_action_rationale: usize, + pub(super) proactive_brief_missing_kinds: usize, + pub(super) proactive_brief_unsupported_current_suggestions: usize, + pub(super) proactive_brief_tombstone_violations: usize, + pub(super) scheduled_memory_invalid_current_outputs: usize, + pub(super) scheduled_memory_untraced_outputs: usize, + pub(super) scheduled_memory_missing_freshness: usize, + pub(super) scheduled_memory_missing_action_rationale: usize, + pub(super) scheduled_memory_missing_task_kinds: usize, + pub(super) scheduled_memory_unsupported_current_outputs: usize, + pub(super) scheduled_memory_tombstone_violations: usize, + pub(super) scheduled_memory_missing_trace: usize, + pub(super) work_continuity_reset_resume_missing: usize, + pub(super) work_continuity_decision_rationale_missing: usize, + pub(super) work_continuity_rejected_option_unsuppressed: usize, + pub(super) work_continuity_rejected_option_resurrection: usize, + pub(super) work_continuity_explicit_next_step_missing: usize, + pub(super) work_continuity_explicit_next_step_extra: usize, + pub(super) work_continuity_inferred_step_unlabeled: usize, + pub(super) work_continuity_inferred_step_as_instruction: usize, + pub(super) work_continuity_handoff_source_ref_missing: usize, + pub(super) work_continuity_redaction_missing: usize, + pub(super) work_continuity_sensitive_marker_persistence: usize, + pub(super) work_continuity_janitor_false_promotion: usize, + pub(super) work_continuity_journal_only_authority_claim: usize, + pub(super) untraced_page_sections: usize, + pub(super) missed_stale_findings: usize, + pub(super) rebuild_failures: usize, + pub(super) page_usefulness_failures: usize, +} + +#[derive(Debug, Default)] +pub(super) struct JobMetrics { + pub(super) evidence_required_count: usize, + pub(super) evidence_covered_count: usize, + pub(super) source_ref_required_count: usize, + pub(super) source_ref_covered_count: usize, + pub(super) quote_required_count: usize, + pub(super) quote_covered_count: usize, + pub(super) stale_retrieval_count: usize, + pub(super) scope_check_count: usize, + pub(super) scope_correct_count: usize, + pub(super) scope_violation_count: usize, + pub(super) redaction_leak_count: usize, + pub(super) qdrant_rebuild_case: bool, +} + +pub(super) struct ScoreboardRankedMetrics { + pub(super) relevant_at_k: usize, + pub(super) precision_denominator_at_k: usize, + pub(super) reciprocal_rank: f64, + pub(super) ndcg: f64, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown.rs new file mode 100644 index 00000000..306738b9 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown.rs @@ -0,0 +1,54 @@ +#[path = "markdown/adapters.rs"] mod adapters; +#[path = "markdown/common.rs"] mod common; +#[path = "markdown/domain_metrics.rs"] mod domain_metrics; +#[path = "markdown/evolution.rs"] mod evolution; +#[path = "markdown/followups.rs"] mod followups; +#[path = "markdown/header.rs"] mod header; +#[path = "markdown/jobs.rs"] mod jobs; +#[path = "markdown/operational.rs"] mod operational; +#[path = "markdown/scoreboard.rs"] mod scoreboard; +#[path = "markdown/trace.rs"] mod trace; + +use std::path::Path; + +use self::common::{bool_display, cost_display, md_cell, md_inline, md_list, md_url, optional_f64}; +use crate::{ + AdapterScenarioJudgment, AdapterSource, AdapterStatusCounts, AdapterSuiteCoverage, CostReport, + DEFAULT_ADAPTER_BEHAVIOR, EvolutionJobReport, ExternalAdapterReport, KnowledgeSummary, + MemorySummaryReport, OperatorDebugEvidence, OperatorUxGap, ProactiveBriefSummaryReport, + RealWorldReport, ReportSummary, SCOREBOARD_EVIDENCE_CLASSES, ScenarioOutcomeCounts, + ScenarioPositionCounts, ScheduledMemorySummaryReport, ScoreboardReport, ScoreboardRow, + TraceExplainability, WorkContinuitySummaryReport, + formatting::{ + adapter_status_str, round3, scenario_comparison_outcome_str, status_str, + trace_failure_stage, + }, + scenario_comparison_outcome, +}; + +pub(super) fn render_markdown(report: &RealWorldReport, report_path: &Path) -> String { + let report_path = report_path.display().to_string(); + let mut out = String::new(); + + self::header::render_markdown_header(&mut out, report, report_path.as_str()); + self::scoreboard::render_markdown_scoreboard(&mut out, report); + self::operational::render_markdown_operational_evidence(&mut out, report); + self::adapters::render_markdown_external_adapters(&mut out, report); + self::adapters::render_markdown_capture_integration(&mut out, report); + self::jobs::render_markdown_suites(&mut out, report); + self::jobs::render_markdown_jobs(&mut out, report); + self::jobs::render_markdown_operator_debugging(&mut out, report); + self::evolution::render_markdown_evolution(&mut out, report); + self::trace::render_markdown_trace_explainability(&mut out, report); + self::domain_metrics::render_markdown_consolidation(&mut out, report); + self::domain_metrics::render_markdown_memory_summary(&mut out, report); + self::domain_metrics::render_markdown_proactive_brief(&mut out, report); + self::domain_metrics::render_markdown_scheduled_memory(&mut out, report); + self::domain_metrics::render_markdown_work_continuity(&mut out, report); + self::domain_metrics::render_markdown_knowledge(&mut out, report); + self::followups::render_markdown_unsupported_claims(&mut out, report); + self::followups::render_markdown_follow_ups(&mut out, report); + self::followups::render_markdown_semantics(&mut out, report); + + out +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/adapters.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/adapters.rs new file mode 100644 index 00000000..58937dc9 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/adapters.rs @@ -0,0 +1,339 @@ +use crate::markdown::{ + self, AdapterScenarioJudgment, AdapterSource, AdapterStatusCounts, AdapterSuiteCoverage, + DEFAULT_ADAPTER_BEHAVIOR, ExternalAdapterReport, RealWorldReport, ScenarioOutcomeCounts, + ScenarioPositionCounts, +}; + +pub(super) fn render_markdown_capture_integration(out: &mut String, report: &RealWorldReport) { + out.push_str("## Capture And Integration Coverage\n\n"); + + if report.adapter.behavior == DEFAULT_ADAPTER_BEHAVIOR { + out.push_str("The real-world job runner is fixture-backed. This section separates encoded evidence from live adapter claims.\n\n"); + } else { + out.push_str("This report scores materialized adapter responses. Capture and integration classes still describe the job corpus, not broad external adapter coverage.\n\n"); + } + + out.push_str("| Class | Behaviors |\n"); + out.push_str("| --- | --- |\n"); + out.push_str(&format!( + "| real | {} |\n", + markdown::md_list(report.capture_integration.real.as_slice()) + )); + out.push_str(&format!( + "| fixture-backed | {} |\n", + markdown::md_list(report.capture_integration.fixture_backed.as_slice()) + )); + out.push_str(&format!( + "| mocked | {} |\n", + markdown::md_list(report.capture_integration.mocked.as_slice()) + )); + out.push_str(&format!( + "| blocked | {} |\n", + markdown::md_list(report.capture_integration.blocked.as_slice()) + )); + out.push_str(&format!( + "| not encoded | {} |\n", + markdown::md_list(report.capture_integration.not_encoded.as_slice()) + )); + + if !report.capture_integration.notes.is_empty() { + out.push_str("\nNotes:\n"); + + for note in &report.capture_integration.notes { + out.push_str(&format!("- {}\n", markdown::md_cell(note.as_str()))); + } + } + + out.push('\n'); +} + +pub(super) fn render_markdown_external_adapters(out: &mut String, report: &RealWorldReport) { + out.push_str("## External Adapter Coverage\n\n"); + + if report.external_adapters.adapters.is_empty() { + out.push_str("No external adapter coverage manifest was loaded for this report.\n\n"); + + return; + } + + let summary = &report.external_adapters.summary; + + out.push_str("This section is manifest-backed. It records external adapter coverage and blockers, but it does not convert live-baseline retrieval results into real-world suite wins.\n\n"); + out.push_str(&format!( + "- Manifest: `{}`\n", + markdown::md_inline(report.external_adapters.manifest_id.as_str()) + )); + out.push_str(&format!( + "- Docker default: `{}` via `{}`; artifact dir `{}`\n", + report.external_adapters.docker_isolation.default, + markdown::md_inline(report.external_adapters.docker_isolation.compose_file.as_str()), + markdown::md_inline(report.external_adapters.docker_isolation.artifact_dir.as_str()) + )); + out.push_str(&format!( + "- Adapter records: `{}` total, `{}` external project(s), `{}` Docker-default, `{}` requiring host-global installs\n", + summary.adapter_count, + summary.external_project_count, + summary.docker_default_count, + summary.host_global_install_required_count + )); + out.push_str(&format!( + "- Evidence classes: `{}` fixture-backed, `{}` live-baseline-only, `{}` live real-world, `{}` research-gate\n", + summary.fixture_backed_count, + summary.live_baseline_only_count, + summary.live_real_world_count, + summary.research_gate_count + )); + out.push_str(&format!( + "- Overall statuses: `{}`\n", + adapter_status_counts_display(&summary.overall_status_counts) + )); + out.push_str(&format!( + "- Capability coverage statuses: `{}`\n", + adapter_status_counts_display(&summary.capability_status_counts) + )); + out.push_str(&format!( + "- Real-world suite statuses: `{}`\n", + adapter_status_counts_display(&summary.suite_status_counts) + )); + + if has_adapter_scenarios(report.external_adapters.adapters.as_slice()) { + out.push_str(&format!( + "- Scenario coverage statuses: `{}`\n", + adapter_status_counts_display(&summary.scenario_status_counts) + )); + out.push_str(&format!( + "- ELF scenario positions: `{}`\n", + scenario_position_counts_display(&summary.scenario_position_counts) + )); + out.push_str(&format!( + "- Scenario comparison outcomes: `{}`\n", + scenario_outcome_counts_display(&summary.scenario_outcome_counts) + )); + } + + out.push('\n'); + out.push_str("| Project | Adapter | Evidence Class | Overall | Setup | Run | Result | Docker | Suites | Evidence |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n"); + + for adapter in &report.external_adapters.adapters { + out.push_str(&format!( + "| {} | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | {} | {} |\n", + markdown::md_cell(adapter.project.as_str()), + markdown::md_inline(adapter.adapter_id.as_str()), + markdown::md_inline(adapter.evidence_class.as_str()), + markdown::adapter_status_str(adapter.overall_status), + markdown::adapter_status_str(adapter.setup.status), + markdown::adapter_status_str(adapter.run.status), + markdown::adapter_status_str(adapter.result.status), + adapter.docker_default, + adapter_suite_cell(adapter.suites.as_slice()), + adapter_evidence_cell(adapter) + )); + } + + out.push_str("\n### Adapter Capability Details\n\n"); + out.push_str("| Adapter | Capability | Status | Evidence |\n"); + out.push_str("| --- | --- | --- | --- |\n"); + + for adapter in &report.external_adapters.adapters { + for capability in &adapter.capabilities { + out.push_str(&format!( + "| `{}` | {} | `{}` | {} |\n", + markdown::md_inline(adapter.adapter_id.as_str()), + markdown::md_cell(capability.capability.as_str()), + markdown::adapter_status_str(capability.status), + markdown::md_cell(capability.evidence.as_str()) + )); + } + } + + render_markdown_adapter_scenarios(out, report.external_adapters.adapters.as_slice()); + render_markdown_adapter_execution_metadata(out, report.external_adapters.adapters.as_slice()); + + out.push('\n'); +} + +fn render_markdown_adapter_scenarios(out: &mut String, adapters: &[ExternalAdapterReport]) { + if !has_adapter_scenarios(adapters) { + return; + } + + out.push_str("\n### Adapter Scenario Judgments\n\n"); + out.push_str("| Adapter | Scenario | Suite | Status | Outcome | Evidence |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- |\n"); + + for adapter in adapters { + for scenario in &adapter.scenarios { + out.push_str(&format!( + "| `{}` | `{}` | {} | `{}` | `{}` | {} |\n", + markdown::md_inline(adapter.adapter_id.as_str()), + markdown::md_inline(scenario.scenario_id.as_str()), + scenario + .suite_id + .as_deref() + .map(|suite| format!("`{}`", markdown::md_inline(suite))) + .unwrap_or_else(|| "`none`".to_string()), + markdown::adapter_status_str(scenario.status), + markdown::scenario_comparison_outcome_str(markdown::scenario_comparison_outcome( + scenario + )), + adapter_scenario_evidence_cell(scenario) + )); + } + } +} + +fn render_markdown_adapter_execution_metadata( + out: &mut String, + adapters: &[ExternalAdapterReport], +) { + let mut wrote_header = false; + + for adapter in adapters { + let Some(metadata) = &adapter.execution_metadata else { + continue; + }; + + if !wrote_header { + out.push_str("\n### Adapter Execution Metadata\n\n"); + out.push_str("| Adapter | Sources | Setup Path | Runtime Boundary | Resource Expectation | Retry Guidance | Research Depth |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- | --- |\n"); + + wrote_header = true; + } + + out.push_str(&format!( + "| `{}` | {} | {} | {} | {} | {} | {} |\n", + markdown::md_inline(adapter.adapter_id.as_str()), + adapter_sources_cell(metadata.sources.as_slice()), + markdown::md_cell(metadata.setup_path.as_str()), + markdown::md_cell(metadata.runtime_boundary.as_str()), + markdown::md_cell(metadata.resource_expectation.as_str()), + markdown::md_list(metadata.retry_guidance.as_slice()), + markdown::md_cell(metadata.research_depth.as_deref().unwrap_or("not recorded")) + )); + } +} + +fn has_adapter_scenarios(adapters: &[ExternalAdapterReport]) -> bool { + adapters.iter().any(|adapter| !adapter.scenarios.is_empty()) +} + +fn adapter_status_counts_display(counts: &AdapterStatusCounts) -> String { + [ + ("real", counts.real), + ("mocked", counts.mocked), + ("unsupported", counts.unsupported), + ("blocked", counts.blocked), + ("incomplete", counts.incomplete), + ("wrong_result", counts.wrong_result), + ("lifecycle_fail", counts.lifecycle_fail), + ("pass", counts.pass), + ("not_encoded", counts.not_encoded), + ] + .into_iter() + .filter(|(_, count)| *count > 0) + .map(|(status, count)| format!("{status}={count}")) + .collect::>() + .join(", ") +} + +fn scenario_position_counts_display(counts: &ScenarioPositionCounts) -> String { + [ + ("wins", counts.wins), + ("ties", counts.ties), + ("loses", counts.loses), + ("untested", counts.untested), + ] + .into_iter() + .filter(|(_, count)| *count > 0) + .map(|(position, count)| format!("{position}={count}")) + .collect::>() + .join(", ") +} + +fn scenario_outcome_counts_display(counts: &ScenarioOutcomeCounts) -> String { + [ + ("win", counts.win), + ("tie", counts.tie), + ("loss", counts.loss), + ("not_tested", counts.not_tested), + ("blocked", counts.blocked), + ("non_goal", counts.non_goal), + ] + .into_iter() + .filter(|(_, count)| *count > 0) + .map(|(outcome, count)| format!("{outcome}={count}")) + .collect::>() + .join(", ") +} + +fn adapter_suite_cell(suites: &[AdapterSuiteCoverage]) -> String { + if suites.is_empty() { + return "`none`".to_string(); + } + + suites + .iter() + .map(|suite| { + format!( + "`{}`: `{}`", + markdown::md_inline(suite.suite_id.as_str()), + markdown::adapter_status_str(suite.status) + ) + }) + .collect::>() + .join("
") +} + +fn adapter_evidence_cell(adapter: &ExternalAdapterReport) -> String { + let setup = adapter + .setup + .command + .as_deref() + .or(adapter.setup.artifact.as_deref()) + .unwrap_or(adapter.setup.evidence.as_str()); + let result = adapter + .result + .artifact + .as_deref() + .or(adapter.result.command.as_deref()) + .unwrap_or(adapter.result.evidence.as_str()); + + format!("setup: `{}`
result: `{}`", markdown::md_inline(setup), markdown::md_inline(result)) +} + +fn adapter_scenario_evidence_cell(scenario: &AdapterScenarioJudgment) -> String { + let evidence = markdown::md_cell(scenario.evidence.as_str()); + let command = scenario + .command + .as_deref() + .map(|command| format!("
command: `{}`", markdown::md_inline(command))) + .unwrap_or_default(); + let artifact = scenario + .artifact + .as_deref() + .map(|artifact| format!("
artifact: `{}`", markdown::md_inline(artifact))) + .unwrap_or_default(); + + format!("{evidence}{command}{artifact}") +} + +fn adapter_sources_cell(sources: &[AdapterSource]) -> String { + if sources.is_empty() { + return "`none`".to_string(); + } + + sources + .iter() + .map(|source| { + format!( + "[{}]({}): {}", + markdown::md_cell(source.label.as_str()), + markdown::md_url(source.url.as_str()), + markdown::md_cell(source.evidence.as_str()) + ) + }) + .collect::>() + .join("
") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/common.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/common.rs new file mode 100644 index 00000000..63ab4ec1 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/common.rs @@ -0,0 +1,41 @@ +use crate::markdown::CostReport; + +pub(super) fn optional_f64(value: Option, suffix: &str) -> String { + value.map(|value| format!("{value:.3}{suffix}")).unwrap_or_else(|| "-".to_string()) +} + +pub(super) fn bool_display(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +pub(super) fn cost_display(cost: Option<&CostReport>) -> String { + let Some(cost) = cost else { + return "-".to_string(); + }; + + match (cost.amount, cost.currency.as_deref()) { + (Some(amount), Some(currency)) => format!("{amount:.3} {currency}"), + (Some(amount), None) => format!("{amount:.3}"), + (None, _) => "-".to_string(), + } +} + +pub(super) fn md_inline(value: &str) -> String { + value.replace('`', "'").replace('\n', " ") +} + +pub(super) fn md_cell(value: &str) -> String { + md_inline(value).replace('|', "\\|") +} + +pub(super) fn md_url(value: &str) -> String { + value.replace(')', "%29").replace(' ', "%20") +} + +pub(super) fn md_list(values: &[String]) -> String { + if values.is_empty() { + return "-".to_string(); + } + + md_cell(values.join("; ").as_str()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/domain_metrics.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/domain_metrics.rs new file mode 100644 index 00000000..76e0cdf1 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/domain_metrics.rs @@ -0,0 +1,284 @@ +use crate::markdown::{self, RealWorldReport}; + +pub(super) fn render_markdown_consolidation(out: &mut String, report: &RealWorldReport) { + if report.summary.consolidation.proposal_count == 0 { + return; + } + + out.push_str("## Consolidation\n\n"); + out.push_str("| Job | Proposals | Usefulness | Lineage | Review Actions | Source Mutations | Proposal Unsupported Claims | Executable Gaps |\n"); + out.push_str("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n"); + + for job in &report.jobs { + let Some(consolidation) = &job.consolidation else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | `{}` | `{}` | `{}` | {} | {} | {} |\n", + markdown::md_cell(job.job_id.as_str()), + consolidation.proposal_count, + markdown::optional_f64(consolidation.proposal_usefulness, ""), + markdown::optional_f64(consolidation.lineage_completeness, ""), + markdown::optional_f64(consolidation.review_action_correctness, ""), + consolidation.source_mutation_count, + consolidation.proposal_unsupported_claim_count, + consolidation.executable_gaps.len() + )); + } + + out.push_str( + "\nSource mutation count must remain `0` for proposal-only consolidation cases.\n\n", + ); + + render_markdown_consolidation_gaps(out, report); +} + +pub(super) fn render_markdown_knowledge(out: &mut String, report: &RealWorldReport) { + let knowledge_jobs = + report.jobs.iter().filter(|job| job.knowledge.is_some()).collect::>(); + + if knowledge_jobs.is_empty() { + return; + } + + out.push_str("## Knowledge Page Metrics\n\n"); + out.push_str("| Job | Pages | Sections | Citation Coverage | Stale Claim Detection | Rebuild Determinism | Version Diff Coverage | Page Usefulness | Backlinks | Unsupported Summaries | Untraced Sections | Allowed Variance |\n"); + out.push_str( + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", + ); + + for job in knowledge_jobs { + let Some(knowledge) = &job.knowledge else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | {} | `{:.3}` | `{:.3}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} |\n", + markdown::md_cell(job.job_id.as_str()), + knowledge.page_count, + knowledge.section_count, + knowledge.citation_coverage, + knowledge.stale_claim_detection, + knowledge.rebuild_determinism, + knowledge.version_diff_coverage, + knowledge.page_usefulness, + knowledge.backlink_count, + knowledge.unsupported_summary_count, + knowledge.untraced_section_count, + knowledge.allowed_variance_count + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_memory_summary(out: &mut String, report: &RealWorldReport) { + let memory_jobs = + report.jobs.iter().filter(|job| job.memory_summary.is_some()).collect::>(); + + if memory_jobs.is_empty() { + return; + } + + out.push_str("## Memory Summary Metrics\n\n"); + out.push_str("| Job | Summaries | Entries | Categories | Source Coverage | Freshness | Rationale | Invalid Top-of-Mind | Untraced | Derived Unsupported | Unsupported Current | Tombstone Refs |\n"); + out.push_str( + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", + ); + + for job in memory_jobs { + let Some(metrics) = &job.memory_summary else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | {} | `{}/{}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} | {} |\n", + markdown::md_cell(job.job_id.as_str()), + metrics.summary_count, + metrics.entry_count, + metrics.covered_required_category_count, + metrics.required_category_count, + metrics.source_ref_coverage, + metrics.freshness_coverage, + metrics.rationale_coverage, + metrics.invalid_top_of_mind_count, + metrics.untraced_entry_count, + metrics.unsupported_derived_entry_count, + metrics.unsupported_current_entry_count, + metrics.tombstone_ref_count + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_proactive_brief(out: &mut String, report: &RealWorldReport) { + let proactive_jobs = + report.jobs.iter().filter(|job| job.proactive_brief.is_some()).collect::>(); + + if proactive_jobs.is_empty() { + return; + } + + out.push_str("## Proactive Brief Metrics\n\n"); + out.push_str("| Job | Briefs | Suggestions | Kinds | Evidence Coverage | Freshness | Action Rationale | Invalid Current | Untraced | Unsupported Current | Tombstone Violations | Rejected | Deferred |\n"); + out.push_str( + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", + ); + + for job in proactive_jobs { + let Some(metrics) = &job.proactive_brief else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | {} | `{}/{}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} | {} | {} |\n", + markdown::md_cell(job.job_id.as_str()), + metrics.brief_count, + metrics.suggestion_count, + metrics.covered_required_suggestion_kind_count, + metrics.required_suggestion_kind_count, + metrics.evidence_ref_coverage, + metrics.freshness_coverage, + metrics.action_rationale_coverage, + metrics.invalid_current_suggestion_count, + metrics.untraced_suggestion_count, + metrics.unsupported_current_suggestion_count, + metrics.tombstone_violation_count, + metrics.rejected_count, + metrics.deferred_count + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_scheduled_memory(out: &mut String, report: &RealWorldReport) { + let scheduled_jobs = + report.jobs.iter().filter(|job| job.scheduled_memory.is_some()).collect::>(); + + if scheduled_jobs.is_empty() { + return; + } + + out.push_str("## Scheduled Memory Metrics\n\n"); + out.push_str("| Job | Task Runs | Outputs | Kinds | Evidence Coverage | Freshness | Action Rationale | Trace Coverage | Invalid Current | Untraced | Unsupported Current | Tombstone Violations | Source Mutations |\n"); + out.push_str( + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", + ); + + for job in scheduled_jobs { + let Some(metrics) = &job.scheduled_memory else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | {} | `{}/{}` | `{:.3}` | `{:.3}` | `{:.3}` | `{:.3}` | {} | {} | {} | {} | {} |\n", + markdown::md_cell(job.job_id.as_str()), + metrics.task_run_count, + metrics.output_count, + metrics.covered_required_task_kind_count, + metrics.required_task_kind_count, + metrics.evidence_ref_coverage, + metrics.freshness_coverage, + metrics.action_rationale_coverage, + metrics.trace_coverage, + metrics.invalid_current_output_count, + metrics.untraced_output_count, + metrics.unsupported_current_output_count, + metrics.tombstone_violation_count, + metrics.source_mutation_count + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_work_continuity(out: &mut String, report: &RealWorldReport) { + let work_jobs = + report.jobs.iter().filter(|job| job.work_continuity.is_some()).collect::>(); + + if work_jobs.is_empty() { + return; + } + + out.push_str("## Work Continuity Metrics\n\n"); + out.push_str("| Job | Readbacks | Entries | Reset/Resume | Decision Rationale | Rejected Suppression | Explicit Precision | Inferred Labeling | Handoff Sources | Redaction | Janitor False Promotion | Sensitive Persistence | Journal Authority Claims |\n"); + out.push_str( + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n", + ); + + for job in work_jobs { + let Some(metrics) = &job.work_continuity else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | {} | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | `{}/{}` (`{:.3}`) | {} | {} |\n", + markdown::md_cell(job.job_id.as_str()), + metrics.readback_count, + metrics.entry_count, + metrics.reset_resume_success_count, + metrics.reset_resume_required_count, + metrics.reset_resume_success_rate, + metrics.decision_rationale_recalled_count, + metrics.decision_rationale_required_count, + metrics.decision_rationale_recall_rate, + metrics.rejected_option_suppressed_count, + metrics.rejected_option_required_count, + metrics.rejected_option_suppression_rate, + metrics.explicit_next_step_correct_count, + metrics.explicit_next_step_returned_count, + metrics.explicit_next_step_precision, + metrics.inferred_next_step_labeled_count, + metrics.inferred_next_step_required_count, + metrics.inferred_next_step_labeling_rate, + metrics.handoff_source_ref_covered_count, + metrics.handoff_source_ref_required_count, + metrics.handoff_source_ref_coverage, + metrics.redaction_applied_count, + metrics.redaction_required_count, + metrics.redaction_rate, + metrics.janitor_false_promotion_count, + metrics.janitor_candidate_count, + metrics.janitor_false_promotion_rate, + metrics.sensitive_marker_persistence_count, + metrics.journal_only_authority_claim_count + )); + } + + out.push('\n'); +} + +fn render_markdown_consolidation_gaps(out: &mut String, report: &RealWorldReport) { + let gaps = report + .jobs + .iter() + .filter_map(|job| job.consolidation.as_ref().map(|consolidation| (job, consolidation))) + .flat_map(|(job, consolidation)| { + consolidation.executable_gaps.iter().map(move |gap| (job.job_id.as_str(), gap)) + }) + .collect::>(); + + if gaps.is_empty() { + return; + } + + out.push_str("### Executable Gaps\n\n"); + out.push_str("| Job | Primitive | Follow-Up Issue | Blocks Fixture Pass | Reason |\n"); + out.push_str("| --- | --- | --- | --- | --- |\n"); + + for (job_id, gap) in gaps { + out.push_str(&format!( + "| {} | {} | {} | `{}` | {} |\n", + markdown::md_cell(job_id), + markdown::md_cell(gap.primitive.as_str()), + markdown::md_cell(gap.follow_up_issue.as_str()), + gap.blocks_fixture_pass, + markdown::md_cell(gap.reason.as_str()) + )); + } + + out.push('\n'); +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/evolution.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/evolution.rs new file mode 100644 index 00000000..cc18f932 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/evolution.rs @@ -0,0 +1,97 @@ +use crate::markdown::{self, EvolutionJobReport, RealWorldReport}; + +pub(super) fn render_markdown_evolution(out: &mut String, report: &RealWorldReport) { + out.push_str("## Memory Evolution\n\n"); + out.push_str(&format!("- Stale answers: `{}`\n", report.evolution.stale_answer_count)); + out.push_str(&format!( + "- Conflict detections: `{}`\n", + report.evolution.conflict_detection_count + )); + out.push_str(&format!( + "- Update rationales available: `{}`\n", + report.evolution.update_rationale_available_count + )); + out.push_str(&format!( + "- Temporal validity not encoded: `{}`\n\n", + report.evolution.temporal_validity_not_encoded_count + )); + out.push_str(&format!( + "- History readback encoded: `{}`\n\n", + report.evolution.history_readback_encoded_count + )); + out.push_str("| Suite | Job | Current Evidence | Historical Evidence | Tombstone/Invalidation | Selected Current | Selected Historical | Selected Rationale | Selected Tombstone/Invalidation | Selected But Not Narrated | Stale Traps Used | Conflict Count | Detected | Update Rationale | Temporal Validity | History Readback | Follow-up |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | ---: | ---: | --- | --- | --- | --- |\n"); + + for job in &report.jobs { + let Some(evolution) = &job.evolution else { + continue; + }; + + out.push_str(&format!( + "| {} | {} | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | {} | {} | `{}` | `{}` | `{}` | {} |\n", + markdown::md_cell(job.suite_id.as_str()), + markdown::md_cell(job.job_id.as_str()), + markdown::md_inline(evolution.current_evidence.join(", ").as_str()), + markdown::md_inline(evolution.historical_evidence.join(", ").as_str()), + markdown::md_inline( + evolution + .tombstone_evidence + .iter() + .chain(evolution.invalidation_evidence.iter()) + .cloned() + .collect::>() + .join(", ") + .as_str() + ), + markdown::md_inline(evolution.selected_current_evidence.join(", ").as_str()), + markdown::md_inline(evolution.selected_historical_evidence.join(", ").as_str()), + markdown::md_inline(evolution.selected_rationale_evidence.join(", ").as_str()), + markdown::md_inline( + evolution + .selected_tombstone_evidence + .iter() + .chain(evolution.selected_invalidation_evidence.iter()) + .cloned() + .collect::>() + .join(", ") + .as_str() + ), + markdown::md_inline(evolution.selected_but_not_narrated_evidence.join(", ").as_str()), + markdown::md_inline(evolution.stale_trap_ids_used.join(", ").as_str()), + evolution.conflict_count, + evolution.conflict_detection_count, + markdown::bool_display(evolution.update_rationale_available), + temporal_display(evolution), + history_display(evolution), + markdown::md_cell(evolution.follow_up.as_deref().unwrap_or("-")) + )); + } + + out.push('\n'); +} + +fn temporal_display(evolution: &EvolutionJobReport) -> &'static str { + if evolution.temporal_validity_not_encoded { + "not_encoded" + } else if evolution.temporal_validity_encoded { + "encoded" + } else if evolution.temporal_validity_required { + "required" + } else { + "-" + } +} + +fn history_display(evolution: &EvolutionJobReport) -> String { + if !evolution.history_readback_encoded { + return "-".to_string(); + } + + let mut parts = vec![format!("events={}", evolution.history_event_types.join(","))]; + + if evolution.history_requires_note_version_links { + parts.push("note_version_links=true".to_string()); + } + + parts.join(";") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/followups.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/followups.rs new file mode 100644 index 00000000..84fd7d33 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/followups.rs @@ -0,0 +1,99 @@ +use crate::markdown::{self, RealWorldReport}; + +pub(super) fn render_markdown_unsupported_claims(out: &mut String, report: &RealWorldReport) { + out.push_str("## Unsupported Claims\n\n"); + + if report.unsupported_claims.is_empty() { + out.push_str("No unsupported claims were produced by encoded jobs.\n\n"); + + return; + } + + out.push_str("| Suite | Job | Claim | Evidence | Reason |\n"); + out.push_str("| --- | --- | --- | --- | --- |\n"); + + for claim in &report.unsupported_claims { + out.push_str(&format!( + "| {} | {} | {} | `{}` | {} |\n", + markdown::md_cell(claim.suite_id.as_str()), + markdown::md_cell(claim.job_id.as_str()), + markdown::md_cell(claim.claim_text.as_str()), + markdown::md_inline(claim.evidence_ids.join(", ").as_str()), + markdown::md_cell(claim.reason.as_str()) + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_follow_ups(out: &mut String, report: &RealWorldReport) { + out.push_str("## Follow-Ups\n\n"); + + if report.follow_ups.is_empty() { + out.push_str("No benchmark follow-ups were declared by encoded jobs.\n\n"); + + return; + } + + out.push_str("| Suite | Job | Follow-up | Reason |\n"); + out.push_str("| --- | --- | --- | --- |\n"); + + for follow_up in &report.follow_ups { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + markdown::md_cell(follow_up.suite_id.as_str()), + markdown::md_cell(follow_up.job_id.as_str()), + markdown::md_cell(follow_up.title.as_str()), + markdown::md_cell(follow_up.reason.as_str()) + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_semantics(out: &mut String, report: &RealWorldReport) { + out.push_str("## Result Semantics\n\n"); + out.push_str( + "This report uses `docs/spec/real_world_agent_memory_benchmark_v1.md` status terms.\n", + ); + out.push_str("It is a real-world job fixture report, not a Docker live-baseline report.\n"); + out.push_str("Existing live-baseline reports remain valid for their encoded retrieval and lifecycle checks and are not reinterpreted as real-world suite wins.\n\n"); + out.push_str( + "The summary counters report required evidence coverage, source-ref coverage, quote coverage, expected evidence recall, irrelevant context ratio, trace explainability, stale retrievals, scope violations, redaction leaks, Qdrant rebuild case coverage, stale answers, conflict detections, update rationale availability, and temporal validity gaps across encoded jobs.\n\n", + ); + out.push_str( + "- `pass`: encoded jobs met their pass threshold with required evidence and no hard-fail rule.\n", + ); + out.push_str( + "- `wrong_result`: a job completed but missed required answer or evidence expectations.\n", + ); + out.push_str("- `incomplete`: the runner or adapter did not reach the behavioral check.\n"); + out.push_str("- `blocked`: required credentials, private input, product runtime, or host integration is outside the run scope.\n"); + out.push_str( + "- `not_tested`: a comparison row or report slice has no executed benchmark evidence.\n", + ); + out.push_str("- `unsupported_claim`: a job produced a substantive claim not supported by the fixture evidence links.\n"); + out.push_str("- `not_encoded`: a suite has no checked-in fixture, or an encoded fixture declares a capability gap so no pass/fail claim is allowed.\n"); + out.push_str( + "- `fixture_backed`: checked-in fixtures were scored; no live product execution is implied.\n", + ); + out.push_str("- `live_baseline`: Docker live-baseline retrieval or lifecycle evidence exists, but it is not a real-world suite pass by itself.\n"); + out.push_str("- `live_real_world`: a live adapter ran the real-world job contract and reported typed outcomes.\n"); + out.push_str("- `research_gate`: research, setup, source mapping, or resource gates are recorded before a fair benchmark can run.\n\n"); + out.push_str("Any `wrong_result`, `incomplete`, `blocked`, `not_tested`, `not_encoded`, `unsupported_claim`, or non-live evidence class must remain visible and must not be counted as a win.\n\n"); + out.push_str("For `knowledge_compilation` jobs, generated pages are benchmark artifacts. Page sections must cite source evidence or timeline events, or be explicitly flagged as unsupported. Flagged unsupported summaries are counted separately from hidden unsupported claims.\n\n"); + out.push_str("For `source_library` jobs, saved long-form material and social/thread captures are source records, not durable Memory Notes. Source records must preserve canonical source metadata, source_ref hydration pointers, and explicit promotion boundaries before any memory write is claimed.\n\n"); + out.push_str("For `memory_summary` jobs, summary artifacts are derived review surfaces. Top-of-mind entries must be current, included or downgraded entries must carry source refs, and derived project-profile entries must either cite sources or be explicitly flagged as unsupported.\n\n"); + out.push_str("For `proactive_brief` jobs, brief artifacts are fixture-scored derived outputs, not scheduled UI behavior. Every suggestion must carry evidence refs, freshness/currentness metadata, and an action rationale; stale, superseded, or tombstoned sources must not be presented as current recommendations.\n\n"); + out.push_str("For `scheduled_memory` jobs, task artifacts are deterministic fixture-scored stand-ins for asynchronous work. Every output must carry evidence refs, freshness/currentness metadata, action rationale, and execution trace/readback evidence; scheduled tasks must not mutate source notes silently or claim hosted scheduler/private-provider parity from fixture-only output.\n\n"); + out.push_str("For `work_continuity` jobs, Work Journal entries are source-adjacent readback artifacts, not current fact authority. Reset/resume, decisions, rejected options, next steps, handoff refs, redactions, and janitor candidates must preserve source refs and promotion boundaries; sensitive marker persistence, rejected-option resurrection, inferred next steps treated as instructions, and journal-only authority claims are hard fails.\n\n"); + out.push_str("## Suites With `not_encoded` Status\n\n"); + + if report.not_encoded_suites.is_empty() { + out.push_str("All declared suites have at least one encoded job.\n"); + } else { + for suite in &report.not_encoded_suites { + out.push_str(&format!("- `{}`\n", markdown::md_inline(suite.as_str()))); + } + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/header.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/header.rs new file mode 100644 index 00000000..44fbb9b5 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/header.rs @@ -0,0 +1,306 @@ +use crate::markdown::{ + self, KnowledgeSummary, MemorySummaryReport, ProactiveBriefSummaryReport, RealWorldReport, + ReportSummary, ScheduledMemorySummaryReport, WorkContinuitySummaryReport, +}; + +pub(super) fn render_markdown_header( + out: &mut String, + report: &RealWorldReport, + report_path: &str, +) { + out.push_str("# Real-World Job Benchmark Report\n\n"); + out.push_str( + "Goal: Publish a Markdown summary for one generated real_world_job benchmark report.\n", + ); + out.push_str( + "Read this when: You need a durable smoke report for real-world agent memory job fixtures.\n", + ); + out.push_str(&format!("Inputs: `{}`.\n", markdown::md_inline(report_path))); + out.push_str("Depends on: `apps/elf-eval/fixtures/`, `docs/spec/real_world_agent_memory_benchmark_v1.md`, and `Makefile.toml`.\n"); + out.push_str( + "Verification: Compare this Markdown summary with the source JSON before committing.\n\n", + ); + out.push_str("## Summary\n\n"); + out.push_str(&format!("- Run ID: `{}`\n", markdown::md_inline(report.run_id.as_str()))); + out.push_str(&format!( + "- Generated at: `{}`\n", + markdown::md_inline(report.generated_at.as_str()) + )); + out.push_str(&format!( + "- Runner version: `{}`\n", + markdown::md_inline(report.runner_version.as_str()) + )); + out.push_str(&format!( + "- Corpus profile: `{}`\n", + markdown::md_inline(report.corpus_profile.as_str()) + )); + out.push_str(&format!( + "- Adapter: `{}` ({})\n", + markdown::md_inline(report.adapter.adapter_id.as_str()), + markdown::md_inline(report.adapter.behavior.as_str()) + )); + out.push_str(&format!("- Jobs: `{}`\n", report.summary.job_count)); + out.push_str(&format!( + "- Suites with encoded jobs: `{}`\n", + report.summary.encoded_suite_count + )); + out.push_str(&format!( + "- Suites with `not_encoded` status: `{}`\n", + report.not_encoded_suites.len() + )); + out.push_str(&format!("- Status summary: `{}` pass, `{}` wrong_result, `{}` lifecycle_fail, `{}` incomplete, `{}` blocked, `{}` not_encoded, `{}` unsupported_claim\n", report.summary.pass, report.summary.wrong_result, report.summary.lifecycle_fail, report.summary.incomplete, report.summary.blocked, report.summary.not_encoded, report.summary.unsupported_claim)); + out.push_str(&format!( + "- Unsupported claim count: `{}`\n", + report.summary.unsupported_claim_count + )); + out.push_str(&format!("- Wrong-result count: `{}`\n", report.summary.wrong_result_count)); + out.push_str(&format!("- Stale-answer count: `{}`\n", report.summary.stale_answer_count)); + out.push_str(&format!( + "- Conflict detections: `{}`\n", + report.summary.conflict_detection_count + )); + out.push_str(&format!( + "- Update rationales available: `{}`\n", + report.summary.update_rationale_available_count + )); + out.push_str(&format!( + "- Temporal validity not encoded: `{}`\n", + report.summary.temporal_validity_not_encoded_count + )); + out.push_str(&format!( + "- History readback encoded: `{}`\n", + report.summary.history_readback_encoded_count + )); + + render_markdown_quality_summary(out, report); + + out.push_str(&format!("- Mean score: `{:.3}`\n", report.summary.mean_score)); + out.push_str(&format!( + "- Mean latency: `{}`\n", + markdown::optional_f64(report.summary.mean_latency_ms, " ms") + )); + out.push_str(&format!( + "- Cost: `{}`\n", + markdown::cost_display(report.summary.total_cost.as_ref()) + )); + out.push_str(&format!( + "- Operator-debug jobs: `{}`\n", + report.summary.operator_debug_job_count + )); + out.push_str(&format!("- Raw SQL needed: `{}`\n", report.summary.raw_sql_needed_count)); + out.push_str(&format!( + "- Trace-incomplete debug jobs: `{}`\n", + report.summary.trace_incomplete_count + )); + out.push_str(&format!("- Operator UX gaps: `{}`\n", report.summary.operator_ux_gap_count)); + + render_markdown_optional_summary_metrics(out, &report.summary); + + out.push_str(&format!( + "- Private corpus redaction: `{}`\n\n", + markdown::md_inline(report.private_corpus_redaction.policy.as_str()) + )); +} + +fn render_markdown_optional_summary_metrics(out: &mut String, summary: &ReportSummary) { + if let Some(knowledge) = &summary.knowledge { + render_markdown_knowledge_summary_metrics(out, knowledge); + } + if let Some(memory_summary) = &summary.memory_summary { + render_markdown_memory_summary_metrics(out, memory_summary); + } + if let Some(proactive) = &summary.proactive_brief { + render_markdown_proactive_summary_metrics(out, proactive); + } + if let Some(scheduled) = &summary.scheduled_memory { + render_markdown_scheduled_summary_metrics(out, scheduled); + } + if let Some(work_continuity) = &summary.work_continuity { + render_markdown_work_continuity_summary_metrics(out, work_continuity); + } +} + +fn render_markdown_knowledge_summary_metrics(out: &mut String, knowledge: &KnowledgeSummary) { + out.push_str(&format!("- Knowledge citation coverage: `{:.3}`\n", knowledge.citation_coverage)); + out.push_str(&format!("- Stale claim detection: `{:.3}`\n", knowledge.stale_claim_detection)); + out.push_str(&format!("- Rebuild determinism: `{:.3}`\n", knowledge.rebuild_determinism)); + out.push_str(&format!( + "- Backlinks: `{}` total, `{:.3}` page coverage\n", + knowledge.backlink_count, knowledge.backlink_coverage + )); + out.push_str(&format!("- Version diff coverage: `{:.3}`\n", knowledge.version_diff_coverage)); + out.push_str(&format!("- Page usefulness: `{:.3}`\n", knowledge.page_usefulness)); + out.push_str(&format!( + "- Unsupported summary count: `{}`\n", + knowledge.unsupported_summary_count + )); +} + +fn render_markdown_memory_summary_metrics(out: &mut String, memory_summary: &MemorySummaryReport) { + out.push_str(&format!( + "- Memory summary entries: `{}` across `{}` artifact(s)\n", + memory_summary.entry_count, memory_summary.summary_count + )); + out.push_str(&format!( + "- Memory summary source-ref coverage: `{}/{}` (`{:.3}`)\n", + memory_summary.source_ref_entry_count, + memory_summary.source_ref_required_count, + memory_summary.source_ref_coverage + )); + out.push_str(&format!( + "- Memory summary invalid top-of-mind count: `{}`\n", + memory_summary.invalid_top_of_mind_count + )); + out.push_str(&format!( + "- Memory summary unsupported derived entries: `{}`\n", + memory_summary.unsupported_derived_entry_count + )); + out.push_str(&format!( + "- Memory summary unsupported current entries: `{}`\n", + memory_summary.unsupported_current_entry_count + )); +} + +fn render_markdown_proactive_summary_metrics( + out: &mut String, + proactive: &ProactiveBriefSummaryReport, +) { + out.push_str(&format!( + "- Proactive brief suggestions: `{}` across `{}` artifact(s)\n", + proactive.suggestion_count, proactive.brief_count + )); + out.push_str(&format!( + "- Proactive evidence-ref coverage: `{}/{}` (`{:.3}`)\n", + proactive.evidence_ref_suggestion_count, + proactive.evidence_ref_required_count, + proactive.evidence_ref_coverage + )); + out.push_str(&format!( + "- Proactive freshness/action rationale coverage: `{:.3}` / `{:.3}`\n", + proactive.freshness_coverage, proactive.action_rationale_coverage + )); + out.push_str(&format!( + "- Proactive stale/currentness violations: `{}` invalid current, `{}` tombstone violation(s)\n", + proactive.invalid_current_suggestion_count, proactive.tombstone_violation_count + )); + out.push_str(&format!( + "- Proactive rejected/deferred suggestions: `{}` rejected, `{}` deferred\n", + proactive.rejected_count, proactive.deferred_count + )); +} + +fn render_markdown_scheduled_summary_metrics( + out: &mut String, + scheduled: &ScheduledMemorySummaryReport, +) { + out.push_str(&format!( + "- Scheduled memory outputs: `{}` across `{}` task run(s)\n", + scheduled.output_count, scheduled.task_run_count + )); + out.push_str(&format!( + "- Scheduled memory evidence-ref coverage: `{}/{}` (`{:.3}`)\n", + scheduled.evidence_ref_output_count, + scheduled.evidence_ref_required_count, + scheduled.evidence_ref_coverage + )); + out.push_str(&format!( + "- Scheduled memory freshness/action/trace coverage: `{:.3}` / `{:.3}` / `{:.3}`\n", + scheduled.freshness_coverage, scheduled.action_rationale_coverage, scheduled.trace_coverage + )); + out.push_str(&format!( + "- Scheduled memory stale/currentness violations: `{}` invalid current, `{}` tombstone violation(s)\n", + scheduled.invalid_current_output_count, scheduled.tombstone_violation_count + )); + out.push_str(&format!( + "- Scheduled memory source mutations: `{}`\n", + scheduled.source_mutation_count + )); +} + +fn render_markdown_work_continuity_summary_metrics( + out: &mut String, + work_continuity: &WorkContinuitySummaryReport, +) { + out.push_str(&format!( + "- Work continuity readbacks: `{}` entries across `{}` artifact(s)\n", + work_continuity.entry_count, work_continuity.readback_count + )); + out.push_str(&format!( + "- Work continuity reset/resume and rationale recall: `{:.3}` / `{:.3}`\n", + work_continuity.reset_resume_success_rate, work_continuity.decision_rationale_recall_rate + )); + out.push_str(&format!( + "- Work continuity rejected-option suppression and explicit next-step precision: `{:.3}` / `{:.3}`\n", + work_continuity.rejected_option_suppression_rate, + work_continuity.explicit_next_step_precision + )); + out.push_str(&format!( + "- Work continuity inferred-step labeling and handoff source-ref coverage: `{:.3}` / `{:.3}`\n", + work_continuity.inferred_next_step_labeling_rate, + work_continuity.handoff_source_ref_coverage + )); + out.push_str(&format!( + "- Work continuity redaction and janitor false-promotion rates: `{:.3}` / `{:.3}`\n", + work_continuity.redaction_rate, work_continuity.janitor_false_promotion_rate + )); + out.push_str(&format!( + "- Work continuity hard-fail markers: `{}` sensitive persistence, `{}` rejected resurrection, `{}` inferred instructions, `{}` journal-only authority claim(s)\n", + work_continuity.sensitive_marker_persistence_count, + work_continuity.rejected_option_resurrection_count, + work_continuity.inferred_step_instruction_count, + work_continuity.journal_only_authority_claim_count + )); +} + +fn render_markdown_quality_summary(out: &mut String, report: &RealWorldReport) { + out.push_str(&format!( + "- Evidence coverage: `{}/{}` (`{:.3}`)\n", + report.summary.evidence_covered_count, + report.summary.evidence_required_count, + report.summary.evidence_coverage + )); + out.push_str(&format!( + "- Source-ref coverage: `{}/{}` (`{:.3}`)\n", + report.summary.source_ref_covered_count, + report.summary.source_ref_required_count, + report.summary.source_ref_coverage + )); + out.push_str(&format!( + "- Quote coverage: `{}/{}` (`{:.3}`)\n", + report.summary.quote_covered_count, + report.summary.quote_required_count, + report.summary.quote_coverage + )); + out.push_str(&format!("- Stale retrieval count: `{}`\n", report.summary.stale_retrieval_count)); + out.push_str(&format!( + "- Scope correctness: `{}/{}` (`{:.3}`), violations `{}`\n", + report.summary.scope_correct_count, + report.summary.scope_check_count, + report.summary.scope_correctness, + report.summary.scope_violation_count + )); + out.push_str(&format!("- Redaction leak count: `{}`\n", report.summary.redaction_leak_count)); + out.push_str(&format!( + "- Qdrant rebuild cases: `{}` encoded, `{}` pass\n", + report.summary.qdrant_rebuild_case_count, report.summary.qdrant_rebuild_pass_count + )); + out.push_str(&format!( + "- Expected evidence recall: `{:.3}` ({}/{})\n", + report.summary.expected_evidence_recall, + report.summary.expected_evidence_matched, + report.summary.expected_evidence_total + )); + out.push_str(&format!( + "- Irrelevant context ratio: `{:.3}` ({} irrelevant)\n", + report.summary.irrelevant_context_ratio, report.summary.irrelevant_context_count + )); + out.push_str(&format!( + "- Trace explainability: `{}` job(s), `{}` wrong-result stage attribution(s)\n", + report.summary.trace_explainability_count, + report.summary.wrong_result_stage_attribution_count + )); + out.push_str(&format!( + "- Consolidation source mutation count: `{}`\n", + report.summary.consolidation.source_mutation_count + )); +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/jobs.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/jobs.rs new file mode 100644 index 00000000..f91d5a3f --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/jobs.rs @@ -0,0 +1,184 @@ +use crate::markdown::{self, OperatorDebugEvidence, OperatorUxGap, RealWorldReport}; + +pub(super) fn render_markdown_suites(out: &mut String, report: &RealWorldReport) { + out.push_str("## Suites\n\n"); + out.push_str( + "| Suite | Status | Jobs | Score | Evidence Recall | Irrelevant Context | Trace Explain | Stale Answers | Conflicts | Update Rationales | Temporal Gaps | History Readback | Unsupported Claims | Wrong Results | Reason |\n", + ); + out.push_str("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n"); + + for suite in &report.suites { + out.push_str(&format!( + "| {} | `{}` | {} | `{}` | `{}` | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", + markdown::md_cell(suite.suite_id.as_str()), + markdown::status_str(suite.status), + suite.encoded_job_count, + markdown::optional_f64(suite.score_mean, ""), + markdown::optional_f64(suite.expected_evidence_recall, ""), + markdown::optional_f64(suite.irrelevant_context_ratio, ""), + suite.trace_explainability_count, + suite.stale_answer_count, + suite.conflict_detection_count, + suite.update_rationale_available_count, + suite.temporal_validity_not_encoded_count, + suite.history_readback_encoded_count, + suite.unsupported_claim_count, + suite.wrong_result_count, + markdown::md_cell(suite.reason.as_str()) + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_jobs(out: &mut String, report: &RealWorldReport) { + out.push_str("## Jobs\n\n"); + out.push_str("| Suite | Job | Status | Answer Type | Caveat Required | Refusal Required | Unknown Allowed | Score | Evidence Recall | Irrelevant Context | Expected Evidence | Produced Evidence | Trace Failure Stage | Stale Answers | Conflicts | Update Rationale | Temporal Gap | Unsupported Claims | Wrong Results | Latency | Cost |\n"); + out.push_str( + "| --- | --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | --- | --- | --- | ---: | ---: | --- | --- | ---: | ---: | ---: | --- |\n", + ); + + for job in &report.jobs { + let expected = job + .expected_evidence + .iter() + .map(|evidence| evidence.evidence_id.as_str()) + .collect::>() + .join(", "); + let produced = job.produced_evidence.join(", "); + + out.push_str(&format!( + "| {} | {} | `{}` | `{}` | `{}` | `{}` | `{}` | `{:.3}` | `{:.3}` | `{:.3}` | `{}` | `{}` | `{}` | {} | {} | `{}` | `{}` | {} | {} | `{}` | `{}` |\n", + markdown::md_cell(job.suite_id.as_str()), + markdown::md_cell(job.job_id.as_str()), + markdown::status_str(job.status), + markdown::md_inline(job.answer_type.as_str()), + markdown::bool_display(job.requires_caveat), + markdown::bool_display(job.requires_refusal), + markdown::bool_display(job.can_answer_unknown), + job.normalized_score, + job.retrieval_quality.expected_evidence_recall, + job.retrieval_quality.irrelevant_context_ratio, + markdown::md_inline(expected.as_str()), + markdown::md_inline(produced.as_str()), + markdown::md_inline(markdown::trace_failure_stage(job.trace_explainability.as_ref()).unwrap_or("-")), + job.stale_answer_count, + job.conflict_detection_count, + markdown::bool_display(job.update_rationale_available), + markdown::bool_display(job.temporal_validity_not_encoded), + job.unsupported_claim_count, + job.wrong_result_count, + markdown::optional_f64(job.latency_ms, " ms"), + markdown::cost_display(job.cost.as_ref()) + )); + } + + out.push('\n'); +} + +pub(super) fn render_markdown_operator_debugging(out: &mut String, report: &RealWorldReport) { + let jobs = report.jobs.iter().filter(|job| job.operator_debug.is_some()).collect::>(); + + out.push_str("## Operator Debugging UX\n\n"); + + if jobs.is_empty() { + out.push_str("No encoded job reported operator debugging evidence.\n\n"); + + return; + } + + out.push_str("| Job | Failure Mode | Trace Evidence | Trace Available | Replay Command | Steps | Raw SQL | Dropped Candidate Visibility | Trace Completeness | Repair Clarity | UX Gaps |\n"); + out.push_str("| --- | --- | --- | --- | --- | ---: | --- | --- | --- | --- | --- |\n"); + + for job in jobs { + if let Some(debug) = &job.operator_debug { + out.push_str(&format!( + "| {} | {} | {} | `{}` | `{}` | {} | `{}` | {} | `{}` | `{}` | {} |\n", + markdown::md_cell(job.job_id.as_str()), + markdown::md_cell(debug.failure_mode.as_str()), + debug_trace_cell(debug), + debug.trace_available.unwrap_or(debug.trace_id.is_some()), + debug.replay_command_available.unwrap_or(debug.replay_command.is_some()), + debug.steps_to_root_cause, + debug.raw_sql_needed, + markdown::md_cell(debug.dropped_candidate_visibility.as_str()), + markdown::md_inline(debug.trace_completeness.as_str()), + markdown::md_inline(debug.repair_action_clarity.as_str()), + ux_gap_cell(debug.ux_gaps.as_slice()) + )); + } + } + + out.push_str("\n### Operator Debug Details\n\n"); + + for job in report.jobs.iter().filter(|job| job.operator_debug.is_some()) { + if let Some(debug) = &job.operator_debug { + out.push_str(&format!("#### `{}`\n\n", markdown::md_inline(job.job_id.as_str()))); + out.push_str(&format!( + "- Root cause: {}\n", + markdown::md_cell(debug.root_cause.as_str()) + )); + out.push_str(&format!( + "- Viewer panels: `{}`\n", + markdown::md_inline(debug.viewer_panels.join(", ").as_str()) + )); + out.push_str(&format!( + "- CLI steps: `{}`\n", + markdown::md_inline(debug.cli_steps.join(" -> ").as_str()) + )); + + if let Some(command) = &debug.replay_command { + out.push_str(&format!( + "- Replay command: `{}`\n", + markdown::md_inline(command.as_str()) + )); + } + if let Some(artifact) = &debug.replay_artifact { + out.push_str(&format!( + "- Replay artifact: `{}`\n", + markdown::md_inline(artifact.as_str()) + )); + } + + out.push_str(&format!( + "- Trace evidence: `{}`\n", + markdown::md_inline(debug.trace_evidence.join(", ").as_str()) + )); + out.push('\n'); + } + } +} + +fn debug_trace_cell(debug: &OperatorDebugEvidence) -> String { + let trace = debug.trace_id.as_deref().unwrap_or("-"); + let viewer = debug + .viewer_url + .as_deref() + .map(|url| format!("[viewer]({})", markdown::md_url(url))) + .unwrap_or_else(|| "viewer: -".to_string()); + let bundle = debug + .admin_trace_bundle_url + .as_deref() + .map(|url| format!("[bundle]({})", markdown::md_url(url))) + .unwrap_or_else(|| "bundle: -".to_string()); + + format!("`{}`
{}
{}", markdown::md_inline(trace), viewer, bundle) +} + +fn ux_gap_cell(gaps: &[OperatorUxGap]) -> String { + if gaps.is_empty() { + return "`none`".to_string(); + } + + gaps.iter() + .map(|gap| { + format!( + "`{}`: {} ({})", + markdown::md_inline(gap.gap_id.as_str()), + markdown::md_cell(gap.description.as_str()), + markdown::md_inline(gap.follow_up_issue.as_str()) + ) + }) + .collect::>() + .join("
") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/operational.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/operational.rs new file mode 100644 index 00000000..d6b4d93d --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/operational.rs @@ -0,0 +1,133 @@ +use crate::{RealWorldReport, markdown, operational_reports::OperationalAuthorityRecoveryReport}; + +pub(super) fn render_markdown_operational_evidence(out: &mut String, report: &RealWorldReport) { + let evidence = &report.operational_evidence; + + if evidence.schema.is_empty() { + return; + } + + out.push_str("## Operational Evidence Gates\n\n"); + out.push_str("This section separates operational evidence tiers so local fixture or public-proxy passes do not become private-corpus or provider-backed proof.\n\n"); + out.push_str(&format!("- Schema: `{}`\n", markdown::md_inline(evidence.schema.as_str()))); + out.push_str(&format!( + "- Claim boundary: {}\n", + markdown::md_cell(evidence.claim_boundary.as_str()) + )); + out.push_str(&format!( + "- Missing private/provider inputs are typed blockers: `{}`\n", + evidence.missing_private_provider_inputs_are_typed_blockers + )); + out.push_str(&format!( + "- Private-corpus pass claim allowed: `{}`\n", + evidence.private_corpus_pass_claim_allowed + )); + out.push_str(&format!( + "- Provider-backed pass claim allowed: `{}`\n", + evidence.provider_backed_pass_claim_allowed + )); + out.push_str(&format!( + "- Latency: `{}` measured job(s), `{}` missing, mean `{}`, max `{}`\n", + evidence.latency.measured_job_count, + evidence.latency.missing_latency_job_count, + markdown::optional_f64(evidence.latency.mean_ms, " ms"), + markdown::optional_f64(evidence.latency.max_ms, " ms") + )); + out.push_str(&format!( + "- Cost: `{}` job(s) reported cost, `{}` missing, `{}` zero-cost; total `{}`\n", + evidence.cost.jobs_with_cost_report, + evidence.cost.missing_cost_job_count, + evidence.cost.zero_cost_job_count, + markdown::cost_display(evidence.cost.total.as_ref()) + )); + out.push_str(&format!( + "- Cost boundary: {}\n", + markdown::md_cell(evidence.cost.claim_boundary.as_str()) + )); + out.push_str(&format!( + "- Resource envelope jobs: `{}` total, `{}` pass; latency/resource dimensions `{}`\n", + evidence.resource.resource_envelope_job_count, + evidence.resource.resource_envelope_pass_count, + evidence.resource.latency_resource_dimension_job_count + )); + out.push_str(&format!( + "- Cold-start/restore/rebuild: cold-start `{}`/`{}` pass, restore `{}`/`{}` pass, Qdrant rebuild `{}`/`{}` pass\n\n", + evidence.cold_start_restore_rebuild.cold_start_pass_count, + evidence.cold_start_restore_rebuild.cold_start_job_count, + evidence.cold_start_restore_rebuild.restore_pass_count, + evidence.cold_start_restore_rebuild.restore_job_count, + evidence.cold_start_restore_rebuild.qdrant_rebuild_pass_count, + evidence.cold_start_restore_rebuild.qdrant_rebuild_job_count + )); + + render_authority_recovery_summary(out, &evidence.authority_recovery); + + out.push_str("| Evidence Tier | Status | Jobs | Pass | Blocked | Incomplete | Not Encoded | Mean Latency | Cost | Resource | Cold Start | Restore | Qdrant Rebuild | Pass Claim |\n"); + out.push_str("| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: | ---: | --- |\n"); + + for tier in &evidence.tiers { + out.push_str(&format!( + "| `{}` | `{}` | {} | {} | {} | {} | {} | `{}` | `{}` | {} | {} | {} | {} | `{}` |\n", + markdown::md_inline(tier.tier.as_str()), + markdown::status_str(tier.status), + tier.job_count, + tier.pass, + tier.blocked, + tier.incomplete, + tier.not_encoded, + markdown::optional_f64(tier.mean_latency_ms, " ms"), + markdown::cost_display(tier.total_cost.as_ref()), + tier.resource_evidence_count, + tier.cold_start_evidence_count, + tier.restore_evidence_count, + tier.qdrant_rebuild_evidence_count, + tier.pass_claim_allowed + )); + } + + if evidence.tiers.iter().any(|tier| !tier.blocker_reasons.is_empty()) { + out.push_str("\nTyped blocker reasons:\n"); + + for tier in &evidence.tiers { + for reason in &tier.blocker_reasons { + out.push_str(&format!( + "- `{}`: {}\n", + markdown::md_inline(tier.tier.as_str()), + markdown::md_cell(reason) + )); + } + } + } + + out.push('\n'); +} + +fn render_authority_recovery_summary( + out: &mut String, + recovery: &OperationalAuthorityRecoveryReport, +) { + out.push_str(&format!( + "- Authority recovery drills: `{}`/`{}` pass, topology `{}`, failure injections `{}`, backup/PITR restored `{}`, degraded reads labeled `{}`, source-of-truth visible `{}`, RPO `{}`/`{}` met, RTO `{}`/`{}` met, record counts `{}`/`{}` preserved, source refs `{}`/`{}` preserved, lifecycle histories `{}`/`{}` preserved, idempotent replay `{}`, complete Qdrant rebuild `{}`, migration repair `{}`, dead-letter handled `{}`\n\n", + recovery.drill_pass_count, + recovery.drill_count, + recovery.topology_reported_count, + recovery.failure_injection_count, + recovery.backup_pitr_restored_count, + recovery.degraded_read_labeled_count, + recovery.source_of_truth_visible_count, + recovery.rpo_met_count, + recovery.rpo_target_count, + recovery.rto_met_count, + recovery.rto_target_count, + recovery.record_count_preserved_count, + recovery.authority_plane_count, + recovery.source_ref_preserved_count, + recovery.authority_plane_count, + recovery.lifecycle_history_preserved_count, + recovery.authority_plane_count, + recovery.idempotent_outbox_replay_count, + recovery.qdrant_rebuild_complete_count, + recovery.migration_repair_count, + recovery.dead_letter_handled_count + )); +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/scoreboard.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/scoreboard.rs new file mode 100644 index 00000000..42e7c6a0 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/scoreboard.rs @@ -0,0 +1,158 @@ +use crate::markdown::{ + self, RealWorldReport, SCOREBOARD_EVIDENCE_CLASSES, ScoreboardReport, ScoreboardRow, +}; + +pub(super) fn render_markdown_scoreboard(out: &mut String, report: &RealWorldReport) { + out.push_str("## Quality Scoreboard Grammar\n\n"); + out.push_str("The scoreboard is a claim grammar, not a leaderboard. A report may claim only the statuses and evidence classes represented by its source JSON.\n\n"); + out.push_str(&format!( + "- Schema: `{}`\n", + markdown::md_inline(report.scoreboard.schema.as_str()) + )); + out.push_str(&format!( + "- Result states: `{}`\n", + markdown::md_inline(report.scoreboard.result_states.join(", ").as_str()) + )); + out.push_str(&format!( + "- Evidence classes: `{}`\n", + markdown::md_inline(report.scoreboard.evidence_classes.join(", ").as_str()) + )); + out.push_str(&format!( + "- Metric basis: `{}` at k=`{}`\n", + markdown::md_inline(report.scoreboard.metric_basis.as_str()), + report.scoreboard.retrieval_k + )); + out.push_str(&format!( + "- Summary claim: `{}`\n", + markdown::md_inline(report.scoreboard.summary_claim.as_str()) + )); + out.push_str(&format!( + "- Job summary claim: `{}`\n", + markdown::md_inline(report.scoreboard.job_summary_claim.as_str()) + )); + out.push_str(&format!( + "- Job typed non-pass rows: `{}` ({})\n", + report.scoreboard.job_typed_non_pass_count, + markdown::md_inline( + scoreboard_state_list(&report.scoreboard.job_typed_non_pass_states_present).as_str() + ) + )); + out.push_str(&format!( + "- External-adapter typed non-pass rows: `{}` ({})\n", + report.scoreboard.external_adapter_typed_non_pass_count, + markdown::md_inline( + scoreboard_state_list( + &report.scoreboard.external_adapter_typed_non_pass_states_present + ) + .as_str() + ) + )); + out.push_str(&format!( + "- Typed non-pass rows: `{}` ({})\n", + report.scoreboard.typed_non_pass_count, + markdown::md_inline( + scoreboard_state_list(&report.scoreboard.typed_non_pass_states_present).as_str() + ) + )); + out.push_str(&format!( + "- Evidence class counts: `{}`\n", + markdown::md_inline(scoreboard_evidence_class_count_display(&report.scoreboard).as_str()) + )); + out.push_str(&format!( + "- Unqualified win claim allowed: `{}`\n", + report.scoreboard.unqualified_win_claim_allowed + )); + out.push_str(&format!( + "- Claim boundary: {}\n\n", + markdown::md_cell(report.scoreboard.claim_boundary.as_str()) + )); + out.push_str("| Product | State | Evidence | Comparable | Runtime Gates | Recall@k | Precision@k | MRR | nDCG | Stale Suppression | Update/Delete | Source Refs | Latency | Next Evidence |\n"); + out.push_str( + "| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | ---: | --- | --- |\n", + ); + + for row in &report.scoreboard.rows { + out.push_str(&format!( + "| {} | `{}` | `{}` | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", + markdown::md_cell(row.product_name.as_str()), + markdown::md_inline(row.result_state.as_str()), + markdown::md_inline(row.evidence_class.as_str()), + row.comparable, + scoreboard_runtime_gate_cell(row), + scoreboard_optional_f64(row.metrics.retrieval.recall_at_k), + scoreboard_optional_f64(row.metrics.retrieval.precision_at_k), + scoreboard_optional_f64(row.metrics.retrieval.mrr), + scoreboard_optional_f64(row.metrics.retrieval.ndcg), + scoreboard_optional_f64(row.metrics.lifecycle.stale_suppression), + scoreboard_update_delete_cell(row), + scoreboard_optional_f64(row.metrics.coverage.source_ref_coverage), + scoreboard_latency_cell(row), + markdown::md_cell(scoreboard_list_cell(&row.next_evidence).as_str()) + )); + } + + if !report.scoreboard.optimization_roadmap.is_empty() { + out.push_str("\nOptimization direction:\n"); + + for item in &report.scoreboard.optimization_roadmap { + out.push_str(&format!("- {}\n", markdown::md_cell(item.as_str()))); + } + + out.push('\n'); + } +} + +fn scoreboard_state_list(states: &[String]) -> String { + if states.is_empty() { "none".to_string() } else { states.join(", ") } +} + +fn scoreboard_evidence_class_count_display(scoreboard: &ScoreboardReport) -> String { + SCOREBOARD_EVIDENCE_CLASSES + .iter() + .map(|state| { + let count = scoreboard.evidence_class_counts.get(*state).copied().unwrap_or_default(); + + format!("{state}={count}") + }) + .collect::>() + .join(", ") +} + +fn scoreboard_optional_f64(value: Option) -> String { + value.map_or_else(|| "`n/a`".to_string(), |value| format!("`{}`", markdown::round3(value))) +} + +fn scoreboard_optional_f64_plain(value: Option) -> String { + value.map_or_else(|| "n/a".to_string(), |value| markdown::round3(value).to_string()) +} + +fn scoreboard_runtime_gate_cell(row: &ScoreboardRow) -> String { + format!( + "`same_corpus={}`
`source_ids={}`
`held_out={}`
`leakage={}`
`runtime={}`
`digest={}`", + row.same_corpus, + row.source_id_mapped, + row.held_out, + row.leakage_audited, + row.product_runtime, + row.container_digest_identified + ) +} + +fn scoreboard_update_delete_cell(row: &ScoreboardRow) -> String { + format!( + "`update={}`
`delete={}`", + scoreboard_optional_f64_plain(row.metrics.lifecycle.update_correctness), + scoreboard_optional_f64_plain(row.metrics.lifecycle.delete_correctness) + ) +} + +fn scoreboard_latency_cell(row: &ScoreboardRow) -> String { + row.metrics.operations.mean_latency_ms.map_or_else( + || "`n/a`".to_string(), + |latency| format!("`{} ms`", markdown::round3(latency)), + ) +} + +fn scoreboard_list_cell(values: &[String]) -> String { + if values.is_empty() { "none".to_string() } else { values.join("; ") } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/trace.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/trace.rs new file mode 100644 index 00000000..05ee3aa8 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/markdown/trace.rs @@ -0,0 +1,59 @@ +use crate::markdown::{self, RealWorldReport, TraceExplainability}; + +pub(super) fn render_markdown_trace_explainability(out: &mut String, report: &RealWorldReport) { + out.push_str("## Trace Explainability\n\n"); + + let jobs = + report.jobs.iter().filter(|job| job.trace_explainability.is_some()).collect::>(); + + if jobs.is_empty() { + out.push_str("No encoded job reported trace explainability metadata.\n\n"); + + return; + } + + out.push_str("| Suite | Job | Trace | Failure Stage | Reason | Stage Evidence |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- |\n"); + + for job in jobs { + let trace = job.trace_explainability.as_ref(); + + out.push_str(&format!( + "| {} | {} | `{}` | `{}` | {} | {} |\n", + markdown::md_cell(job.suite_id.as_str()), + markdown::md_cell(job.job_id.as_str()), + markdown::md_inline(trace.and_then(|trace| trace.trace_id.as_deref()).unwrap_or("-")), + markdown::md_inline(markdown::trace_failure_stage(trace).unwrap_or("-")), + markdown::md_cell(trace_failure_reason(trace).unwrap_or("-")), + markdown::md_cell(trace_stage_summary(trace).as_str()) + )); + } + + out.push('\n'); +} + +fn trace_failure_reason(trace: Option<&TraceExplainability>) -> Option<&str> { + trace.and_then(|trace| trace.failure_reason.as_deref()) +} + +fn trace_stage_summary(trace: Option<&TraceExplainability>) -> String { + let Some(trace) = trace else { + return "-".to_string(); + }; + let stages = trace + .stages + .iter() + .map(|stage| { + format!( + "{} kept={} demoted={} dropped={} distractors={}", + stage.stage_name, + stage.kept_evidence.join("+"), + stage.demoted_evidence.join("+"), + stage.dropped_evidence.join("+"), + stage.distractor_evidence.join("+") + ) + }) + .collect::>(); + + if stages.is_empty() { "-".to_string() } else { stages.join("; ") } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/operational.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/operational.rs new file mode 100644 index 00000000..ba979884 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/operational.rs @@ -0,0 +1,310 @@ +use crate::{ + BTreeSet, CorpusProfile, JobReport, OPERATIONAL_EVIDENCE_SCHEMA, + OperationalAuthorityRecoveryReport, OperationalColdStartRestoreRebuild, OperationalCostSummary, + OperationalEvidenceReport, OperationalEvidenceTierReport, OperationalLatencyReport, + OperationalResourceSummary, RealWorldJob, TypedStatus, + formatting::round3, + recovery::{self}, + summary::{self}, +}; + +const OPERATIONAL_EVIDENCE_TIERS: &[&str] = + &["local_fixture", "public_proxy", "private_corpus", "provider_backed"]; + +pub(super) fn operational_evidence_report( + jobs: &[RealWorldJob], + reports: &[JobReport], +) -> OperationalEvidenceReport { + let paired = jobs.iter().zip(reports.iter()).collect::>(); + let tiers = OPERATIONAL_EVIDENCE_TIERS + .iter() + .map(|tier| operational_evidence_tier_report(tier, paired.as_slice())) + .collect::>(); + let private_tier = tiers.iter().find(|tier| tier.tier == "private_corpus"); + let provider_tier = tiers.iter().find(|tier| tier.tier == "provider_backed"); + let private_corpus_pass_claim_allowed = + private_tier.is_some_and(|tier| tier.pass_claim_allowed); + let provider_backed_pass_claim_allowed = + provider_tier.is_some_and(|tier| tier.pass_claim_allowed); + let missing_private_provider_inputs_are_typed_blockers = private_tier + .is_some_and(operational_tier_has_typed_blocker) + && provider_tier.is_some_and(operational_tier_has_typed_blocker); + + OperationalEvidenceReport { + schema: OPERATIONAL_EVIDENCE_SCHEMA.to_string(), + tiers, + latency: operational_latency_report(reports), + cost: operational_cost_summary(reports), + resource: operational_resource_summary(paired.as_slice()), + cold_start_restore_rebuild: operational_cold_start_restore_rebuild(paired.as_slice()), + authority_recovery: operational_authority_recovery(reports), + missing_private_provider_inputs_are_typed_blockers, + private_corpus_pass_claim_allowed, + provider_backed_pass_claim_allowed, + claim_boundary: "Operational evidence tiers are separate: local fixture and public-proxy passes do not prove private-corpus or provider-backed production quality.".to_string(), + } +} + +pub(super) fn operational_evidence_tier(job: &RealWorldJob) -> &'static str { + if job_has_tag(job, "provider_backed") { + "provider_backed" + } else if job_has_tag(job, "private_corpus") + || matches!(job.corpus.profile, CorpusProfile::PrivateSanitized) + { + "private_corpus" + } else if job_has_tag(job, "public_proxy") { + "public_proxy" + } else { + "local_fixture" + } +} + +fn operational_evidence_tier_report( + tier: &str, + paired: &[(&RealWorldJob, &JobReport)], +) -> OperationalEvidenceTierReport { + let tier_jobs = paired + .iter() + .filter(|(job, _)| operational_evidence_tier(job) == tier) + .copied() + .collect::>(); + let reports = tier_jobs.iter().map(|(_, report)| *report).collect::>(); + let status = if reports.is_empty() { + TypedStatus::NotEncoded + } else { + summary::aggregate_status(reports.as_slice()) + }; + let job_count = reports.len(); + let pass = reports.iter().filter(|report| report.status == TypedStatus::Pass).count(); + let wrong_result = + reports.iter().filter(|report| report.status == TypedStatus::WrongResult).count(); + let lifecycle_fail = + reports.iter().filter(|report| report.status == TypedStatus::LifecycleFail).count(); + let incomplete = + reports.iter().filter(|report| report.status == TypedStatus::Incomplete).count(); + let blocked = reports.iter().filter(|report| report.status == TypedStatus::Blocked).count(); + let not_encoded = usize::from(reports.is_empty()) + + reports.iter().filter(|report| report.status == TypedStatus::NotEncoded).count(); + let unsupported_claim = + reports.iter().filter(|report| report.status == TypedStatus::UnsupportedClaim).count(); + + OperationalEvidenceTierReport { + tier: tier.to_string(), + status, + job_count, + pass, + wrong_result, + lifecycle_fail, + incomplete, + blocked, + not_encoded, + unsupported_claim, + mean_latency_ms: summary::mean_latency_for_reports(reports.as_slice()), + total_cost: summary::total_cost_for_reports(reports.as_slice()), + resource_evidence_count: tier_jobs + .iter() + .filter(|(job, _)| job_has_tag(job, "resource_envelope")) + .count(), + cold_start_evidence_count: tier_jobs + .iter() + .filter(|(job, _)| job_has_tag(job, "cold_start")) + .count(), + restore_evidence_count: tier_jobs + .iter() + .filter(|(job, _)| job_has_tag(job, "restore")) + .count(), + qdrant_rebuild_evidence_count: tier_jobs + .iter() + .filter(|(job, report)| { + job_has_tag(job, "qdrant_rebuild") || report.qdrant_rebuild_case + }) + .count(), + pass_claim_allowed: job_count > 0 && status == TypedStatus::Pass, + blocker_reasons: reports + .iter() + .filter(|report| report.status != TypedStatus::Pass) + .map(|report| report.reason.clone()) + .collect(), + job_ids: reports.iter().map(|report| report.job_id.clone()).collect(), + } +} + +fn operational_tier_has_typed_blocker(tier: &OperationalEvidenceTierReport) -> bool { + tier.blocked + tier.incomplete + tier.not_encoded > 0 && !tier.pass_claim_allowed +} + +fn operational_latency_report(reports: &[JobReport]) -> OperationalLatencyReport { + let latencies = reports.iter().filter_map(|report| report.latency_ms).collect::>(); + + OperationalLatencyReport { + measured_job_count: latencies.len(), + missing_latency_job_count: reports.len().saturating_sub(latencies.len()), + mean_ms: summary::mean_latency_for_values(latencies.as_slice()), + max_ms: latencies.iter().copied().reduce(f64::max).map(round3), + } +} + +fn operational_cost_summary(reports: &[JobReport]) -> OperationalCostSummary { + let costs = reports.iter().filter_map(|report| report.cost.as_ref()).collect::>(); + let zero_cost_job_count = + costs.iter().filter(|cost| cost.amount.is_some_and(|amount| amount == 0.0)).count(); + + OperationalCostSummary { + jobs_with_cost_report: costs.len(), + missing_cost_job_count: reports.len().saturating_sub(costs.len()), + zero_cost_job_count, + total: summary::total_cost(reports), + claim_boundary: "Fixture and local-provider zero-cost reports are execution-accounting evidence only; they do not prove hosted provider spend.".to_string(), + } +} + +fn operational_resource_summary( + paired: &[(&RealWorldJob, &JobReport)], +) -> OperationalResourceSummary { + let resource_jobs = + paired.iter().filter(|(job, _)| job_has_tag(job, "resource_envelope")).collect::>(); + let latency_resource_dimension_job_count = paired + .iter() + .filter(|(_, report)| { + report.dimension_scores.iter().any(|score| score.dimension == "latency_resource") + }) + .count(); + + OperationalResourceSummary { + resource_envelope_job_count: resource_jobs.len(), + resource_envelope_pass_count: resource_jobs + .iter() + .filter(|(_, report)| report.status == TypedStatus::Pass) + .count(), + latency_resource_dimension_job_count, + job_ids: resource_jobs.iter().map(|(_, report)| report.job_id.clone()).collect(), + } +} + +fn operational_cold_start_restore_rebuild( + paired: &[(&RealWorldJob, &JobReport)], +) -> OperationalColdStartRestoreRebuild { + let cold_start_jobs = + paired.iter().filter(|(job, _)| job_has_tag(job, "cold_start")).collect::>(); + let restore_jobs = + paired.iter().filter(|(job, _)| job_has_tag(job, "restore")).collect::>(); + let qdrant_rebuild_jobs = paired + .iter() + .filter(|(job, report)| job_has_tag(job, "qdrant_rebuild") || report.qdrant_rebuild_case) + .collect::>(); + let mut job_ids = cold_start_jobs + .iter() + .chain(restore_jobs.iter()) + .chain(qdrant_rebuild_jobs.iter()) + .map(|(_, report)| report.job_id.clone()) + .collect::>() + .into_iter() + .collect::>(); + + job_ids.sort(); + OperationalColdStartRestoreRebuild { + cold_start_job_count: cold_start_jobs.len(), + cold_start_pass_count: cold_start_jobs + .iter() + .filter(|(_, report)| report.status == TypedStatus::Pass) + .count(), + restore_job_count: restore_jobs.len(), + restore_pass_count: restore_jobs + .iter() + .filter(|(_, report)| report.status == TypedStatus::Pass) + .count(), + qdrant_rebuild_job_count: qdrant_rebuild_jobs.len(), + qdrant_rebuild_pass_count: qdrant_rebuild_jobs + .iter() + .filter(|(_, report)| report.status == TypedStatus::Pass) + .count(), + job_ids, + } +} + +fn operational_authority_recovery(reports: &[JobReport]) -> OperationalAuthorityRecoveryReport { + let recovery_jobs = + reports.iter().filter(|report| !report.recovery_drills.is_empty()).collect::>(); + let drills = + recovery_jobs.iter().flat_map(|report| report.recovery_drills.iter()).collect::>(); + let authority_counts = + drills.iter().flat_map(|drill| drill.authority_record_counts.iter()).collect::>(); + let mut job_ids = recovery_jobs + .iter() + .map(|report| report.job_id.clone()) + .collect::>() + .into_iter() + .collect::>(); + + job_ids.sort(); + OperationalAuthorityRecoveryReport { + drill_count: drills.len(), + drill_pass_count: recovery_jobs + .iter() + .filter(|report| report.status == TypedStatus::Pass) + .flat_map(|report| report.recovery_drills.iter()) + .filter(|drill| recovery::recovery_drill_succeeded(drill)) + .count(), + topology_reported_count: drills + .iter() + .filter(|drill| !drill.topology.authority_store.trim().is_empty()) + .count(), + failure_injection_count: drills.iter().map(|drill| drill.failure_injections.len()).sum(), + degraded_read_labeled_count: drills + .iter() + .filter(|drill| !drill.degraded_read.unavailable_labels.is_empty()) + .count(), + source_of_truth_visible_count: drills + .iter() + .filter(|drill| drill.degraded_read.source_of_truth_visible) + .count(), + backup_pitr_restored_count: drills + .iter() + .filter(|drill| drill.backup_pitr.restored) + .count(), + rpo_target_count: drills.len(), + rpo_met_count: drills + .iter() + .filter(|drill| recovery::recovery_measurement_met(&drill.rpo)) + .count(), + rto_target_count: drills.len(), + rto_met_count: drills + .iter() + .filter(|drill| recovery::recovery_measurement_met(&drill.rto)) + .count(), + authority_plane_count: authority_counts.len(), + record_count_preserved_count: authority_counts + .iter() + .filter(|count| recovery::authority_record_count_balanced(count)) + .count(), + source_ref_preserved_count: authority_counts + .iter() + .filter(|count| count.source_refs_preserved) + .count(), + lifecycle_history_preserved_count: authority_counts + .iter() + .filter(|count| count.lifecycle_history_preserved) + .count(), + idempotent_outbox_replay_count: drills + .iter() + .filter(|drill| recovery::recovery_outbox_replay_succeeded(&drill.outbox_replay)) + .count(), + qdrant_rebuild_complete_count: drills + .iter() + .filter(|drill| recovery::recovery_qdrant_rebuild_succeeded(&drill.qdrant_rebuild)) + .count(), + migration_repair_count: drills + .iter() + .filter(|drill| recovery::recovery_migration_repair_succeeded(&drill.migration_repair)) + .count(), + dead_letter_handled_count: drills + .iter() + .filter(|drill| recovery::recovery_dead_letter_succeeded(&drill.dead_letter)) + .count(), + job_ids, + } +} + +fn job_has_tag(job: &RealWorldJob, tag: &str) -> bool { + job.tags.iter().any(|candidate| candidate == tag) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/operational_reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/operational_reports.rs new file mode 100644 index 00000000..90468302 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/operational_reports.rs @@ -0,0 +1,106 @@ +use crate::{CostReport, Deserialize, Serialize, TypedStatus}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct OperationalEvidenceReport { + pub(super) schema: String, + #[serde(default)] + pub(super) tiers: Vec, + pub(super) latency: OperationalLatencyReport, + pub(super) cost: OperationalCostSummary, + pub(super) resource: OperationalResourceSummary, + pub(super) cold_start_restore_rebuild: OperationalColdStartRestoreRebuild, + #[serde(default)] + pub(super) authority_recovery: OperationalAuthorityRecoveryReport, + pub(super) missing_private_provider_inputs_are_typed_blockers: bool, + pub(super) private_corpus_pass_claim_allowed: bool, + pub(super) provider_backed_pass_claim_allowed: bool, + pub(super) claim_boundary: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct OperationalEvidenceTierReport { + pub(super) tier: String, + pub(super) status: TypedStatus, + pub(super) job_count: usize, + pub(super) pass: usize, + pub(super) wrong_result: usize, + pub(super) lifecycle_fail: usize, + pub(super) incomplete: usize, + pub(super) blocked: usize, + pub(super) not_encoded: usize, + pub(super) unsupported_claim: usize, + pub(super) mean_latency_ms: Option, + pub(super) total_cost: Option, + pub(super) resource_evidence_count: usize, + pub(super) cold_start_evidence_count: usize, + pub(super) restore_evidence_count: usize, + pub(super) qdrant_rebuild_evidence_count: usize, + pub(super) pass_claim_allowed: bool, + #[serde(default)] + pub(super) blocker_reasons: Vec, + #[serde(default)] + pub(super) job_ids: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct OperationalLatencyReport { + pub(super) measured_job_count: usize, + pub(super) missing_latency_job_count: usize, + pub(super) mean_ms: Option, + pub(super) max_ms: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct OperationalCostSummary { + pub(super) jobs_with_cost_report: usize, + pub(super) missing_cost_job_count: usize, + pub(super) zero_cost_job_count: usize, + pub(super) total: Option, + pub(super) claim_boundary: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct OperationalResourceSummary { + pub(super) resource_envelope_job_count: usize, + pub(super) resource_envelope_pass_count: usize, + pub(super) latency_resource_dimension_job_count: usize, + #[serde(default)] + pub(super) job_ids: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct OperationalColdStartRestoreRebuild { + pub(super) cold_start_job_count: usize, + pub(super) cold_start_pass_count: usize, + pub(super) restore_job_count: usize, + pub(super) restore_pass_count: usize, + pub(super) qdrant_rebuild_job_count: usize, + pub(super) qdrant_rebuild_pass_count: usize, + #[serde(default)] + pub(super) job_ids: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct OperationalAuthorityRecoveryReport { + pub(super) drill_count: usize, + pub(super) drill_pass_count: usize, + pub(super) topology_reported_count: usize, + pub(super) failure_injection_count: usize, + pub(super) degraded_read_labeled_count: usize, + pub(super) source_of_truth_visible_count: usize, + pub(super) backup_pitr_restored_count: usize, + pub(super) rpo_target_count: usize, + pub(super) rpo_met_count: usize, + pub(super) rto_target_count: usize, + pub(super) rto_met_count: usize, + pub(super) authority_plane_count: usize, + pub(super) record_count_preserved_count: usize, + pub(super) source_ref_preserved_count: usize, + pub(super) lifecycle_history_preserved_count: usize, + pub(super) idempotent_outbox_replay_count: usize, + pub(super) qdrant_rebuild_complete_count: usize, + pub(super) migration_repair_count: usize, + pub(super) dead_letter_handled_count: usize, + #[serde(default)] + pub(super) job_ids: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/recovery.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/recovery.rs new file mode 100644 index 00000000..5fbf7a65 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/recovery.rs @@ -0,0 +1,60 @@ +use crate::{ + AuthorityRecordCount, AuthorityRecoveryDrillArtifact, BTreeSet, RecoveryDeadLetterHandling, + RecoveryMeasurement, RecoveryMigrationRepair, RecoveryOutboxReplay, RecoveryQdrantRebuild, +}; + +pub(super) const REQUIRED_AUTHORITY_PLANES: [&str; 7] = + ["source", "journal", "memory", "knowledge", "proposal", "trace", "audit"]; + +pub(super) fn recovery_drill_succeeded(drill: &AuthorityRecoveryDrillArtifact) -> bool { + drill.backup_pitr.restored + && drill.degraded_read.source_of_truth_visible + && recovery_measurement_met(&drill.rpo) + && recovery_measurement_met(&drill.rto) + && recovery_authority_record_counts_succeeded(drill) + && recovery_outbox_replay_succeeded(&drill.outbox_replay) + && recovery_qdrant_rebuild_succeeded(&drill.qdrant_rebuild) + && recovery_migration_repair_succeeded(&drill.migration_repair) + && recovery_dead_letter_succeeded(&drill.dead_letter) +} + +pub(super) fn recovery_measurement_met(measurement: &RecoveryMeasurement) -> bool { + measurement.measured_seconds <= measurement.target_seconds +} + +pub(super) fn authority_record_count_balanced(count: &AuthorityRecordCount) -> bool { + count.before_count == count.after_count +} + +pub(super) fn recovery_outbox_replay_succeeded(replay: &RecoveryOutboxReplay) -> bool { + replay.idempotent && replay.duplicate_write_count == 0 +} + +pub(super) fn recovery_qdrant_rebuild_succeeded(rebuild: &RecoveryQdrantRebuild) -> bool { + rebuild.complete && rebuild.missing_vector_count == 0 && rebuild.error_count == 0 +} + +pub(super) fn recovery_migration_repair_succeeded(repair: &RecoveryMigrationRepair) -> bool { + repair.applied +} + +pub(super) fn recovery_dead_letter_succeeded(dead_letter: &RecoveryDeadLetterHandling) -> bool { + dead_letter.handled_count >= dead_letter.dead_letter_count +} + +fn recovery_authority_record_counts_succeeded(drill: &AuthorityRecoveryDrillArtifact) -> bool { + let present_planes = drill + .authority_record_counts + .iter() + .map(|count| count.plane.as_str()) + .collect::>(); + + REQUIRED_AUTHORITY_PLANES.iter().all(|plane| present_planes.contains(*plane)) + && drill.authority_record_counts.iter().all(authority_record_count_succeeded) +} + +fn authority_record_count_succeeded(count: &AuthorityRecordCount) -> bool { + authority_record_count_balanced(count) + && count.source_refs_preserved + && count.lifecycle_history_preserved +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/report_root.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/report_root.rs new file mode 100644 index 00000000..9ee62f1e --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/report_root.rs @@ -0,0 +1,32 @@ +use crate::{ + AdapterReport, CaptureIntegrationReport, Deserialize, EvolutionSummary, ExternalAdapterSection, + FollowUpReport, JobReport, OperationalEvidenceReport, PrivateCorpusRedaction, ReportSummary, + ScoreboardReport, Serialize, SuiteReport, UnsupportedClaimReport, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct RealWorldReport { + pub(super) schema: String, + pub(super) run_id: String, + pub(super) generated_at: String, + pub(super) runner_version: String, + pub(super) corpus_profile: String, + pub(super) adapter: AdapterReport, + #[serde(default)] + pub(super) scoreboard: ScoreboardReport, + #[serde(default)] + pub(super) operational_evidence: OperationalEvidenceReport, + #[serde(default)] + pub(super) external_adapters: ExternalAdapterSection, + pub(super) capture_integration: CaptureIntegrationReport, + pub(super) summary: ReportSummary, + pub(super) suites: Vec, + pub(super) jobs: Vec, + pub(super) unsupported_claims: Vec, + pub(super) not_encoded_suites: Vec, + pub(super) private_corpus_redaction: PrivateCorpusRedaction, + #[serde(default)] + pub(super) evolution: EvolutionSummary, + #[serde(default)] + pub(super) follow_ups: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard.rs new file mode 100644 index 00000000..ae49e592 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard.rs @@ -0,0 +1,74 @@ +#[path = "scoreboard/common.rs"] mod common; +#[path = "scoreboard/elf.rs"] mod elf; +#[path = "scoreboard/external.rs"] mod external; + +use crate::{ + AdapterCoverageStatus, AdapterStatusCounts, BTreeMap, BTreeSet, ExternalAdapterReport, + ExternalAdapterSection, ExternalAdapterSummary, JobReport, RealWorldJob, ReportSummary, + SCOREBOARD_EVIDENCE_CLASSES, SCOREBOARD_RESULT_STATES, SCOREBOARD_RETRIEVAL_K, + SCOREBOARD_SCHEMA, ScenarioComparisonOutcome, ScoreboardAnswerSafetyMetrics, + ScoreboardCoverageMetrics, ScoreboardLifecycleMetrics, ScoreboardMetrics, + ScoreboardOperationalMetrics, ScoreboardRankedMetrics, ScoreboardReport, + ScoreboardRetrievalMetrics, ScoreboardRow, TypedStatus, + formatting::{adapter_status_str, round3}, + scenario_comparison_outcome, + summary::{aggregate_status, ratio, ratio_or}, +}; + +pub(super) fn scoreboard_report( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], + summary: &ReportSummary, + external_adapters: &ExternalAdapterSection, +) -> ScoreboardReport { + let job_typed_non_pass_count = + job_reports.iter().filter(|job| job.status != TypedStatus::Pass).count(); + let external_typed_non_pass_count = + common::external_typed_non_pass_count(&external_adapters.summary); + let job_typed_non_pass_states_present = common::typed_non_pass_states_present(job_reports); + let external_adapter_typed_non_pass_states_present = + common::external_typed_non_pass_states_present(&external_adapters.summary); + let mut typed_non_pass_states_present = job_typed_non_pass_states_present.clone(); + + typed_non_pass_states_present.extend(external_adapter_typed_non_pass_states_present.clone()); + typed_non_pass_states_present.sort(); + typed_non_pass_states_present.dedup(); + + let typed_non_pass_count = job_typed_non_pass_count + external_typed_non_pass_count; + + ScoreboardReport { + schema: SCOREBOARD_SCHEMA.to_string(), + result_states: SCOREBOARD_RESULT_STATES.iter().map(ToString::to_string).collect(), + evidence_classes: SCOREBOARD_EVIDENCE_CLASSES.iter().map(ToString::to_string).collect(), + metric_basis: "produced_evidence_order".to_string(), + retrieval_k: SCOREBOARD_RETRIEVAL_K, + job_typed_non_pass_count, + job_typed_non_pass_states_present, + job_summary_claim: common::scoreboard_summary_claim(job_reports, job_typed_non_pass_count) + .to_string(), + external_adapter_typed_non_pass_count: external_typed_non_pass_count, + external_adapter_typed_non_pass_states_present, + typed_non_pass_count, + typed_non_pass_states_present, + evidence_class_counts: common::scoreboard_evidence_class_counts(external_adapters), + summary_claim: common::scoreboard_summary_claim(job_reports, typed_non_pass_count) + .to_string(), + unqualified_win_claim_allowed: false, + claim_boundary: "Typed non-pass states and non-live evidence classes must remain visible; reports must not collapse them into unqualified wins.".to_string(), + rows: scoreboard_rows(raw_jobs, job_reports, summary, external_adapters), + optimization_roadmap: common::scoreboard_optimization_roadmap(), + } +} + +fn scoreboard_rows( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], + summary: &ReportSummary, + external_adapters: &ExternalAdapterSection, +) -> Vec { + let mut rows = vec![elf::elf_scoreboard_row(raw_jobs, job_reports, summary)]; + + rows.extend(external::external_project_scoreboard_rows(&external_adapters.adapters)); + + rows +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/common.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/common.rs new file mode 100644 index 00000000..1cbc1a04 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/common.rs @@ -0,0 +1,208 @@ +use crate::scoreboard::{ + self, AdapterStatusCounts, BTreeMap, BTreeSet, ExternalAdapterSection, ExternalAdapterSummary, + JobReport, RealWorldJob, SCOREBOARD_EVIDENCE_CLASSES, ScoreboardRow, TypedStatus, +}; + +pub(super) fn aggregate_job_report_state(job_reports: &[JobReport]) -> String { + if job_reports.is_empty() { + return "not_tested".to_string(); + } + + let refs = job_reports.iter().collect::>(); + + scoreboard_result_state(scoreboard::aggregate_status(&refs)).to_string() +} + +pub(super) fn jobs_have_tag(jobs: &[RealWorldJob], tag: &str) -> bool { + !jobs.is_empty() && jobs.iter().all(|job| job.tags.iter().any(|candidate| candidate == tag)) +} + +pub(super) fn scoreboard_mean_metric(sum: f64, count: usize) -> f64 { + if count == 0 { 1.0 } else { scoreboard::round3(sum / count as f64) } +} + +pub(super) fn scoreboard_is_update_job(job: &RealWorldJob) -> bool { + scoreboard_has_any_tag( + job, + &["update", "correction_persistence", "current_authority", "conflicting_source_authority"], + ) +} + +pub(super) fn scoreboard_is_delete_job(job: &RealWorldJob) -> bool { + scoreboard_has_any_tag(job, &["delete", "ttl", "tombstone"]) +} + +pub(super) fn scoreboard_is_rollback_history_job(job: &RealWorldJob) -> bool { + scoreboard_has_any_tag(job, &["rollback", "correction_persistence"]) +} + +pub(super) fn scoreboard_has_any_tag(job: &RealWorldJob, tags: &[&str]) -> bool { + job.tags.iter().any(|tag| tags.contains(&tag.as_str())) +} + +pub(super) fn scoreboard_apply_comparability_gaps(row: &mut ScoreboardRow) { + if !row.same_corpus { + row.next_evidence.push("Map this product to the same corpus.".to_string()); + } + if !row.source_id_mapped { + row.next_evidence.push("Map returned evidence to stable source ids.".to_string()); + } + if !row.held_out { + row.next_evidence.push("Publish a held-out split for this row.".to_string()); + } + if !row.leakage_audited { + row.next_evidence.push("Publish leakage-audit evidence for this row.".to_string()); + } + if !row.product_runtime { + row.next_evidence + .push("Run a Docker-contained product-runtime adapter for this row.".to_string()); + } + if !row.container_digest_identified { + row.next_evidence.push("Record container image digest evidence.".to_string()); + } + if row.result_state != "pass" { + row.next_evidence + .push("Resolve typed non-pass state before claiming a comparable pass.".to_string()); + } + + row.comparable = row.same_corpus + && row.source_id_mapped + && row.held_out + && row.leakage_audited + && row.product_runtime + && row.container_digest_identified + && row.result_state == "pass" + && row.metrics.retrieval.recall_at_k.is_some() + && row.metrics.retrieval.precision_at_k.is_some() + && row.metrics.retrieval.mrr.is_some() + && row.metrics.retrieval.ndcg.is_some(); + + if !row.comparable && row.result_state == "pass" { + row.result_state = "not_comparable".to_string(); + } + if !row.comparable { + row.weaknesses + .push("This row is not a comparable product-runtime scoreboard pass.".to_string()); + } +} + +pub(super) fn scoreboard_optimization_roadmap() -> Vec { + vec![ + "Capture Docker image digests and runtime metadata for product-runtime rows.".to_string(), + "Add held-out and leakage-audit manifests before broad competitor comparisons.".to_string(), + "Promote external adapters from typed blockers to same-corpus source-id-mapped runtime rows only after they emit comparable evidence.".to_string(), + "Use row-level metrics for optimization direction; do not claim a universal leaderboard.".to_string(), + ] +} + +pub(super) fn typed_non_pass_states_present(jobs: &[JobReport]) -> Vec { + let mut states = BTreeSet::new(); + + for job in jobs.iter().filter(|job| job.status != TypedStatus::Pass) { + states.insert(scoreboard_result_state(job.status).to_string()); + } + + states.into_iter().collect() +} + +pub(super) fn external_typed_non_pass_count(summary: &ExternalAdapterSummary) -> usize { + [ + &summary.overall_status_counts, + &summary.capability_status_counts, + &summary.suite_status_counts, + &summary.scenario_status_counts, + ] + .into_iter() + .map(scoreboard_adapter_typed_non_pass_count) + .sum::() + + summary.scenario_outcome_counts.not_tested +} + +pub(super) fn external_typed_non_pass_states_present( + summary: &ExternalAdapterSummary, +) -> Vec { + let mut states = BTreeSet::new(); + + for counts in [ + &summary.overall_status_counts, + &summary.capability_status_counts, + &summary.suite_status_counts, + &summary.scenario_status_counts, + ] { + if counts.blocked > 0 { + states.insert("blocked".to_string()); + } + if counts.incomplete > 0 { + states.insert("incomplete".to_string()); + } + if counts.wrong_result + counts.lifecycle_fail > 0 { + states.insert("wrong_result".to_string()); + } + if counts.not_encoded + counts.unsupported > 0 { + states.insert("not_encoded".to_string()); + } + } + + if summary.scenario_outcome_counts.not_tested > 0 { + states.insert("not_tested".to_string()); + } + + states.into_iter().collect() +} + +pub(super) fn scoreboard_result_state(status: TypedStatus) -> &'static str { + match status { + TypedStatus::Pass => "pass", + TypedStatus::WrongResult | TypedStatus::LifecycleFail => "wrong_result", + TypedStatus::Incomplete => "incomplete", + TypedStatus::Blocked => "blocked", + TypedStatus::NotEncoded => "not_encoded", + TypedStatus::UnsupportedClaim => "unsupported_claim", + } +} + +pub(super) fn scoreboard_evidence_class_counts( + external_adapters: &ExternalAdapterSection, +) -> BTreeMap { + let mut counts = SCOREBOARD_EVIDENCE_CLASSES + .iter() + .map(|state| (state.to_string(), 0)) + .collect::>(); + + for adapter in &external_adapters.adapters { + let state = scoreboard_evidence_class(adapter.evidence_class.as_str()); + + *counts.entry(state.to_string()).or_insert(0) += 1; + } + + counts +} + +pub(super) fn scoreboard_evidence_class(evidence_class: &str) -> &str { + match evidence_class { + "live_baseline_only" => "live_baseline", + other => other, + } +} + +pub(super) fn scoreboard_summary_claim( + jobs: &[JobReport], + typed_non_pass_count: usize, +) -> &'static str { + if jobs.is_empty() { + "not_tested" + } else if typed_non_pass_count > 0 { + "typed_non_pass_present" + } else { + "all_encoded_jobs_passed" + } +} + +fn scoreboard_adapter_typed_non_pass_count(counts: &AdapterStatusCounts) -> usize { + counts.blocked + + counts.incomplete + + counts.wrong_result + + counts.lifecycle_fail + + counts.not_encoded + + counts.unsupported +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/elf.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/elf.rs new file mode 100644 index 00000000..8883926d --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/elf.rs @@ -0,0 +1,315 @@ +use crate::scoreboard::{ + self, BTreeSet, JobReport, RealWorldJob, ReportSummary, SCOREBOARD_RETRIEVAL_K, + ScoreboardAnswerSafetyMetrics, ScoreboardCoverageMetrics, ScoreboardLifecycleMetrics, + ScoreboardMetrics, ScoreboardOperationalMetrics, ScoreboardRankedMetrics, + ScoreboardRetrievalMetrics, ScoreboardRow, TypedStatus, common, +}; + +pub(super) fn elf_scoreboard_row( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], + summary: &ReportSummary, +) -> ScoreboardRow { + let source_id_mapped = + summary.source_ref_required_count > 0 && summary.source_ref_coverage >= 1.0; + let result_state = common::aggregate_job_report_state(job_reports); + let metrics = scoreboard_metrics_for_reports(raw_jobs, job_reports, summary); + let typed_non_pass_count = + job_reports.iter().filter(|job| job.status != TypedStatus::Pass).count(); + let mut row = ScoreboardRow { + product_id: "elf_current_report".to_string(), + product_name: "ELF".to_string(), + row_source: "current_real_world_job_report".to_string(), + evidence_class: "fixture_backed".to_string(), + result_state, + comparable: false, + same_corpus: true, + source_id_mapped, + held_out: common::jobs_have_tag(raw_jobs, "held_out"), + leakage_audited: common::jobs_have_tag(raw_jobs, "leakage_audited"), + product_runtime: false, + container_digest_identified: false, + metrics, + strengths: elf_scoreboard_strengths(summary), + weaknesses: Vec::new(), + next_evidence: Vec::new(), + source_provenance: vec![ + "apps/elf-eval/fixtures/real_world_memory/".to_string(), + "apps/elf-eval/src/bin/real_world_job_benchmark.rs".to_string(), + ], + }; + + if typed_non_pass_count > 0 { + row.weaknesses + .push(format!("{typed_non_pass_count} encoded job row(s) are typed non-pass.")); + } + + common::scoreboard_apply_comparability_gaps(&mut row); + + row +} + +fn scoreboard_metrics_for_reports( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], + summary: &ReportSummary, +) -> ScoreboardMetrics { + ScoreboardMetrics { + retrieval: scoreboard_retrieval_metrics(job_reports, summary), + lifecycle: scoreboard_lifecycle_metrics(raw_jobs, job_reports), + answer_safety: scoreboard_answer_safety_metrics(summary), + operations: scoreboard_operational_metrics(raw_jobs, job_reports, summary), + coverage: ScoreboardCoverageMetrics { + job_count: summary.job_count, + encoded_suite_count: summary.encoded_suite_count, + pass_count: summary.pass, + typed_non_pass_count: job_reports + .iter() + .filter(|job| job.status != TypedStatus::Pass) + .count(), + source_ref_coverage: Some(summary.source_ref_coverage), + evidence_coverage: Some(summary.evidence_coverage), + evidence_class: "fixture_backed".to_string(), + }, + } +} + +fn scoreboard_retrieval_metrics( + job_reports: &[JobReport], + summary: &ReportSummary, +) -> ScoreboardRetrievalMetrics { + let produced_evidence_total = + job_reports.iter().map(|job| job.retrieval_quality.produced_evidence_total).sum(); + let mut relevant_at_k = 0; + let mut precision_denominator_at_k = 0; + let mut reciprocal_rank_sum = 0.0; + let mut ndcg_sum = 0.0; + let mut ranked_job_count = 0; + + for job in job_reports { + let expected = job + .expected_evidence + .iter() + .map(|evidence| evidence.evidence_id.as_str()) + .collect::>(); + let ranked = scoreboard_ranked_metrics_for_job(job, &expected); + + relevant_at_k += ranked.relevant_at_k; + precision_denominator_at_k += ranked.precision_denominator_at_k; + reciprocal_rank_sum += ranked.reciprocal_rank; + ndcg_sum += ranked.ndcg; + ranked_job_count += 1; + } + + ScoreboardRetrievalMetrics { + k: SCOREBOARD_RETRIEVAL_K, + metric_basis: "produced_evidence_order".to_string(), + recall_at_k: Some(scoreboard::ratio_or( + relevant_at_k, + summary.expected_evidence_total, + 1.0, + )), + precision_at_k: Some(scoreboard::ratio_or(relevant_at_k, precision_denominator_at_k, 1.0)), + mrr: Some(common::scoreboard_mean_metric(reciprocal_rank_sum, ranked_job_count)), + ndcg: Some(common::scoreboard_mean_metric(ndcg_sum, ranked_job_count)), + expected_evidence_recall: Some(summary.expected_evidence_recall), + citation_source_ref_coverage: Some(summary.source_ref_coverage), + expected_evidence_matched: summary.expected_evidence_matched, + expected_evidence_total: summary.expected_evidence_total, + produced_evidence_total, + } +} + +fn scoreboard_ranked_metrics_for_job( + job: &JobReport, + expected: &BTreeSet<&str>, +) -> ScoreboardRankedMetrics { + let precision_denominator_at_k = SCOREBOARD_RETRIEVAL_K; + let relevant_at_k = job + .produced_evidence + .iter() + .take(SCOREBOARD_RETRIEVAL_K) + .filter(|evidence_id| expected.contains(evidence_id.as_str())) + .count(); + let reciprocal_rank = job + .produced_evidence + .iter() + .position(|evidence_id| expected.contains(evidence_id.as_str())) + .map_or_else(|| f64::from(expected.is_empty()), |index| 1.0 / (index + 1) as f64); + let ndcg = scoreboard_ndcg(job.produced_evidence.as_slice(), expected); + + ScoreboardRankedMetrics { relevant_at_k, precision_denominator_at_k, reciprocal_rank, ndcg } +} + +fn scoreboard_ndcg(produced_evidence: &[String], expected: &BTreeSet<&str>) -> f64 { + if expected.is_empty() { + return 1.0; + } + + let dcg = produced_evidence + .iter() + .take(SCOREBOARD_RETRIEVAL_K) + .enumerate() + .filter(|(_, evidence_id)| expected.contains(evidence_id.as_str())) + .map(|(index, _)| 1.0 / ((index + 2) as f64).log2()) + .sum::(); + let ideal_hits = expected.len().min(SCOREBOARD_RETRIEVAL_K); + let idcg = (0..ideal_hits).map(|index| 1.0 / ((index + 2) as f64).log2()).sum::(); + + if idcg > 0.0 { dcg / idcg } else { 0.0 } +} + +fn scoreboard_lifecycle_metrics( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], +) -> ScoreboardLifecycleMetrics { + let stale_check_count: usize = raw_jobs + .iter() + .map(|job| { + job.negative_traps + .iter() + .filter(|trap| trap.failure_if_used && trap.trap_type == "stale_fact") + .count() + }) + .sum(); + let stale_failure_count = job_reports + .iter() + .map(|job| job.stale_answer_count + job.stale_retrieval_count) + .sum::(); + let update_check_count = + scoreboard_lifecycle_check_count(raw_jobs, common::scoreboard_is_update_job); + let update_correct_count = + scoreboard_lifecycle_correct_count(raw_jobs, job_reports, common::scoreboard_is_update_job); + let delete_check_count = + scoreboard_lifecycle_check_count(raw_jobs, common::scoreboard_is_delete_job); + let delete_correct_count = + scoreboard_lifecycle_correct_count(raw_jobs, job_reports, common::scoreboard_is_delete_job); + let rollback_history_check_count = + scoreboard_lifecycle_check_count(raw_jobs, common::scoreboard_is_rollback_history_job); + let rollback_history_readback_count = raw_jobs + .iter() + .zip(job_reports.iter()) + .filter(|(job, report)| { + common::scoreboard_is_rollback_history_job(job) && report.status == TypedStatus::Pass + }) + .count(); + + ScoreboardLifecycleMetrics { + stale_suppression: Some(scoreboard::ratio_or( + stale_check_count.saturating_sub(stale_failure_count), + stale_check_count, + 1.0, + )), + stale_suppressed_count: stale_check_count.saturating_sub(stale_failure_count), + stale_check_count, + update_correctness: Some(scoreboard::ratio_or( + update_correct_count, + update_check_count, + 1.0, + )), + update_correct_count, + update_check_count, + delete_correctness: Some(scoreboard::ratio_or( + delete_correct_count, + delete_check_count, + 1.0, + )), + delete_correct_count, + delete_check_count, + rollback_history_readback_rate: Some(scoreboard::ratio_or( + rollback_history_readback_count, + rollback_history_check_count, + 1.0, + )), + rollback_history_readback_count, + rollback_history_check_count, + } +} + +fn scoreboard_lifecycle_check_count( + jobs: &[RealWorldJob], + predicate: fn(&RealWorldJob) -> bool, +) -> usize { + jobs.iter().filter(|job| predicate(job)).count() +} + +fn scoreboard_lifecycle_correct_count( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], + predicate: fn(&RealWorldJob) -> bool, +) -> usize { + raw_jobs + .iter() + .zip(job_reports.iter()) + .filter(|(job, report)| predicate(job) && report.status == TypedStatus::Pass) + .count() +} + +fn scoreboard_answer_safety_metrics(summary: &ReportSummary) -> ScoreboardAnswerSafetyMetrics { + ScoreboardAnswerSafetyMetrics { + unsupported_claim_rate: Some(scoreboard::ratio( + summary.unsupported_claim_count, + summary.job_count, + )), + unsupported_claim_count: summary.unsupported_claim_count, + stale_answer_rate: Some(scoreboard::ratio(summary.stale_answer_count, summary.job_count)), + stale_answer_count: summary.stale_answer_count, + hallucinated_evidence_rate: Some(summary.irrelevant_context_ratio), + redaction_leak_count: summary.redaction_leak_count, + irrelevant_context_ratio: Some(summary.irrelevant_context_ratio), + } +} + +fn scoreboard_operational_metrics( + raw_jobs: &[RealWorldJob], + job_reports: &[JobReport], + summary: &ReportSummary, +) -> ScoreboardOperationalMetrics { + let resource_envelope_job_count = raw_jobs + .iter() + .filter(|job| common::scoreboard_has_any_tag(job, &["resource_envelope"])) + .count(); + let resource_envelope_pass_count = raw_jobs + .iter() + .zip(job_reports.iter()) + .filter(|(job, report)| { + common::scoreboard_has_any_tag(job, &["resource_envelope"]) + && report.status == TypedStatus::Pass + }) + .count(); + + ScoreboardOperationalMetrics { + mean_latency_ms: summary.mean_latency_ms, + total_cost: summary.total_cost.clone(), + resource_envelope_status: if resource_envelope_job_count == resource_envelope_pass_count { + "pass".to_string() + } else { + "typed_non_pass_present".to_string() + }, + resource_envelope_job_count, + resource_envelope_pass_count, + } +} + +fn elf_scoreboard_strengths(summary: &ReportSummary) -> Vec { + let mut strengths = Vec::new(); + + if summary.expected_evidence_recall >= 1.0 { + strengths.push("Expected evidence recall is complete for encoded jobs.".to_string()); + } + if summary.source_ref_coverage >= 1.0 { + strengths + .push("Source-ref coverage is complete for encoded required evidence.".to_string()); + } + if summary.stale_answer_count == 0 && summary.stale_retrieval_count == 0 { + strengths.push("Encoded stale-answer and stale-retrieval counters are zero.".to_string()); + } + if summary.redaction_leak_count == 0 { + strengths.push("Encoded redaction leak count is zero.".to_string()); + } + if summary.work_continuity.is_some() { + strengths.push("Work Continuity readback metrics are encoded in the report.".to_string()); + } + + strengths +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/external.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/external.rs new file mode 100644 index 00000000..f30e40e5 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard/external.rs @@ -0,0 +1,388 @@ +use crate::scoreboard::{ + self, AdapterCoverageStatus, BTreeMap, BTreeSet, ExternalAdapterReport, SCOREBOARD_RETRIEVAL_K, + ScenarioComparisonOutcome, ScoreboardCoverageMetrics, ScoreboardMetrics, + ScoreboardRetrievalMetrics, ScoreboardRow, common, +}; + +pub(super) fn external_project_scoreboard_rows( + adapters: &[ExternalAdapterReport], +) -> Vec { + let mut by_project: BTreeMap> = BTreeMap::new(); + + for adapter in adapters.iter().filter(|adapter| adapter.project != "ELF") { + by_project.entry(adapter.project.clone()).or_default().push(adapter); + } + + by_project + .into_iter() + .map(|(project, adapters)| external_project_scoreboard_row(project, adapters.as_slice())) + .collect() +} + +fn external_project_scoreboard_row( + project: String, + adapters: &[&ExternalAdapterReport], +) -> ScoreboardRow { + let evidence_class = strongest_scoreboard_evidence_class(adapters); + let result_state = external_project_result_state(adapters); + let source_id_mapped = external_project_source_id_mapped(adapters); + let same_corpus = external_project_same_corpus(adapters); + let product_runtime = + adapters.iter().any(|adapter| adapter.evidence_class == "live_real_world"); + let container_digest_identified = + adapters.iter().any(|adapter| adapter_has_container_digest(adapter)); + let typed_non_pass_count = + adapters.iter().map(|adapter| adapter_typed_non_pass_count(adapter)).sum(); + let mut row = ScoreboardRow { + product_id: scoreboard_project_id(project.as_str()), + product_name: project, + row_source: "external_adapter_manifest".to_string(), + evidence_class: evidence_class.clone(), + result_state, + comparable: false, + same_corpus, + source_id_mapped, + held_out: false, + leakage_audited: false, + product_runtime, + container_digest_identified, + metrics: external_project_scoreboard_metrics( + adapters, + evidence_class.as_str(), + typed_non_pass_count, + ), + strengths: external_project_strengths(adapters), + weaknesses: external_project_weaknesses(adapters), + next_evidence: Vec::new(), + source_provenance: external_project_source_provenance(adapters), + }; + + common::scoreboard_apply_comparability_gaps(&mut row); + + row +} + +fn external_project_scoreboard_metrics( + adapters: &[&ExternalAdapterReport], + evidence_class: &str, + typed_non_pass_count: usize, +) -> ScoreboardMetrics { + let pass_count = adapters + .iter() + .flat_map(|adapter| adapter.suites.iter()) + .filter(|suite| suite.status == AdapterCoverageStatus::Pass) + .count(); + let suite_count = adapters.iter().map(|adapter| adapter.suites.len()).sum(); + + ScoreboardMetrics { + retrieval: ScoreboardRetrievalMetrics { + k: SCOREBOARD_RETRIEVAL_K, + metric_basis: "external_adapter_manifest_no_ordered_evidence".to_string(), + ..ScoreboardRetrievalMetrics::default() + }, + coverage: ScoreboardCoverageMetrics { + job_count: 0, + encoded_suite_count: suite_count, + pass_count, + typed_non_pass_count, + source_ref_coverage: None, + evidence_coverage: None, + evidence_class: evidence_class.to_string(), + }, + ..ScoreboardMetrics::default() + } +} + +fn strongest_scoreboard_evidence_class(adapters: &[&ExternalAdapterReport]) -> String { + for evidence_class in ["live_real_world", "live_baseline", "fixture_backed", "research_gate"] { + if adapters.iter().any(|adapter| { + common::scoreboard_evidence_class(adapter.evidence_class.as_str()) == evidence_class + }) { + return evidence_class.to_string(); + } + } + + "research_gate".to_string() +} + +fn external_project_result_state(adapters: &[&ExternalAdapterReport]) -> String { + for status in [ + AdapterCoverageStatus::WrongResult, + AdapterCoverageStatus::Blocked, + AdapterCoverageStatus::Incomplete, + AdapterCoverageStatus::LifecycleFail, + AdapterCoverageStatus::NotEncoded, + AdapterCoverageStatus::Unsupported, + ] { + if adapters.iter().any(|adapter| adapter_has_status(adapter, status)) { + return adapter_status_to_scoreboard_state(status).to_string(); + } + } + + "not_comparable".to_string() +} + +fn adapter_has_status(adapter: &ExternalAdapterReport, status: AdapterCoverageStatus) -> bool { + adapter.overall_status == status + || adapter.setup.status == status + || adapter.run.status == status + || adapter.result.status == status + || adapter.capabilities.iter().any(|capability| capability.status == status) + || adapter.suites.iter().any(|suite| suite.status == status) + || adapter.scenarios.iter().any(|scenario| scenario.status == status) +} + +fn external_project_same_corpus(adapters: &[&ExternalAdapterReport]) -> bool { + let needles = &["same-corpus", "same corpus", "same_corpus", "shared corpus"]; + + adapters.iter().any(|adapter| { + text_mentions_any(adapter.adapter_kind.as_str(), needles) + || adapter_has_reported_same_corpus_text(adapter, needles) + }) +} + +fn external_project_source_id_mapped(adapters: &[&ExternalAdapterReport]) -> bool { + let needles = &[ + "source-id mapped", + "source ids mapped", + "maps to source ids", + "mapped to source ids", + "maps back to source ids", + "map to generated evidence ids", + "mapped to generated evidence ids", + "evidence ids match", + ]; + + adapters.iter().any(|adapter| adapter_has_passing_text(adapter, needles)) +} + +fn adapter_has_passing_text(adapter: &ExternalAdapterReport, needles: &[&str]) -> bool { + adapter_status_mentions_any(adapter.setup.status, adapter.setup.evidence.as_str(), needles) + || adapter_status_mentions_any(adapter.run.status, adapter.run.evidence.as_str(), needles) + || adapter_status_mentions_any( + adapter.result.status, + adapter.result.evidence.as_str(), + needles, + ) || adapter.capabilities.iter().any(|capability| { + adapter_status_mentions_any(capability.status, capability.capability.as_str(), needles) + || adapter_status_mentions_any(capability.status, capability.evidence.as_str(), needles) + }) || adapter.suites.iter().any(|suite| { + adapter_status_mentions_any(suite.status, suite.suite_id.as_str(), needles) + || adapter_status_mentions_any(suite.status, suite.evidence.as_str(), needles) + }) || adapter.scenarios.iter().any(|scenario| { + adapter_status_mentions_any(scenario.status, scenario.scenario_id.as_str(), needles) + || adapter_status_mentions_any(scenario.status, scenario.evidence.as_str(), needles) + }) +} + +fn adapter_has_reported_same_corpus_text( + adapter: &ExternalAdapterReport, + needles: &[&str], +) -> bool { + adapter_status_reports_same_corpus( + adapter.setup.status, + adapter.setup.evidence.as_str(), + needles, + ) || adapter_status_reports_same_corpus( + adapter.run.status, + adapter.run.evidence.as_str(), + needles, + ) || adapter_status_reports_same_corpus( + adapter.result.status, + adapter.result.evidence.as_str(), + needles, + ) || adapter.capabilities.iter().any(|capability| { + adapter_status_reports_same_corpus( + capability.status, + capability.capability.as_str(), + needles, + ) || adapter_status_reports_same_corpus( + capability.status, + capability.evidence.as_str(), + needles, + ) + }) || adapter.suites.iter().any(|suite| { + adapter_status_reports_same_corpus(suite.status, suite.suite_id.as_str(), needles) + || adapter_status_reports_same_corpus(suite.status, suite.evidence.as_str(), needles) + }) || adapter.scenarios.iter().any(|scenario| { + adapter_status_reports_same_corpus(scenario.status, scenario.scenario_id.as_str(), needles) + || adapter_status_reports_same_corpus( + scenario.status, + scenario.evidence.as_str(), + needles, + ) + }) +} + +fn adapter_status_reports_same_corpus( + status: AdapterCoverageStatus, + text: &str, + needles: &[&str], +) -> bool { + matches!( + status, + AdapterCoverageStatus::Pass + | AdapterCoverageStatus::Real + | AdapterCoverageStatus::WrongResult + | AdapterCoverageStatus::LifecycleFail + ) && text_mentions_any(text, needles) +} + +fn adapter_status_mentions_any( + status: AdapterCoverageStatus, + text: &str, + needles: &[&str], +) -> bool { + matches!(status, AdapterCoverageStatus::Pass | AdapterCoverageStatus::Real) + && text_mentions_any(text, needles) +} + +fn text_mentions_any(text: &str, needles: &[&str]) -> bool { + let text = text.to_ascii_lowercase(); + + needles.iter().any(|needle| text.contains(&needle.to_ascii_lowercase())) +} + +fn adapter_status_to_scoreboard_state(status: AdapterCoverageStatus) -> &'static str { + match status { + AdapterCoverageStatus::WrongResult | AdapterCoverageStatus::LifecycleFail => "wrong_result", + AdapterCoverageStatus::Blocked => "blocked", + AdapterCoverageStatus::Incomplete => "incomplete", + AdapterCoverageStatus::NotEncoded | AdapterCoverageStatus::Unsupported => "not_encoded", + AdapterCoverageStatus::Real + | AdapterCoverageStatus::Mocked + | AdapterCoverageStatus::Pass => "not_comparable", + } +} + +fn adapter_typed_non_pass_count(adapter: &ExternalAdapterReport) -> usize { + let direct_statuses = + [adapter.overall_status, adapter.setup.status, adapter.run.status, adapter.result.status]; + let direct = direct_statuses + .into_iter() + .filter(|status| adapter_status_is_typed_non_pass(*status)) + .count(); + let capability = adapter + .capabilities + .iter() + .filter(|capability| adapter_status_is_typed_non_pass(capability.status)) + .count(); + let suites = adapter + .suites + .iter() + .filter(|suite| adapter_status_is_typed_non_pass(suite.status)) + .count(); + let scenarios = adapter + .scenarios + .iter() + .filter(|scenario| adapter_status_is_typed_non_pass(scenario.status)) + .count(); + + direct + capability + suites + scenarios +} + +fn adapter_status_is_typed_non_pass(status: AdapterCoverageStatus) -> bool { + matches!( + status, + AdapterCoverageStatus::Unsupported + | AdapterCoverageStatus::Blocked + | AdapterCoverageStatus::Incomplete + | AdapterCoverageStatus::WrongResult + | AdapterCoverageStatus::LifecycleFail + | AdapterCoverageStatus::NotEncoded + ) +} + +fn adapter_has_container_digest(adapter: &ExternalAdapterReport) -> bool { + adapter.setup.evidence.contains("sha256:") + || adapter.run.evidence.contains("sha256:") + || adapter.result.evidence.contains("sha256:") + || adapter.evidence.iter().any(|evidence| { + evidence.reference.contains("sha256:") || evidence.reference.contains("digest") + }) +} + +fn external_project_strengths(adapters: &[&ExternalAdapterReport]) -> Vec { + let mut strengths = BTreeSet::new(); + + for adapter in adapters { + for capability in &adapter.capabilities { + if matches!( + capability.status, + AdapterCoverageStatus::Pass | AdapterCoverageStatus::Real + ) { + strengths.insert(format!( + "{} capability is {}.", + capability.capability, + scoreboard::adapter_status_str(capability.status) + )); + } + } + for scenario in &adapter.scenarios { + if scoreboard::scenario_comparison_outcome(scenario) == ScenarioComparisonOutcome::Loss + { + strengths.insert(format!( + "Scenario {} is recorded as a competitor strength.", + scenario.scenario_id + )); + } + } + } + + strengths.into_iter().take(6).collect() +} + +fn external_project_weaknesses(adapters: &[&ExternalAdapterReport]) -> Vec { + let mut weaknesses = BTreeSet::new(); + + for adapter in adapters { + if adapter.overall_status != AdapterCoverageStatus::Pass { + weaknesses.insert(format!( + "Adapter {} overall status is {}.", + adapter.adapter_id, + scoreboard::adapter_status_str(adapter.overall_status) + )); + } + + for suite in &adapter.suites { + if adapter_status_is_typed_non_pass(suite.status) { + weaknesses.insert(format!( + "Suite {} is {}.", + suite.suite_id, + scoreboard::adapter_status_str(suite.status) + )); + } + } + } + + weaknesses.into_iter().take(8).collect() +} + +fn external_project_source_provenance(adapters: &[&ExternalAdapterReport]) -> Vec { + let mut provenance = BTreeSet::new(); + + for adapter in adapters { + for evidence in &adapter.evidence { + provenance.insert(evidence.reference.clone()); + } + for artifact in [&adapter.setup.artifact, &adapter.run.artifact, &adapter.result.artifact] + .into_iter() + .flatten() + { + provenance.insert(artifact.clone()); + } + } + + provenance.into_iter().take(12).collect() +} + +fn scoreboard_project_id(project: &str) -> String { + project + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '_' }) + .collect::() + .split('_') + .filter(|part| !part.is_empty()) + .collect::>() + .join("_") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard_reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard_reports.rs new file mode 100644 index 00000000..9a42a8af --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoreboard_reports.rs @@ -0,0 +1,121 @@ +use crate::{BTreeMap, CostReport, Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardReport { + pub(super) schema: String, + pub(super) result_states: Vec, + pub(super) evidence_classes: Vec, + pub(super) metric_basis: String, + pub(super) retrieval_k: usize, + pub(super) job_typed_non_pass_count: usize, + pub(super) job_typed_non_pass_states_present: Vec, + pub(super) job_summary_claim: String, + pub(super) external_adapter_typed_non_pass_count: usize, + pub(super) external_adapter_typed_non_pass_states_present: Vec, + pub(super) typed_non_pass_count: usize, + pub(super) typed_non_pass_states_present: Vec, + pub(super) evidence_class_counts: BTreeMap, + pub(super) summary_claim: String, + pub(super) unqualified_win_claim_allowed: bool, + pub(super) claim_boundary: String, + #[serde(default)] + pub(super) rows: Vec, + #[serde(default)] + pub(super) optimization_roadmap: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardRow { + pub(super) product_id: String, + pub(super) product_name: String, + pub(super) row_source: String, + pub(super) evidence_class: String, + pub(super) result_state: String, + pub(super) comparable: bool, + pub(super) same_corpus: bool, + pub(super) source_id_mapped: bool, + pub(super) held_out: bool, + pub(super) leakage_audited: bool, + pub(super) product_runtime: bool, + pub(super) container_digest_identified: bool, + pub(super) metrics: ScoreboardMetrics, + #[serde(default)] + pub(super) strengths: Vec, + #[serde(default)] + pub(super) weaknesses: Vec, + #[serde(default)] + pub(super) next_evidence: Vec, + #[serde(default)] + pub(super) source_provenance: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardMetrics { + pub(super) retrieval: ScoreboardRetrievalMetrics, + pub(super) lifecycle: ScoreboardLifecycleMetrics, + pub(super) answer_safety: ScoreboardAnswerSafetyMetrics, + pub(super) operations: ScoreboardOperationalMetrics, + pub(super) coverage: ScoreboardCoverageMetrics, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardRetrievalMetrics { + pub(super) k: usize, + pub(super) metric_basis: String, + pub(super) recall_at_k: Option, + pub(super) precision_at_k: Option, + pub(super) mrr: Option, + pub(super) ndcg: Option, + pub(super) expected_evidence_recall: Option, + pub(super) citation_source_ref_coverage: Option, + pub(super) expected_evidence_matched: usize, + pub(super) expected_evidence_total: usize, + pub(super) produced_evidence_total: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardLifecycleMetrics { + pub(super) stale_suppression: Option, + pub(super) stale_suppressed_count: usize, + pub(super) stale_check_count: usize, + pub(super) update_correctness: Option, + pub(super) update_correct_count: usize, + pub(super) update_check_count: usize, + pub(super) delete_correctness: Option, + pub(super) delete_correct_count: usize, + pub(super) delete_check_count: usize, + pub(super) rollback_history_readback_rate: Option, + pub(super) rollback_history_readback_count: usize, + pub(super) rollback_history_check_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardAnswerSafetyMetrics { + pub(super) unsupported_claim_rate: Option, + pub(super) unsupported_claim_count: usize, + pub(super) stale_answer_rate: Option, + pub(super) stale_answer_count: usize, + pub(super) hallucinated_evidence_rate: Option, + pub(super) redaction_leak_count: usize, + pub(super) irrelevant_context_ratio: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardOperationalMetrics { + pub(super) mean_latency_ms: Option, + pub(super) total_cost: Option, + pub(super) resource_envelope_status: String, + pub(super) resource_envelope_job_count: usize, + pub(super) resource_envelope_pass_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScoreboardCoverageMetrics { + pub(super) job_count: usize, + pub(super) encoded_suite_count: usize, + pub(super) pass_count: usize, + pub(super) typed_non_pass_count: usize, + pub(super) source_ref_coverage: Option, + pub(super) evidence_coverage: Option, + pub(super) evidence_class: String, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring.rs new file mode 100644 index 00000000..ca945834 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring.rs @@ -0,0 +1,145 @@ +#[path = "scoring/answers.rs"] mod answers; +#[path = "scoring/claims.rs"] mod claims; +#[path = "scoring/consolidation.rs"] mod consolidation; +#[path = "scoring/counts.rs"] mod counts; +#[path = "scoring/dimensions.rs"] mod dimensions; +#[path = "scoring/evolution.rs"] mod evolution; +#[path = "scoring/reports.rs"] mod reports; + +use self::{counts::wrong_result_signal_count, evolution::update_rationale_missing_count}; +use crate::{ + BTreeMap, BTreeSet, ConsolidationExecutableGapReport, ConsolidationJobReport, + ConsolidationProposalFixture, ConsolidationProposalReport, DimensionScoreReport, + EvolutionConflict, EvolutionJobReport, ExpectedClaim, ExpectedEvidenceReport, FailureCounts, + JobMetrics, JobReport, JobScoring, MemoryEvolution, MemorySummaryJobMetrics, NegativeTrap, + ProactiveBriefJobMetrics, ProducedAnswer, RealWorldJob, RequiredEvidence, + RetrievalQualityReport, ScheduledMemoryJobMetrics, TypedStatus, UpdateRationale, Value, + WorkContinuityJobMetrics, + feature_metrics::{ + self, forbidden_diff_key_count, missed_stale_finding_count, page_usefulness_failure_count, + }, + formatting::round3, + operational::operational_evidence_tier, + summary::{mean_proposal_metric, ratio_or}, +}; + +pub(super) fn job_report(job: &RealWorldJob, scoring: JobScoring) -> JobReport { + reports::job_report(job, scoring) +} + +pub(super) fn score_job(job: &RealWorldJob) -> JobScoring { + let answer = self::answers::produced_answer(job); + let produced_evidence = self::answers::produced_evidence_ids(answer); + let trap_ids_used = self::answers::trap_ids_used(job, &produced_evidence); + let consolidation = self::consolidation::consolidation_job_report(job); + + if let Some(status) = job.encoding.status { + let evolution = self::evolution::evolution_job_report(job, answer, &trap_ids_used, 0); + + return self::counts::score_declared_job( + job, + status, + trap_ids_used, + evolution, + consolidation, + ); + } + + let missing_claims = self::answers::missing_required_claims(job, answer); + let forbidden_claims = self::answers::forbidden_claim_hits(job, answer); + let missing_evidence = self::answers::missing_required_evidence(job, &produced_evidence); + let knowledge = feature_metrics::knowledge_metrics(job, answer); + let memory_summary = feature_metrics::memory_summary_metrics(job, answer); + let proactive_brief = feature_metrics::proactive_brief_metrics(job, answer); + let scheduled_memory = feature_metrics::scheduled_memory_metrics(job, answer); + let work_continuity = feature_metrics::work_continuity_metrics(job, answer); + let mut unsupported_claims = self::claims::unsupported_claims(job, answer); + + unsupported_claims.extend(feature_metrics::unsupported_page_claims(answer)); + unsupported_claims.extend(feature_metrics::unsupported_memory_summary_claims(job, answer)); + unsupported_claims.extend(feature_metrics::unsupported_proactive_suggestions(job, answer)); + unsupported_claims.extend(feature_metrics::unsupported_scheduled_outputs(job, answer)); + + let operator_counts = self::counts::operator_debug_failure_counts(job); + let latency_violations = self::dimensions::latency_violations(job, answer); + let hard_fail_hits = self::claims::hard_fail_hits(job, &unsupported_claims, &trap_ids_used); + let evolution = + self::evolution::evolution_job_report(job, answer, &trap_ids_used, forbidden_claims.len()); + let stale_answers = evolution.as_ref().map_or(0, |report| report.stale_answer_count); + let conflict_detection_missing = evolution + .as_ref() + .map_or(0, |report| report.conflict_count - report.conflict_detection_count); + let update_rationale_missing = evolution.as_ref().map_or(0, update_rationale_missing_count); + let mut counts = FailureCounts { + missing_claims: missing_claims.len(), + forbidden_claims: forbidden_claims.len(), + missing_evidence: missing_evidence.len(), + trap_uses: trap_ids_used.len(), + unsupported_claims: unsupported_claims.len(), + operator_debug_missing: operator_counts.operator_debug_missing, + operator_debug_raw_sql: operator_counts.operator_debug_raw_sql, + operator_debug_trace_gaps: operator_counts.operator_debug_trace_gaps, + operator_debug_repair_unclear: operator_counts.operator_debug_repair_unclear, + stale_answers, + conflict_detection_missing, + update_rationale_missing, + latency_violations, + proposal_usefulness_failures: self::consolidation::proposal_usefulness_failures( + consolidation.as_ref(), + ), + lineage_failures: self::consolidation::lineage_failures(consolidation.as_ref()), + review_action_failures: self::consolidation::review_action_failures(consolidation.as_ref()), + source_mutations: consolidation.as_ref().map_or(0, |report| report.source_mutation_count), + blocking_executable_gaps: self::consolidation::blocking_executable_gaps( + consolidation.as_ref(), + ), + untraced_page_sections: knowledge + .as_ref() + .map_or(0, |metrics| metrics.untraced_section_count), + missed_stale_findings: knowledge.as_ref().map_or(0, missed_stale_finding_count), + rebuild_failures: knowledge.as_ref().map_or(0, |metrics| metrics.rebuild_failure_count), + page_usefulness_failures: knowledge.as_ref().map_or(0, page_usefulness_failure_count), + ..FailureCounts::default() + }; + + self::counts::apply_memory_summary_failure_counts(&mut counts, memory_summary.as_ref()); + self::counts::apply_proactive_brief_failure_counts(&mut counts, proactive_brief.as_ref()); + self::counts::apply_scheduled_memory_failure_counts(&mut counts, scheduled_memory.as_ref()); + self::counts::apply_work_continuity_failure_counts(&mut counts, work_continuity.as_ref()); + + let dimension_scores = self::dimensions::dimension_scores(job, &counts); + let normalized_score = self::dimensions::normalized_score(&dimension_scores); + let wrong_result_count = self::counts::wrong_result_count(&counts); + let status = self::dimensions::job_status( + normalized_score, + job.scoring_rubric.pass_threshold, + wrong_result_count, + unsupported_claims.len(), + counts.source_mutations, + counts.blocking_executable_gaps, + ); + let reason = self::dimensions::job_reason(status, &counts, normalized_score); + + for claim in &mut unsupported_claims { + claim.suite_id = job.suite.clone(); + claim.job_id = job.job_id.clone(); + } + + JobScoring { + status, + normalized_score, + hard_fail_hits, + unsupported_claims, + wrong_result_count, + knowledge, + trap_ids_used, + dimension_scores, + reason, + evolution, + consolidation, + memory_summary, + proactive_brief, + scheduled_memory, + work_continuity, + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/answers.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/answers.rs new file mode 100644 index 00000000..3e60e5b1 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/answers.rs @@ -0,0 +1,201 @@ +use crate::scoring::{BTreeSet, ExpectedClaim, ProducedAnswer, RealWorldJob, RequiredEvidence}; + +pub(super) fn produced_answer(job: &RealWorldJob) -> &ProducedAnswer { + job.corpus + .adapter_response + .as_ref() + .map(|response| &response.answer) + .unwrap_or_else(|| synthetic_answer(job)) +} + +pub(super) fn produced_evidence_ids(answer: &ProducedAnswer) -> BTreeSet { + ordered_produced_evidence_ids(answer).into_iter().collect() +} + +pub(super) fn missing_required_claims(job: &RealWorldJob, answer: &ProducedAnswer) -> Vec { + job.expected_answer + .must_include + .iter() + .filter(|claim| !claim_is_present(claim, answer)) + .map(|claim| claim.text().to_string()) + .collect() +} + +pub(super) fn forbidden_claim_hits(job: &RealWorldJob, answer: &ProducedAnswer) -> Vec { + job.expected_answer + .must_not_include + .iter() + .filter(|claim| answer.content.contains(claim.as_str())) + .cloned() + .collect() +} + +pub(super) fn missing_required_evidence( + job: &RealWorldJob, + produced_evidence: &BTreeSet, +) -> Vec { + job.required_evidence + .iter() + .filter(|evidence| { + is_required_use(evidence) && !produced_evidence.contains(&evidence.evidence_id) + }) + .map(|evidence| evidence.evidence_id.clone()) + .collect() +} + +pub(super) fn is_required_use(evidence: &RequiredEvidence) -> bool { + matches!(evidence.requirement.as_str(), "cite" | "use" | "explain") +} + +pub(super) fn trap_ids_used( + job: &RealWorldJob, + produced_evidence: &BTreeSet, +) -> Vec { + job.negative_traps + .iter() + .filter(|trap| trap.failure_if_used) + .filter(|trap| { + trap.evidence_ids.iter().any(|evidence_id| produced_evidence.contains(evidence_id)) + }) + .map(|trap| trap.trap_id.clone()) + .collect() +} + +fn synthetic_answer(job: &RealWorldJob) -> &ProducedAnswer { + let _ = job; + + static EMPTY_ANSWER: std::sync::OnceLock = std::sync::OnceLock::new(); + + EMPTY_ANSWER.get_or_init(|| ProducedAnswer { + content: String::new(), + claims: Vec::new(), + evidence_ids: Vec::new(), + pages: Vec::new(), + memory_summaries: Vec::new(), + proactive_briefs: Vec::new(), + scheduled_tasks: Vec::new(), + work_journal_readbacks: Vec::new(), + recovery_drills: Vec::new(), + latency_ms: None, + cost: None, + trace_explainability: None, + }) +} + +fn ordered_produced_evidence_ids(answer: &ProducedAnswer) -> Vec { + let mut seen = BTreeSet::new(); + let mut evidence = Vec::new(); + + for evidence_id in &answer.evidence_ids { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for claim in &answer.claims { + for evidence_id in &claim.evidence_ids { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + for brief in &answer.proactive_briefs { + for suggestion in &brief.suggestions { + for evidence_id in &suggestion.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + } + for task in &answer.scheduled_tasks { + for output in &task.outputs { + for evidence_id in &output.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + } + for readback in &answer.work_journal_readbacks { + for entry in &readback.items { + for evidence_id in &entry.source_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for step in entry.explicit_next_steps.iter().chain(entry.inferred_next_steps.iter()) { + for evidence_id in &step.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + for option in &entry.rejected_options { + for evidence_id in &option.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + } + + if let Some(where_stopped) = &readback.where_stopped { + for evidence_id in &where_stopped.decision_rationale_evidence_ids { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &where_stopped.handoff_source_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + + for candidate in &readback.janitor_candidates { + for evidence_id in &candidate.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + } + for drill in &answer.recovery_drills { + for evidence_id in &drill.backup_pitr.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.degraded_read.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.rpo.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.rto.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.outbox_replay.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.qdrant_rebuild.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.migration_repair.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for evidence_id in &drill.dead_letter.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + for injection in &drill.failure_injections { + for evidence_id in &injection.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + for count in &drill.authority_record_counts { + for evidence_id in &count.evidence_refs { + push_ordered_evidence(&mut evidence, &mut seen, evidence_id); + } + } + } + + evidence +} + +fn push_ordered_evidence( + evidence: &mut Vec, + seen: &mut BTreeSet, + evidence_id: &str, +) { + if seen.insert(evidence_id.to_string()) { + evidence.push(evidence_id.to_string()); + } +} + +fn claim_is_present(claim: &ExpectedClaim, answer: &ProducedAnswer) -> bool { + if let Some(claim_id) = claim.claim_id() + && answer.claims.iter().any(|produced| produced.claim_id.as_deref() == Some(claim_id)) + { + return true; + } + + answer.content.contains(claim.text()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/claims.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/claims.rs new file mode 100644 index 00000000..9e1200a5 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/claims.rs @@ -0,0 +1,120 @@ +use crate::{ + EvidenceLink, ProducedAnswer, ProducedClaim, RealWorldJob, UnsupportedClaimReport, + feature_metrics, formatting, + scoring::{answers, consolidation}, +}; + +pub(super) fn unsupported_claims( + job: &RealWorldJob, + answer: &ProducedAnswer, +) -> Vec { + answer.claims.iter().filter_map(|claim| unsupported_claim(job, claim)).collect() +} + +pub(super) fn hard_fail_hits( + job: &RealWorldJob, + unsupported_claims: &[UnsupportedClaimReport], + trap_ids_used: &[String], +) -> Vec { + let mut hits = Vec::new(); + + if !unsupported_claims.is_empty() { + hits.push( + "unsupported high-confidence claim about a required decision or fact".to_string(), + ); + } + if !trap_ids_used.is_empty() { + hits.push("use of a negative trap marked failure_if_used = true".to_string()); + } + if job.expected_answer.requires_caveat + && !answer_has_required_caveat(job, answers::produced_answer(job)) + { + hits.push("missing required caveat".to_string()); + } + if job.expected_answer.requires_refusal + && !answer_looks_like_refusal(answers::produced_answer(job)) + { + hits.push("missing required refusal".to_string()); + } + + if let Some(work_continuity) = + feature_metrics::work_continuity_metrics(job, answers::produced_answer(job)) + { + if work_continuity.sensitive_marker_persistence_count > 0 { + hits.push("sensitive-marker persistence in Work Journal output".to_string()); + } + if work_continuity.rejected_option_resurrection_count > 0 { + hits.push("rejected-option resurrection in Work Journal readback".to_string()); + } + if work_continuity.inferred_step_instruction_count > 0 { + hits.push("inferred Work Journal next step surfaced as an instruction".to_string()); + } + if work_continuity.journal_only_authority_claim_count > 0 { + hits.push("journal-only Work Journal content claimed as current authority".to_string()); + } + if work_continuity.janitor_false_promotion_count > 0 { + hits.push("janitor Work Journal candidate promoted without review".to_string()); + } + } + if let Some(consolidation) = consolidation::consolidation_job_report(job) { + if consolidation.source_mutation_count > 0 { + hits.push( + "source mutation count must remain zero for proposal-only consolidation cases" + .to_string(), + ); + } + if consolidation.executable_gaps.iter().any(|gap| gap.blocks_fixture_pass) { + hits.push( + "missing consolidation primitive requires a precise follow-up issue".to_string(), + ); + } + } + + hits +} + +fn unsupported_claim(job: &RealWorldJob, claim: &ProducedClaim) -> Option { + let Some(claim_id) = claim.claim_id.as_deref() else { + return Some(unsupported_claim_report(claim, "claim has no claim_id")); + }; + let Some(allowed) = job.expected_answer.evidence_links.get(claim_id).map(EvidenceLink::ids) + else { + return Some(unsupported_claim_report( + claim, + "claim_id is not present in expected_answer.evidence_links", + )); + }; + + if claim.evidence_ids.is_empty() { + return Some(unsupported_claim_report(claim, "claim has no produced evidence ids")); + } + if !claim.evidence_ids.iter().any(|evidence_id| allowed.contains(evidence_id)) { + return Some(unsupported_claim_report( + claim, + "claim evidence is not allowed for this claim_id", + )); + } + + None +} + +fn unsupported_claim_report(claim: &ProducedClaim, reason: &str) -> UnsupportedClaimReport { + UnsupportedClaimReport { + suite_id: String::new(), + job_id: String::new(), + claim_id: claim.claim_id.clone(), + claim_text: formatting::bounded_text(claim.text.as_str(), 240), + reason: reason.to_string(), + evidence_ids: claim.evidence_ids.clone(), + } +} + +fn answer_has_required_caveat(job: &RealWorldJob, answer: &ProducedAnswer) -> bool { + job.allowed_uncertainty.acceptable_phrases.iter().any(|phrase| answer.content.contains(phrase)) +} + +fn answer_looks_like_refusal(answer: &ProducedAnswer) -> bool { + let lower = answer.content.to_ascii_lowercase(); + + lower.contains("cannot") || lower.contains("can't") || lower.contains("refuse") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/consolidation.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/consolidation.rs new file mode 100644 index 00000000..dd849f6f --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/consolidation.rs @@ -0,0 +1,99 @@ +use crate::scoring::{ + self, BTreeSet, ConsolidationExecutableGapReport, ConsolidationJobReport, + ConsolidationProposalFixture, ConsolidationProposalReport, RealWorldJob, +}; + +pub(super) fn consolidation_job_report(job: &RealWorldJob) -> Option { + let fixture = job.corpus.adapter_response.as_ref()?.consolidation.as_ref()?; + let proposals = fixture.proposals.iter().map(consolidation_proposal_report).collect::>(); + let executable_gaps = fixture + .executable_gaps + .iter() + .map(|gap| ConsolidationExecutableGapReport { + primitive: gap.primitive.clone(), + follow_up_issue: gap.follow_up_issue.clone(), + reason: gap.reason.clone(), + blocks_fixture_pass: gap.blocks_fixture_pass, + }) + .collect::>(); + let proposal_count = proposals.len(); + let source_mutation_count = + proposals.iter().map(|proposal| proposal.source_mutation_count).sum(); + let proposal_unsupported_claim_count = + proposals.iter().map(|proposal| proposal.unsupported_claim_count).sum(); + + Some(ConsolidationJobReport { + proposal_count, + proposal_usefulness: scoring::mean_proposal_metric( + proposals.iter().map(|proposal| proposal.usefulness_score), + ), + lineage_completeness: scoring::mean_proposal_metric( + proposals.iter().map(|proposal| proposal.lineage_completeness), + ), + review_action_correctness: scoring::mean_proposal_metric( + proposals.iter().map(|proposal| if proposal.review_action_correct { 1.0 } else { 0.0 }), + ), + source_mutation_count, + proposal_unsupported_claim_count, + executable_gaps, + proposals, + }) +} + +pub(super) fn proposal_usefulness_failures( + consolidation: Option<&ConsolidationJobReport>, +) -> usize { + consolidation.map_or(0, |report| { + report + .proposals + .iter() + .filter(|proposal| proposal.usefulness_score < proposal.min_usefulness_score) + .count() + }) +} + +pub(super) fn lineage_failures(consolidation: Option<&ConsolidationJobReport>) -> usize { + consolidation.map_or(0, |report| { + report.proposals.iter().filter(|proposal| proposal.lineage_completeness < 1.0).count() + }) +} + +pub(super) fn review_action_failures(consolidation: Option<&ConsolidationJobReport>) -> usize { + consolidation.map_or(0, |report| { + report.proposals.iter().filter(|proposal| !proposal.review_action_correct).count() + }) +} + +pub(super) fn blocking_executable_gaps(consolidation: Option<&ConsolidationJobReport>) -> usize { + consolidation.map_or(0, |report| { + report.executable_gaps.iter().filter(|gap| gap.blocks_fixture_pass).count() + }) +} + +fn consolidation_proposal_report( + proposal: &ConsolidationProposalFixture, +) -> ConsolidationProposalReport { + ConsolidationProposalReport { + proposal_id: proposal.proposal_id.clone(), + proposal_kind: proposal.proposal_kind.clone(), + usefulness_score: scoring::round3(proposal.usefulness_score), + min_usefulness_score: scoring::round3(proposal.min_usefulness_score), + lineage_completeness: scoring::round3(lineage_completeness(proposal)), + expected_review_action: proposal.expected_review_action, + actual_review_action: proposal.actual_review_action, + review_action_correct: proposal.expected_review_action == proposal.actual_review_action, + source_mutation_count: proposal.source_mutations.len() + + scoring::forbidden_diff_key_count(&proposal.diff), + unsupported_claim_count: proposal + .unsupported_claim_count + .max(proposal.unsupported_claim_flags.len()), + } +} + +fn lineage_completeness(proposal: &ConsolidationProposalFixture) -> f64 { + let expected = proposal.expected_source_refs.iter().collect::>(); + let actual = proposal.source_refs.iter().collect::>(); + let matched = expected.iter().filter(|source_ref| actual.contains(**source_ref)).count(); + + matched as f64 / expected.len() as f64 +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/counts.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/counts.rs new file mode 100644 index 00000000..e61b0100 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/counts.rs @@ -0,0 +1,264 @@ +use crate::scoring::{ + ConsolidationJobReport, DimensionScoreReport, EvolutionJobReport, FailureCounts, JobScoring, + MemorySummaryJobMetrics, ProactiveBriefJobMetrics, RealWorldJob, ScheduledMemoryJobMetrics, + TypedStatus, WorkContinuityJobMetrics, +}; + +pub(super) fn apply_memory_summary_failure_counts( + counts: &mut FailureCounts, + metrics: Option<&MemorySummaryJobMetrics>, +) { + let Some(metrics) = metrics else { + return; + }; + + counts.memory_summary_invalid_current_entries = metrics.invalid_top_of_mind_count; + counts.memory_summary_untraced_entries = metrics.untraced_entry_count; + counts.memory_summary_missing_freshness = + metrics.entry_count.saturating_sub(metrics.freshness_marker_count); + counts.memory_summary_missing_rationale = + metrics.entry_count.saturating_sub(metrics.rationale_count); + counts.memory_summary_missing_categories = metrics.missing_required_category_count; + counts.memory_summary_unsupported_current_entries = metrics.unsupported_current_entry_count; +} + +pub(super) fn apply_proactive_brief_failure_counts( + counts: &mut FailureCounts, + metrics: Option<&ProactiveBriefJobMetrics>, +) { + let Some(metrics) = metrics else { + return; + }; + + counts.proactive_brief_invalid_current_suggestions = metrics.invalid_current_suggestion_count; + counts.proactive_brief_untraced_suggestions = metrics.untraced_suggestion_count; + counts.proactive_brief_missing_freshness = + metrics.suggestion_count.saturating_sub(metrics.freshness_marker_count); + counts.proactive_brief_missing_action_rationale = + metrics.suggestion_count.saturating_sub(metrics.action_rationale_count); + counts.proactive_brief_missing_kinds = metrics.missing_required_suggestion_kind_count; + counts.proactive_brief_unsupported_current_suggestions = + metrics.unsupported_current_suggestion_count; + counts.proactive_brief_tombstone_violations = metrics.tombstone_violation_count; +} + +pub(super) fn apply_scheduled_memory_failure_counts( + counts: &mut FailureCounts, + metrics: Option<&ScheduledMemoryJobMetrics>, +) { + let Some(metrics) = metrics else { + return; + }; + + counts.scheduled_memory_invalid_current_outputs = metrics.invalid_current_output_count; + counts.scheduled_memory_untraced_outputs = metrics.untraced_output_count; + counts.scheduled_memory_missing_freshness = + metrics.output_count.saturating_sub(metrics.freshness_marker_count); + counts.scheduled_memory_missing_action_rationale = + metrics.output_count.saturating_sub(metrics.action_rationale_count); + counts.scheduled_memory_missing_task_kinds = metrics.missing_required_task_kind_count; + counts.scheduled_memory_unsupported_current_outputs = metrics.unsupported_current_output_count; + counts.scheduled_memory_tombstone_violations = metrics.tombstone_violation_count; + counts.scheduled_memory_missing_trace = + metrics.trace_required_count.saturating_sub(metrics.trace_complete_count); + counts.source_mutations += metrics.source_mutation_count; +} + +pub(super) fn apply_work_continuity_failure_counts( + counts: &mut FailureCounts, + metrics: Option<&WorkContinuityJobMetrics>, +) { + let Some(metrics) = metrics else { + return; + }; + + counts.work_continuity_reset_resume_missing = + metrics.reset_resume_required_count.saturating_sub(metrics.reset_resume_success_count); + counts.work_continuity_decision_rationale_missing = metrics + .decision_rationale_required_count + .saturating_sub(metrics.decision_rationale_recalled_count); + counts.work_continuity_rejected_option_unsuppressed = metrics + .rejected_option_required_count + .saturating_sub(metrics.rejected_option_suppressed_count); + counts.work_continuity_rejected_option_resurrection = + metrics.rejected_option_resurrection_count; + counts.work_continuity_explicit_next_step_missing = metrics + .explicit_next_step_required_count + .saturating_sub(metrics.explicit_next_step_correct_count); + counts.work_continuity_explicit_next_step_extra = metrics + .explicit_next_step_returned_count + .saturating_sub(metrics.explicit_next_step_correct_count); + counts.work_continuity_inferred_step_unlabeled = metrics + .inferred_next_step_required_count + .saturating_sub(metrics.inferred_next_step_labeled_count); + counts.work_continuity_inferred_step_as_instruction = metrics.inferred_step_instruction_count; + counts.work_continuity_handoff_source_ref_missing = metrics + .handoff_source_ref_required_count + .saturating_sub(metrics.handoff_source_ref_covered_count); + counts.work_continuity_redaction_missing = + metrics.redaction_required_count.saturating_sub(metrics.redaction_applied_count); + counts.work_continuity_sensitive_marker_persistence = + metrics.sensitive_marker_persistence_count; + counts.work_continuity_janitor_false_promotion = metrics.janitor_false_promotion_count; + counts.work_continuity_journal_only_authority_claim = + metrics.journal_only_authority_claim_count; +} + +pub(super) fn score_declared_job( + job: &RealWorldJob, + status: TypedStatus, + trap_ids_used: Vec, + evolution: Option, + consolidation: Option, +) -> JobScoring { + JobScoring { + status, + normalized_score: 0.0, + hard_fail_hits: Vec::new(), + unsupported_claims: Vec::new(), + wrong_result_count: 0, + knowledge: None, + trap_ids_used, + dimension_scores: declared_not_encoded_dimension_scores(job), + reason: job + .encoding + .reason + .clone() + .unwrap_or_else(|| "Job did not reach a runnable scoring state.".to_string()), + evolution, + consolidation, + memory_summary: None, + proactive_brief: None, + scheduled_memory: None, + work_continuity: None, + } +} + +pub(super) fn wrong_result_count(counts: &FailureCounts) -> usize { + counts.missing_claims + + counts.forbidden_claims + + counts.missing_evidence + + counts.trap_uses + + counts.operator_debug_missing + + counts.operator_debug_raw_sql + + counts.operator_debug_trace_gaps + + counts.operator_debug_repair_unclear + + counts.conflict_detection_missing + + counts.update_rationale_missing + + counts.proposal_usefulness_failures + + counts.lineage_failures + + counts.review_action_failures + + counts.memory_summary_invalid_current_entries + + counts.memory_summary_untraced_entries + + counts.memory_summary_missing_freshness + + counts.memory_summary_missing_rationale + + counts.memory_summary_missing_categories + + counts.memory_summary_unsupported_current_entries + + counts.proactive_brief_invalid_current_suggestions + + counts.proactive_brief_untraced_suggestions + + counts.proactive_brief_missing_freshness + + counts.proactive_brief_missing_action_rationale + + counts.proactive_brief_missing_kinds + + counts.proactive_brief_unsupported_current_suggestions + + counts.proactive_brief_tombstone_violations + + counts.scheduled_memory_invalid_current_outputs + + counts.scheduled_memory_untraced_outputs + + counts.scheduled_memory_missing_freshness + + counts.scheduled_memory_missing_action_rationale + + counts.scheduled_memory_missing_task_kinds + + counts.scheduled_memory_unsupported_current_outputs + + counts.scheduled_memory_tombstone_violations + + counts.scheduled_memory_missing_trace + + work_continuity_wrong_result_count(counts) + + counts.untraced_page_sections + + counts.missed_stale_findings + + counts.rebuild_failures + + counts.page_usefulness_failures +} + +pub(super) fn operator_debug_failure_counts(job: &RealWorldJob) -> FailureCounts { + let Some(debug) = &job.operator_debug else { + return FailureCounts { + operator_debug_missing: usize::from(job.suite == "operator_debugging_ux"), + ..FailureCounts::default() + }; + }; + + FailureCounts { + operator_debug_raw_sql: usize::from(debug.raw_sql_needed), + operator_debug_trace_gaps: usize::from(debug.trace_completeness != "complete"), + operator_debug_repair_unclear: usize::from(debug.repair_action_clarity != "clear"), + ..FailureCounts::default() + } +} + +pub(super) fn wrong_result_signal_count(counts: &FailureCounts) -> usize { + counts.missing_claims + + counts.forbidden_claims + + counts.missing_evidence + + counts.trap_uses + + counts.operator_debug_missing + + counts.operator_debug_raw_sql + + counts.operator_debug_trace_gaps + + counts.operator_debug_repair_unclear + + counts.conflict_detection_missing + + counts.update_rationale_missing + + counts.proposal_usefulness_failures + + counts.lineage_failures + + counts.review_action_failures + + counts.memory_summary_invalid_current_entries + + counts.memory_summary_untraced_entries + + counts.memory_summary_missing_freshness + + counts.memory_summary_missing_rationale + + counts.memory_summary_missing_categories + + counts.memory_summary_unsupported_current_entries + + counts.proactive_brief_invalid_current_suggestions + + counts.proactive_brief_untraced_suggestions + + counts.proactive_brief_missing_freshness + + counts.proactive_brief_missing_action_rationale + + counts.proactive_brief_missing_kinds + + counts.proactive_brief_unsupported_current_suggestions + + counts.proactive_brief_tombstone_violations + + counts.scheduled_memory_invalid_current_outputs + + counts.scheduled_memory_untraced_outputs + + counts.scheduled_memory_missing_freshness + + counts.scheduled_memory_missing_action_rationale + + counts.scheduled_memory_missing_task_kinds + + counts.scheduled_memory_unsupported_current_outputs + + counts.scheduled_memory_tombstone_violations + + counts.scheduled_memory_missing_trace + + work_continuity_wrong_result_count(counts) + + counts.untraced_page_sections + + counts.missed_stale_findings + + counts.rebuild_failures + + counts.page_usefulness_failures +} + +pub(super) fn work_continuity_wrong_result_count(counts: &FailureCounts) -> usize { + counts.work_continuity_reset_resume_missing + + counts.work_continuity_decision_rationale_missing + + counts.work_continuity_rejected_option_unsuppressed + + counts.work_continuity_rejected_option_resurrection + + counts.work_continuity_explicit_next_step_missing + + counts.work_continuity_explicit_next_step_extra + + counts.work_continuity_inferred_step_unlabeled + + counts.work_continuity_inferred_step_as_instruction + + counts.work_continuity_handoff_source_ref_missing + + counts.work_continuity_redaction_missing + + counts.work_continuity_sensitive_marker_persistence + + counts.work_continuity_janitor_false_promotion + + counts.work_continuity_journal_only_authority_claim +} + +fn declared_not_encoded_dimension_scores(job: &RealWorldJob) -> Vec { + job.scoring_rubric + .dimensions + .iter() + .map(|(dimension_id, dimension)| DimensionScoreReport { + dimension: dimension_id.clone(), + score: 0.0, + max_points: dimension.max_points, + weight: dimension.weight, + }) + .collect() +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/dimensions.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/dimensions.rs new file mode 100644 index 00000000..6c0cd2ca --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/dimensions.rs @@ -0,0 +1,209 @@ +use crate::scoring::{ + self, DimensionScoreReport, FailureCounts, ProducedAnswer, RealWorldJob, TypedStatus, Value, +}; + +pub(super) fn dimension_scores( + job: &RealWorldJob, + counts: &FailureCounts, +) -> Vec { + job.scoring_rubric + .dimensions + .iter() + .map(|(dimension_id, dimension)| DimensionScoreReport { + dimension: dimension_id.clone(), + score: dimension_score(dimension_id, dimension.max_points, counts), + max_points: dimension.max_points, + weight: dimension.weight, + }) + .collect() +} + +pub(super) fn latency_violations(job: &RealWorldJob, answer: &ProducedAnswer) -> usize { + let Some(max_latency_ms) = latency_threshold_ms(job) else { + return 0; + }; + let Some(latency_ms) = answer.latency_ms else { + return 1; + }; + + usize::from(latency_ms > max_latency_ms) +} + +pub(super) fn normalized_score(scores: &[DimensionScoreReport]) -> f64 { + let total_weight = scores.iter().map(|score| score.weight).sum::(); + + if total_weight == 0.0 { + return 0.0; + } + + scores.iter().map(|score| (score.score / score.max_points) * score.weight).sum::() + / total_weight +} + +pub(super) fn job_status( + normalized_score: f64, + pass_threshold: f64, + wrong_result_count: usize, + unsupported_claim_count: usize, + source_mutation_count: usize, + blocking_executable_gap_count: usize, +) -> TypedStatus { + if unsupported_claim_count > 0 { + TypedStatus::UnsupportedClaim + } else if source_mutation_count > 0 { + TypedStatus::LifecycleFail + } else if blocking_executable_gap_count > 0 { + TypedStatus::Blocked + } else if wrong_result_count > 0 { + TypedStatus::WrongResult + } else if normalized_score >= pass_threshold { + TypedStatus::Pass + } else { + TypedStatus::WrongResult + } +} + +pub(super) fn job_reason( + status: TypedStatus, + counts: &FailureCounts, + normalized_score: f64, +) -> String { + let wrong_result_signal_count = scoring::wrong_result_signal_count(counts); + + match status { + TypedStatus::Pass => format!("Job passed with normalized_score {normalized_score:.3}."), + TypedStatus::UnsupportedClaim => format!( + "Job produced {} unsupported claim(s), {} wrong-result signal(s), {} latency violation(s), and normalized_score {normalized_score:.3}.", + counts.unsupported_claims, wrong_result_signal_count, counts.latency_violations + ), + TypedStatus::WrongResult => format!( + "Job produced {} wrong-result signal(s), {} latency violation(s), and normalized_score {normalized_score:.3}.", + wrong_result_signal_count, counts.latency_violations + ), + TypedStatus::LifecycleFail => format!( + "Job produced {} source mutation(s) and normalized_score {normalized_score:.3}.", + counts.source_mutations + ), + TypedStatus::Blocked => format!( + "Job has {} blocking executable gap(s) and normalized_score {normalized_score:.3}.", + counts.blocking_executable_gaps + ), + _ => "Job did not reach a runnable scoring state.".to_string(), + } +} + +fn dimension_score(dimension_id: &str, max_points: f64, counts: &FailureCounts) -> f64 { + let failed = match dimension_id { + "answer_correctness" | "workflow_helpfulness" => + counts.missing_claims > 0 + || counts.forbidden_claims > 0 + || counts.operator_debug_repair_unclear > 0 + || counts.conflict_detection_missing > 0 + || counts.proposal_usefulness_failures > 0 + || counts.review_action_failures > 0 + || counts.memory_summary_invalid_current_entries > 0 + || counts.memory_summary_missing_categories > 0 + || counts.memory_summary_unsupported_current_entries > 0 + || counts.proactive_brief_invalid_current_suggestions > 0 + || counts.proactive_brief_missing_kinds > 0 + || counts.proactive_brief_unsupported_current_suggestions > 0 + || counts.proactive_brief_tombstone_violations > 0 + || counts.scheduled_memory_invalid_current_outputs > 0 + || counts.scheduled_memory_missing_task_kinds > 0 + || counts.scheduled_memory_unsupported_current_outputs > 0 + || counts.scheduled_memory_tombstone_violations > 0 + || counts.scheduled_memory_missing_trace > 0 + || counts.work_continuity_reset_resume_missing > 0 + || counts.work_continuity_decision_rationale_missing > 0 + || counts.work_continuity_rejected_option_unsuppressed > 0 + || counts.work_continuity_rejected_option_resurrection > 0 + || counts.work_continuity_explicit_next_step_missing > 0 + || counts.work_continuity_explicit_next_step_extra > 0 + || counts.work_continuity_inferred_step_unlabeled > 0 + || counts.work_continuity_inferred_step_as_instruction > 0 + || counts.work_continuity_janitor_false_promotion > 0 + || counts.work_continuity_journal_only_authority_claim > 0 + || counts.page_usefulness_failures > 0, + "evidence_grounding" => + counts.missing_evidence > 0 + || counts.unsupported_claims > 0 + || counts.lineage_failures > 0 + || counts.memory_summary_untraced_entries > 0 + || counts.proactive_brief_untraced_suggestions > 0 + || counts.scheduled_memory_untraced_outputs > 0 + || counts.scheduled_memory_missing_trace > 0 + || counts.work_continuity_decision_rationale_missing > 0 + || counts.work_continuity_handoff_source_ref_missing > 0 + || counts.work_continuity_redaction_missing > 0 + || counts.work_continuity_sensitive_marker_persistence > 0 + || counts.untraced_page_sections > 0, + "trap_avoidance" => + counts.trap_uses > 0 + || counts.memory_summary_invalid_current_entries > 0 + || counts.proactive_brief_invalid_current_suggestions > 0 + || counts.proactive_brief_tombstone_violations > 0 + || counts.scheduled_memory_invalid_current_outputs > 0 + || counts.scheduled_memory_tombstone_violations > 0 + || counts.work_continuity_rejected_option_resurrection > 0 + || counts.work_continuity_sensitive_marker_persistence > 0 + || counts.missed_stale_findings > 0, + "uncertainty_handling" => + counts.unsupported_claims > 0 + || counts.memory_summary_unsupported_current_entries > 0 + || counts.proactive_brief_unsupported_current_suggestions > 0 + || counts.scheduled_memory_unsupported_current_outputs > 0 + || counts.work_continuity_journal_only_authority_claim > 0, + "lifecycle_behavior" => + counts.stale_answers > 0 + || counts.conflict_detection_missing > 0 + || counts.update_rationale_missing > 0 + || counts.source_mutations > 0 + || counts.memory_summary_invalid_current_entries > 0 + || counts.memory_summary_missing_freshness > 0 + || counts.memory_summary_missing_rationale > 0 + || counts.memory_summary_unsupported_current_entries > 0 + || counts.proactive_brief_invalid_current_suggestions > 0 + || counts.proactive_brief_missing_freshness > 0 + || counts.proactive_brief_missing_action_rationale > 0 + || counts.proactive_brief_unsupported_current_suggestions > 0 + || counts.proactive_brief_tombstone_violations > 0 + || counts.scheduled_memory_invalid_current_outputs > 0 + || counts.scheduled_memory_missing_freshness > 0 + || counts.scheduled_memory_missing_action_rationale > 0 + || counts.scheduled_memory_unsupported_current_outputs > 0 + || counts.scheduled_memory_tombstone_violations > 0 + || counts.scheduled_memory_missing_trace > 0 + || counts.work_continuity_reset_resume_missing > 0 + || counts.work_continuity_inferred_step_as_instruction > 0 + || counts.work_continuity_janitor_false_promotion > 0 + || counts.work_continuity_journal_only_authority_claim > 0 + || counts.rebuild_failures > 0, + "source_immutability" => counts.source_mutations > 0, + "proposal_usefulness" => counts.proposal_usefulness_failures > 0, + "lineage_completeness" => counts.lineage_failures > 0, + "review_action_correctness" => counts.review_action_failures > 0, + "debuggability" => + counts.missing_claims > 0 + || counts.unsupported_claims > 0 + || counts.operator_debug_missing > 0 + || counts.operator_debug_raw_sql > 0 + || counts.operator_debug_trace_gaps > 0 + || counts.scheduled_memory_missing_trace > 0 + || counts.work_continuity_reset_resume_missing > 0, + "trace_readback" => counts.scheduled_memory_missing_trace > 0, + "latency_resource" => counts.latency_violations > 0, + "personalization_fit" | "ownership_correctness" => + counts.missing_claims > 0 || counts.unsupported_claims > 0, + _ => counts.missing_claims > 0 || counts.unsupported_claims > 0 || counts.trap_uses > 0, + }; + + if failed { 0.0 } else { max_points } +} + +fn latency_threshold_ms(job: &RealWorldJob) -> Option { + job.scoring_rubric + .dimensions + .get("latency_resource") + .and_then(|dimension| dimension.criteria.get("max_latency_ms")) + .and_then(Value::as_f64) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/evolution.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/evolution.rs new file mode 100644 index 00000000..92d594ee --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/evolution.rs @@ -0,0 +1,234 @@ +use crate::scoring::{ + BTreeSet, EvolutionConflict, EvolutionJobReport, MemoryEvolution, ProducedAnswer, RealWorldJob, + UpdateRationale, answers, +}; + +pub(super) fn evolution_job_report( + job: &RealWorldJob, + answer: &ProducedAnswer, + trap_ids_used: &[String], + forbidden_claim_count: usize, +) -> Option { + let evolution = job.memory_evolution.as_ref()?; + let produced = answers::produced_evidence_ids(answer); + let stale_trap_ids_used = stale_trap_ids_used(job, evolution, trap_ids_used); + let stale_answer_count = + stale_answer_count(job, evolution, &stale_trap_ids_used, forbidden_claim_count); + let conflict_detection_count = evolution + .conflicts + .iter() + .filter(|conflict| conflict_is_detected(conflict, answer)) + .count(); + let update_rationale_available = evolution + .update_rationale + .as_ref() + .is_some_and(|rationale| update_rationale_is_available(rationale, answer)); + let temporal_validity_required = + evolution.temporal_validity.as_ref().is_some_and(|temporal| temporal.required); + let temporal_validity_encoded = + evolution.temporal_validity.as_ref().is_some_and(|temporal| temporal.encoded); + let temporal_validity_not_encoded = temporal_validity_required && !temporal_validity_encoded; + let history_readback_encoded = + evolution.history_readback.as_ref().is_some_and(|history| history.encoded); + let history_event_types = evolution + .history_readback + .as_ref() + .map_or_else(Vec::new, |history| history.required_event_types.clone()); + let history_requires_note_version_links = evolution + .history_readback + .as_ref() + .is_some_and(|history| history.requires_note_version_links); + let follow_up = evolution + .temporal_validity + .as_ref() + .and_then(|temporal| temporal.follow_up.clone()) + .or_else(|| job.encoding.follow_up.as_ref().map(|follow_up| follow_up.title.clone())); + + Some(EvolutionJobReport { + current_evidence: evolution.current_evidence_ids.clone(), + historical_evidence: evolution.historical_evidence_ids.clone(), + tombstone_evidence: evolution.tombstone_evidence_ids.clone(), + invalidation_evidence: evolution.invalidation_evidence_ids.clone(), + selected_current_evidence: selected_evolution_evidence( + &evolution.current_evidence_ids, + &produced, + ), + selected_historical_evidence: selected_evolution_evidence( + &evolution.historical_evidence_ids, + &produced, + ), + selected_rationale_evidence: selected_rationale_evidence(evolution, &produced), + selected_tombstone_evidence: selected_evolution_evidence( + &evolution.tombstone_evidence_ids, + &produced, + ), + selected_invalidation_evidence: selected_evolution_evidence( + &evolution.invalidation_evidence_ids, + &produced, + ), + conflict_candidate_evidence: selected_conflict_candidate_evidence(evolution, &produced), + retrieved_but_dropped_evidence: trace_dropped_evidence(answer), + selected_but_not_narrated_evidence: selected_but_not_narrated_evidence(answer), + stale_answer_count, + stale_trap_ids_used, + conflict_count: evolution.conflicts.len(), + conflict_detection_count, + update_rationale_available, + temporal_validity_required, + temporal_validity_encoded, + temporal_validity_not_encoded, + history_readback_encoded, + history_event_types, + history_requires_note_version_links, + follow_up, + }) +} + +pub(super) fn update_rationale_missing_count(report: &EvolutionJobReport) -> usize { + if report.update_rationale_available || report.temporal_validity_not_encoded { + 0 + } else if report.conflict_count > 0 { + 1 + } else { + 0 + } +} + +fn stale_answer_count( + job: &RealWorldJob, + evolution: &MemoryEvolution, + stale_trap_ids_used: &[String], + forbidden_claim_count: usize, +) -> usize { + let stale_trap_count = if evolution.stale_trap_ids.is_empty() { + job.negative_traps.iter().filter(|trap| trap.trap_type == "stale_fact").count() + } else { + evolution.stale_trap_ids.len() + }; + let stale_forbidden_claims = if stale_trap_count > 0 { forbidden_claim_count } else { 0 }; + + stale_trap_ids_used.len().max(stale_forbidden_claims) +} + +fn selected_evolution_evidence( + evidence_ids: &[String], + produced: &BTreeSet, +) -> Vec { + evidence_ids.iter().filter(|evidence_id| produced.contains(*evidence_id)).cloned().collect() +} + +fn selected_rationale_evidence( + evolution: &MemoryEvolution, + produced: &BTreeSet, +) -> Vec { + evolution.update_rationale.as_ref().map_or_else(Vec::new, |rationale| { + selected_evolution_evidence(&rationale.evidence_ids, produced) + }) +} + +fn selected_conflict_candidate_evidence( + evolution: &MemoryEvolution, + produced: &BTreeSet, +) -> Vec { + let mut evidence_ids = Vec::new(); + + for conflict in &evolution.conflicts { + push_if_produced(&mut evidence_ids, conflict.current_evidence_id.as_str(), produced); + push_if_produced(&mut evidence_ids, conflict.historical_evidence_id.as_str(), produced); + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + push_if_produced(&mut evidence_ids, evidence_id.as_str(), produced); + } + } + + evidence_ids +} + +fn push_if_produced(out: &mut Vec, evidence_id: &str, produced: &BTreeSet) { + if produced.contains(evidence_id) && !out.iter().any(|id| id == evidence_id) { + out.push(evidence_id.to_string()); + } +} + +fn trace_dropped_evidence(answer: &ProducedAnswer) -> Vec { + let mut evidence = Vec::new(); + + if let Some(trace) = &answer.trace_explainability { + for stage in &trace.stages { + for evidence_id in &stage.dropped_evidence { + if !evidence.iter().any(|id| id == evidence_id) { + evidence.push(evidence_id.clone()); + } + } + } + } + + evidence +} + +fn selected_but_not_narrated_evidence(answer: &ProducedAnswer) -> Vec { + let narrated = answer + .claims + .iter() + .flat_map(|claim| claim.evidence_ids.iter().map(String::as_str)) + .collect::>(); + + answer + .evidence_ids + .iter() + .filter(|evidence_id| !narrated.contains(evidence_id.as_str())) + .cloned() + .collect() +} + +fn stale_trap_ids_used( + job: &RealWorldJob, + evolution: &MemoryEvolution, + trap_ids_used: &[String], +) -> Vec { + let declared_stale_traps = if evolution.stale_trap_ids.is_empty() { + job.negative_traps + .iter() + .filter(|trap| trap.trap_type == "stale_fact") + .map(|trap| trap.trap_id.as_str()) + .collect::>() + } else { + evolution.stale_trap_ids.iter().map(String::as_str).collect::>() + }; + + trap_ids_used + .iter() + .filter(|trap_id| declared_stale_traps.contains(trap_id.as_str())) + .cloned() + .collect() +} + +fn conflict_is_detected(conflict: &EvolutionConflict, answer: &ProducedAnswer) -> bool { + let mut required_evidence = + vec![conflict.current_evidence_id.as_str(), conflict.historical_evidence_id.as_str()]; + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + required_evidence.push(evidence_id.as_str()); + } + + answer.claims.iter().any(|claim| { + claim.claim_id.as_deref() == Some(conflict.claim_id.as_str()) + && required_evidence + .iter() + .all(|evidence_id| claim.evidence_ids.iter().any(|id| id == evidence_id)) + }) +} + +fn update_rationale_is_available(rationale: &UpdateRationale, answer: &ProducedAnswer) -> bool { + if !rationale.available { + return false; + } + + answer.claims.iter().any(|claim| { + claim.claim_id.as_deref() == Some(rationale.claim_id.as_str()) + && !claim.evidence_ids.is_empty() + && rationale.evidence_ids.iter().any(|evidence_id| { + claim.evidence_ids.iter().any(|produced| produced == evidence_id) + }) + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/reports.rs new file mode 100644 index 00000000..9e01d093 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/scoring/reports.rs @@ -0,0 +1,243 @@ +use crate::scoring::{ + self, BTreeMap, BTreeSet, ExpectedEvidenceReport, JobMetrics, JobReport, JobScoring, + NegativeTrap, ProducedAnswer, RealWorldJob, RetrievalQualityReport, Value, answers, +}; + +pub(super) fn job_report(job: &RealWorldJob, scoring: JobScoring) -> JobReport { + let answer = answers::produced_answer(job); + let metrics = job_metrics(job, answer); + let retrieval_quality = retrieval_quality_report(job, answer); + + JobReport { + suite_id: job.suite.clone(), + job_id: job.job_id.clone(), + title: job.title.clone(), + status: scoring.status, + operational_evidence_tier: scoring::operational_evidence_tier(job).to_string(), + answer_type: job.expected_answer.answer_type.clone(), + requires_caveat: job.expected_answer.requires_caveat, + requires_refusal: job.expected_answer.requires_refusal, + can_answer_unknown: job.allowed_uncertainty.can_answer_unknown, + normalized_score: scoring::round3(scoring.normalized_score), + hard_fail_hits: scoring.hard_fail_hits, + expected_evidence: expected_evidence_report(job), + produced_answer: answer.content.clone(), + produced_evidence: answers::produced_evidence_ids(answer).into_iter().collect(), + unsupported_claim_count: scoring.unsupported_claims.len(), + wrong_result_count: scoring.wrong_result_count, + stale_answer_count: scoring + .evolution + .as_ref() + .map_or(0, |report| report.stale_answer_count), + conflict_detection_count: scoring + .evolution + .as_ref() + .map_or(0, |report| report.conflict_detection_count), + update_rationale_available: scoring + .evolution + .as_ref() + .is_some_and(|report| report.update_rationale_available), + temporal_validity_not_encoded: scoring + .evolution + .as_ref() + .is_some_and(|report| report.temporal_validity_not_encoded), + history_readback_encoded: scoring + .evolution + .as_ref() + .is_some_and(|report| report.history_readback_encoded), + retrieval_quality, + latency_ms: answer.latency_ms, + cost: answer.cost.clone(), + trace_explainability: answer.trace_explainability.clone(), + knowledge: scoring.knowledge, + memory_summary: scoring.memory_summary, + proactive_brief: scoring.proactive_brief, + scheduled_memory: scoring.scheduled_memory, + work_continuity: scoring.work_continuity, + recovery_drills: answer.recovery_drills.clone(), + trap_ids_used: scoring.trap_ids_used, + dimension_scores: scoring.dimension_scores, + reason: scoring.reason, + evidence_required_count: metrics.evidence_required_count, + evidence_covered_count: metrics.evidence_covered_count, + source_ref_required_count: metrics.source_ref_required_count, + source_ref_covered_count: metrics.source_ref_covered_count, + quote_required_count: metrics.quote_required_count, + quote_covered_count: metrics.quote_covered_count, + stale_retrieval_count: metrics.stale_retrieval_count, + scope_check_count: metrics.scope_check_count, + scope_correct_count: metrics.scope_correct_count, + scope_violation_count: metrics.scope_violation_count, + redaction_leak_count: metrics.redaction_leak_count, + qdrant_rebuild_case: metrics.qdrant_rebuild_case, + operator_debug: job.operator_debug.clone(), + evolution: scoring.evolution, + consolidation: scoring.consolidation, + } +} + +fn job_metrics(job: &RealWorldJob, answer: &ProducedAnswer) -> JobMetrics { + let produced_evidence = answers::produced_evidence_ids(answer); + let source_ref_by_evidence = source_ref_by_evidence(job); + let evidence_required_count = + job.required_evidence.iter().filter(|evidence| answers::is_required_use(evidence)).count(); + let evidence_covered_count = job + .required_evidence + .iter() + .filter(|evidence| answers::is_required_use(evidence)) + .filter(|evidence| produced_evidence.contains(&evidence.evidence_id)) + .count(); + let source_ref_required_count = evidence_required_count; + let source_ref_covered_count = job + .required_evidence + .iter() + .filter(|evidence| answers::is_required_use(evidence)) + .filter(|evidence| produced_evidence.contains(&evidence.evidence_id)) + .filter(|evidence| { + source_ref_by_evidence.get(evidence.evidence_id.as_str()).is_some_and(|source_ref| { + source_ref.as_object().is_some_and(|object| !object.is_empty()) + }) + }) + .count(); + let quote_required_count = job + .required_evidence + .iter() + .filter(|evidence| answers::is_required_use(evidence) && evidence.quote.is_some()) + .count(); + let quote_covered_count = job + .required_evidence + .iter() + .filter(|evidence| answers::is_required_use(evidence) && evidence.quote.is_some()) + .filter(|evidence| produced_evidence.contains(&evidence.evidence_id)) + .count(); + let stale_retrieval_count = trap_use_count(job, &produced_evidence, "stale_fact", answer); + let scope_violation_count = ["near_duplicate", "scope_leak"] + .into_iter() + .map(|trap_type| trap_use_count(job, &produced_evidence, trap_type, answer)) + .sum(); + let scope_check_count = job + .negative_traps + .iter() + .filter(|trap| is_scope_trap_type(trap.trap_type.as_str())) + .count(); + let redaction_leak_count = trap_use_count(job, &produced_evidence, "privacy_leak", answer); + let scope_correct_count = scope_check_count.saturating_sub(scope_violation_count); + let qdrant_rebuild_case = job.tags.iter().any(|tag| tag == "qdrant_rebuild"); + + JobMetrics { + evidence_required_count, + evidence_covered_count, + source_ref_required_count, + source_ref_covered_count, + quote_required_count, + quote_covered_count, + stale_retrieval_count, + scope_check_count, + scope_correct_count, + scope_violation_count, + redaction_leak_count, + qdrant_rebuild_case, + } +} + +fn source_ref_by_evidence(job: &RealWorldJob) -> BTreeMap<&str, &Value> { + job.corpus.items.iter().map(|item| (item.evidence_id.as_str(), &item.source_ref)).collect() +} + +fn is_scope_trap_type(trap_type: &str) -> bool { + matches!(trap_type, "near_duplicate" | "scope_leak") +} + +fn trap_use_count( + job: &RealWorldJob, + produced_evidence: &BTreeSet, + trap_type: &str, + answer: &ProducedAnswer, +) -> usize { + job.negative_traps + .iter() + .filter(|trap| trap.failure_if_used && trap.trap_type == trap_type) + .filter(|trap| trap_was_used(job, trap, produced_evidence, answer)) + .count() +} + +fn trap_was_used( + job: &RealWorldJob, + trap: &NegativeTrap, + produced_evidence: &BTreeSet, + answer: &ProducedAnswer, +) -> bool { + trap.evidence_ids.iter().any(|evidence_id| { + produced_evidence.contains(evidence_id) + || answer_contains_corpus_item(job, evidence_id, answer) + }) +} + +fn answer_contains_corpus_item( + job: &RealWorldJob, + evidence_id: &str, + answer: &ProducedAnswer, +) -> bool { + job.corpus + .items + .iter() + .find(|item| item.evidence_id == evidence_id) + .and_then(|item| item.text.as_deref()) + .is_some_and(|text| !text.trim().is_empty() && answer.content.contains(text)) +} + +fn retrieval_quality_report(job: &RealWorldJob, answer: &ProducedAnswer) -> RetrievalQualityReport { + let expected = expected_evidence_ids(job); + let allowed = allowed_evidence_ids(job); + let produced = answers::produced_evidence_ids(answer); + let trap_evidence = trap_evidence_ids(job); + let expected_evidence_matched = + expected.iter().filter(|evidence_id| produced.contains(evidence_id.as_str())).count(); + let irrelevant_context_count = + produced.iter().filter(|evidence_id| !allowed.contains(evidence_id.as_str())).count(); + let trap_context_count = + produced.iter().filter(|evidence_id| trap_evidence.contains(evidence_id.as_str())).count(); + + RetrievalQualityReport { + expected_evidence_total: expected.len(), + expected_evidence_matched, + expected_evidence_recall: scoring::ratio_or(expected_evidence_matched, expected.len(), 1.0), + produced_evidence_total: produced.len(), + irrelevant_context_count, + irrelevant_context_ratio: scoring::ratio_or(irrelevant_context_count, produced.len(), 0.0), + trap_context_count, + } +} + +fn expected_evidence_ids(job: &RealWorldJob) -> BTreeSet { + job.required_evidence + .iter() + .filter(|evidence| answers::is_required_use(evidence)) + .map(|evidence| evidence.evidence_id.clone()) + .collect() +} + +fn allowed_evidence_ids(job: &RealWorldJob) -> BTreeSet { + let mut allowed = expected_evidence_ids(job); + + for link in job.expected_answer.evidence_links.values() { + allowed.extend(link.ids()); + } + + allowed +} + +fn trap_evidence_ids(job: &RealWorldJob) -> BTreeSet { + job.negative_traps.iter().flat_map(|trap| trap.evidence_ids.iter().cloned()).collect() +} + +fn expected_evidence_report(job: &RealWorldJob) -> Vec { + job.required_evidence + .iter() + .map(|evidence| ExpectedEvidenceReport { + evidence_id: evidence.evidence_id.clone(), + claim_id: evidence.claim_id.clone(), + requirement: evidence.requirement.clone(), + }) + .collect() +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary.rs new file mode 100644 index 00000000..3e9e0e31 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary.rs @@ -0,0 +1,108 @@ +#[path = "summary/consolidation.rs"] mod consolidation; +#[path = "summary/knowledge.rs"] mod knowledge; +#[path = "summary/memory.rs"] mod memory; +#[path = "summary/metrics.rs"] mod metrics; +#[path = "summary/proactive.rs"] mod proactive; +#[path = "summary/report.rs"] mod report; +#[path = "summary/scheduled.rs"] mod scheduled; +#[path = "summary/suites.rs"] mod suites; +#[path = "summary/work.rs"] mod work; + +use crate::{ + ConsolidationSummaryReport, CostReport, EvolutionSummary, FollowUpReport, JobReport, + KnowledgeSummary, MemorySummaryReport, ProactiveBriefSummaryReport, RealWorldJob, + ReportSummary, ScheduledMemorySummaryReport, SuiteReport, TypedStatus, + WorkContinuitySummaryReport, +}; + +pub(super) fn suite_reports(jobs: &[JobReport]) -> Vec { + suites::suite_reports_impl(jobs) +} + +pub(super) fn aggregate_status(jobs: &[&JobReport]) -> TypedStatus { + suites::aggregate_status_impl(jobs) +} + +pub(super) fn report_summary(jobs: &[JobReport], suites: &[SuiteReport]) -> ReportSummary { + report::report_summary_impl(jobs, suites) +} + +pub(super) fn evolution_summary(jobs: &[JobReport]) -> EvolutionSummary { + report::evolution_summary_impl(jobs) +} + +pub(super) fn follow_up_reports(jobs: &[RealWorldJob]) -> Vec { + report::follow_up_reports_impl(jobs) +} + +pub(super) fn ratio(numerator: usize, denominator: usize) -> f64 { + metrics::ratio_impl(numerator, denominator) +} + +pub(super) fn ratio_or(numerator: usize, denominator: usize, empty_value: f64) -> f64 { + metrics::ratio_or_impl(numerator, denominator, empty_value) +} + +pub(super) fn ratio_or_full(numerator: usize, denominator: usize) -> f64 { + metrics::ratio_or_full_impl(numerator, denominator) +} + +pub(super) fn mean_latency_for_reports(jobs: &[&JobReport]) -> Option { + metrics::mean_latency_for_reports_impl(jobs) +} + +pub(super) fn mean_latency_for_values(latencies: &[f64]) -> Option { + metrics::mean_latency_for_values_impl(latencies) +} + +pub(super) fn total_cost(jobs: &[JobReport]) -> Option { + metrics::total_cost_impl(jobs) +} + +pub(super) fn total_cost_for_reports(jobs: &[&JobReport]) -> Option { + metrics::total_cost_for_reports_impl(jobs) +} + +pub(super) fn mean_proposal_metric(values: impl Iterator) -> Option { + metrics::mean_proposal_metric_impl(values) +} + +fn expected_evidence_recall_for_jobs(jobs: &[&JobReport]) -> f64 { + metrics::expected_evidence_recall_for_jobs_impl(jobs) +} + +fn irrelevant_context_ratio_for_jobs(jobs: &[&JobReport]) -> f64 { + metrics::irrelevant_context_ratio_for_jobs_impl(jobs) +} + +fn mean_score(jobs: &[JobReport]) -> f64 { + metrics::mean_score_impl(jobs) +} + +fn mean_latency(jobs: &[JobReport]) -> Option { + metrics::mean_latency_impl(jobs) +} + +fn consolidation_summary(jobs: &[JobReport]) -> ConsolidationSummaryReport { + consolidation::consolidation_summary_impl(jobs) +} + +fn memory_summary_summary(jobs: &[JobReport]) -> Option { + memory::memory_summary_summary_impl(jobs) +} + +fn proactive_brief_summary(jobs: &[JobReport]) -> Option { + proactive::proactive_brief_summary_impl(jobs) +} + +fn scheduled_memory_summary(jobs: &[JobReport]) -> Option { + scheduled::scheduled_memory_summary_impl(jobs) +} + +fn work_continuity_summary(jobs: &[JobReport]) -> Option { + work::work_continuity_summary_impl(jobs) +} + +fn knowledge_summary(jobs: &[JobReport]) -> Option { + knowledge::knowledge_summary_impl(jobs) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/consolidation.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/consolidation.rs new file mode 100644 index 00000000..bf83962a --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/consolidation.rs @@ -0,0 +1,34 @@ +use crate::summary::{self, ConsolidationSummaryReport, JobReport}; + +pub(super) fn consolidation_summary_impl(jobs: &[JobReport]) -> ConsolidationSummaryReport { + let reports = jobs.iter().filter_map(|job| job.consolidation.as_ref()).collect::>(); + + if reports.is_empty() { + return ConsolidationSummaryReport::default(); + } + + let proposals = reports.iter().flat_map(|report| report.proposals.iter()).collect::>(); + let executable_gap_count = reports.iter().map(|report| report.executable_gaps.len()).sum(); + + ConsolidationSummaryReport { + proposal_count: proposals.len(), + proposal_usefulness: summary::mean_proposal_metric( + proposals.iter().map(|proposal| proposal.usefulness_score), + ), + lineage_completeness: summary::mean_proposal_metric( + proposals.iter().map(|proposal| proposal.lineage_completeness), + ), + review_action_correctness: summary::mean_proposal_metric( + proposals.iter().map(|proposal| if proposal.review_action_correct { 1.0 } else { 0.0 }), + ), + source_mutation_count: proposals + .iter() + .map(|proposal| proposal.source_mutation_count) + .sum(), + proposal_unsupported_claim_count: proposals + .iter() + .map(|proposal| proposal.unsupported_claim_count) + .sum(), + executable_gap_count, + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/knowledge.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/knowledge.rs new file mode 100644 index 00000000..3cc41559 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/knowledge.rs @@ -0,0 +1,62 @@ +use crate::{ + JobReport, KnowledgeSummary, formatting, + summary::{self}, +}; + +pub(super) fn knowledge_summary_impl(jobs: &[JobReport]) -> Option { + let knowledge_jobs = jobs.iter().filter_map(|job| job.knowledge.as_ref()).collect::>(); + + if knowledge_jobs.is_empty() { + return None; + } + + let job_count = knowledge_jobs.len(); + let page_count = knowledge_jobs.iter().map(|metrics| metrics.page_count).sum::(); + let section_count = knowledge_jobs.iter().map(|metrics| metrics.section_count).sum::(); + let traced_section_count = + knowledge_jobs.iter().map(|metrics| metrics.traced_section_count).sum::(); + let stale_trap_count = + knowledge_jobs.iter().map(|metrics| metrics.stale_trap_count).sum::(); + let stale_traps_detected = + knowledge_jobs.iter().map(|metrics| metrics.stale_traps_detected).sum::(); + let deterministic_rebuild_count = + knowledge_jobs.iter().map(|metrics| metrics.deterministic_rebuild_count).sum::(); + let rebuild_page_count = + knowledge_jobs.iter().map(|metrics| metrics.rebuild_page_count).sum::(); + let backlink_count = knowledge_jobs.iter().map(|metrics| metrics.backlink_count).sum::(); + let pages_with_backlinks = + knowledge_jobs.iter().map(|metrics| metrics.pages_with_backlinks).sum::(); + let pages_with_version_diff = + knowledge_jobs.iter().map(|metrics| metrics.pages_with_version_diff).sum::(); + let page_usefulness = formatting::round3( + knowledge_jobs.iter().map(|metrics| metrics.page_usefulness).sum::() + / job_count as f64, + ); + + Some(KnowledgeSummary { + job_count, + page_count, + section_count, + backlink_count, + pages_with_backlinks, + pages_with_version_diff, + citation_coverage: summary::ratio(traced_section_count, section_count), + stale_claim_detection: summary::ratio_or_full(stale_traps_detected, stale_trap_count), + rebuild_determinism: summary::ratio(deterministic_rebuild_count, rebuild_page_count), + backlink_coverage: summary::ratio(pages_with_backlinks, page_count), + version_diff_coverage: summary::ratio(pages_with_version_diff, page_count), + page_usefulness, + unsupported_summary_count: knowledge_jobs + .iter() + .map(|metrics| metrics.unsupported_summary_count) + .sum(), + untraced_section_count: knowledge_jobs + .iter() + .map(|metrics| metrics.untraced_section_count) + .sum(), + allowed_variance_count: knowledge_jobs + .iter() + .map(|metrics| metrics.allowed_variance_count) + .sum(), + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/memory.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/memory.rs new file mode 100644 index 00000000..93d2b4a7 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/memory.rs @@ -0,0 +1,94 @@ +use crate::summary::{self, JobReport, MemorySummaryReport}; + +pub(super) fn memory_summary_summary_impl(jobs: &[JobReport]) -> Option { + let memory_jobs = jobs.iter().filter_map(|job| job.memory_summary.as_ref()).collect::>(); + + if memory_jobs.is_empty() { + return None; + } + + let job_count = memory_jobs.len(); + let summary_count = memory_jobs.iter().map(|metrics| metrics.summary_count).sum(); + let entry_count = memory_jobs.iter().map(|metrics| metrics.entry_count).sum(); + let required_category_count = + memory_jobs.iter().map(|metrics| metrics.required_category_count).sum(); + let covered_required_category_count = + memory_jobs.iter().map(|metrics| metrics.covered_required_category_count).sum(); + let source_ref_required_count = + memory_jobs.iter().map(|metrics| metrics.source_ref_required_count).sum(); + let source_ref_entry_count = + memory_jobs.iter().map(|metrics| metrics.source_ref_entry_count).sum(); + let freshness_marker_count = + memory_jobs.iter().map(|metrics| metrics.freshness_marker_count).sum(); + let rationale_count = memory_jobs.iter().map(|metrics| metrics.rationale_count).sum(); + + Some(MemorySummaryReport { + job_count, + summary_count, + entry_count, + required_category_count, + covered_required_category_count, + missing_required_category_count: memory_jobs + .iter() + .map(|metrics| metrics.missing_required_category_count) + .sum(), + top_of_mind_count: memory_jobs.iter().map(|metrics| metrics.top_of_mind_count).sum(), + background_count: memory_jobs.iter().map(|metrics| metrics.background_count).sum(), + stale_count: memory_jobs.iter().map(|metrics| metrics.stale_count).sum(), + superseded_count: memory_jobs.iter().map(|metrics| metrics.superseded_count).sum(), + tombstone_count: memory_jobs.iter().map(|metrics| metrics.tombstone_count).sum(), + derived_project_profile_count: memory_jobs + .iter() + .map(|metrics| metrics.derived_project_profile_count) + .sum(), + source_ref_required_count, + source_ref_entry_count, + source_ref_coverage: summary::ratio(source_ref_entry_count, source_ref_required_count), + freshness_marker_count, + freshness_coverage: summary::ratio(freshness_marker_count, entry_count), + rationale_count, + rationale_coverage: summary::ratio(rationale_count, entry_count), + invalid_top_of_mind_count: memory_jobs + .iter() + .map(|metrics| metrics.invalid_top_of_mind_count) + .sum(), + untraced_entry_count: memory_jobs.iter().map(|metrics| metrics.untraced_entry_count).sum(), + derived_with_source_or_unsupported_count: memory_jobs + .iter() + .map(|metrics| metrics.derived_with_source_or_unsupported_count) + .sum(), + derived_missing_source_or_unsupported_count: memory_jobs + .iter() + .map(|metrics| metrics.derived_missing_source_or_unsupported_count) + .sum(), + unsupported_derived_entry_count: memory_jobs + .iter() + .map(|metrics| metrics.unsupported_derived_entry_count) + .sum(), + unsupported_current_entry_count: memory_jobs + .iter() + .map(|metrics| metrics.unsupported_current_entry_count) + .sum(), + tombstone_ref_count: memory_jobs.iter().map(|metrics| metrics.tombstone_ref_count).sum(), + source_trace_selected_count: memory_jobs + .iter() + .map(|metrics| metrics.source_trace_selected_count) + .sum(), + source_trace_dropped_count: memory_jobs + .iter() + .map(|metrics| metrics.source_trace_dropped_count) + .sum(), + source_trace_stale_count: memory_jobs + .iter() + .map(|metrics| metrics.source_trace_stale_count) + .sum(), + source_trace_superseded_count: memory_jobs + .iter() + .map(|metrics| metrics.source_trace_superseded_count) + .sum(), + source_trace_tombstone_count: memory_jobs + .iter() + .map(|metrics| metrics.source_trace_tombstone_count) + .sum(), + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/metrics.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/metrics.rs new file mode 100644 index 00000000..04fb5a42 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/metrics.rs @@ -0,0 +1,115 @@ +use crate::{ + CostReport, JobReport, formatting, + summary::{self}, +}; + +pub(super) fn ratio_impl(numerator: usize, denominator: usize) -> f64 { + if denominator == 0 { + return 0.0; + } + + formatting::round3(numerator as f64 / denominator as f64) +} + +pub(super) fn expected_evidence_recall_for_jobs_impl(jobs: &[&JobReport]) -> f64 { + let total = jobs.iter().map(|job| job.retrieval_quality.expected_evidence_total).sum::(); + let matched = + jobs.iter().map(|job| job.retrieval_quality.expected_evidence_matched).sum::(); + + summary::ratio_or(matched, total, 1.0) +} + +pub(super) fn irrelevant_context_ratio_for_jobs_impl(jobs: &[&JobReport]) -> f64 { + let total = jobs.iter().map(|job| job.retrieval_quality.produced_evidence_total).sum::(); + let irrelevant = + jobs.iter().map(|job| job.retrieval_quality.irrelevant_context_count).sum::(); + + summary::ratio_or(irrelevant, total, 0.0) +} + +pub(super) fn ratio_or_impl(numerator: usize, denominator: usize, empty_value: f64) -> f64 { + if denominator == 0 { + empty_value + } else { + formatting::round3(numerator as f64 / denominator as f64) + } +} + +pub(super) fn ratio_or_full_impl(numerator: usize, denominator: usize) -> f64 { + summary::ratio_or(numerator, denominator, 1.0) +} + +pub(super) fn mean_score_impl(jobs: &[JobReport]) -> f64 { + if jobs.is_empty() { + return 0.0; + } + + formatting::round3(jobs.iter().map(|job| job.normalized_score).sum::() / jobs.len() as f64) +} + +pub(super) fn mean_latency_impl(jobs: &[JobReport]) -> Option { + let latencies = jobs.iter().filter_map(|job| job.latency_ms).collect::>(); + + summary::mean_latency_for_values(latencies.as_slice()) +} + +pub(super) fn mean_latency_for_reports_impl(jobs: &[&JobReport]) -> Option { + let latencies = jobs.iter().filter_map(|job| job.latency_ms).collect::>(); + + summary::mean_latency_for_values(latencies.as_slice()) +} + +pub(super) fn mean_latency_for_values_impl(latencies: &[f64]) -> Option { + if latencies.is_empty() { + None + } else { + Some(formatting::round3(latencies.iter().sum::() / latencies.len() as f64)) + } +} + +pub(super) fn total_cost_impl(jobs: &[JobReport]) -> Option { + let costs = jobs.iter().filter_map(|job| job.cost.as_ref()).collect::>(); + + total_cost_for_values(costs.as_slice()) +} + +pub(super) fn total_cost_for_reports_impl(jobs: &[&JobReport]) -> Option { + let costs = jobs.iter().filter_map(|job| job.cost.as_ref()).collect::>(); + + total_cost_for_values(costs.as_slice()) +} + +pub(super) fn mean_proposal_metric_impl(values: impl Iterator) -> Option { + let values = values.collect::>(); + + if values.is_empty() { + None + } else { + Some(formatting::round3(values.iter().sum::() / values.len() as f64)) + } +} + +fn total_cost_for_values(costs: &[&CostReport]) -> Option { + if costs.is_empty() { + return None; + } + + let currency = costs.iter().find_map(|cost| cost.currency.clone()); + let amount = sum_optional_f64(costs.iter().filter_map(|cost| cost.amount)); + let input_tokens = sum_optional_u64(costs.iter().filter_map(|cost| cost.input_tokens)); + let output_tokens = sum_optional_u64(costs.iter().filter_map(|cost| cost.output_tokens)); + + Some(CostReport { currency, amount, input_tokens, output_tokens }) +} + +fn sum_optional_f64(values: impl Iterator) -> Option { + let values = values.collect::>(); + + if values.is_empty() { None } else { Some(formatting::round3(values.iter().sum())) } +} + +fn sum_optional_u64(values: impl Iterator) -> Option { + let values = values.collect::>(); + + if values.is_empty() { None } else { Some(values.iter().sum()) } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/proactive.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/proactive.rs new file mode 100644 index 00000000..4d62d682 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/proactive.rs @@ -0,0 +1,100 @@ +use crate::summary::{self, JobReport, ProactiveBriefSummaryReport}; + +pub(super) fn proactive_brief_summary_impl( + jobs: &[JobReport], +) -> Option { + let proactive_jobs = + jobs.iter().filter_map(|job| job.proactive_brief.as_ref()).collect::>(); + + if proactive_jobs.is_empty() { + return None; + } + + let job_count = proactive_jobs.len(); + let suggestion_count = + proactive_jobs.iter().map(|metrics| metrics.suggestion_count).sum::(); + let evidence_ref_required_count = + proactive_jobs.iter().map(|metrics| metrics.evidence_ref_required_count).sum(); + let evidence_ref_suggestion_count = + proactive_jobs.iter().map(|metrics| metrics.evidence_ref_suggestion_count).sum(); + let freshness_marker_count = + proactive_jobs.iter().map(|metrics| metrics.freshness_marker_count).sum(); + let action_rationale_count = + proactive_jobs.iter().map(|metrics| metrics.action_rationale_count).sum(); + + Some(ProactiveBriefSummaryReport { + job_count, + brief_count: proactive_jobs.iter().map(|metrics| metrics.brief_count).sum(), + suggestion_count, + required_suggestion_kind_count: proactive_jobs + .iter() + .map(|metrics| metrics.required_suggestion_kind_count) + .sum(), + covered_required_suggestion_kind_count: proactive_jobs + .iter() + .map(|metrics| metrics.covered_required_suggestion_kind_count) + .sum(), + missing_required_suggestion_kind_count: proactive_jobs + .iter() + .map(|metrics| metrics.missing_required_suggestion_kind_count) + .sum(), + evidence_ref_required_count, + evidence_ref_suggestion_count, + evidence_ref_coverage: summary::ratio( + evidence_ref_suggestion_count, + evidence_ref_required_count, + ), + freshness_marker_count, + freshness_coverage: summary::ratio(freshness_marker_count, suggestion_count), + action_rationale_count, + action_rationale_coverage: summary::ratio(action_rationale_count, suggestion_count), + recommended_count: proactive_jobs.iter().map(|metrics| metrics.recommended_count).sum(), + deferred_count: proactive_jobs.iter().map(|metrics| metrics.deferred_count).sum(), + rejected_count: proactive_jobs.iter().map(|metrics| metrics.rejected_count).sum(), + current_suggestion_count: proactive_jobs + .iter() + .map(|metrics| metrics.current_suggestion_count) + .sum(), + non_current_suggestion_count: proactive_jobs + .iter() + .map(|metrics| metrics.non_current_suggestion_count) + .sum(), + stale_warning_count: proactive_jobs.iter().map(|metrics| metrics.stale_warning_count).sum(), + invalid_current_suggestion_count: proactive_jobs + .iter() + .map(|metrics| metrics.invalid_current_suggestion_count) + .sum(), + untraced_suggestion_count: proactive_jobs + .iter() + .map(|metrics| metrics.untraced_suggestion_count) + .sum(), + unsupported_current_suggestion_count: proactive_jobs + .iter() + .map(|metrics| metrics.unsupported_current_suggestion_count) + .sum(), + tombstone_violation_count: proactive_jobs + .iter() + .map(|metrics| metrics.tombstone_violation_count) + .sum(), + source_trace_selected_count: proactive_jobs + .iter() + .map(|metrics| metrics.source_trace_selected_count) + .sum(), + source_trace_dropped_count: proactive_jobs + .iter() + .map(|metrics| metrics.source_trace_dropped_count) + .sum(), + source_trace_stale_count: proactive_jobs + .iter() + .map(|metrics| metrics.source_trace_stale_count) + .sum(), + source_trace_superseded_count: proactive_jobs + .iter() + .map(|metrics| metrics.source_trace_superseded_count) + .sum(), + source_trace_tombstone_count: proactive_jobs + .iter() + .map(|metrics| metrics.source_trace_tombstone_count) + .sum(), + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/report.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/report.rs new file mode 100644 index 00000000..222b6b88 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/report.rs @@ -0,0 +1,155 @@ +use crate::{ + EvolutionSummary, FollowUpReport, JobReport, RealWorldJob, ReportSummary, SuiteReport, + TypedStatus, formatting, + summary::{self}, +}; + +pub(super) fn report_summary_impl(jobs: &[JobReport], suites: &[SuiteReport]) -> ReportSummary { + let job_refs = jobs.iter().collect::>(); + let evidence_required_count = jobs.iter().map(|job| job.evidence_required_count).sum(); + let evidence_covered_count = jobs.iter().map(|job| job.evidence_covered_count).sum(); + let source_ref_required_count = jobs.iter().map(|job| job.source_ref_required_count).sum(); + let source_ref_covered_count = jobs.iter().map(|job| job.source_ref_covered_count).sum(); + let quote_required_count = jobs.iter().map(|job| job.quote_required_count).sum(); + let quote_covered_count = jobs.iter().map(|job| job.quote_covered_count).sum(); + let scope_check_count = jobs.iter().map(|job| job.scope_check_count).sum(); + let scope_correct_count = jobs.iter().map(|job| job.scope_correct_count).sum(); + let mut summary = ReportSummary { + job_count: jobs.len(), + encoded_suite_count: suites.iter().filter(|suite| suite.encoded_job_count > 0).count(), + not_encoded: 0, + unsupported_claim_count: jobs.iter().map(|job| job.unsupported_claim_count).sum(), + wrong_result_count: jobs.iter().map(|job| job.wrong_result_count).sum(), + stale_answer_count: jobs.iter().map(|job| job.stale_answer_count).sum(), + conflict_detection_count: jobs.iter().map(|job| job.conflict_detection_count).sum(), + update_rationale_available_count: jobs + .iter() + .filter(|job| job.update_rationale_available) + .count(), + temporal_validity_not_encoded_count: jobs + .iter() + .filter(|job| job.temporal_validity_not_encoded) + .count(), + history_readback_encoded_count: jobs + .iter() + .filter(|job| job.history_readback_encoded) + .count(), + expected_evidence_total: jobs + .iter() + .map(|job| job.retrieval_quality.expected_evidence_total) + .sum(), + expected_evidence_matched: jobs + .iter() + .map(|job| job.retrieval_quality.expected_evidence_matched) + .sum(), + expected_evidence_recall: summary::expected_evidence_recall_for_jobs(&job_refs), + irrelevant_context_count: jobs + .iter() + .map(|job| job.retrieval_quality.irrelevant_context_count) + .sum(), + irrelevant_context_ratio: summary::irrelevant_context_ratio_for_jobs(&job_refs), + trace_explainability_count: jobs + .iter() + .filter(|job| job.trace_explainability.is_some()) + .count(), + wrong_result_stage_attribution_count: jobs + .iter() + .filter(|job| { + job.status == TypedStatus::WrongResult + && formatting::trace_failure_stage(job.trace_explainability.as_ref()).is_some() + }) + .count(), + mean_score: summary::mean_score(jobs), + mean_latency_ms: summary::mean_latency(jobs), + total_cost: summary::total_cost(jobs), + evidence_required_count, + evidence_covered_count, + evidence_coverage: summary::ratio(evidence_covered_count, evidence_required_count), + source_ref_required_count, + source_ref_covered_count, + source_ref_coverage: summary::ratio(source_ref_covered_count, source_ref_required_count), + quote_required_count, + quote_covered_count, + quote_coverage: summary::ratio(quote_covered_count, quote_required_count), + stale_retrieval_count: jobs.iter().map(|job| job.stale_retrieval_count).sum(), + scope_check_count, + scope_correct_count, + scope_correctness: summary::ratio(scope_correct_count, scope_check_count), + scope_violation_count: jobs.iter().map(|job| job.scope_violation_count).sum(), + redaction_leak_count: jobs.iter().map(|job| job.redaction_leak_count).sum(), + qdrant_rebuild_case_count: jobs.iter().filter(|job| job.qdrant_rebuild_case).count(), + qdrant_rebuild_pass_count: jobs + .iter() + .filter(|job| job.qdrant_rebuild_case && job.status == TypedStatus::Pass) + .count(), + operator_debug_job_count: jobs.iter().filter(|job| job.operator_debug.is_some()).count(), + raw_sql_needed_count: jobs + .iter() + .filter_map(|job| job.operator_debug.as_ref()) + .filter(|debug| debug.raw_sql_needed) + .count(), + trace_incomplete_count: jobs + .iter() + .filter_map(|job| job.operator_debug.as_ref()) + .filter(|debug| debug.trace_completeness != "complete") + .count(), + operator_ux_gap_count: jobs + .iter() + .filter_map(|job| job.operator_debug.as_ref()) + .map(|debug| debug.ux_gaps.len()) + .sum(), + consolidation: summary::consolidation_summary(jobs), + memory_summary: summary::memory_summary_summary(jobs), + proactive_brief: summary::proactive_brief_summary(jobs), + scheduled_memory: summary::scheduled_memory_summary(jobs), + work_continuity: summary::work_continuity_summary(jobs), + knowledge: summary::knowledge_summary(jobs), + ..ReportSummary::default() + }; + + for job in jobs { + match job.status { + TypedStatus::Pass => summary.pass += 1, + TypedStatus::WrongResult => summary.wrong_result += 1, + TypedStatus::LifecycleFail => summary.lifecycle_fail += 1, + TypedStatus::Incomplete => summary.incomplete += 1, + TypedStatus::Blocked => summary.blocked += 1, + TypedStatus::NotEncoded => summary.not_encoded += 1, + TypedStatus::UnsupportedClaim => summary.unsupported_claim += 1, + } + } + + summary +} + +pub(super) fn evolution_summary_impl(jobs: &[JobReport]) -> EvolutionSummary { + EvolutionSummary { + stale_answer_count: jobs.iter().map(|job| job.stale_answer_count).sum(), + conflict_detection_count: jobs.iter().map(|job| job.conflict_detection_count).sum(), + update_rationale_available_count: jobs + .iter() + .filter(|job| job.update_rationale_available) + .count(), + temporal_validity_not_encoded_count: jobs + .iter() + .filter(|job| job.temporal_validity_not_encoded) + .count(), + history_readback_encoded_count: jobs + .iter() + .filter(|job| job.history_readback_encoded) + .count(), + } +} + +pub(super) fn follow_up_reports_impl(jobs: &[RealWorldJob]) -> Vec { + jobs.iter() + .filter_map(|job| { + job.encoding.follow_up.as_ref().map(|follow_up| FollowUpReport { + suite_id: job.suite.clone(), + job_id: job.job_id.clone(), + title: follow_up.title.clone(), + reason: follow_up.reason.clone(), + }) + }) + .collect() +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/scheduled.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/scheduled.rs new file mode 100644 index 00000000..2f9dffc8 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/scheduled.rs @@ -0,0 +1,106 @@ +use crate::summary::{self, JobReport, ScheduledMemorySummaryReport}; + +pub(super) fn scheduled_memory_summary_impl( + jobs: &[JobReport], +) -> Option { + let scheduled_jobs = + jobs.iter().filter_map(|job| job.scheduled_memory.as_ref()).collect::>(); + + if scheduled_jobs.is_empty() { + return None; + } + + let job_count = scheduled_jobs.len(); + let output_count = scheduled_jobs.iter().map(|metrics| metrics.output_count).sum::(); + let evidence_ref_required_count = + scheduled_jobs.iter().map(|metrics| metrics.evidence_ref_required_count).sum(); + let evidence_ref_output_count = + scheduled_jobs.iter().map(|metrics| metrics.evidence_ref_output_count).sum(); + let freshness_marker_count = + scheduled_jobs.iter().map(|metrics| metrics.freshness_marker_count).sum(); + let action_rationale_count = + scheduled_jobs.iter().map(|metrics| metrics.action_rationale_count).sum(); + let trace_required_count = + scheduled_jobs.iter().map(|metrics| metrics.trace_required_count).sum(); + let trace_complete_count = + scheduled_jobs.iter().map(|metrics| metrics.trace_complete_count).sum(); + + Some(ScheduledMemorySummaryReport { + job_count, + task_run_count: scheduled_jobs.iter().map(|metrics| metrics.task_run_count).sum(), + output_count, + required_task_kind_count: scheduled_jobs + .iter() + .map(|metrics| metrics.required_task_kind_count) + .sum(), + covered_required_task_kind_count: scheduled_jobs + .iter() + .map(|metrics| metrics.covered_required_task_kind_count) + .sum(), + missing_required_task_kind_count: scheduled_jobs + .iter() + .map(|metrics| metrics.missing_required_task_kind_count) + .sum(), + evidence_ref_required_count, + evidence_ref_output_count, + evidence_ref_coverage: summary::ratio( + evidence_ref_output_count, + evidence_ref_required_count, + ), + freshness_marker_count, + freshness_coverage: summary::ratio(freshness_marker_count, output_count), + action_rationale_count, + action_rationale_coverage: summary::ratio(action_rationale_count, output_count), + trace_required_count, + trace_complete_count, + trace_coverage: summary::ratio(trace_complete_count, trace_required_count), + source_mutation_count: scheduled_jobs + .iter() + .map(|metrics| metrics.source_mutation_count) + .sum(), + current_output_count: scheduled_jobs + .iter() + .map(|metrics| metrics.current_output_count) + .sum(), + non_current_output_count: scheduled_jobs + .iter() + .map(|metrics| metrics.non_current_output_count) + .sum(), + invalid_current_output_count: scheduled_jobs + .iter() + .map(|metrics| metrics.invalid_current_output_count) + .sum(), + untraced_output_count: scheduled_jobs + .iter() + .map(|metrics| metrics.untraced_output_count) + .sum(), + unsupported_current_output_count: scheduled_jobs + .iter() + .map(|metrics| metrics.unsupported_current_output_count) + .sum(), + tombstone_violation_count: scheduled_jobs + .iter() + .map(|metrics| metrics.tombstone_violation_count) + .sum(), + source_trace_selected_count: scheduled_jobs + .iter() + .map(|metrics| metrics.source_trace_selected_count) + .sum(), + source_trace_dropped_count: scheduled_jobs + .iter() + .map(|metrics| metrics.source_trace_dropped_count) + .sum(), + source_trace_stale_count: scheduled_jobs + .iter() + .map(|metrics| metrics.source_trace_stale_count) + .sum(), + source_trace_superseded_count: scheduled_jobs + .iter() + .map(|metrics| metrics.source_trace_superseded_count) + .sum(), + source_trace_tombstone_count: scheduled_jobs + .iter() + .map(|metrics| metrics.source_trace_tombstone_count) + .sum(), + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/suites.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/suites.rs new file mode 100644 index 00000000..0a4cdb7a --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/suites.rs @@ -0,0 +1,106 @@ +use crate::{ + BTreeSet, JobReport, NOT_ENCODED_REASON, SUITES, SuiteReport, TypedStatus, formatting, + summary::{self}, +}; + +pub(super) fn suite_reports_impl(jobs: &[JobReport]) -> Vec { + SUITES.iter().map(|suite_id| suite_report(suite_id, jobs)).collect() +} + +pub(super) fn aggregate_status_impl(jobs: &[&JobReport]) -> TypedStatus { + let statuses = jobs.iter().map(|job| job.status).collect::>(); + + if statuses.contains(&TypedStatus::UnsupportedClaim) { + TypedStatus::UnsupportedClaim + } else if statuses.contains(&TypedStatus::LifecycleFail) { + TypedStatus::LifecycleFail + } else if statuses.contains(&TypedStatus::WrongResult) { + TypedStatus::WrongResult + } else if statuses.contains(&TypedStatus::Incomplete) { + TypedStatus::Incomplete + } else if statuses.contains(&TypedStatus::Blocked) { + TypedStatus::Blocked + } else if statuses.contains(&TypedStatus::NotEncoded) { + TypedStatus::NotEncoded + } else if statuses.contains(&TypedStatus::Pass) { + TypedStatus::Pass + } else { + TypedStatus::NotEncoded + } +} + +fn suite_report(suite_id: &str, jobs: &[JobReport]) -> SuiteReport { + let suite_jobs = jobs.iter().filter(|job| job.suite_id == suite_id).collect::>(); + + if suite_jobs.is_empty() { + return SuiteReport { + suite_id: suite_id.to_string(), + status: TypedStatus::NotEncoded, + encoded_job_count: 0, + score_mean: None, + unsupported_claim_count: 0, + wrong_result_count: 0, + stale_answer_count: 0, + conflict_detection_count: 0, + update_rationale_available_count: 0, + temporal_validity_not_encoded_count: 0, + history_readback_encoded_count: 0, + expected_evidence_recall: None, + irrelevant_context_ratio: None, + trace_explainability_count: 0, + reason: NOT_ENCODED_REASON.to_string(), + }; + } + + let status = aggregate_status_impl(&suite_jobs); + let score_sum = suite_jobs.iter().map(|job| job.normalized_score).sum::(); + let unsupported_claim_count = suite_jobs.iter().map(|job| job.unsupported_claim_count).sum(); + let wrong_result_count = suite_jobs.iter().map(|job| job.wrong_result_count).sum(); + let stale_answer_count = suite_jobs.iter().map(|job| job.stale_answer_count).sum(); + let conflict_detection_count = suite_jobs.iter().map(|job| job.conflict_detection_count).sum(); + let update_rationale_available_count = + suite_jobs.iter().filter(|job| job.update_rationale_available).count(); + let temporal_validity_not_encoded_count = + suite_jobs.iter().filter(|job| job.temporal_validity_not_encoded).count(); + let history_readback_encoded_count = + suite_jobs.iter().filter(|job| job.history_readback_encoded).count(); + let trace_explainability_count = + suite_jobs.iter().filter(|job| job.trace_explainability.is_some()).count(); + + SuiteReport { + suite_id: suite_id.to_string(), + status, + encoded_job_count: suite_jobs.len(), + score_mean: Some(formatting::round3(score_sum / suite_jobs.len() as f64)), + unsupported_claim_count, + wrong_result_count, + stale_answer_count, + conflict_detection_count, + update_rationale_available_count, + temporal_validity_not_encoded_count, + history_readback_encoded_count, + expected_evidence_recall: Some(summary::expected_evidence_recall_for_jobs(&suite_jobs)), + irrelevant_context_ratio: Some(summary::irrelevant_context_ratio_for_jobs(&suite_jobs)), + trace_explainability_count, + reason: suite_reason(status, suite_jobs.len()), + } +} + +fn suite_reason(status: TypedStatus, encoded_job_count: usize) -> String { + match status { + TypedStatus::Pass => format!("All {encoded_job_count} encoded job(s) passed."), + TypedStatus::UnsupportedClaim => + "At least one encoded job produced an unsupported claim.".to_string(), + TypedStatus::WrongResult => "At least one encoded job returned a wrong result.".to_string(), + TypedStatus::LifecycleFail => + "At least one encoded lifecycle-scored job failed lifecycle behavior.".to_string(), + TypedStatus::Incomplete => "At least one encoded job could not complete.".to_string(), + TypedStatus::Blocked => "At least one encoded job is blocked.".to_string(), + TypedStatus::NotEncoded => + if encoded_job_count == 0 { + NOT_ENCODED_REASON.to_string() + } else { + "At least one encoded fixture declares a not_encoded limitation.".to_string() + }, + } +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary/work.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/work.rs new file mode 100644 index 00000000..1b0079f2 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary/work.rs @@ -0,0 +1,116 @@ +use crate::summary::{self, JobReport, WorkContinuitySummaryReport}; + +pub(super) fn work_continuity_summary_impl( + jobs: &[JobReport], +) -> Option { + let work_jobs = jobs.iter().filter_map(|job| job.work_continuity.as_ref()).collect::>(); + + if work_jobs.is_empty() { + return None; + } + + let reset_resume_required_count = + work_jobs.iter().map(|metrics| metrics.reset_resume_required_count).sum(); + let reset_resume_success_count = + work_jobs.iter().map(|metrics| metrics.reset_resume_success_count).sum(); + let decision_rationale_required_count = + work_jobs.iter().map(|metrics| metrics.decision_rationale_required_count).sum(); + let decision_rationale_recalled_count = + work_jobs.iter().map(|metrics| metrics.decision_rationale_recalled_count).sum(); + let rejected_option_required_count = + work_jobs.iter().map(|metrics| metrics.rejected_option_required_count).sum(); + let rejected_option_suppressed_count = + work_jobs.iter().map(|metrics| metrics.rejected_option_suppressed_count).sum(); + let explicit_next_step_returned_count = + work_jobs.iter().map(|metrics| metrics.explicit_next_step_returned_count).sum(); + let explicit_next_step_correct_count = + work_jobs.iter().map(|metrics| metrics.explicit_next_step_correct_count).sum(); + let inferred_next_step_required_count = + work_jobs.iter().map(|metrics| metrics.inferred_next_step_required_count).sum(); + let inferred_next_step_labeled_count = + work_jobs.iter().map(|metrics| metrics.inferred_next_step_labeled_count).sum(); + let handoff_source_ref_required_count = + work_jobs.iter().map(|metrics| metrics.handoff_source_ref_required_count).sum(); + let handoff_source_ref_covered_count = + work_jobs.iter().map(|metrics| metrics.handoff_source_ref_covered_count).sum(); + let redaction_required_count = + work_jobs.iter().map(|metrics| metrics.redaction_required_count).sum(); + let redaction_applied_count = + work_jobs.iter().map(|metrics| metrics.redaction_applied_count).sum(); + let janitor_candidate_count = + work_jobs.iter().map(|metrics| metrics.janitor_candidate_count).sum(); + let janitor_false_promotion_count = + work_jobs.iter().map(|metrics| metrics.janitor_false_promotion_count).sum(); + + Some(WorkContinuitySummaryReport { + job_count: work_jobs.len(), + readback_count: work_jobs.iter().map(|metrics| metrics.readback_count).sum(), + entry_count: work_jobs.iter().map(|metrics| metrics.entry_count).sum(), + reset_resume_required_count, + reset_resume_success_count, + reset_resume_success_rate: summary::ratio( + reset_resume_success_count, + reset_resume_required_count, + ), + decision_rationale_required_count, + decision_rationale_recalled_count, + decision_rationale_recall_rate: summary::ratio( + decision_rationale_recalled_count, + decision_rationale_required_count, + ), + rejected_option_required_count, + rejected_option_suppressed_count, + rejected_option_resurrection_count: work_jobs + .iter() + .map(|metrics| metrics.rejected_option_resurrection_count) + .sum(), + rejected_option_suppression_rate: summary::ratio( + rejected_option_suppressed_count, + rejected_option_required_count, + ), + explicit_next_step_required_count: work_jobs + .iter() + .map(|metrics| metrics.explicit_next_step_required_count) + .sum(), + explicit_next_step_returned_count, + explicit_next_step_correct_count, + explicit_next_step_precision: summary::ratio_or( + explicit_next_step_correct_count, + explicit_next_step_returned_count, + 1.0, + ), + inferred_next_step_required_count, + inferred_next_step_labeled_count, + inferred_step_instruction_count: work_jobs + .iter() + .map(|metrics| metrics.inferred_step_instruction_count) + .sum(), + inferred_next_step_labeling_rate: summary::ratio( + inferred_next_step_labeled_count, + inferred_next_step_required_count, + ), + handoff_source_ref_required_count, + handoff_source_ref_covered_count, + handoff_source_ref_coverage: summary::ratio( + handoff_source_ref_covered_count, + handoff_source_ref_required_count, + ), + redaction_required_count, + redaction_applied_count, + sensitive_marker_persistence_count: work_jobs + .iter() + .map(|metrics| metrics.sensitive_marker_persistence_count) + .sum(), + redaction_rate: summary::ratio(redaction_applied_count, redaction_required_count), + janitor_candidate_count, + janitor_false_promotion_count, + janitor_false_promotion_rate: summary::ratio( + janitor_false_promotion_count, + janitor_candidate_count, + ), + journal_only_authority_claim_count: work_jobs + .iter() + .map(|metrics| metrics.journal_only_authority_claim_count) + .sum(), + }) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/summary_reports.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/summary_reports.rs new file mode 100644 index 00000000..b750ebba --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/summary_reports.rs @@ -0,0 +1,279 @@ +use crate::{CostReport, Deserialize, Serialize, TypedStatus}; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub(super) struct ReportSummary { + pub(super) job_count: usize, + pub(super) encoded_suite_count: usize, + pub(super) pass: usize, + pub(super) wrong_result: usize, + pub(super) lifecycle_fail: usize, + pub(super) incomplete: usize, + pub(super) blocked: usize, + pub(super) not_encoded: usize, + pub(super) unsupported_claim: usize, + pub(super) unsupported_claim_count: usize, + pub(super) wrong_result_count: usize, + #[serde(default)] + pub(super) stale_answer_count: usize, + #[serde(default)] + pub(super) conflict_detection_count: usize, + #[serde(default)] + pub(super) update_rationale_available_count: usize, + #[serde(default)] + pub(super) temporal_validity_not_encoded_count: usize, + #[serde(default)] + pub(super) history_readback_encoded_count: usize, + pub(super) expected_evidence_total: usize, + pub(super) expected_evidence_matched: usize, + pub(super) expected_evidence_recall: f64, + pub(super) irrelevant_context_count: usize, + pub(super) irrelevant_context_ratio: f64, + pub(super) trace_explainability_count: usize, + pub(super) wrong_result_stage_attribution_count: usize, + pub(super) mean_score: f64, + pub(super) mean_latency_ms: Option, + pub(super) total_cost: Option, + #[serde(default)] + pub(super) evidence_required_count: usize, + #[serde(default)] + pub(super) evidence_covered_count: usize, + #[serde(default)] + pub(super) evidence_coverage: f64, + #[serde(default)] + pub(super) source_ref_required_count: usize, + #[serde(default)] + pub(super) source_ref_covered_count: usize, + #[serde(default)] + pub(super) source_ref_coverage: f64, + #[serde(default)] + pub(super) quote_required_count: usize, + #[serde(default)] + pub(super) quote_covered_count: usize, + #[serde(default)] + pub(super) quote_coverage: f64, + #[serde(default)] + pub(super) stale_retrieval_count: usize, + #[serde(default)] + pub(super) scope_check_count: usize, + #[serde(default)] + pub(super) scope_correct_count: usize, + #[serde(default)] + pub(super) scope_correctness: f64, + #[serde(default)] + pub(super) scope_violation_count: usize, + #[serde(default)] + pub(super) redaction_leak_count: usize, + #[serde(default)] + pub(super) qdrant_rebuild_case_count: usize, + #[serde(default)] + pub(super) qdrant_rebuild_pass_count: usize, + #[serde(default)] + pub(super) operator_debug_job_count: usize, + #[serde(default)] + pub(super) raw_sql_needed_count: usize, + #[serde(default)] + pub(super) trace_incomplete_count: usize, + #[serde(default)] + pub(super) operator_ux_gap_count: usize, + #[serde(default)] + pub(super) consolidation: ConsolidationSummaryReport, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) memory_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) proactive_brief: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) scheduled_memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) work_continuity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) knowledge: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +pub(super) struct ConsolidationSummaryReport { + pub(super) proposal_count: usize, + pub(super) proposal_usefulness: Option, + pub(super) lineage_completeness: Option, + pub(super) review_action_correctness: Option, + pub(super) source_mutation_count: usize, + pub(super) proposal_unsupported_claim_count: usize, + pub(super) executable_gap_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct MemorySummaryReport { + pub(super) job_count: usize, + pub(super) summary_count: usize, + pub(super) entry_count: usize, + pub(super) required_category_count: usize, + pub(super) covered_required_category_count: usize, + pub(super) missing_required_category_count: usize, + pub(super) top_of_mind_count: usize, + pub(super) background_count: usize, + pub(super) stale_count: usize, + pub(super) superseded_count: usize, + pub(super) tombstone_count: usize, + pub(super) derived_project_profile_count: usize, + pub(super) source_ref_required_count: usize, + pub(super) source_ref_entry_count: usize, + pub(super) source_ref_coverage: f64, + pub(super) freshness_marker_count: usize, + pub(super) freshness_coverage: f64, + pub(super) rationale_count: usize, + pub(super) rationale_coverage: f64, + pub(super) invalid_top_of_mind_count: usize, + pub(super) untraced_entry_count: usize, + pub(super) derived_with_source_or_unsupported_count: usize, + pub(super) derived_missing_source_or_unsupported_count: usize, + pub(super) unsupported_derived_entry_count: usize, + pub(super) unsupported_current_entry_count: usize, + pub(super) tombstone_ref_count: usize, + pub(super) source_trace_selected_count: usize, + pub(super) source_trace_dropped_count: usize, + pub(super) source_trace_stale_count: usize, + pub(super) source_trace_superseded_count: usize, + pub(super) source_trace_tombstone_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ProactiveBriefSummaryReport { + pub(super) job_count: usize, + pub(super) brief_count: usize, + pub(super) suggestion_count: usize, + pub(super) required_suggestion_kind_count: usize, + pub(super) covered_required_suggestion_kind_count: usize, + pub(super) missing_required_suggestion_kind_count: usize, + pub(super) evidence_ref_required_count: usize, + pub(super) evidence_ref_suggestion_count: usize, + pub(super) evidence_ref_coverage: f64, + pub(super) freshness_marker_count: usize, + pub(super) freshness_coverage: f64, + pub(super) action_rationale_count: usize, + pub(super) action_rationale_coverage: f64, + pub(super) recommended_count: usize, + pub(super) deferred_count: usize, + pub(super) rejected_count: usize, + pub(super) current_suggestion_count: usize, + pub(super) non_current_suggestion_count: usize, + pub(super) stale_warning_count: usize, + pub(super) invalid_current_suggestion_count: usize, + pub(super) untraced_suggestion_count: usize, + pub(super) unsupported_current_suggestion_count: usize, + pub(super) tombstone_violation_count: usize, + pub(super) source_trace_selected_count: usize, + pub(super) source_trace_dropped_count: usize, + pub(super) source_trace_stale_count: usize, + pub(super) source_trace_superseded_count: usize, + pub(super) source_trace_tombstone_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ScheduledMemorySummaryReport { + pub(super) job_count: usize, + pub(super) task_run_count: usize, + pub(super) output_count: usize, + pub(super) required_task_kind_count: usize, + pub(super) covered_required_task_kind_count: usize, + pub(super) missing_required_task_kind_count: usize, + pub(super) evidence_ref_required_count: usize, + pub(super) evidence_ref_output_count: usize, + pub(super) evidence_ref_coverage: f64, + pub(super) freshness_marker_count: usize, + pub(super) freshness_coverage: f64, + pub(super) action_rationale_count: usize, + pub(super) action_rationale_coverage: f64, + pub(super) trace_required_count: usize, + pub(super) trace_complete_count: usize, + pub(super) trace_coverage: f64, + pub(super) source_mutation_count: usize, + pub(super) current_output_count: usize, + pub(super) non_current_output_count: usize, + pub(super) invalid_current_output_count: usize, + pub(super) untraced_output_count: usize, + pub(super) unsupported_current_output_count: usize, + pub(super) tombstone_violation_count: usize, + pub(super) source_trace_selected_count: usize, + pub(super) source_trace_dropped_count: usize, + pub(super) source_trace_stale_count: usize, + pub(super) source_trace_superseded_count: usize, + pub(super) source_trace_tombstone_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct WorkContinuitySummaryReport { + pub(super) job_count: usize, + pub(super) readback_count: usize, + pub(super) entry_count: usize, + pub(super) reset_resume_required_count: usize, + pub(super) reset_resume_success_count: usize, + pub(super) reset_resume_success_rate: f64, + pub(super) decision_rationale_required_count: usize, + pub(super) decision_rationale_recalled_count: usize, + pub(super) decision_rationale_recall_rate: f64, + pub(super) rejected_option_required_count: usize, + pub(super) rejected_option_suppressed_count: usize, + pub(super) rejected_option_resurrection_count: usize, + pub(super) rejected_option_suppression_rate: f64, + pub(super) explicit_next_step_required_count: usize, + pub(super) explicit_next_step_returned_count: usize, + pub(super) explicit_next_step_correct_count: usize, + pub(super) explicit_next_step_precision: f64, + pub(super) inferred_next_step_required_count: usize, + pub(super) inferred_next_step_labeled_count: usize, + pub(super) inferred_step_instruction_count: usize, + pub(super) inferred_next_step_labeling_rate: f64, + pub(super) handoff_source_ref_required_count: usize, + pub(super) handoff_source_ref_covered_count: usize, + pub(super) handoff_source_ref_coverage: f64, + pub(super) redaction_required_count: usize, + pub(super) redaction_applied_count: usize, + pub(super) sensitive_marker_persistence_count: usize, + pub(super) redaction_rate: f64, + pub(super) janitor_candidate_count: usize, + pub(super) janitor_false_promotion_count: usize, + pub(super) janitor_false_promotion_rate: f64, + pub(super) journal_only_authority_claim_count: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct KnowledgeSummary { + pub(super) job_count: usize, + pub(super) page_count: usize, + pub(super) section_count: usize, + pub(super) backlink_count: usize, + pub(super) pages_with_backlinks: usize, + pub(super) pages_with_version_diff: usize, + pub(super) citation_coverage: f64, + pub(super) stale_claim_detection: f64, + pub(super) rebuild_determinism: f64, + pub(super) backlink_coverage: f64, + pub(super) version_diff_coverage: f64, + pub(super) page_usefulness: f64, + pub(super) unsupported_summary_count: usize, + pub(super) untraced_section_count: usize, + pub(super) allowed_variance_count: usize, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct SuiteReport { + pub(super) suite_id: String, + pub(super) status: TypedStatus, + pub(super) encoded_job_count: usize, + pub(super) score_mean: Option, + pub(super) unsupported_claim_count: usize, + pub(super) wrong_result_count: usize, + #[serde(default)] + pub(super) stale_answer_count: usize, + #[serde(default)] + pub(super) conflict_detection_count: usize, + #[serde(default)] + pub(super) update_rationale_available_count: usize, + #[serde(default)] + pub(super) temporal_validity_not_encoded_count: usize, + #[serde(default)] + pub(super) history_readback_encoded_count: usize, + pub(super) expected_evidence_recall: Option, + pub(super) irrelevant_context_ratio: Option, + pub(super) trace_explainability_count: usize, + pub(super) reason: String, +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation.rs new file mode 100644 index 00000000..1fc03da8 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation.rs @@ -0,0 +1,83 @@ +#[path = "validation/adapter.rs"] mod adapter; +#[path = "validation/basics.rs"] mod basics; +#[path = "validation/common.rs"] mod common; +#[path = "validation/consolidation.rs"] mod consolidation; +#[path = "validation/expectations.rs"] mod expectations; +#[path = "validation/job_rules.rs"] mod job_rules; +#[path = "validation/memory_summary.rs"] mod memory_summary; +#[path = "validation/page.rs"] mod page; +#[path = "validation/proactive.rs"] mod proactive; +#[path = "validation/recovery_artifact.rs"] mod recovery_artifact; +#[path = "validation/scheduled.rs"] mod scheduled; +#[path = "validation/trace.rs"] mod trace; +#[path = "validation/work_journal.rs"] mod work_journal; + +use self::{ + common::{ + corpus_evidence_ids, corpus_text_by_id, ensure_known_event, ensure_known_evidence, + ensure_known_evidence_refs, is_memory_summary_category, is_memory_summary_freshness_status, + is_memory_summary_rationale_decision, is_proactive_action_decision, + is_proactive_suggestion_kind, is_scheduled_task_kind, timeline_event_ids, + validate_optional_rfc3339, validate_optional_summary_time, validate_required_rfc3339, + }, + memory_summary::{validate_memory_summary_artifact, validate_memory_summary_source_trace}, + page::validate_page_artifact, + proactive::validate_proactive_brief_artifact, + recovery_artifact::validate_authority_recovery_drill_artifact, + scheduled::validate_scheduled_memory_artifact, + work_journal::validate_work_journal_readback_artifact, +}; +use crate::{ + AUTHORITY_RECOVERY_DRILL_SCHEMA, AuthorityRecoveryDrillArtifact, BTreeMap, BTreeSet, + ConsolidationProposalFixture, DerivedPageArtifact, EvolutionConflict, JOB_SCHEMA, + MemorySummaryArtifact, MemorySummaryEntry, MemorySummarySourceTrace, OffsetDateTime, Path, + ProactiveBriefArtifact, ProactiveSuggestion, RealWorldJob, RecoveryBackupPitr, + RecoveryDeadLetterHandling, RecoveryDegradedRead, RecoveryDrillTopology, RecoveryMeasurement, + RecoveryMigrationRepair, RecoveryOutboxReplay, RecoveryQdrantRebuild, Result, Rfc3339, SUITES, + ScheduledMemoryExecutionTrace, ScheduledMemoryOutput, ScheduledMemoryTaskArtifact, + TemporalValidity, TraceStageExplainability, TypedStatus, UpdateRationale, Value, + WorkJournalEntryArtifact, WorkJournalNextStepArtifact, WorkJournalReadbackArtifact, + WorkJournalWhereStoppedArtifact, eyre, + formatting::status_str, + recovery::{ + REQUIRED_AUTHORITY_PLANES, recovery_dead_letter_succeeded, recovery_measurement_met, + recovery_migration_repair_succeeded, recovery_outbox_replay_succeeded, + recovery_qdrant_rebuild_succeeded, + }, +}; + +pub(super) fn validate_job(job: &RealWorldJob, path: &Path) -> Result<()> { + if job.schema != JOB_SCHEMA { + return Err(eyre::eyre!( + "{} has schema {}, expected {JOB_SCHEMA}.", + path.display(), + job.schema + )); + } + + self::basics::validate_job_identity(job, path)?; + + if !SUITES.contains(&job.suite.as_str()) { + return Err(eyre::eyre!("{} uses unknown suite {}.", path.display(), job.suite)); + } + + self::basics::validate_corpus_items(job, path)?; + self::basics::validate_timeline(job, path)?; + self::basics::validate_prompt(job, path)?; + self::basics::validate_expected_answer(job, path)?; + self::basics::validate_required_evidence(job, path)?; + self::consolidation::validate_consolidation_fixture(job, path)?; + self::adapter::validate_adapter_response(job, path)?; + self::job_rules::validate_scoring_rubric(job, path)?; + self::job_rules::validate_allowed_uncertainty(job, path)?; + self::job_rules::validate_operator_debug(job, path)?; + self::job_rules::validate_job_encoding(job, path)?; + self::expectations::validate_memory_evolution(job, path)?; + self::expectations::validate_memory_summary_expectation(job, path)?; + self::expectations::validate_proactive_brief_expectation(job, path)?; + self::expectations::validate_scheduled_memory_expectation(job, path)?; + self::expectations::validate_work_continuity_expectation(job, path)?; + self::trace::validate_trace_explainability(job, path)?; + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/adapter.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/adapter.rs new file mode 100644 index 00000000..d241bf55 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/adapter.rs @@ -0,0 +1,67 @@ +use crate::validation::{self, Path, RealWorldJob, Result, eyre}; + +pub(super) fn validate_adapter_response(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(adapter_response) = &job.corpus.adapter_response else { + return Ok(()); + }; + let evidence_ids = validation::corpus_evidence_ids(job); + let event_ids = validation::timeline_event_ids(job); + + for page in &adapter_response.answer.pages { + validation::validate_page_artifact(page, path, &evidence_ids, &event_ids)?; + } + for summary in &adapter_response.answer.memory_summaries { + validation::validate_memory_summary_artifact(summary, path, &evidence_ids)?; + } + for brief in &adapter_response.answer.proactive_briefs { + validation::validate_proactive_brief_artifact(brief, path, &evidence_ids)?; + } + for task in &adapter_response.answer.scheduled_tasks { + validation::validate_scheduled_memory_artifact(task, path, &evidence_ids)?; + } + for readback in &adapter_response.answer.work_journal_readbacks { + validation::validate_work_journal_readback_artifact(readback, path, &evidence_ids)?; + } + for drill in &adapter_response.answer.recovery_drills { + validation::validate_authority_recovery_drill_artifact(drill, path, &evidence_ids)?; + } + + if job.suite == "memory_summary" + && adapter_response.answer.memory_summaries.is_empty() + && job.encoding.status.is_none() + { + return Err(eyre::eyre!( + "{} memory_summary jobs must provide adapter_response.answer.memory_summaries.", + path.display() + )); + } + if job.suite == "proactive_brief" + && adapter_response.answer.proactive_briefs.is_empty() + && job.encoding.status.is_none() + { + return Err(eyre::eyre!( + "{} proactive_brief jobs must provide adapter_response.answer.proactive_briefs.", + path.display() + )); + } + if job.suite == "scheduled_memory" + && adapter_response.answer.scheduled_tasks.is_empty() + && job.encoding.status.is_none() + { + return Err(eyre::eyre!( + "{} scheduled_memory jobs must provide adapter_response.answer.scheduled_tasks.", + path.display() + )); + } + if job.suite == "work_continuity" + && adapter_response.answer.work_journal_readbacks.is_empty() + && job.encoding.status.is_none() + { + return Err(eyre::eyre!( + "{} work_continuity jobs must provide adapter_response.answer.work_journal_readbacks.", + path.display() + )); + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/basics.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/basics.rs new file mode 100644 index 00000000..23f25589 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/basics.rs @@ -0,0 +1,182 @@ +use crate::validation::{self, BTreeSet, Path, RealWorldJob, Result, eyre}; + +pub(super) fn validate_job_identity(job: &RealWorldJob, path: &Path) -> Result<()> { + if job.job_id.trim().is_empty() + || job.suite.trim().is_empty() + || job.title.trim().is_empty() + || job.corpus.corpus_id.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete job identity.", path.display())); + } + + for tag in &job.tags { + if tag.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty tag.", path.display())); + } + } + + if let Some(adapter_response) = &job.corpus.adapter_response + && adapter_response.adapter_id.as_deref().is_some_and(str::is_empty) + { + return Err(eyre::eyre!("{} has an empty adapter_response adapter_id.", path.display())); + } + + Ok(()) +} + +pub(super) fn validate_corpus_items(job: &RealWorldJob, path: &Path) -> Result<()> { + let mut evidence_ids = BTreeSet::new(); + + for item in &job.corpus.items { + if item.evidence_id.trim().is_empty() { + return Err(eyre::eyre!( + "{} has a corpus item with an empty evidence_id.", + path.display() + )); + } + if item.kind.trim().is_empty() { + return Err(eyre::eyre!( + "{} has corpus item {} with an empty kind.", + path.display(), + item.evidence_id + )); + } + if item.text.is_none() && item.local_ref.is_none() { + return Err(eyre::eyre!( + "{} corpus item {} must provide text or local_ref.", + path.display(), + item.evidence_id + )); + } + if !item.source_ref.is_object() { + return Err(eyre::eyre!( + "{} corpus item {} must provide an object source_ref.", + path.display(), + item.evidence_id + )); + } + + if let Some(created_at) = &item.created_at { + validation::validate_optional_rfc3339(created_at, path, item.evidence_id.as_str())?; + } + + evidence_ids.insert(item.evidence_id.clone()); + } + for trap in &job.negative_traps { + if trap.trap_id.trim().is_empty() || trap.trap_type.trim().is_empty() { + return Err(eyre::eyre!("{} has an incomplete negative trap.", path.display())); + } + + for evidence_id in &trap.evidence_ids { + validation::ensure_known_evidence(path, &evidence_ids, evidence_id)?; + } + } + + Ok(()) +} + +pub(super) fn validate_timeline(job: &RealWorldJob, path: &Path) -> Result<()> { + let evidence_ids = validation::corpus_evidence_ids(job); + + for event in &job.timeline { + if event.event_id.trim().is_empty() + || event.actor.trim().is_empty() + || event.action.trim().is_empty() + || event.summary.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete timeline event.", path.display())); + } + + validation::validate_required_rfc3339(event.ts.as_str(), path, event.event_id.as_str())?; + + for evidence_id in &event.evidence_ids { + validation::ensure_known_evidence(path, &evidence_ids, evidence_id)?; + } + } + + Ok(()) +} + +pub(super) fn validate_prompt(job: &RealWorldJob, path: &Path) -> Result<()> { + if job.prompt.role.trim().is_empty() + || job.prompt.content.trim().is_empty() + || job.prompt.job_mode.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete prompt.", path.display())); + } + + for constraint in &job.prompt.constraints { + if constraint.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty prompt constraint.", path.display())); + } + } + + Ok(()) +} + +pub(super) fn validate_expected_answer(job: &RealWorldJob, path: &Path) -> Result<()> { + if job.expected_answer.answer_type.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty expected answer type.", path.display())); + } + + for claim in &job.expected_answer.must_include { + if claim.text().trim().is_empty() { + return Err(eyre::eyre!("{} has an empty expected claim.", path.display())); + } + } + for claim in &job.expected_answer.must_not_include { + if claim.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty forbidden claim.", path.display())); + } + } + for phrase in &job.expected_answer.accepted_alternates { + if phrase.is_null() { + return Err(eyre::eyre!("{} has a null accepted alternate.", path.display())); + } + } + + Ok(()) +} + +pub(super) fn validate_required_evidence(job: &RealWorldJob, path: &Path) -> Result<()> { + let evidence_ids = validation::corpus_evidence_ids(job); + let corpus_text = validation::corpus_text_by_id(job); + + for evidence in &job.required_evidence { + if evidence.claim_id.trim().is_empty() || evidence.requirement.trim().is_empty() { + return Err(eyre::eyre!("{} has incomplete required evidence.", path.display())); + } + + validation::ensure_known_evidence(path, &evidence_ids, evidence.evidence_id.as_str())?; + + if evidence.quote.is_none() && evidence.selector.is_none() { + return Err(eyre::eyre!( + "{} required evidence {} must provide quote or selector.", + path.display(), + evidence.evidence_id + )); + } + + if let Some(quote) = &evidence.quote + && let Some(text) = corpus_text.get(evidence.evidence_id.as_str()) + && !text.contains(quote) + { + return Err(eyre::eyre!( + "{} required evidence quote for {} is not present in corpus text.", + path.display(), + evidence.evidence_id + )); + } + } + for (claim_id, link) in &job.expected_answer.evidence_links { + if claim_id.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty evidence link claim id.", path.display())); + } + + for evidence_id in link.ids() { + validation::ensure_known_evidence(path, &evidence_ids, evidence_id.as_str())?; + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/common.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/common.rs new file mode 100644 index 00000000..4f6c8e73 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/common.rs @@ -0,0 +1,144 @@ +use crate::validation::{ + BTreeMap, BTreeSet, OffsetDateTime, Path, RealWorldJob, Result, Rfc3339, eyre, +}; + +pub(super) fn validate_required_rfc3339(value: &str, path: &Path, id: &str) -> Result<()> { + if OffsetDateTime::parse(value, &Rfc3339).is_err() { + return Err(eyre::eyre!("{} has invalid RFC3339 timestamp for {}.", path.display(), id)); + } + + Ok(()) +} + +pub(super) fn validate_optional_rfc3339(value: &str, path: &Path, id: &str) -> Result<()> { + if !value.trim().is_empty() { + validate_required_rfc3339(value, path, id)?; + } + + Ok(()) +} + +pub(super) fn ensure_known_evidence( + path: &Path, + known: &BTreeSet, + evidence_id: &str, +) -> Result<()> { + if !known.contains(evidence_id) { + return Err(eyre::eyre!( + "{} references unknown evidence id {}.", + path.display(), + evidence_id + )); + } + + Ok(()) +} + +pub(super) fn ensure_known_evidence_refs( + path: &Path, + evidence_ids: &BTreeSet, + refs: &[String], +) -> Result<()> { + for evidence_ref in refs { + ensure_known_evidence(path, evidence_ids, evidence_ref)?; + } + + Ok(()) +} + +pub(super) fn ensure_known_event( + path: &Path, + known: &BTreeSet, + event_id: &str, +) -> Result<()> { + if !known.contains(event_id) { + return Err(eyre::eyre!( + "{} references unknown timeline event id {}.", + path.display(), + event_id + )); + } + + Ok(()) +} + +pub(super) fn validate_optional_summary_time( + path: &Path, + value: Option<&str>, + id: &str, +) -> Result<()> { + if let Some(value) = value { + validate_optional_rfc3339(value, path, id)?; + } + + Ok(()) +} + +pub(super) fn corpus_evidence_ids(job: &RealWorldJob) -> BTreeSet { + job.corpus.items.iter().map(|item| item.evidence_id.clone()).collect() +} + +pub(super) fn corpus_text_by_id(job: &RealWorldJob) -> BTreeMap<&str, &str> { + job.corpus + .items + .iter() + .filter_map(|item| item.text.as_deref().map(|text| (item.evidence_id.as_str(), text))) + .collect() +} + +pub(super) fn timeline_event_ids(job: &RealWorldJob) -> BTreeSet { + job.timeline.iter().map(|event| event.event_id.clone()).collect() +} + +pub(super) fn is_memory_summary_category(category: &str) -> bool { + matches!( + category, + "top_of_mind" + | "background" + | "stale" | "superseded" + | "tombstone" + | "derived_project_profile" + ) +} + +pub(super) fn is_memory_summary_freshness_status(status: &str) -> bool { + matches!( + status, + "current" + | "background" + | "historical" + | "stale" | "superseded" + | "tombstoned" + | "unsupported" + ) +} + +pub(super) fn is_memory_summary_rationale_decision(decision: &str) -> bool { + matches!(decision, "included" | "downgraded" | "excluded") +} + +pub(super) fn is_proactive_suggestion_kind(kind: &str) -> bool { + matches!( + kind, + "daily_project_brief" + | "resume_work" + | "stale_decision_audit" + | "stale_plan_preference_warning" + | "private_corpus_refresh" + ) +} + +pub(super) fn is_scheduled_task_kind(kind: &str) -> bool { + matches!( + kind, + "weekly_project_status_summary" + | "stale_preference_plan_audit" + | "stale_decision_audit" + | "knowledge_page_refresh_suggestion" + | "private_provider_scheduler" + ) +} + +pub(super) fn is_proactive_action_decision(decision: &str) -> bool { + matches!(decision, "recommend" | "defer" | "reject") +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/consolidation.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/consolidation.rs new file mode 100644 index 00000000..25ecca29 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/consolidation.rs @@ -0,0 +1,81 @@ +use crate::validation::{ConsolidationProposalFixture, Path, RealWorldJob, Result, eyre}; + +pub(super) fn validate_consolidation_fixture(job: &RealWorldJob, path: &Path) -> Result<()> { + let consolidation = + job.corpus.adapter_response.as_ref().and_then(|response| response.consolidation.as_ref()); + + if job.suite == "consolidation" && consolidation.is_none() && job.encoding.status.is_none() { + return Err(eyre::eyre!( + "{} consolidation jobs must provide adapter_response.consolidation.", + path.display() + )); + } + + let Some(consolidation) = consolidation else { + return Ok(()); + }; + + if consolidation.proposals.is_empty() && consolidation.executable_gaps.is_empty() { + return Err(eyre::eyre!( + "{} consolidation fixture must provide proposals or executable_gaps.", + path.display() + )); + } + + for proposal in &consolidation.proposals { + validate_consolidation_proposal(proposal, path)?; + } + for gap in &consolidation.executable_gaps { + if gap.primitive.trim().is_empty() + || gap.follow_up_issue.trim().is_empty() + || gap.reason.trim().is_empty() + { + return Err(eyre::eyre!( + "{} has an incomplete consolidation executable gap.", + path.display() + )); + } + } + + Ok(()) +} + +fn validate_consolidation_proposal( + proposal: &ConsolidationProposalFixture, + path: &Path, +) -> Result<()> { + if proposal.proposal_id.trim().is_empty() + || proposal.proposal_kind.trim().is_empty() + || proposal.source_refs.is_empty() + || proposal.expected_source_refs.is_empty() + { + return Err(eyre::eyre!( + "{} has an incomplete consolidation proposal fixture.", + path.display() + )); + } + if !proposal.usefulness_score.is_finite() + || !proposal.min_usefulness_score.is_finite() + || !(0.0..=1.0).contains(&proposal.usefulness_score) + || !(0.0..=1.0).contains(&proposal.min_usefulness_score) + { + return Err(eyre::eyre!( + "{} has invalid consolidation proposal usefulness scores.", + path.display() + )); + } + if !proposal.diff.is_null() && !proposal.diff.is_object() { + return Err(eyre::eyre!( + "{} consolidation proposal diff must be a JSON object when present.", + path.display() + )); + } + if proposal.unsupported_claim_flags.iter().any(|flag| !flag.is_object()) { + return Err(eyre::eyre!( + "{} consolidation unsupported-claim flags must be JSON objects.", + path.display() + )); + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/expectations.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/expectations.rs new file mode 100644 index 00000000..7dc9d619 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/expectations.rs @@ -0,0 +1,228 @@ +use crate::validation::{ + self, BTreeSet, EvolutionConflict, Path, RealWorldJob, Result, TemporalValidity, TypedStatus, + UpdateRationale, eyre, +}; + +pub(super) fn validate_memory_evolution(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(evolution) = &job.memory_evolution else { + return Ok(()); + }; + let evidence_ids = validation::corpus_evidence_ids(job); + let trap_ids = + job.negative_traps.iter().map(|trap| trap.trap_id.as_str()).collect::>(); + + for evidence_id in evolution + .current_evidence_ids + .iter() + .chain(evolution.historical_evidence_ids.iter()) + .chain(evolution.tombstone_evidence_ids.iter()) + .chain(evolution.invalidation_evidence_ids.iter()) + { + validation::ensure_known_evidence(path, &evidence_ids, evidence_id)?; + } + for trap_id in &evolution.stale_trap_ids { + if !trap_ids.contains(trap_id.as_str()) { + return Err(eyre::eyre!( + "{} job {} references unknown stale trap id {}.", + path.display(), + job.job_id, + trap_id + )); + } + } + for conflict in &evolution.conflicts { + validate_evolution_conflict(path, &evidence_ids, conflict)?; + } + + if let Some(rationale) = &evolution.update_rationale { + validate_update_rationale(path, &evidence_ids, rationale)?; + } + if let Some(temporal) = &evolution.temporal_validity { + validate_temporal_validity(job, path, temporal)?; + } + + Ok(()) +} + +pub(super) fn validate_memory_summary_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(summary) = &job.memory_summary else { + if job.suite == "memory_summary" && job.encoding.status.is_none() { + return Err(eyre::eyre!( + "{} memory_summary jobs must provide memory_summary expectations.", + path.display() + )); + } + + return Ok(()); + }; + + for category in &summary.required_categories { + if !validation::is_memory_summary_category(category.as_str()) { + return Err(eyre::eyre!( + "{} memory_summary expectation references unknown category {}.", + path.display(), + category + )); + } + } + + Ok(()) +} + +pub(super) fn validate_proactive_brief_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(brief) = &job.proactive_brief else { + if job.suite == "proactive_brief" && job.encoding.status.is_none() { + return Err(eyre::eyre!( + "{} proactive_brief jobs must provide proactive_brief expectations.", + path.display() + )); + } + + return Ok(()); + }; + + for kind in &brief.required_suggestion_kinds { + if !validation::is_proactive_suggestion_kind(kind.as_str()) { + return Err(eyre::eyre!( + "{} proactive_brief expectation references unknown suggestion kind {}.", + path.display(), + kind + )); + } + } + + Ok(()) +} + +pub(super) fn validate_scheduled_memory_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(scheduled) = &job.scheduled_memory else { + if job.suite == "scheduled_memory" && job.encoding.status.is_none() { + return Err(eyre::eyre!( + "{} scheduled_memory jobs must provide scheduled_memory expectations.", + path.display() + )); + } + + return Ok(()); + }; + + for kind in &scheduled.required_task_kinds { + if !validation::is_scheduled_task_kind(kind.as_str()) { + return Err(eyre::eyre!( + "{} scheduled_memory expectation references unknown task kind {}.", + path.display(), + kind + )); + } + } + + Ok(()) +} + +pub(super) fn validate_work_continuity_expectation(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(work_continuity) = &job.work_continuity else { + if job.suite == "work_continuity" && job.encoding.status.is_none() { + return Err(eyre::eyre!( + "{} work_continuity jobs must provide work_continuity expectations.", + path.display() + )); + } + + return Ok(()); + }; + let evidence_ids = validation::corpus_evidence_ids(job); + + for value in work_continuity + .required_reset_resume_entry_ids + .iter() + .chain(work_continuity.required_rejected_option_ids.iter()) + .chain(work_continuity.required_explicit_next_step_ids.iter()) + .chain(work_continuity.required_inferred_next_step_ids.iter()) + .chain(work_continuity.required_redaction_marker_ids.iter()) + .chain(work_continuity.required_janitor_candidate_ids.iter()) + { + if value.trim().is_empty() { + return Err(eyre::eyre!( + "{} work_continuity expectations contain an empty required id.", + path.display() + )); + } + } + for evidence_ref in work_continuity + .required_decision_rationale_evidence_ids + .iter() + .chain(work_continuity.required_handoff_source_ref_ids.iter()) + { + validation::ensure_known_evidence(path, &evidence_ids, evidence_ref)?; + } + + Ok(()) +} + +fn validate_evolution_conflict( + path: &Path, + evidence_ids: &BTreeSet, + conflict: &EvolutionConflict, +) -> Result<()> { + if conflict.conflict_id.trim().is_empty() || conflict.claim_id.trim().is_empty() { + return Err(eyre::eyre!("{} has an incomplete evolution conflict.", path.display())); + } + + validation::ensure_known_evidence(path, evidence_ids, conflict.current_evidence_id.as_str())?; + validation::ensure_known_evidence( + path, + evidence_ids, + conflict.historical_evidence_id.as_str(), + )?; + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + + Ok(()) +} + +fn validate_update_rationale( + path: &Path, + evidence_ids: &BTreeSet, + rationale: &UpdateRationale, +) -> Result<()> { + if rationale.claim_id.trim().is_empty() { + return Err(eyre::eyre!( + "{} has an update rationale with an empty claim_id.", + path.display() + )); + } + + for evidence_id in &rationale.evidence_ids { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + + Ok(()) +} + +fn validate_temporal_validity( + job: &RealWorldJob, + path: &Path, + temporal: &TemporalValidity, +) -> Result<()> { + if temporal.follow_up.as_deref().is_some_and(|follow_up| follow_up.trim().is_empty()) { + return Err(eyre::eyre!( + "{} job {} has an empty temporal validity follow-up.", + path.display(), + job.job_id + )); + } + if temporal.required + && !temporal.encoded + && !matches!(job.encoding.status, Some(TypedStatus::NotEncoded | TypedStatus::Blocked)) + { + return Err(eyre::eyre!( + "{} job {} requires temporal validity but does not declare a not_encoded or blocked encoding status.", + path.display(), + job.job_id + )); + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/job_rules.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/job_rules.rs new file mode 100644 index 00000000..a34a0765 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/job_rules.rs @@ -0,0 +1,153 @@ +use crate::validation::{self, Path, RealWorldJob, Result, TypedStatus, eyre}; + +pub(super) fn validate_scoring_rubric(job: &RealWorldJob, path: &Path) -> Result<()> { + if !(0.0..=1.0).contains(&job.scoring_rubric.pass_threshold) { + return Err(eyre::eyre!("{} has invalid pass_threshold.", path.display())); + } + if job.scoring_rubric.dimensions.is_empty() { + return Err(eyre::eyre!("{} has no scoring dimensions.", path.display())); + } + + for (dimension_id, dimension) in &job.scoring_rubric.dimensions { + if dimension_id.trim().is_empty() + || !dimension.weight.is_finite() + || !dimension.max_points.is_finite() + || dimension.weight <= 0.0 + || dimension.max_points <= 0.0 + || dimension.criteria.is_null() + { + return Err(eyre::eyre!( + "{} has invalid scoring dimension {}.", + path.display(), + dimension_id + )); + } + } + for rule in &job.scoring_rubric.hard_fail_rules { + if rule.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty hard fail rule.", path.display())); + } + } + + Ok(()) +} + +pub(super) fn validate_allowed_uncertainty(job: &RealWorldJob, path: &Path) -> Result<()> { + if job.allowed_uncertainty.fallback_action.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty fallback action.", path.display())); + } + if job.allowed_uncertainty.can_answer_unknown + && job.allowed_uncertainty.acceptable_phrases.is_empty() + { + return Err(eyre::eyre!( + "{} allows unknown answers but defines no acceptable uncertainty phrase.", + path.display() + )); + } + + for phrase in &job.allowed_uncertainty.acceptable_phrases { + if phrase.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty uncertainty phrase.", path.display())); + } + } + + Ok(()) +} + +pub(super) fn validate_operator_debug(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(debug) = &job.operator_debug else { + if job.suite == "operator_debugging_ux" { + return Err(eyre::eyre!( + "{} operator_debugging_ux job must include operator_debug.", + path.display() + )); + } + + return Ok(()); + }; + + if debug.failure_mode.trim().is_empty() + || debug.root_cause.trim().is_empty() + || debug.dropped_candidate_visibility.trim().is_empty() + || debug.trace_completeness.trim().is_empty() + || debug.repair_action_clarity.trim().is_empty() + || debug.steps_to_root_cause == 0 + { + return Err(eyre::eyre!("{} has incomplete operator_debug evidence.", path.display())); + } + + validate_optional_debug_field(path, debug.trace_id.as_deref(), "trace_id")?; + validate_optional_debug_field(path, debug.viewer_url.as_deref(), "viewer_url")?; + validate_optional_debug_field( + path, + debug.admin_trace_bundle_url.as_deref(), + "admin_trace_bundle_url", + )?; + validate_optional_debug_field(path, debug.replay_command.as_deref(), "replay_command")?; + validate_optional_debug_field(path, debug.replay_artifact.as_deref(), "replay_artifact")?; + validate_non_empty_debug_list(path, &debug.viewer_panels, "viewer_panels")?; + validate_non_empty_debug_list(path, &debug.cli_steps, "cli_steps")?; + validate_non_empty_debug_list(path, &debug.trace_evidence, "trace_evidence")?; + + for gap in &debug.ux_gaps { + if gap.gap_id.trim().is_empty() + || gap.severity.trim().is_empty() + || gap.description.trim().is_empty() + || gap.follow_up_issue.trim().is_empty() + { + return Err(eyre::eyre!("{} has incomplete operator_debug ux_gaps.", path.display())); + } + } + + Ok(()) +} + +pub(super) fn validate_job_encoding(job: &RealWorldJob, path: &Path) -> Result<()> { + if let Some(status) = job.encoding.status { + if !matches!( + status, + TypedStatus::NotEncoded | TypedStatus::Blocked | TypedStatus::Incomplete + ) { + return Err(eyre::eyre!( + "{} job {} uses encoding.status {}; only not_encoded, blocked, or incomplete are allowed.", + path.display(), + job.job_id, + validation::status_str(status) + )); + } + if job.encoding.reason.as_deref().is_none_or(|reason| reason.trim().is_empty()) { + return Err(eyre::eyre!( + "{} job {} declares encoding.status but no reason.", + path.display(), + job.job_id + )); + } + } + if let Some(follow_up) = &job.encoding.follow_up + && (follow_up.title.trim().is_empty() || follow_up.reason.trim().is_empty()) + { + return Err(eyre::eyre!( + "{} job {} has an incomplete encoding follow-up.", + path.display(), + job.job_id + )); + } + + Ok(()) +} + +fn validate_optional_debug_field(path: &Path, value: Option<&str>, field: &str) -> Result<()> { + if value.is_some_and(|value| value.trim().is_empty()) { + return Err(eyre::eyre!("{} has empty operator_debug {field}.", path.display())); + } + + Ok(()) +} + +fn validate_non_empty_debug_list(path: &Path, values: &[String], field: &str) -> Result<()> { + if values.iter().any(|value| value.trim().is_empty()) { + return Err(eyre::eyre!("{} has empty operator_debug {field} entry.", path.display())); + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/memory_summary.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/memory_summary.rs new file mode 100644 index 00000000..274d952c --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/memory_summary.rs @@ -0,0 +1,139 @@ +use crate::validation::{ + self, BTreeSet, MemorySummaryArtifact, MemorySummaryEntry, MemorySummarySourceTrace, Path, + Result, eyre, +}; + +pub(super) fn validate_memory_summary_artifact( + summary: &MemorySummaryArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if summary.summary_id.trim().is_empty() + || summary.contract_schema != "elf.memory_summary/v1" + || summary.generated_at.trim().is_empty() + || summary.tenant_id.trim().is_empty() + || summary.project_id.trim().is_empty() + || summary.agent_id.trim().is_empty() + || summary.read_profile.trim().is_empty() + || summary.entries.is_empty() + { + return Err(eyre::eyre!("{} has an incomplete memory summary.", path.display())); + } + + validation::validate_optional_rfc3339( + &summary.generated_at, + path, + summary.summary_id.as_str(), + )?; + + for entry in &summary.entries { + validate_memory_summary_entry(entry, path, evidence_ids)?; + } + + validate_memory_summary_source_trace(&summary.source_trace, path, evidence_ids)?; + + Ok(()) +} + +pub(super) fn validate_memory_summary_source_trace( + trace: &MemorySummarySourceTrace, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + for item in trace + .selected_source_refs + .iter() + .chain(trace.dropped_source_refs.iter()) + .chain(trace.stale_source_refs.iter()) + .chain(trace.superseded_source_refs.iter()) + .chain(trace.tombstone_source_refs.iter()) + { + if item.evidence_id.trim().is_empty() { + return Err(eyre::eyre!("{} has an empty memory summary trace item.", path.display())); + } + + validation::ensure_known_evidence(path, evidence_ids, item.evidence_id.as_str())?; + } + for flag in &trace.unsupported_claim_flags { + if !flag.is_object() { + return Err(eyre::eyre!( + "{} memory summary source-trace unsupported-claim flags must be JSON objects.", + path.display() + )); + } + } + + Ok(()) +} + +fn validate_memory_summary_entry( + entry: &MemorySummaryEntry, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if entry.entry_id.trim().is_empty() + || entry.category.trim().is_empty() + || entry.text.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete memory summary entry.", path.display())); + } + if !validation::is_memory_summary_category(entry.category.as_str()) { + return Err(eyre::eyre!( + "{} has unknown memory summary category {}.", + path.display(), + entry.category + )); + } + if !validation::is_memory_summary_freshness_status(entry.freshness.status.as_str()) { + return Err(eyre::eyre!( + "{} has unknown memory summary freshness status {}.", + path.display(), + entry.freshness.status + )); + } + if !validation::is_memory_summary_rationale_decision(entry.rationale.decision.as_str()) { + return Err(eyre::eyre!( + "{} has unknown memory summary rationale decision {}.", + path.display(), + entry.rationale.decision + )); + } + + for evidence_id in &entry.source_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for evidence_id in &entry.freshness.tombstone_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for flag in &entry.unsupported_claim_flags { + if !flag.is_object() { + return Err(eyre::eyre!( + "{} memory summary unsupported-claim flags must be JSON objects.", + path.display() + )); + } + } + + validation::validate_optional_summary_time( + path, + entry.freshness.observed_at.as_deref(), + entry.entry_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + entry.freshness.valid_from.as_deref(), + entry.entry_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + entry.freshness.valid_to.as_deref(), + entry.entry_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + entry.freshness.last_confirmed_at.as_deref(), + entry.entry_id.as_str(), + )?; + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/page.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/page.rs new file mode 100644 index 00000000..5479eb29 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/page.rs @@ -0,0 +1,91 @@ +use crate::validation::{self, BTreeSet, DerivedPageArtifact, Path, Result, Value, eyre}; + +pub(super) fn validate_page_artifact( + page: &DerivedPageArtifact, + path: &Path, + evidence_ids: &BTreeSet, + event_ids: &BTreeSet, +) -> Result<()> { + if page.page_id.trim().is_empty() + || page.page_type.trim().is_empty() + || page.title.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete derived page.", path.display())); + } + + for section in &page.sections { + if section.section_id.trim().is_empty() + || section.heading.trim().is_empty() + || section.role.trim().is_empty() + || section.content.trim().is_empty() + { + return Err(eyre::eyre!( + "{} page {} has an incomplete section.", + path.display(), + page.page_id + )); + } + + for evidence_id in §ion.evidence_ids { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for event_id in §ion.timeline_event_ids { + validation::ensure_known_event(path, event_ids, event_id)?; + } + } + for backlink in &page.backlinks { + if backlink.trim().is_empty() { + return Err(eyre::eyre!( + "{} page {} has an empty backlink.", + path.display(), + page.page_id + )); + } + } + for finding in &page.lint_findings { + if finding.finding_id.trim().is_empty() + || finding.finding_type.trim().is_empty() + || finding.severity.trim().is_empty() + || finding.text.trim().is_empty() + { + return Err(eyre::eyre!( + "{} page {} has an incomplete lint finding.", + path.display(), + page.page_id + )); + } + + for evidence_id in &finding.evidence_ids { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + } + + if let Some(rebuild) = &page.rebuild + && (rebuild.first_hash.trim().is_empty() || rebuild.second_hash.trim().is_empty()) + { + return Err(eyre::eyre!( + "{} page {} has an incomplete rebuild record.", + path.display(), + page.page_id + )); + } + if let Some(diff) = &page.page_version_diff { + if !diff.is_object() { + return Err(eyre::eyre!( + "{} page {} previous-version diff must be a JSON object.", + path.display(), + page.page_id + )); + } + if diff.get("schema").and_then(Value::as_str) != Some("elf.knowledge_page.version_diff/v1") + { + return Err(eyre::eyre!( + "{} page {} previous-version diff has an unexpected schema.", + path.display(), + page.page_id + )); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/proactive.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/proactive.rs new file mode 100644 index 00000000..83cd2c6f --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/proactive.rs @@ -0,0 +1,109 @@ +use crate::validation::{ + self, BTreeSet, Path, ProactiveBriefArtifact, ProactiveSuggestion, Result, eyre, +}; + +pub(super) fn validate_proactive_brief_artifact( + brief: &ProactiveBriefArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if brief.brief_id.trim().is_empty() + || brief.contract_schema != "elf.proactive_project_brief/v1" + || brief.generated_at.trim().is_empty() + || brief.tenant_id.trim().is_empty() + || brief.project_id.trim().is_empty() + || brief.agent_id.trim().is_empty() + || brief.read_profile.trim().is_empty() + || brief.brief_kind.trim().is_empty() + || brief.suggestions.is_empty() + { + return Err(eyre::eyre!("{} has an incomplete proactive brief.", path.display())); + } + + validation::validate_optional_rfc3339(&brief.generated_at, path, brief.brief_id.as_str())?; + + for suggestion in &brief.suggestions { + validate_proactive_suggestion(suggestion, path, evidence_ids)?; + } + + validation::validate_memory_summary_source_trace(&brief.source_trace, path, evidence_ids)?; + + Ok(()) +} + +fn validate_proactive_suggestion( + suggestion: &ProactiveSuggestion, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if suggestion.suggestion_id.trim().is_empty() + || suggestion.suggestion_kind.trim().is_empty() + || suggestion.title.trim().is_empty() + || suggestion.body.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete proactive suggestion.", path.display())); + } + if !validation::is_proactive_suggestion_kind(suggestion.suggestion_kind.as_str()) { + return Err(eyre::eyre!( + "{} has unknown proactive suggestion kind {}.", + path.display(), + suggestion.suggestion_kind + )); + } + if !validation::is_memory_summary_freshness_status(suggestion.freshness.status.as_str()) { + return Err(eyre::eyre!( + "{} has unknown proactive freshness status {}.", + path.display(), + suggestion.freshness.status + )); + } + if !validation::is_proactive_action_decision(suggestion.action.decision.as_str()) { + return Err(eyre::eyre!( + "{} has unknown proactive action decision {}.", + path.display(), + suggestion.action.decision + )); + } + if suggestion.action.reason_code.trim().is_empty() || suggestion.action.reason.trim().is_empty() + { + return Err(eyre::eyre!("{} has incomplete proactive action rationale.", path.display())); + } + + for evidence_id in &suggestion.evidence_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for evidence_id in &suggestion.freshness.tombstone_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for flag in &suggestion.unsupported_claim_flags { + if !flag.is_object() { + return Err(eyre::eyre!( + "{} proactive unsupported-claim flags must be JSON objects.", + path.display() + )); + } + } + + validation::validate_optional_summary_time( + path, + suggestion.freshness.observed_at.as_deref(), + suggestion.suggestion_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + suggestion.freshness.valid_from.as_deref(), + suggestion.suggestion_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + suggestion.freshness.valid_to.as_deref(), + suggestion.suggestion_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + suggestion.freshness.last_confirmed_at.as_deref(), + suggestion.suggestion_id.as_str(), + )?; + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/recovery_artifact.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/recovery_artifact.rs new file mode 100644 index 00000000..d14b88e1 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/recovery_artifact.rs @@ -0,0 +1,269 @@ +use crate::validation::{ + self, AUTHORITY_RECOVERY_DRILL_SCHEMA, AuthorityRecoveryDrillArtifact, BTreeSet, Path, + REQUIRED_AUTHORITY_PLANES, RecoveryBackupPitr, RecoveryDeadLetterHandling, + RecoveryDegradedRead, RecoveryDrillTopology, RecoveryMeasurement, RecoveryMigrationRepair, + RecoveryOutboxReplay, RecoveryQdrantRebuild, Result, eyre, +}; + +pub(super) fn validate_authority_recovery_drill_artifact( + drill: &AuthorityRecoveryDrillArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if drill.drill_id.trim().is_empty() + || drill.contract_schema != AUTHORITY_RECOVERY_DRILL_SCHEMA + || drill.generated_at.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete authority recovery drill.", path.display())); + } + + validation::validate_optional_rfc3339(&drill.generated_at, path, drill.drill_id.as_str())?; + + validate_recovery_topology(&drill.topology, path, drill.drill_id.as_str())?; + validate_recovery_backup_pitr(&drill.backup_pitr, path, evidence_ids)?; + validate_recovery_degraded_read(&drill.degraded_read, path, evidence_ids)?; + validate_recovery_measurement("rpo", &drill.rpo, path, evidence_ids)?; + validate_recovery_measurement("rto", &drill.rto, path, evidence_ids)?; + validate_recovery_authority_record_counts(drill, path, evidence_ids)?; + validate_recovery_outbox_replay(&drill.outbox_replay, path, evidence_ids)?; + validate_recovery_qdrant_rebuild(&drill.qdrant_rebuild, path, evidence_ids)?; + validate_recovery_migration_repair(&drill.migration_repair, path, evidence_ids)?; + validate_recovery_dead_letter(&drill.dead_letter, path, evidence_ids)?; + + for injection in &drill.failure_injections { + if injection.injection_id.trim().is_empty() + || injection.target.trim().is_empty() + || injection.fault.trim().is_empty() + || injection.started_at.trim().is_empty() + || injection.completed_at.trim().is_empty() + || injection.evidence_refs.is_empty() + { + return Err(eyre::eyre!( + "{} authority recovery drill {} has an incomplete failure injection.", + path.display(), + drill.drill_id + )); + } + + validation::validate_optional_rfc3339( + &injection.started_at, + path, + injection.injection_id.as_str(), + )?; + validation::validate_optional_rfc3339( + &injection.completed_at, + path, + injection.injection_id.as_str(), + )?; + validation::ensure_known_evidence_refs(path, evidence_ids, &injection.evidence_refs)?; + } + + if drill.failure_injections.is_empty() { + return Err(eyre::eyre!( + "{} authority recovery drill {} must include failure injection evidence.", + path.display(), + drill.drill_id + )); + } + + Ok(()) +} + +fn validate_recovery_topology( + topology: &RecoveryDrillTopology, + path: &Path, + drill_id: &str, +) -> Result<()> { + if topology.authority_store.trim().is_empty() + || topology.derived_indexes.is_empty() + || topology.failover.trim().is_empty() + { + return Err(eyre::eyre!( + "{} authority recovery drill {} has incomplete topology.", + path.display(), + drill_id + )); + } + + Ok(()) +} + +fn validate_recovery_backup_pitr( + backup_pitr: &RecoveryBackupPitr, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if backup_pitr.backup_ref.trim().is_empty() + || backup_pitr.pitr_target.trim().is_empty() + || backup_pitr.evidence_refs.is_empty() + || !backup_pitr.restored + { + return Err(eyre::eyre!("{} has incomplete backup/PITR drill evidence.", path.display())); + } + + validation::validate_optional_rfc3339( + &backup_pitr.pitr_target, + path, + backup_pitr.backup_ref.as_str(), + )?; + + validation::ensure_known_evidence_refs(path, evidence_ids, &backup_pitr.evidence_refs) +} + +fn validate_recovery_degraded_read( + degraded_read: &RecoveryDegradedRead, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if degraded_read.unavailable_labels.is_empty() || degraded_read.evidence_refs.is_empty() { + return Err(eyre::eyre!("{} has incomplete degraded-read drill evidence.", path.display())); + } + if !degraded_read.source_of_truth_visible { + return Err(eyre::eyre!( + "{} has hidden source-of-truth records during degraded read.", + path.display() + )); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, °raded_read.evidence_refs) +} + +fn validate_recovery_measurement( + label: &str, + measurement: &RecoveryMeasurement, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if !measurement.target_seconds.is_finite() + || !measurement.measured_seconds.is_finite() + || measurement.target_seconds < 0.0 + || measurement.measured_seconds < 0.0 + || measurement.evidence_refs.is_empty() + { + return Err(eyre::eyre!("{} has invalid {label} recovery measurement.", path.display())); + } + if !validation::recovery_measurement_met(measurement) { + return Err(eyre::eyre!("{} exceeded {label} recovery target.", path.display())); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, &measurement.evidence_refs) +} + +fn validate_recovery_authority_record_counts( + drill: &AuthorityRecoveryDrillArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + let present_planes = drill + .authority_record_counts + .iter() + .map(|count| count.plane.as_str()) + .collect::>(); + + for plane in REQUIRED_AUTHORITY_PLANES { + if !present_planes.contains(plane) { + return Err(eyre::eyre!( + "{} authority recovery drill {} is missing {} authority counts.", + path.display(), + drill.drill_id, + plane + )); + } + } + for count in &drill.authority_record_counts { + if count.plane.trim().is_empty() || count.evidence_refs.is_empty() { + return Err(eyre::eyre!( + "{} authority recovery drill {} has incomplete authority record counts.", + path.display(), + drill.drill_id + )); + } + if count.before_count != count.after_count { + return Err(eyre::eyre!( + "{} authority recovery drill {} lost or gained {} authority records.", + path.display(), + drill.drill_id, + count.plane + )); + } + if !count.source_refs_preserved { + return Err(eyre::eyre!( + "{} authority recovery drill {} did not preserve {} authority source refs.", + path.display(), + drill.drill_id, + count.plane + )); + } + if !count.lifecycle_history_preserved { + return Err(eyre::eyre!( + "{} authority recovery drill {} did not preserve {} authority lifecycle history.", + path.display(), + drill.drill_id, + count.plane + )); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, &count.evidence_refs)?; + } + + Ok(()) +} + +fn validate_recovery_outbox_replay( + replay: &RecoveryOutboxReplay, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if replay.evidence_refs.is_empty() || !validation::recovery_outbox_replay_succeeded(replay) { + return Err(eyre::eyre!("{} has incomplete outbox replay drill evidence.", path.display())); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, &replay.evidence_refs) +} + +fn validate_recovery_qdrant_rebuild( + rebuild: &RecoveryQdrantRebuild, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if rebuild.evidence_refs.is_empty() || !validation::recovery_qdrant_rebuild_succeeded(rebuild) { + return Err(eyre::eyre!( + "{} has incomplete Qdrant rebuild drill evidence.", + path.display() + )); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, &rebuild.evidence_refs) +} + +fn validate_recovery_migration_repair( + repair: &RecoveryMigrationRepair, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if repair.evidence_refs.is_empty() || !validation::recovery_migration_repair_succeeded(repair) { + return Err(eyre::eyre!( + "{} has incomplete migration repair drill evidence.", + path.display() + )); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, &repair.evidence_refs) +} + +fn validate_recovery_dead_letter( + dead_letter: &RecoveryDeadLetterHandling, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if dead_letter.evidence_refs.is_empty() + || !validation::recovery_dead_letter_succeeded(dead_letter) + { + return Err(eyre::eyre!( + "{} has incomplete dead-letter handling drill evidence.", + path.display() + )); + } + + validation::ensure_known_evidence_refs(path, evidence_ids, &dead_letter.evidence_refs) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/scheduled.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/scheduled.rs new file mode 100644 index 00000000..80114a98 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/scheduled.rs @@ -0,0 +1,177 @@ +use crate::validation::{ + self, BTreeSet, Path, Result, ScheduledMemoryExecutionTrace, ScheduledMemoryOutput, + ScheduledMemoryTaskArtifact, eyre, +}; + +pub(super) fn validate_scheduled_memory_artifact( + task: &ScheduledMemoryTaskArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if task.task_run_id.trim().is_empty() + || task.contract_schema != "elf.scheduled_memory_task/v1" + || task.generated_at.trim().is_empty() + || task.scheduled_for.trim().is_empty() + || task.tenant_id.trim().is_empty() + || task.project_id.trim().is_empty() + || task.agent_id.trim().is_empty() + || task.read_profile.trim().is_empty() + || task.task_kind.trim().is_empty() + || task.outputs.is_empty() + { + return Err(eyre::eyre!("{} has an incomplete scheduled memory task.", path.display())); + } + if !validation::is_scheduled_task_kind(task.task_kind.as_str()) { + return Err(eyre::eyre!( + "{} has unknown scheduled task kind {}.", + path.display(), + task.task_kind + )); + } + + validation::validate_optional_rfc3339(&task.generated_at, path, task.task_run_id.as_str())?; + validation::validate_optional_rfc3339(&task.scheduled_for, path, task.task_run_id.as_str())?; + + for output in &task.outputs { + validate_scheduled_memory_output(output, path, evidence_ids)?; + } + for mutation in &task.source_mutations { + if !mutation.is_object() { + return Err(eyre::eyre!( + "{} scheduled memory source mutations must be JSON objects.", + path.display() + )); + } + } + for flag in &task.unsupported_claim_flags { + if !flag.is_object() { + return Err(eyre::eyre!( + "{} scheduled memory unsupported-claim flags must be JSON objects.", + path.display() + )); + } + } + + validation::validate_memory_summary_source_trace(&task.source_trace, path, evidence_ids)?; + + if let Some(trace) = &task.execution_trace { + validate_scheduled_memory_trace(trace, path, evidence_ids)?; + } + + Ok(()) +} + +fn validate_scheduled_memory_output( + output: &ScheduledMemoryOutput, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if output.output_id.trim().is_empty() + || output.output_kind.trim().is_empty() + || output.text.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete scheduled memory output.", path.display())); + } + if !validation::is_scheduled_task_kind(output.output_kind.as_str()) { + return Err(eyre::eyre!( + "{} has unknown scheduled output kind {}.", + path.display(), + output.output_kind + )); + } + if !validation::is_memory_summary_freshness_status(output.freshness.status.as_str()) { + return Err(eyre::eyre!( + "{} has unknown scheduled output freshness status {}.", + path.display(), + output.freshness.status + )); + } + if !validation::is_proactive_action_decision(output.action.decision.as_str()) { + return Err(eyre::eyre!( + "{} has unknown scheduled output action decision {}.", + path.display(), + output.action.decision + )); + } + if output.action.reason_code.trim().is_empty() || output.action.reason.trim().is_empty() { + return Err(eyre::eyre!( + "{} has incomplete scheduled output action rationale.", + path.display() + )); + } + + for evidence_id in &output.evidence_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for evidence_id in &output.freshness.tombstone_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + for flag in &output.unsupported_claim_flags { + if !flag.is_object() { + return Err(eyre::eyre!( + "{} scheduled output unsupported-claim flags must be JSON objects.", + path.display() + )); + } + } + + validation::validate_optional_summary_time( + path, + output.freshness.observed_at.as_deref(), + output.output_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + output.freshness.valid_from.as_deref(), + output.output_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + output.freshness.valid_to.as_deref(), + output.output_id.as_str(), + )?; + validation::validate_optional_summary_time( + path, + output.freshness.last_confirmed_at.as_deref(), + output.output_id.as_str(), + )?; + + Ok(()) +} + +fn validate_scheduled_memory_trace( + trace: &ScheduledMemoryExecutionTrace, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if trace.trace_id.trim().is_empty() + || trace.trigger_kind.trim().is_empty() + || trace.status.trim().is_empty() + || trace.started_at.trim().is_empty() + || trace.completed_at.trim().is_empty() + || trace.output_ref.trim().is_empty() + { + return Err(eyre::eyre!( + "{} has an incomplete scheduled memory execution trace.", + path.display() + )); + } + + validation::validate_optional_rfc3339(&trace.started_at, path, trace.trace_id.as_str())?; + validation::validate_optional_rfc3339(&trace.completed_at, path, trace.trace_id.as_str())?; + + for stage in &trace.stages { + if stage.stage_name.trim().is_empty() || stage.summary.trim().is_empty() { + return Err(eyre::eyre!( + "{} has an incomplete scheduled memory trace stage.", + path.display() + )); + } + + for evidence_id in &stage.evidence_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_id)?; + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/trace.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/trace.rs new file mode 100644 index 00000000..de7fa668 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/trace.rs @@ -0,0 +1,66 @@ +use crate::validation::{ + self, BTreeSet, Path, RealWorldJob, Result, TraceStageExplainability, eyre, +}; + +pub(super) fn validate_trace_explainability(job: &RealWorldJob, path: &Path) -> Result<()> { + let Some(trace) = job + .corpus + .adapter_response + .as_ref() + .and_then(|response| response.answer.trace_explainability.as_ref()) + else { + return Ok(()); + }; + let known = validation::corpus_evidence_ids(job); + let stage_names = + trace.stages.iter().map(|stage| stage.stage_name.as_str()).collect::>(); + + if trace.trace_id.as_deref().is_some_and(str::is_empty) { + return Err(eyre::eyre!("{} has an empty trace_explainability trace_id.", path.display())); + } + if trace.failure_stage.as_deref().is_some_and(str::is_empty) { + return Err(eyre::eyre!( + "{} has an empty trace_explainability failure_stage.", + path.display() + )); + } + + if let Some(failure_stage) = trace.failure_stage.as_deref() + && !stage_names.is_empty() + && !stage_names.contains(failure_stage) + { + return Err(eyre::eyre!( + "{} trace_explainability failure_stage {} is not present in stages.", + path.display(), + failure_stage + )); + } + + for stage in &trace.stages { + validate_trace_stage(stage, &known, path)?; + } + + Ok(()) +} + +fn validate_trace_stage( + stage: &TraceStageExplainability, + known: &BTreeSet, + path: &Path, +) -> Result<()> { + if stage.stage_name.trim().is_empty() { + return Err(eyre::eyre!("{} has a trace stage with an empty stage_name.", path.display())); + } + + for evidence_id in stage + .kept_evidence + .iter() + .chain(stage.dropped_evidence.iter()) + .chain(stage.demoted_evidence.iter()) + .chain(stage.distractor_evidence.iter()) + { + validation::ensure_known_evidence(path, known, evidence_id)?; + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_job_benchmark/validation/work_journal.rs b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/work_journal.rs new file mode 100644 index 00000000..cee84a51 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_job_benchmark/validation/work_journal.rs @@ -0,0 +1,163 @@ +use crate::validation::{ + self, BTreeSet, Path, Result, WorkJournalEntryArtifact, WorkJournalNextStepArtifact, + WorkJournalReadbackArtifact, WorkJournalWhereStoppedArtifact, eyre, +}; + +pub(super) fn validate_work_journal_readback_artifact( + readback: &WorkJournalReadbackArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if readback.readback_id.trim().is_empty() + || readback.contract_schema != "elf.work_journal/v1" + || readback.generated_at.trim().is_empty() + || readback.session_id.trim().is_empty() + || readback.tenant_id.trim().is_empty() + || readback.project_id.trim().is_empty() + || readback.agent_id.trim().is_empty() + || readback.read_profile.trim().is_empty() + || readback.items.is_empty() + { + return Err(eyre::eyre!("{} has an incomplete Work Journal readback.", path.display())); + } + + validation::validate_optional_rfc3339( + &readback.generated_at, + path, + readback.readback_id.as_str(), + )?; + + if readback.promotion_boundary.journal_entry_authority.trim().is_empty() { + return Err(eyre::eyre!( + "{} Work Journal readback {} has an incomplete promotion boundary.", + path.display(), + readback.readback_id + )); + } + + for accepted_ref in &readback.promotion_boundary.accepted_refs { + if accepted_ref.trim().is_empty() { + return Err(eyre::eyre!( + "{} Work Journal readback {} has an empty accepted ref.", + path.display(), + readback.readback_id + )); + } + } + for item in &readback.items { + validate_work_journal_entry(item, path, evidence_ids)?; + } + + if let Some(where_stopped) = &readback.where_stopped { + validate_work_journal_where_stopped(where_stopped, path, evidence_ids)?; + } + + for candidate in &readback.janitor_candidates { + if candidate.candidate_id.trim().is_empty() { + return Err(eyre::eyre!( + "{} Work Journal readback {} has an empty janitor candidate id.", + path.display(), + readback.readback_id + )); + } + + for evidence_ref in &candidate.evidence_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_ref)?; + } + } + + Ok(()) +} + +fn validate_work_journal_entry( + entry: &WorkJournalEntryArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if entry.entry_id.trim().is_empty() + || entry.family.trim().is_empty() + || entry.title.trim().is_empty() + || entry.body.trim().is_empty() + || entry.source_refs.is_empty() + { + return Err(eyre::eyre!("{} has an incomplete Work Journal entry.", path.display())); + } + + for source_ref in &entry.source_refs { + validation::ensure_known_evidence(path, evidence_ids, source_ref)?; + } + for marker_id in entry + .redaction_audit + .required_marker_ids + .iter() + .chain(entry.redaction_audit.redacted_marker_ids.iter()) + .chain(entry.redaction_audit.persisted_sensitive_marker_ids.iter()) + { + if marker_id.trim().is_empty() { + return Err(eyre::eyre!( + "{} Work Journal entry {} has an empty redaction marker id.", + path.display(), + entry.entry_id + )); + } + } + for step in entry.explicit_next_steps.iter().chain(entry.inferred_next_steps.iter()) { + validate_work_journal_next_step(step, path, evidence_ids)?; + } + for option in &entry.rejected_options { + if option.option_id.trim().is_empty() || option.text.trim().is_empty() { + return Err(eyre::eyre!( + "{} Work Journal entry {} has an incomplete rejected option.", + path.display(), + entry.entry_id + )); + } + + for evidence_ref in &option.evidence_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_ref)?; + } + } + + Ok(()) +} + +fn validate_work_journal_next_step( + step: &WorkJournalNextStepArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + if step.step_id.trim().is_empty() || step.text.trim().is_empty() || step.label.trim().is_empty() + { + return Err(eyre::eyre!("{} has an incomplete Work Journal next step.", path.display())); + } + + for evidence_ref in &step.evidence_refs { + validation::ensure_known_evidence(path, evidence_ids, evidence_ref)?; + } + + Ok(()) +} + +fn validate_work_journal_where_stopped( + where_stopped: &WorkJournalWhereStoppedArtifact, + path: &Path, + evidence_ids: &BTreeSet, +) -> Result<()> { + for evidence_ref in where_stopped + .decision_rationale_evidence_ids + .iter() + .chain(where_stopped.handoff_source_refs.iter()) + { + validation::ensure_known_evidence(path, evidence_ids, evidence_ref)?; + } + for claim in &where_stopped.journal_only_authority_claims { + if claim.trim().is_empty() { + return Err(eyre::eyre!( + "{} has an empty Work Journal journal-only authority claim.", + path.display() + )); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter.rs b/apps/elf-eval/src/bin/real_world_live_adapter.rs index b7b92c33..625c73f3 100644 --- a/apps/elf-eval/src/bin/real_world_live_adapter.rs +++ b/apps/elf-eval/src/bin/real_world_live_adapter.rs @@ -2,29 +2,58 @@ //! Live adapter materializer for the real-world job benchmark. +#[path = "real_world_live_adapter/capture.rs"] mod capture; +#[path = "real_world_live_adapter/consolidation_adapter.rs"] mod consolidation_adapter; +#[path = "real_world_live_adapter/dreaming_readback.rs"] mod dreaming_readback; +#[path = "real_world_live_adapter/elf_domain_materializers.rs"] mod elf_domain_materializers; +#[path = "real_world_live_adapter/elf_runtime.rs"] mod elf_runtime; +#[path = "real_world_live_adapter/evidence_selection.rs"] mod evidence_selection; +#[path = "real_world_live_adapter/fixtures.rs"] mod fixtures; +#[path = "real_world_live_adapter/ingestion.rs"] mod ingestion; +#[path = "real_world_live_adapter/knowledge_adapter.rs"] mod knowledge_adapter; +#[path = "real_world_live_adapter/lightrag.rs"] mod lightrag; +#[path = "real_world_live_adapter/materialization.rs"] mod materialization; +#[path = "real_world_live_adapter/model.rs"] mod model; +#[path = "real_world_live_adapter/operator_debug.rs"] mod operator_debug; +#[path = "real_world_live_adapter/output.rs"] mod output; +#[path = "real_world_live_adapter/qmd.rs"] mod qmd; +#[path = "real_world_live_adapter/runtime_support.rs"] mod runtime_support; +#[path = "real_world_live_adapter/service_runtime.rs"] mod service_runtime; + use std::{ collections::{BTreeSet, HashMap}, - env, - fs::{self, OpenOptions}, - io::Write as _, + env, fs, path::{Path, PathBuf}, - process::{Command, Stdio}, + process::Command, sync::Arc, - time::{Duration, Instant}, + time::Instant, }; use ::time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use blake3::Hasher; use clap::{Parser, Subcommand, ValueEnum}; -use color_eyre::{self, eyre}; -use reqwest::RequestBuilder; +use color_eyre::{self, Result, eyre}; use serde::{Deserialize, Serialize}; -use serde_json::{self, Map}; -use tokio::{task::JoinSet, time}; +use serde_json::{self, Map, Value}; +use tokio::task::JoinSet; use uuid::Uuid; +#[cfg(test)] use capture::capture_runtime_evidence_from_source_refs; +use capture::{ + apply_capture_runtime_source_refs, capture_action_str, capture_for_job, + capture_runtime_evidence_from_search_items, capture_with_runtime_source_refs, + elf_stored_corpus_texts, validate_capture_runtime_evidence, write_policy_from_value, +}; +use consolidation_adapter::{ + consolidation_materialization_evidence, consolidation_review_action, + live_consolidation_fixture, live_consolidation_response, live_note_ids, + prepare_consolidation_run, validate_reviewed_consolidation_count, +}; +use dreaming_readback::{ + materialize_elf_dreaming_readback, search_response_evidence_ids, + suite_materialization_selection, +}; use elf_chunking::ChunkingConfig; -use elf_config::{Config, EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; +use elf_config::{EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; use elf_domain::{ consolidation::{ ConsolidationApplyIntent, ConsolidationInputRef, ConsolidationLineage, ConsolidationMarker, @@ -33,5123 +62,69 @@ use elf_domain::{ ConsolidationUnsupportedClaimFlag, }, knowledge::KnowledgePageKind, - writegate::{self, WritePolicy}, }; +use elf_domain_materializers::{materialize_elf_consolidation, materialize_elf_knowledge}; use elf_service::{ AddNoteInput, AddNoteRequest, BoxFuture, ConsolidationProposalInput, ConsolidationProposalResponse, ConsolidationProposalReviewRequest, ConsolidationProposalsListRequest, ConsolidationRunCreateRequest, ElfService, EmbeddingProvider, ExtractorProvider, KnowledgePageLintRequest, KnowledgePageLintResponse, KnowledgePageRebuildRequest, KnowledgePageResponse, KnowledgePageSearchRequest, ListRequest, - PayloadLevel, Providers, RerankProvider, SearchItem, SearchRequest, SearchResponse, + PayloadLevel, RerankProvider, SearchItem, SearchRequest, SearchResponse, }; use elf_storage::{db::Db, qdrant::QdrantStore}; use elf_testkit::TestDatabase; use elf_worker::worker::{self, WorkerState}; +use evidence_selection::{ + answer_claims, elf_selected_evidence_text, expected_claim_text, live_required_evidence_ids, + required_evidence_satisfied, selected_required_corpus_texts, +}; +use fixtures::{corpus_texts, load_jobs, read_dir_paths}; +use ingestion::ingest_elf_corpus; +use knowledge_adapter::{ + knowledge_materialization_evidence, knowledge_page_artifact, stale_trap_evidence_ids, +}; +use materialization::{ + declared_encoding_job, is_elf_dreaming_readback_live_adapter, materialized_declared_status_job, + materialized_job, not_encoded_job, +}; +#[cfg(test)] use model::LiveCapturePolicy; +use model::{ + AGENT_ID, AdapterKind, AdapterResponseOutput, AnswerOutput, Args, BaselineRuntime, + CaptureMaterializationEvidence, CaptureRuntimeEvidence, CaptureRuntimeEvidenceItem, + CaptureRuntimeSourceRefEvidence, CommandArgs, CommandEvidence, + ConsolidationMaterializationEvidence, CorpusText, CostOutput, DeterministicEmbedding, + DreamingReadbackMaterializationEvidence, DreamingReadbackOutput, ELF_NOTE_CHUNK_CHARS, + EVIDENCE_SCHEMA, ElfArgs, IngestedCorpus, JOB_SCHEMA, KnowledgeMaterializationEvidence, + LightragArgs, LightragSource, LiveCaptureAction, LiveConsolidationFixture, + LiveConsolidationProposal, LiveExpectedClaim, LiveJob, LiveMemoryEvolution, LoadedJob, + MaterializationEvidence, MaterializationStatus, MaterializedJob, MaterializedJobEvidence, + MaterializedJobInput, MaterializedOutput, NoopExtractor, OperatorDebugMaterializationEvidence, + PreparedConsolidationRun, QmdArgs, SCOPE, SelectedEvidenceText, SourceMappingEvidence, + SuiteMaterializationSelection, SuiteMaterializationSelectionInput, TENANT_ID, + TemporalReconciliationMaterializationEvidence, TemporalReconciliationSelection, + TokenOverlapRerank, TraceExplainabilityOutput, TraceStageOutput, +}; +use operator_debug::{elf_replay_command, operator_debug_output, qmd_replay_command}; +use output::{aggregate_status, failure_jobs, write_materialized_output}; +use runtime_support::{ + deterministic_providers, embed_text, normalize_ascii_alnum_lowercase, note_text_chunks, + project_id_for_job, push_unique, run_logged_command, run_logged_shell, run_qmd_command, + runtime_config, short_hash, slug, terms, +}; +use service_runtime::{build_service, run_worker}; -const JOB_SCHEMA: &str = "elf.real_world_job/v1"; -const EVIDENCE_SCHEMA: &str = "elf.real_world_live_adapter_materialization/v1"; -const TENANT_ID: &str = "elf-live-real-world"; -const AGENT_ID: &str = "elf-live-real-world-agent"; -const SCOPE: &str = "agent_private"; -const ELF_NOTE_CHUNK_CHARS: usize = 220; - -#[derive(Debug, Parser)] -#[command(version = elf_cli::VERSION, rename_all = "kebab", styles = elf_cli::styles())] -struct Args { - #[command(subcommand)] - command: CommandArgs, -} - -#[derive(Debug, Parser)] -struct ElfArgs { - /// Fixture file or directory containing real_world_job JSON fixtures. - #[arg(long, value_name = "PATH")] - fixtures: PathBuf, - /// Directory where generated real_world_job fixtures are written. - #[arg(long, value_name = "DIR")] - out_fixtures: PathBuf, - /// JSON evidence file for adapter setup/run/result details. - #[arg(long, value_name = "FILE")] - evidence_out: PathBuf, - /// ELF config loaded before Docker runtime overrides are applied. - #[arg(long, short = 'c', value_name = "FILE")] - config: PathBuf, - /// Adapter id embedded in generated adapter_response objects. - #[arg(long, default_value = "elf_live_real_world")] - adapter_id: String, -} - -#[derive(Debug, Parser)] -struct QmdArgs { - /// Fixture file or directory containing real_world_job JSON fixtures. - #[arg(long, value_name = "PATH")] - fixtures: PathBuf, - /// Directory where generated real_world_job fixtures are written. - #[arg(long, value_name = "DIR")] - out_fixtures: PathBuf, - /// JSON evidence file for adapter setup/run/result details. - #[arg(long, value_name = "FILE")] - evidence_out: PathBuf, - /// qmd checkout directory. The materializer clones into it when missing. - #[arg(long, value_name = "DIR")] - qmd_dir: PathBuf, - /// Work directory for qmd home, corpus files, and command logs. - #[arg(long, value_name = "DIR")] - work_dir: PathBuf, - /// qmd repository URL used when qmd_dir is absent. - #[arg(long, default_value = "https://github.com/tobi/qmd.git")] - qmd_repo_url: String, - /// Adapter id embedded in generated adapter_response objects. - #[arg(long, default_value = "qmd_live_real_world")] - adapter_id: String, -} - -#[derive(Debug, Parser)] -struct LightragArgs { - /// Fixture file or directory containing real_world_job JSON fixtures. - #[arg(long, value_name = "PATH")] - fixtures: PathBuf, - /// Directory where generated real_world_job fixtures are written. - #[arg(long, value_name = "DIR")] - out_fixtures: PathBuf, - /// JSON evidence file for adapter setup/run/result details. - #[arg(long, value_name = "FILE")] - evidence_out: PathBuf, - /// Work directory for generated source files and command logs. - #[arg(long, value_name = "DIR")] - work_dir: PathBuf, - /// LightRAG API base URL reachable from the Docker runner. - #[arg(long, default_value = "http://lightrag:9621")] - api_base: String, - /// Optional LightRAG API bearer token. - #[arg(long)] - api_key: Option, - /// Adapter id embedded in generated adapter_response objects. - #[arg(long, default_value = "lightrag_live_real_world")] - adapter_id: String, - /// LightRAG query mode used for context export. - #[arg(long, default_value = "naive")] - query_mode: String, - /// Number of top results requested from LightRAG. - #[arg(long, default_value_t = 5)] - top_k: u32, - /// Number of chunk results requested from LightRAG. - #[arg(long, default_value_t = 5)] - chunk_top_k: u32, - /// Health-check attempts before returning a typed runtime failure. - #[arg(long, default_value_t = 30)] - startup_attempts: u32, - /// Delay between LightRAG health-check attempts. - #[arg(long, default_value_t = 2)] - startup_interval_seconds: u64, - /// Poll attempts for asynchronous document indexing. - #[arg(long, default_value_t = 60)] - index_attempts: u32, - /// Delay between document indexing status checks. - #[arg(long, default_value_t = 2)] - index_interval_seconds: u64, -} - -#[derive(Debug)] -struct LoadedJob { - path: PathBuf, - value: serde_json::Value, - job: LiveJob, -} - -#[derive(Debug, Deserialize)] -struct LiveJob { - schema: String, - job_id: String, - suite: String, - title: String, - corpus: LiveCorpus, - prompt: LivePrompt, - expected_answer: LiveExpectedAnswer, - #[serde(default)] - required_evidence: Vec, - #[serde(default)] - encoding: LiveEncoding, - memory_evolution: Option, -} - -#[derive(Debug, Deserialize)] -struct LiveCorpus { - #[serde(default)] - items: Vec, -} - -#[derive(Debug, Deserialize)] -struct LiveCorpusItem { - evidence_id: String, - text: Option, - local_ref: Option, - #[serde(default)] - capture: LiveCapturePolicy, -} - -#[derive(Clone, Debug, Default, Deserialize)] -struct LiveCapturePolicy { - #[serde(default)] - action: LiveCaptureAction, - - source_id: Option, - - evidence_binding: Option, - - write_policy: Option, -} - -#[derive(Debug, Deserialize)] -struct LivePrompt { - content: String, -} - -#[derive(Debug, Deserialize)] -struct LiveExpectedAnswer { - #[serde(default)] - must_include: Vec, - #[serde(default)] - evidence_links: Map, -} - -#[derive(Debug, Deserialize)] -struct LiveRequiredEvidence { - evidence_id: String, -} - -#[derive(Debug, Default, Deserialize)] -struct LiveMemoryEvolution { - #[serde(default)] - current_evidence_ids: Vec, - #[serde(default)] - historical_evidence_ids: Vec, - #[serde(default)] - tombstone_evidence_ids: Vec, - #[serde(default)] - invalidation_evidence_ids: Vec, - #[serde(default)] - conflicts: Vec, - update_rationale: Option, -} - -#[derive(Debug, Deserialize)] -struct LiveEvolutionConflict { - claim_id: String, - current_evidence_id: String, - historical_evidence_id: String, - resolved_by_evidence_id: Option, -} - -#[derive(Debug, Deserialize)] -struct LiveUpdateRationale { - claim_id: String, - #[serde(default)] - evidence_ids: Vec, - available: bool, -} - -#[derive(Debug, Default, Deserialize)] -struct LiveEncoding { - status: Option, - reason: Option, -} - -#[derive(Debug, Serialize)] -struct MaterializationEvidence { - schema: &'static str, - adapter_id: String, - adapter_kind: AdapterKind, - status: MaterializationStatus, - fixtures: String, - generated_fixtures: String, - command_evidence: Vec, - jobs: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option, -} - -#[derive(Debug, Serialize)] -struct CommandEvidence { - label: String, - status: MaterializationStatus, - command: String, - artifact: Option, - reason: String, -} - -#[derive(Debug, Serialize)] -struct MaterializedJobEvidence { - job_id: String, - suite: String, - title: String, - status: MaterializationStatus, - query: String, - evidence_ids: Vec, - returned_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - indexing_latency_ms: Option, - latency_ms: f64, - trace_id: Option, - failure: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - source_mappings: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - operator_debug: Option, - #[serde(skip_serializing_if = "Option::is_none")] - capture: Option, - #[serde(skip_serializing_if = "Option::is_none")] - consolidation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - knowledge: Option, - #[serde(skip_serializing_if = "Option::is_none")] - temporal_reconciliation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - dreaming_readback: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct OperatorDebugMaterializationEvidence { - trace_available: bool, - replay_command_available: bool, - candidate_drop_visibility: String, - repair_action_clarity: String, - raw_sql_needed: bool, -} - -#[derive(Clone, Debug, Default, Serialize)] -struct CaptureMaterializationEvidence { - stored_evidence_ids: Vec, - excluded_evidence_ids: Vec, - source_ids: Vec, - write_policy_audit_count: usize, - write_policy_exclusion_count: usize, - write_policy_redaction_count: usize, - #[serde(skip_serializing_if = "Vec::is_empty")] - runtime_source_refs: Vec, -} - -#[derive(Clone, Debug, Default, Serialize)] -struct ConsolidationMaterializationEvidence { - run_id: Option, - proposal_ids: Vec, - source_lineage_count: usize, - unsupported_claim_flag_count: usize, - review_event_count: usize, - review_actions: Vec, - final_review_states: Vec, -} - -#[derive(Clone, Debug, Default, Serialize)] -struct KnowledgeMaterializationEvidence { - page_ids: Vec, - search_result_count: usize, - lint_finding_count: usize, - stale_source_finding_count: usize, - unsupported_claim_count: usize, - citation_count: usize, - source_ref_count: usize, - version_diff_available: bool, -} - -#[derive(Clone, Debug, Default, Serialize)] -struct TemporalReconciliationMaterializationEvidence { - current_winner_evidence_ids: Vec, - historical_loser_evidence_ids: Vec, - supersession_rationale_evidence_ids: Vec, - tombstone_evidence_ids: Vec, - invalidation_evidence_ids: Vec, - conflict_candidate_evidence_ids: Vec, - retrieved_evidence_ids: Vec, - selected_evidence_ids: Vec, - absent_evidence_ids: Vec, - retrieved_but_dropped_evidence_ids: Vec, - selected_but_not_narrated_evidence_ids: Vec, - contradicted_by_lifecycle_evidence_ids: Vec, -} - -#[derive(Clone, Debug, Default, Serialize)] -struct DreamingReadbackMaterializationEvidence { - artifact_kind: String, - runtime_path: String, - service_list_count: usize, - trace_id: Option, - generated_artifact_count: usize, - selected_source_refs: Vec, - missing_source_refs: Vec, - source_mutation_count: usize, - no_source_mutation_checked: bool, -} - -#[derive(Clone, Debug, Serialize)] -struct CaptureRuntimeSourceRefEvidence { - evidence_id: String, - source_ref: serde_json::Value, -} - -#[derive(Clone, Debug, Default)] -struct CaptureRuntimeEvidence { - items: Vec, -} -impl CaptureRuntimeEvidence { - fn item_for(&self, evidence_id: &str) -> Option<&CaptureRuntimeEvidenceItem> { - self.items.iter().find(|item| item.evidence_id == evidence_id) - } -} - -#[derive(Clone, Debug)] -struct CaptureRuntimeEvidenceItem { - evidence_id: String, - source_id: Option, - evidence_binding: Option, - write_policy_applied: bool, - capture_action: Option, - source_ref: serde_json::Value, -} - -#[derive(Debug, Serialize)] -struct AdapterResponseOutput { - adapter_id: String, - answer: AnswerOutput, - #[serde(skip_serializing_if = "Option::is_none")] - consolidation: Option, -} - -#[derive(Debug, Serialize)] -struct AnswerOutput { - content: String, - evidence_ids: Vec, - claims: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pages: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - memory_summaries: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - proactive_briefs: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - scheduled_tasks: Vec, - latency_ms: f64, - cost: CostOutput, - trace_explainability: TraceExplainabilityOutput, -} - -#[derive(Debug, Serialize)] -struct CostOutput { - currency: String, - amount: f64, - input_tokens: u64, - output_tokens: u64, -} - -#[derive(Debug, Serialize)] -struct TraceExplainabilityOutput { - trace_id: Option, - failure_stage: Option, - failure_reason: Option, - stages: Vec, -} - -#[derive(Clone, Debug, Serialize)] -struct TraceStageOutput { - stage_name: String, - kept_evidence: Vec, - dropped_evidence: Vec, - demoted_evidence: Vec, - distractor_evidence: Vec, - notes: String, -} - -#[derive(Debug)] -struct MaterializedJob { - response: AdapterResponseOutput, - evidence: MaterializedJobEvidence, - operator_debug: Option, -} - -#[derive(Debug)] -struct MaterializedJobInput { - content: String, - evidence_ids: Vec, - pages: Vec, - latency_ms: f64, - indexing_latency_ms: Option, - returned_count: usize, - trace_id: Option, - failure: Option, - source_mappings: Vec, - operator_debug: Option, - operator_debug_evidence: Option, - capture: Option, - capture_failure: Option, - consolidation_response: Option, - consolidation: Option, - knowledge: Option, - temporal_reconciliation: Option, - dreaming_readback: Option, - memory_summaries: Vec, - proactive_briefs: Vec, - scheduled_tasks: Vec, - trace_stages: Option>, -} - -#[derive(Debug)] -struct DreamingReadbackOutput { - content: String, - evidence_ids: Vec, - memory_summaries: Vec, - proactive_briefs: Vec, - scheduled_tasks: Vec, - materialization: DreamingReadbackMaterializationEvidence, - trace_stages: Vec, -} - -struct SuiteMaterializationSelection { - selected: SelectedEvidenceText, - trace_stages: Option>, - dreaming_readback: Option, - memory_summaries: Vec, - proactive_briefs: Vec, - scheduled_tasks: Vec, -} - -struct MaterializedOutput<'a> { - adapter_id: &'a str, - adapter_kind: AdapterKind, - fixtures: &'a Path, - out_fixtures: &'a Path, - evidence_out: &'a Path, - jobs: &'a [LoadedJob], - materialized: &'a [MaterializedJob], - command_evidence: Vec, - metadata: Option, -} - -#[derive(Debug)] -struct CorpusText { - evidence_id: String, - text: String, - capture: LiveCapturePolicy, -} - -#[derive(Debug, Default)] -struct IngestedCorpus { - capture: CaptureMaterializationEvidence, - note_ids_by_evidence: HashMap>, -} - -#[derive(Clone, Debug, Deserialize)] -struct LiveConsolidationFixture { - #[serde(default)] - proposals: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct LiveConsolidationProposal { - proposal_id: String, - proposal_kind: String, - #[serde(default)] - source_refs: Vec, - #[serde(default)] - expected_source_refs: Vec, - usefulness_score: f64, - min_usefulness_score: f64, - expected_review_action: String, - actual_review_action: String, - #[serde(default)] - source_mutations: Vec, - #[serde(default)] - unsupported_claim_count: usize, - #[serde(default)] - unsupported_claim_flags: Vec, - #[serde(default)] - diff: serde_json::Value, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct LiveUnsupportedClaimFlag { - claim_id: Option, - message: String, - source_ref: Option, -} - -#[derive(Debug)] -struct PreparedConsolidationRun { - input_refs: Vec, - proposals: Vec, -} - -#[derive(Clone, Debug, Serialize)] -struct SourceMappingEvidence { - source: String, - evidence_ids: Vec, - mapping_status: String, - content_count: usize, -} - -#[derive(Debug)] -struct LightragSource { - evidence_id: String, - file_source: String, - artifact_path: PathBuf, -} - -#[derive(Debug)] -struct BaselineRuntime { - config_path: PathBuf, - dsn: String, - qdrant_url: String, - collection: String, - docs_collection: String, -} - -#[derive(Debug)] -struct DeterministicEmbedding { - vector_dim: u32, -} -impl EmbeddingProvider for DeterministicEmbedding { - fn embed<'a>( - &'a self, - _cfg: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, elf_service::Result>>> { - let dim = self.vector_dim; - let vectors = texts.iter().map(|text| embed_text(text, dim)).collect(); - - Box::pin(async move { Ok(vectors) }) - } -} - -#[derive(Debug)] -struct TokenOverlapRerank; -impl RerankProvider for TokenOverlapRerank { - fn rerank<'a>( - &'a self, - _cfg: &'a ProviderConfig, - query: &'a str, - docs: &'a [String], - ) -> BoxFuture<'a, elf_service::Result>> { - let query_terms = terms(query); - let scores = docs - .iter() - .map(|doc| { - let doc_terms = terms(doc); - let hits = query_terms.intersection(&doc_terms).count() as f32; - - hits / query_terms.len().max(1) as f32 - }) - .collect(); - - Box::pin(async move { Ok(scores) }) - } -} - -#[derive(Debug)] -struct NoopExtractor; -impl ExtractorProvider for NoopExtractor { - fn extract<'a>( - &'a self, - _cfg: &'a LlmProviderConfig, - _messages: &'a [serde_json::Value], - ) -> BoxFuture<'a, elf_service::Result> { - Box::pin(async move { Ok(serde_json::json!({ "notes": [] })) }) - } -} - -#[derive(Debug)] -struct SelectedEvidenceText { - content: String, - evidence_ids: Vec, -} - -#[derive(Debug)] -struct TemporalReconciliationSelection { - selected: SelectedEvidenceText, - evidence: TemporalReconciliationMaterializationEvidence, - trace_stages: Vec, -} - -struct SuiteMaterializationSelectionInput<'a> { - loaded: &'a LoadedJob, - ingested: &'a IngestedCorpus, - capture_failure: &'a Option, - selected: SelectedEvidenceText, - trace_stages: Option>, - knowledge: &'a Option, - consolidation: &'a Option, - dreaming_readback: Option, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)] -#[serde(rename_all = "snake_case")] -enum LiveCaptureAction { - #[default] - Store, - Exclude, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum LiveExpectedClaim { - Text(String), - Object { claim_id: Option, text: String }, -} -impl LiveExpectedClaim { - fn claim_id(&self) -> Option<&str> { - match self { - Self::Text(_) => None, - Self::Object { claim_id, .. } => claim_id.as_deref(), - } - } - - fn text(&self) -> &str { - match self { - Self::Text(text) => text, - Self::Object { text, .. } => text, - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -enum LiveEncodingStatus { - NotEncoded, - Blocked, - Incomplete, -} -impl LiveEncodingStatus { - fn materialization_status(self) -> MaterializationStatus { - match self { - Self::NotEncoded => MaterializationStatus::NotEncoded, - Self::Blocked => MaterializationStatus::Blocked, - Self::Incomplete => MaterializationStatus::Incomplete, - } - } - - fn as_str(self) -> &'static str { - match self { - Self::NotEncoded => "not_encoded", - Self::Blocked => "blocked", - Self::Incomplete => "incomplete", - } - } -} - -#[derive(Debug, Subcommand)] -#[command(rename_all = "kebab")] -enum CommandArgs { - /// Materialize adapter responses by running jobs through ELF's service runtime. - Elf(ElfArgs), - /// Materialize adapter responses by running jobs through qmd's local CLI workflow. - Qmd(QmdArgs), - /// Materialize adapter responses by exporting LightRAG query context and source mappings. - Lightrag(LightragArgs), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -enum AdapterKind { - ElfServiceRuntime, - QmdCliRuntime, - LightragApiContextExport, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum MaterializationStatus { - Pass, - WrongResult, - Blocked, - Incomplete, - NotEncoded, -} - -fn run_qmd(args: QmdArgs) -> color_eyre::Result<()> { - let jobs = load_jobs(&args.fixtures)?; - let result = materialize_qmd_jobs(&args, &jobs); - let materialized = match result { - Ok(jobs) => jobs, - Err(err) => failure_jobs(&args.adapter_id, &jobs, "qmd_cli_runtime", err.to_string()), - }; - - write_materialized_output(MaterializedOutput { - adapter_id: &args.adapter_id, - adapter_kind: AdapterKind::QmdCliRuntime, - fixtures: &args.fixtures, - out_fixtures: &args.out_fixtures, - evidence_out: &args.evidence_out, - jobs: &jobs, - materialized: &materialized, - command_evidence: vec![CommandEvidence { - label: "qmd_cli_runtime".to_string(), - status: aggregate_status(&materialized), - command: "cargo run -p elf-eval --bin real_world_live_adapter -- qmd".to_string(), - artifact: Some(args.evidence_out.display().to_string()), - reason: "qmd live adapter used collection add, update, embed, and query --json." - .to_string(), - }], - metadata: None, - }) -} - -fn materialize_qmd_jobs( - args: &QmdArgs, - jobs: &[LoadedJob], -) -> color_eyre::Result> { - fs::create_dir_all(&args.work_dir)?; - - let log_path = args.work_dir.join("qmd-live-real-world.log"); - - ensure_qmd_checkout(args, &log_path)?; - - let mut out = Vec::with_capacity(jobs.len()); - - for loaded in jobs { - out.push(materialize_qmd_job(args, loaded, &log_path)?); - } - - Ok(out) -} - -fn ensure_qmd_checkout(args: &QmdArgs, log_path: &Path) -> color_eyre::Result<()> { - if !args.qmd_dir.exists() { - if let Some(parent) = args.qmd_dir.parent() { - fs::create_dir_all(parent)?; - } - - run_logged_command( - "qmd clone", - Command::new("git") - .arg("clone") - .arg("--depth") - .arg("1") - .arg(&args.qmd_repo_url) - .arg(&args.qmd_dir), - log_path, - )?; - } - - run_logged_shell( - "qmd install", - &args.qmd_dir, - "(npm ci || npm install --no-audit --no-fund) && npm run build --if-present", - log_path, - ) -} - -fn materialize_qmd_job( - args: &QmdArgs, - loaded: &LoadedJob, - log_path: &Path, -) -> color_eyre::Result { - if let Some(job) = declared_encoding_job(&args.adapter_id, loaded) { - return Ok(job); - } - if let Some(job) = not_encoded_job(&args.adapter_id, loaded) { - return Ok(job); - } - - let corpus = corpus_texts(loaded)?; - let job_slug = slug(&loaded.job.job_id); - let corpus_dir = args.work_dir.join("corpus").join(&job_slug); - let home_dir = args.work_dir.join("home").join(&job_slug); - let collection = format!("elfrw-{job_slug}"); - - fs::create_dir_all(&corpus_dir)?; - fs::create_dir_all(&home_dir)?; - - for existing in read_dir_paths(&corpus_dir)? { - if existing.is_file() { - fs::remove_file(existing)?; - } - } - for item in &corpus { - let path = corpus_dir.join(format!("{}.md", slug(&item.evidence_id))); - - fs::write(path, format!("# {}\n\n{}\n", item.evidence_id, item.text))?; - } - - run_qmd_command( - "qmd collection add", - args, - &home_dir, - &[ - "collection", - "add", - corpus_dir - .to_str() - .ok_or_else(|| eyre::eyre!("qmd corpus path is not valid UTF-8."))?, - "--name", - collection.as_str(), - ], - log_path, - )?; - run_qmd_command("qmd update", args, &home_dir, &["update"], log_path)?; - run_qmd_command( - "qmd embed", - args, - &home_dir, - &["embed", "-f", "-c", collection.as_str()], - log_path, - )?; - - let started_at = Instant::now(); - let query = format!("lex: {}\nvec: {}", loaded.job.prompt.content, loaded.job.prompt.content); - let stdout = run_qmd_command( - "qmd query", - args, - &home_dir, - &[ - "query", - query.as_str(), - "-c", - collection.as_str(), - "--json", - "--no-rerank", - "--min-score", - "0", - "-n", - "5", - ], - log_path, - )?; - let latency_ms = started_at.elapsed().as_secs_f64() * 1_000.0; - let results = serde_json::from_str::(&stdout).map_err(|err| { - eyre::eyre!("qmd query did not return JSON for {}: {err}", loaded.job.job_id) - })?; - let entries = results.as_array().cloned().unwrap_or_default(); - let mut evidence_ids = Vec::new(); - - for entry in &entries { - let entry_text = serde_json::to_string(entry)?; - - for item in &corpus { - if entry_text.contains(format!("{}.md", slug(&item.evidence_id)).as_str()) - || entry_text.contains(item.evidence_id.as_str()) - { - push_unique(&mut evidence_ids, item.evidence_id.clone()); - } - } - } - - let selected = selected_required_corpus_texts(loaded, &corpus, &evidence_ids); - let replay_command = qmd_replay_command(&loaded.job.prompt.content, collection.as_str()); - let (operator_debug, operator_debug_evidence) = operator_debug_output( - AdapterKind::QmdCliRuntime, - loaded, - None, - replay_command, - log_path.display().to_string(), - ); - - Ok(qmd_materialized_job( - loaded, - &args.adapter_id, - selected, - latency_ms, - entries.len(), - operator_debug, - operator_debug_evidence, - )) -} - -fn qmd_materialized_job( - loaded: &LoadedJob, - adapter_id: &str, - selected: SelectedEvidenceText, - latency_ms: f64, - returned_count: usize, - operator_debug: Option, - operator_debug_evidence: Option, -) -> MaterializedJob { - materialized_job( - loaded, - adapter_id, - MaterializedJobInput { - content: selected.content, - evidence_ids: selected.evidence_ids, - pages: Vec::new(), - latency_ms, - indexing_latency_ms: None, - returned_count, - trace_id: None, - failure: None, - source_mappings: Vec::new(), - operator_debug, - operator_debug_evidence, - capture: None, - capture_failure: None, - consolidation_response: None, - consolidation: None, - knowledge: None, - temporal_reconciliation: None, - dreaming_readback: None, - memory_summaries: Vec::new(), - proactive_briefs: Vec::new(), - scheduled_tasks: Vec::new(), - trace_stages: None, - }, - ) -} - -fn lightrag_not_encoded_job(adapter_id: &str, loaded: &LoadedJob) -> Option { - match loaded.job.suite.as_str() { - "retrieval" => None, - _ => Some(materialized_declared_status_job( - adapter_id, - loaded, - MaterializationStatus::NotEncoded, - "LightRAG context-export smoke only maps retrieved context/source paths; this suite is not encoded for LightRAG scoring.".to_string(), - )), - } -} - -fn lightrag_failure_jobs( - adapter_id: &str, - jobs: &[LoadedJob], - stage: &str, - reason: String, -) -> Vec { - jobs.iter() - .map(|job| { - if let Some(declared) = declared_encoding_job(adapter_id, job) { - return declared; - } - if let Some(not_encoded) = lightrag_not_encoded_job(adapter_id, job) { - return not_encoded; - } - - materialized_job( - job, - adapter_id, - MaterializedJobInput { - content: String::new(), - evidence_ids: Vec::new(), - pages: Vec::new(), - latency_ms: 0.0, - indexing_latency_ms: None, - returned_count: 0, - trace_id: None, - failure: Some(format!("{stage}: {reason}")), - source_mappings: Vec::new(), - operator_debug: None, - operator_debug_evidence: None, - capture: None, - capture_failure: None, - consolidation_response: None, - consolidation: None, - knowledge: None, - temporal_reconciliation: None, - dreaming_readback: None, - memory_summaries: Vec::new(), - proactive_briefs: Vec::new(), - scheduled_tasks: Vec::new(), - trace_stages: None, - }, - ) - }) - .collect() -} - -fn write_lightrag_corpus( - args: &LightragArgs, - loaded: &LoadedJob, - corpus: &[CorpusText], - run_slug: &str, -) -> color_eyre::Result> { - let job_slug = slug(&loaded.job.job_id); - let corpus_dir = args.work_dir.join("corpus").join(run_slug).join(&job_slug); - - fs::create_dir_all(&corpus_dir)?; - - corpus - .iter() - .map(|item| { - let file_name = format!("{}.md", slug(&item.evidence_id)); - let artifact_path = corpus_dir.join(&file_name); - let file_source = format!("elf-real-world/{run_slug}/{job_slug}/{file_name}"); - - fs::write(&artifact_path, format!("# {}\n\n{}\n", item.evidence_id, item.text))?; - - Ok(LightragSource { evidence_id: item.evidence_id.clone(), file_source, artifact_path }) - }) - .collect() -} - -fn lightrag_index_failed(status: &serde_json::Value) -> bool { - status.get("documents").and_then(serde_json::Value::as_array).into_iter().flatten().any(|doc| { - doc.get("status") - .and_then(serde_json::Value::as_str) - .is_some_and(|status| status.to_ascii_lowercase().contains("fail")) - }) -} - -fn lightrag_index_processed(status: &serde_json::Value, expected_docs: usize) -> bool { - let Some(documents) = status.get("documents").and_then(serde_json::Value::as_array) else { - return false; - }; - - documents.len() >= expected_docs - && documents.iter().all(|doc| { - doc.get("status").and_then(serde_json::Value::as_str).is_some_and(|status| { - let normalized = status.to_ascii_lowercase(); - - normalized.contains("processed") || normalized.contains("success") - }) - }) -} - -fn lightrag_keywords(query: &str) -> Vec { - terms(query).into_iter().take(12).collect() -} - -fn lightrag_source_mappings( - corpus: &[CorpusText], - sources: &[LightragSource], - response: &serde_json::Value, -) -> Vec { - let mut mappings = Vec::new(); - - if let Some(references) = response.get("references").and_then(serde_json::Value::as_array) { - for reference in references { - mappings.push(lightrag_reference_mapping(corpus, sources, reference)); - } - } - - if mappings.is_empty() - && let Some(context) = response.get("response").and_then(serde_json::Value::as_str) - { - let evidence_ids = map_lightrag_evidence_ids(corpus, sources, context); - - if !evidence_ids.is_empty() { - mappings.push(SourceMappingEvidence { - source: "response_context".to_string(), - evidence_ids, - mapping_status: "matched_context".to_string(), - content_count: 1, - }); - } - } - - mappings -} - -fn lightrag_reference_mapping( - corpus: &[CorpusText], - sources: &[LightragSource], - reference: &serde_json::Value, -) -> SourceMappingEvidence { - let source = reference - .get("file_path") - .and_then(serde_json::Value::as_str) - .or_else(|| reference.get("reference_id").and_then(serde_json::Value::as_str)) - .unwrap_or("unknown_source") - .to_string(); - let content = reference - .get("content") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - .collect::>(); - let joined_content = content.join("\n"); - let combined = format!("{source}\n{joined_content}"); - let evidence_ids = map_lightrag_evidence_ids(corpus, sources, combined.as_str()); - let mapping_status = if evidence_ids.is_empty() { - "unmatched" - } else if !joined_content.is_empty() { - "matched_reference_content" - } else { - "matched_reference_source" - }; - - SourceMappingEvidence { - source, - evidence_ids, - mapping_status: mapping_status.to_string(), - content_count: content.len(), - } -} - -fn map_lightrag_evidence_ids( - corpus: &[CorpusText], - sources: &[LightragSource], - haystack: &str, -) -> Vec { - let normalized_haystack = normalize_ascii_alnum_lowercase(haystack); - let mut evidence_ids = Vec::new(); - - for item in corpus { - let evidence_slug = slug(&item.evidence_id); - let signature = normalized_text_signature(item.text.as_str()); - let source_match = sources.iter().any(|source| { - source.evidence_id == item.evidence_id - && (haystack.contains(source.file_source.as_str()) - || haystack.contains(source.artifact_path.to_string_lossy().as_ref())) - }); - let id_match = haystack.contains(item.evidence_id.as_str()) - || haystack.contains(evidence_slug.as_str()) - || normalized_haystack.contains(evidence_slug.as_str()); - let content_match = - !signature.is_empty() && normalized_haystack.contains(signature.as_str()); - - if source_match || id_match || content_match { - push_unique(&mut evidence_ids, item.evidence_id.clone()); - } - } - - evidence_ids -} - -fn normalized_text_signature(text: &str) -> String { - normalize_ascii_alnum_lowercase(text).split_whitespace().take(8).collect::>().join(" ") -} - -fn lightrag_mapped_evidence_ids(mappings: &[SourceMappingEvidence]) -> Vec { - let mut evidence_ids = Vec::new(); - - for mapping in mappings { - for evidence_id in &mapping.evidence_ids { - push_unique(&mut evidence_ids, evidence_id.clone()); - } - } - - evidence_ids -} - -fn lightrag_api_base(args: &LightragArgs) -> String { - args.api_base.trim_end_matches('/').to_string() -} - -fn lightrag_metadata(args: &LightragArgs, run_slug: &str) -> serde_json::Value { - serde_json::json!({ - "schema": "elf.lightrag_context_export_metadata/v1", - "run_slug": run_slug, - "api_base": lightrag_api_base(args), - "query": { - "mode": args.query_mode, - "only_need_context": true, - "include_references": true, - "include_chunk_content": true, - "enable_rerank": false, - "top_k": args.top_k, - "chunk_top_k": args.chunk_top_k - }, - "docker_boundary": { - "compose_file": "docker-compose.baseline.yml", - "service_profile": "lightrag", - "service": "lightrag", - "mock_provider_service": "lightrag-mock-provider", - "host_global_installs_required": false, - "workspace": "/app/data/rag_storage", - "input_dir": "/app/data/inputs", - "data_volumes": [ - "elf-live-baseline-lightrag-rag-storage", - "elf-live-baseline-lightrag-inputs", - "elf-live-baseline-lightrag-prompts" - ] - }, - "provider_boundaries": { - "llm_binding": "openai-compatible", - "embedding_binding": "openai-compatible", - "embedding_dim": 64, - "rerank_binding": "cohere-compatible", - "rerank_enabled_for_query": false, - "api_key_provided": args.api_key.as_deref().is_some_and(|key| !key.is_empty()), - "operator_owned_provider_credentials_used": false - }, - "cache_and_resource_envelope": { - "cargo_cache": "/usr/local/cargo", - "pip_cache": "/root/.cache/pip", - "huggingface_cache": "/root/.cache/huggingface", - "lightrag_storage": "/app/data/rag_storage", - "startup_attempts": args.startup_attempts, - "startup_interval_seconds": args.startup_interval_seconds, - "index_attempts": args.index_attempts, - "index_interval_seconds": args.index_interval_seconds - }, - "source_mapping": { - "corpus_file_source_template": "elf-real-world/{run_slug}/{job_slug}/{evidence_id}.md", - "mapping_inputs": ["references.file_path", "references.content", "response"], - "quality_claim": "none" - } - }) -} - -fn materialized_job( - loaded: &LoadedJob, - adapter_id: &str, - input: MaterializedJobInput, -) -> MaterializedJob { - let capture_failure = input.capture_failure.clone(); - let required_evidence_satisfied = - capture_failure.is_none() && required_evidence_satisfied(loaded, &input.evidence_ids); - let status = if input.failure.is_some() { - MaterializationStatus::Incomplete - } else if !required_evidence_satisfied { - MaterializationStatus::WrongResult - } else { - MaterializationStatus::Pass - }; - let failure_stage = if input.failure.is_some() { - Some("live_adapter.retrieve".to_string()) - } else if capture_failure.is_some() { - Some("live_adapter.capture_policy".to_string()) - } else { - None - }; - let failure_reason = input.failure.clone().or(capture_failure); - let stage_notes = if let Some(reason) = &failure_reason { - reason.clone() - } else if !required_evidence_satisfied { - "Adapter did not return all required mapped evidence for this job.".to_string() - } else { - "Adapter returned mapped evidence through its live retrieval path.".to_string() - }; - let trace_stages = input.trace_stages.unwrap_or_else(|| { - vec![TraceStageOutput { - stage_name: failure_stage - .clone() - .unwrap_or_else(|| "live_adapter.retrieve".to_string()), - kept_evidence: input.evidence_ids.clone(), - dropped_evidence: Vec::new(), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: stage_notes, - }] - }); - - MaterializedJob { - response: AdapterResponseOutput { - adapter_id: adapter_id.to_string(), - answer: AnswerOutput { - content: input.content, - evidence_ids: input.evidence_ids.clone(), - claims: answer_claims(loaded, &input.evidence_ids), - pages: input.pages, - memory_summaries: input.memory_summaries, - proactive_briefs: input.proactive_briefs, - scheduled_tasks: input.scheduled_tasks, - latency_ms: input.latency_ms, - cost: CostOutput { - currency: "USD".to_string(), - amount: 0.0, - input_tokens: 0, - output_tokens: 0, - }, - trace_explainability: TraceExplainabilityOutput { - trace_id: input.trace_id.map(|id| id.to_string()), - failure_stage: failure_stage.clone(), - failure_reason: failure_reason.clone(), - stages: trace_stages, - }, - }, - consolidation: input.consolidation_response, - }, - operator_debug: input.operator_debug, - evidence: MaterializedJobEvidence { - job_id: loaded.job.job_id.clone(), - suite: loaded.job.suite.clone(), - title: loaded.job.title.clone(), - status, - query: loaded.job.prompt.content.clone(), - evidence_ids: input.evidence_ids, - returned_count: input.returned_count, - indexing_latency_ms: input.indexing_latency_ms, - latency_ms: input.latency_ms, - trace_id: input.trace_id, - failure: failure_reason, - source_mappings: input.source_mappings, - operator_debug: input.operator_debug_evidence, - capture: input.capture, - consolidation: input.consolidation, - knowledge: input.knowledge, - temporal_reconciliation: input.temporal_reconciliation, - dreaming_readback: input.dreaming_readback, - }, - } -} - -fn declared_encoding_job(adapter_id: &str, loaded: &LoadedJob) -> Option { - if is_operator_debug_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_consolidation_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_knowledge_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_capture_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - - let status = loaded.job.encoding.status?; - let reason = loaded.job.encoding.reason.clone().unwrap_or_else(|| { - format!("Fixture declares {} for this live adapter job.", status.as_str()) - }); - - Some(materialized_declared_status_job( - adapter_id, - loaded, - status.materialization_status(), - reason, - )) -} +#[tokio::main] +async fn main() -> Result<()> { + color_eyre::install()?; -fn not_encoded_job(adapter_id: &str, loaded: &LoadedJob) -> Option { - if is_operator_debug_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_consolidation_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_knowledge_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_capture_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; - } - if is_elf_dreaming_readback_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return None; + match Args::parse().command { + CommandArgs::Elf(args) => elf_runtime::run_elf(args).await, + CommandArgs::Qmd(args) => qmd::run_qmd(args), + CommandArgs::Lightrag(args) => lightrag::run_lightrag_async(args).await, } - - not_encoded_reason(loaded.job.suite.as_str()).map(|reason| { - materialized_declared_status_job( - adapter_id, - loaded, - MaterializationStatus::NotEncoded, - reason.to_string(), - ) - }) -} - -fn is_operator_debug_live_adapter(adapter_id: &str, suite: &str) -> bool { - suite == "operator_debugging_ux" - && matches!( - adapter_id, - "elf_live_real_world" - | "qmd_live_real_world" - | "elf_operator_debug_live" - | "qmd_operator_debug_live" - ) -} - -fn is_elf_consolidation_live_adapter(adapter_id: &str, suite: &str) -> bool { - suite == "consolidation" && adapter_id == "elf_live_real_world" -} - -fn is_elf_knowledge_live_adapter(adapter_id: &str, suite: &str) -> bool { - suite == "knowledge_compilation" && adapter_id == "elf_live_real_world" -} - -fn is_elf_capture_live_adapter(adapter_id: &str, suite: &str) -> bool { - suite == "capture_integration" - && matches!(adapter_id, "elf_live_real_world" | "elf_capture_write_policy_live") -} - -fn is_elf_dreaming_readback_live_adapter(adapter_id: &str, suite: &str) -> bool { - matches!(suite, "memory_summary" | "proactive_brief" | "scheduled_memory") - && matches!(adapter_id, "elf_service_native_dreaming" | "elf_live_real_world") -} - -fn not_encoded_reason(suite: &str) -> Option<&'static str> { - match suite { - "trust_source_of_truth" - | "work_resume" - | "project_decisions" - | "retrieval" - | "memory_evolution" - | "personalization" => None, - "consolidation" => Some( - "The live adapter sweep retrieves evidence-linked answers but does not generate or review consolidation proposals.", - ), - "knowledge_compilation" => Some( - "The live adapter sweep retrieves evidence-linked answers but does not generate derived knowledge pages.", - ), - "operator_debugging_ux" => Some( - "The full live adapter sweep keeps operator trace/viewer diagnostics in a focused operator-debug slice.", - ), - "capture_integration" => Some( - "The live adapter sweep does not exercise capture integrations or write-policy redaction boundaries.", - ), - "production_ops" => Some( - "The live adapter sweep does not run backup/restore, private corpus, provider credential, or backfill operations.", - ), - _ => Some("The live adapter sweep has no encoded runtime path for this suite."), - } -} - -fn materialized_declared_status_job( - adapter_id: &str, - loaded: &LoadedJob, - status: MaterializationStatus, - reason: String, -) -> MaterializedJob { - let failure = match status { - MaterializationStatus::Pass | MaterializationStatus::WrongResult => None, - MaterializationStatus::Blocked - | MaterializationStatus::Incomplete - | MaterializationStatus::NotEncoded => Some(reason.clone()), - }; - - MaterializedJob { - response: AdapterResponseOutput { - adapter_id: adapter_id.to_string(), - answer: AnswerOutput { - content: String::new(), - evidence_ids: Vec::new(), - claims: Vec::new(), - pages: Vec::new(), - memory_summaries: Vec::new(), - proactive_briefs: Vec::new(), - scheduled_tasks: Vec::new(), - latency_ms: 0.0, - cost: CostOutput { - currency: "USD".to_string(), - amount: 0.0, - input_tokens: 0, - output_tokens: 0, - }, - trace_explainability: TraceExplainabilityOutput { - trace_id: None, - failure_stage: Some("live_adapter.suite_support".to_string()), - failure_reason: failure.clone(), - stages: vec![TraceStageOutput { - stage_name: "live_adapter.suite_support".to_string(), - kept_evidence: Vec::new(), - dropped_evidence: Vec::new(), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: reason.clone(), - }], - }, - }, - consolidation: None, - }, - evidence: MaterializedJobEvidence { - job_id: loaded.job.job_id.clone(), - suite: loaded.job.suite.clone(), - title: loaded.job.title.clone(), - status, - query: loaded.job.prompt.content.clone(), - evidence_ids: Vec::new(), - returned_count: 0, - indexing_latency_ms: None, - latency_ms: 0.0, - trace_id: None, - failure, - source_mappings: Vec::new(), - operator_debug: None, - capture: None, - consolidation: None, - knowledge: None, - temporal_reconciliation: None, - dreaming_readback: None, - }, - operator_debug: None, - } -} - -fn operator_debug_output( - adapter_kind: AdapterKind, - loaded: &LoadedJob, - trace_id: Option, - replay_command: String, - replay_artifact: String, -) -> (Option, Option) { - if loaded.job.suite != "operator_debugging_ux" { - return (None, None); - } - - let Some(source) = loaded.value.get("operator_debug") else { - return (None, None); - }; - let mut debug = source.clone(); - let Some(object) = debug.as_object_mut() else { - return (None, None); - }; - let trace_available = trace_id.is_some(); - let replay_command_available = !replay_command.trim().is_empty(); - let raw_sql_needed = false; - let repair_action_clarity = if replay_command_available { "clear" } else { "unclear" }; - let candidate_drop_visibility = - operator_debug_candidate_visibility(adapter_kind, object).to_string(); - - object.insert("trace_available".to_string(), serde_json::Value::Bool(trace_available)); - object.insert( - "replay_command_available".to_string(), - serde_json::Value::Bool(replay_command_available), - ); - object.insert("raw_sql_needed".to_string(), serde_json::Value::Bool(raw_sql_needed)); - object.insert( - "dropped_candidate_visibility".to_string(), - serde_json::Value::String(candidate_drop_visibility.clone()), - ); - object.insert( - "trace_completeness".to_string(), - serde_json::Value::String( - operator_debug_trace_completeness(adapter_kind, trace_available).to_string(), - ), - ); - object.insert( - "repair_action_clarity".to_string(), - serde_json::Value::String(repair_action_clarity.to_string()), - ); - object.insert("replay_command".to_string(), serde_json::Value::String(replay_command.clone())); - object.insert("replay_artifact".to_string(), serde_json::Value::String(replay_artifact)); - - match adapter_kind { - AdapterKind::ElfServiceRuntime => - if let Some(trace_id) = trace_id { - let trace_id = trace_id.to_string(); - - object.insert("trace_id".to_string(), serde_json::Value::String(trace_id.clone())); - object.insert( - "viewer_url".to_string(), - serde_json::Value::String(format!("/viewer?trace_id={trace_id}")), - ); - object.insert( - "admin_trace_bundle_url".to_string(), - serde_json::Value::String(format!( - "/v2/admin/traces/{trace_id}/bundle?mode=full&stage_items_limit=128&candidates_limit=200" - )), - ); - }, - AdapterKind::QmdCliRuntime => { - object.remove("trace_id"); - object.remove("viewer_url"); - object.remove("admin_trace_bundle_url"); - object.insert("viewer_panels".to_string(), serde_json::json!(["qmd JSON Replay Rows"])); - }, - AdapterKind::LightragApiContextExport => {}, - } - - let mut cli_steps = string_array_from_object(object, "cli_steps"); - - push_unique(&mut cli_steps, replay_command); - - object.insert("cli_steps".to_string(), serde_json::json!(cli_steps)); - - ( - Some(debug), - Some(OperatorDebugMaterializationEvidence { - trace_available, - replay_command_available, - candidate_drop_visibility, - repair_action_clarity: repair_action_clarity.to_string(), - raw_sql_needed, - }), - ) -} - -fn operator_debug_trace_completeness( - adapter_kind: AdapterKind, - trace_available: bool, -) -> &'static str { - match adapter_kind { - AdapterKind::ElfServiceRuntime if trace_available => "complete", - AdapterKind::ElfServiceRuntime => "missing", - AdapterKind::QmdCliRuntime | AdapterKind::LightragApiContextExport => "not_available", - } -} - -fn operator_debug_candidate_visibility( - adapter_kind: AdapterKind, - object: &Map, -) -> &str { - match adapter_kind { - AdapterKind::ElfServiceRuntime => object - .get("dropped_candidate_visibility") - .and_then(serde_json::Value::as_str) - .unwrap_or("visible through trace bundle replay candidates"), - AdapterKind::QmdCliRuntime => - "qmd top-k replay output is available, but intermediate candidate-drop stages are not exposed", - AdapterKind::LightragApiContextExport => "not encoded for this adapter", - } -} - -fn string_array_from_object(object: &Map, key: &str) -> Vec { - object - .get(key) - .and_then(serde_json::Value::as_array) - .map(|items| { - items.iter().filter_map(serde_json::Value::as_str).map(ToString::to_string).collect() - }) - .unwrap_or_default() -} - -fn elf_replay_command(trace_id: Uuid, project_id: &str) -> String { - format!( - "curl -fsS {} -H {} -H {} -H {}", - shell_quote(format!( - "http://127.0.0.1:51891/v2/admin/traces/{trace_id}/bundle?mode=full&stage_items_limit=128&candidates_limit=200" - ) - .as_str()), - shell_quote("X-ELF-Tenant-Id: elf-live-real-world"), - shell_quote(format!("X-ELF-Project-Id: {project_id}").as_str()), - shell_quote("X-ELF-Agent-Id: elf-live-real-world-agent") - ) -} - -fn qmd_replay_command(query: &str, collection: &str) -> String { - format!( - "npx tsx src/cli/qmd.ts query {} -c {} --json --no-rerank --min-score 0 -n 5", - shell_quote(format!("lex: {query}\nvec: {query}").as_str()), - shell_quote(collection) - ) -} - -fn shell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -fn evidence_linked_claims(loaded: &LoadedJob, evidence_ids: &[String]) -> Vec { - loaded - .job - .expected_answer - .must_include - .iter() - .filter_map(|claim| { - let claim_id = claim.claim_id()?; - let allowed = - evidence_link_ids(loaded.job.expected_answer.evidence_links.get(claim_id)?); - let produced = evidence_ids - .iter() - .filter(|evidence_id| allowed.iter().any(|allowed_id| allowed_id == *evidence_id)) - .cloned() - .collect::>(); - - if produced.is_empty() { - return None; - } - - Some(serde_json::json!({ - "claim_id": claim_id, - "text": claim.text(), - "evidence_ids": produced, - "confidence": "derived_from_live_retrieval" - })) - }) - .collect() -} - -fn answer_claims(loaded: &LoadedJob, evidence_ids: &[String]) -> Vec { - if loaded.job.memory_evolution.is_some() { - let claims = temporal_reconciliation_claims(loaded, evidence_ids); - - if !claims.is_empty() { - return claims; - } - } - - evidence_linked_claims(loaded, evidence_ids) -} - -fn temporal_reconciliation_claims( - loaded: &LoadedJob, - evidence_ids: &[String], -) -> Vec { - let Some(evolution) = &loaded.job.memory_evolution else { - return Vec::new(); - }; - let selected = evidence_ids.iter().map(String::as_str).collect::>(); - let mut claims = Vec::new(); - let mut claim_ids = BTreeSet::new(); - - for expected in &loaded.job.expected_answer.must_include { - let Some(claim_id) = expected.claim_id() else { - continue; - }; - let mut claim_evidence = temporal_claim_evidence(evolution, claim_id, &selected); - - if claim_evidence.is_empty() - && let Some(allowed) = loaded.job.expected_answer.evidence_links.get(claim_id) - { - claim_evidence = selected_allowed_evidence(allowed, &selected); - } - if claim_evidence.is_empty() { - continue; - } - - claim_ids.insert(claim_id.to_string()); - claims.push(json_claim(claim_id, expected.text(), claim_evidence)); - } - - if let Some(rationale) = &evolution.update_rationale - && rationale.available - && !claim_ids.contains(rationale.claim_id.as_str()) - { - let claim_evidence = rationale - .evidence_ids - .iter() - .filter(|id| selected.contains(id.as_str())) - .cloned() - .collect::>(); - - if !claim_evidence.is_empty() { - let text = expected_claim_text_for_id(loaded, rationale.claim_id.as_str()) - .unwrap_or("The supersession rationale is selected as lifecycle evidence."); - - claims.push(json_claim(rationale.claim_id.as_str(), text, claim_evidence)); - } - } - - claims -} - -fn temporal_claim_evidence( - evolution: &LiveMemoryEvolution, - claim_id: &str, - selected: &BTreeSet<&str>, -) -> Vec { - let mut evidence = Vec::new(); - - for conflict in &evolution.conflicts { - if conflict.claim_id != claim_id { - continue; - } - - push_if_selected(&mut evidence, conflict.current_evidence_id.as_str(), selected); - push_if_selected(&mut evidence, conflict.historical_evidence_id.as_str(), selected); - - if let Some(rationale_id) = &conflict.resolved_by_evidence_id { - push_if_selected(&mut evidence, rationale_id.as_str(), selected); - } - } - - evidence -} - -fn selected_allowed_evidence( - allowed: &serde_json::Value, - selected: &BTreeSet<&str>, -) -> Vec { - evidence_link_ids(allowed).into_iter().filter(|id| selected.contains(id.as_str())).collect() -} - -fn expected_claim_text_for_id<'a>(loaded: &'a LoadedJob, claim_id: &str) -> Option<&'a str> { - loaded - .job - .expected_answer - .must_include - .iter() - .find(|claim| claim.claim_id() == Some(claim_id)) - .map(LiveExpectedClaim::text) -} - -fn json_claim(claim_id: &str, text: &str, evidence_ids: Vec) -> serde_json::Value { - serde_json::json!({ - "claim_id": claim_id, - "text": text, - "evidence_ids": evidence_ids, - "confidence": "derived_from_live_temporal_reconciliation" - }) -} - -fn push_if_selected(out: &mut Vec, evidence_id: &str, selected: &BTreeSet<&str>) { - if selected.contains(evidence_id) { - push_unique(out, evidence_id.to_string()); - } -} - -fn evidence_link_ids(value: &serde_json::Value) -> Vec { - if let Some(id) = value.as_str() { - return vec![id.to_string()]; - } - - value - .as_array() - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .map(ToString::to_string) - .collect::>() - }) - .unwrap_or_default() -} - -fn required_evidence_satisfied(loaded: &LoadedJob, evidence_ids: &[String]) -> bool { - if loaded.job.required_evidence.is_empty() { - return !evidence_ids.is_empty(); - } - - loaded - .job - .required_evidence - .iter() - .all(|required| evidence_ids.iter().any(|id| id == &required.evidence_id)) -} - -fn selected_required_corpus_texts( - loaded: &LoadedJob, - corpus: &[CorpusText], - retrieved_evidence_ids: &[String], -) -> SelectedEvidenceText { - let required_ids = loaded - .job - .required_evidence - .iter() - .map(|evidence| evidence.evidence_id.as_str()) - .collect::>(); - let mut selected_ids = Vec::new(); - - if required_ids.is_empty() { - for evidence_id in retrieved_evidence_ids.iter().take(1) { - push_unique(&mut selected_ids, evidence_id.clone()); - } - } else { - for evidence in &loaded.job.required_evidence { - if retrieved_evidence_ids.iter().any(|id| id == &evidence.evidence_id) { - push_unique(&mut selected_ids, evidence.evidence_id.clone()); - } - } - } - - let content = selected_ids - .iter() - .filter_map(|evidence_id| { - corpus - .iter() - .find(|item| item.evidence_id == *evidence_id) - .map(|item| item.text.clone()) - }) - .collect::>() - .join("\n\n"); - - SelectedEvidenceText { content, evidence_ids: selected_ids } -} - -fn temporal_reconciliation_selection( - loaded: &LoadedJob, - corpus: &[CorpusText], - retrieved_evidence_ids: &[String], - ingested: &IngestedCorpus, -) -> Option { - let evolution = loaded.job.memory_evolution.as_ref()?; - let relevant_ids = temporal_reconciliation_relevant_ids(loaded, evolution); - let retrieved_ids = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); - let mut selected_ids = Vec::new(); - - for evidence_id in &relevant_ids { - if retrieved_ids.contains(evidence_id.as_str()) - && ingested.note_ids_by_evidence.contains_key(evidence_id) - { - push_unique(&mut selected_ids, evidence_id.clone()); - } - } - - if selected_ids.is_empty() { - return None; - } - - let content = temporal_reconciliation_content(loaded, corpus, &selected_ids); - let selected = SelectedEvidenceText { content, evidence_ids: selected_ids.clone() }; - let evidence = temporal_reconciliation_evidence( - evolution, - &relevant_ids, - retrieved_evidence_ids, - &selected_ids, - ingested, - loaded, - ); - let trace_stages = - temporal_reconciliation_trace_stages(evolution, retrieved_evidence_ids, &evidence); - - Some(TemporalReconciliationSelection { selected, evidence, trace_stages }) -} - -fn temporal_reconciliation_relevant_ids( - loaded: &LoadedJob, - evolution: &LiveMemoryEvolution, -) -> Vec { - let mut ids = Vec::new(); - - for evidence in &loaded.job.required_evidence { - push_unique(&mut ids, evidence.evidence_id.clone()); - } - for evidence_id in &evolution.current_evidence_ids { - push_unique(&mut ids, evidence_id.clone()); - } - for evidence_id in &evolution.historical_evidence_ids { - push_unique(&mut ids, evidence_id.clone()); - } - for evidence_id in &evolution.tombstone_evidence_ids { - push_unique(&mut ids, evidence_id.clone()); - } - for evidence_id in &evolution.invalidation_evidence_ids { - push_unique(&mut ids, evidence_id.clone()); - } - for conflict in &evolution.conflicts { - push_unique(&mut ids, conflict.current_evidence_id.clone()); - push_unique(&mut ids, conflict.historical_evidence_id.clone()); - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - push_unique(&mut ids, evidence_id.clone()); - } - } - - if let Some(rationale) = &evolution.update_rationale - && rationale.available - { - for evidence_id in &rationale.evidence_ids { - push_unique(&mut ids, evidence_id.clone()); - } - } - - ids -} - -fn temporal_reconciliation_content( - loaded: &LoadedJob, - corpus: &[CorpusText], - selected_ids: &[String], -) -> String { - let expected = loaded - .job - .expected_answer - .must_include - .iter() - .map(LiveExpectedClaim::text) - .collect::>() - .join(" "); - let evidence_summary = selected_ids - .iter() - .filter_map(|evidence_id| { - corpus - .iter() - .find(|item| item.evidence_id == *evidence_id) - .map(|item| format!("{evidence_id}: {}", item.text)) - }) - .collect::>() - .join("\n"); - - if evidence_summary.is_empty() { - expected - } else { - format!("{expected}\n\nTemporal reconciliation evidence:\n{evidence_summary}") - } -} - -fn temporal_reconciliation_evidence( - evolution: &LiveMemoryEvolution, - relevant_ids: &[String], - retrieved_evidence_ids: &[String], - selected_ids: &[String], - ingested: &IngestedCorpus, - loaded: &LoadedJob, -) -> TemporalReconciliationMaterializationEvidence { - let selected = selected_ids.iter().map(String::as_str).collect::>(); - let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); - let mut evidence = TemporalReconciliationMaterializationEvidence { - current_winner_evidence_ids: selected_subset(&evolution.current_evidence_ids, &selected), - historical_loser_evidence_ids: selected_subset( - &evolution.historical_evidence_ids, - &selected, - ), - supersession_rationale_evidence_ids: evolution - .update_rationale - .as_ref() - .filter(|rationale| rationale.available) - .map_or_else(Vec::new, |rationale| selected_subset(&rationale.evidence_ids, &selected)), - tombstone_evidence_ids: selected_subset(&evolution.tombstone_evidence_ids, &selected), - invalidation_evidence_ids: selected_subset(&evolution.invalidation_evidence_ids, &selected), - conflict_candidate_evidence_ids: conflict_candidate_ids(evolution, &selected), - retrieved_evidence_ids: retrieved_evidence_ids.to_vec(), - selected_evidence_ids: selected_ids.to_vec(), - absent_evidence_ids: relevant_ids - .iter() - .filter(|id| !ingested.note_ids_by_evidence.contains_key(*id)) - .cloned() - .collect(), - retrieved_but_dropped_evidence_ids: relevant_ids - .iter() - .filter(|id| retrieved.contains(id.as_str()) && !selected.contains(id.as_str())) - .cloned() - .collect(), - selected_but_not_narrated_evidence_ids: selected_but_not_narrated_ids(loaded, selected_ids), - contradicted_by_lifecycle_evidence_ids: Vec::new(), - }; - - for evidence_id in evidence - .historical_loser_evidence_ids - .iter() - .chain(evidence.tombstone_evidence_ids.iter()) - .chain(evidence.invalidation_evidence_ids.iter()) - { - push_unique(&mut evidence.contradicted_by_lifecycle_evidence_ids, evidence_id.clone()); - } - - evidence -} - -fn selected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { - ids.iter().filter(|id| selected.contains(id.as_str())).cloned().collect() -} - -fn conflict_candidate_ids( - evolution: &LiveMemoryEvolution, - selected: &BTreeSet<&str>, -) -> Vec { - let mut ids = Vec::new(); - - for conflict in &evolution.conflicts { - push_if_selected(&mut ids, conflict.current_evidence_id.as_str(), selected); - push_if_selected(&mut ids, conflict.historical_evidence_id.as_str(), selected); - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - push_if_selected(&mut ids, evidence_id.as_str(), selected); - } - } - - ids -} - -fn selected_but_not_narrated_ids(loaded: &LoadedJob, selected_ids: &[String]) -> Vec { - let claims = temporal_reconciliation_claims(loaded, selected_ids); - let narrated = claims - .iter() - .flat_map(|claim| { - claim - .get("evidence_ids") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - }) - .collect::>(); - - selected_ids.iter().filter(|id| !narrated.contains(id.as_str())).cloned().collect() -} - -fn temporal_reconciliation_trace_stages( - evolution: &LiveMemoryEvolution, - retrieved_evidence_ids: &[String], - evidence: &TemporalReconciliationMaterializationEvidence, -) -> Vec { - let selected = - evidence.selected_evidence_ids.iter().map(String::as_str).collect::>(); - let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); - let expected_not_retrieved = evidence - .selected_evidence_ids - .iter() - .filter(|id| !retrieved.contains(id.as_str())) - .cloned() - .collect::>(); - - vec![ - TraceStageOutput { - stage_name: "live_adapter.retrieve".to_string(), - kept_evidence: retrieved_evidence_ids.to_vec(), - dropped_evidence: expected_not_retrieved, - demoted_evidence: Vec::new(), - distractor_evidence: evidence.absent_evidence_ids.clone(), - notes: - "Search output is compared with the temporal reconciliation evidence contract." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.current_winner".to_string(), - kept_evidence: evidence.current_winner_evidence_ids.clone(), - dropped_evidence: unselected_subset(&evolution.current_evidence_ids, &selected), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Current evidence selected as the answer winner.".to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.historical_loser".to_string(), - kept_evidence: evidence.historical_loser_evidence_ids.clone(), - dropped_evidence: unselected_subset(&evolution.historical_evidence_ids, &selected), - demoted_evidence: evidence.historical_loser_evidence_ids.clone(), - distractor_evidence: Vec::new(), - notes: "Historical evidence preserved as history, not as the current answer." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.supersession_rationale".to_string(), - kept_evidence: evidence.supersession_rationale_evidence_ids.clone(), - dropped_evidence: evolution - .update_rationale - .as_ref() - .map_or_else(Vec::new, |rationale| { - unselected_subset(&rationale.evidence_ids, &selected) - }), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Rationale evidence selected to explain why the older fact was superseded." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.tombstone_invalidation".to_string(), - kept_evidence: evidence - .tombstone_evidence_ids - .iter() - .chain(evidence.invalidation_evidence_ids.iter()) - .cloned() - .collect(), - dropped_evidence: evolution - .tombstone_evidence_ids - .iter() - .chain(evolution.invalidation_evidence_ids.iter()) - .filter(|id| !selected.contains(id.as_str())) - .cloned() - .collect(), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Tombstone or TTL invalidation evidence remains answerable when present." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.conflict_candidates".to_string(), - kept_evidence: evidence.conflict_candidate_evidence_ids.clone(), - dropped_evidence: evidence.retrieved_but_dropped_evidence_ids.clone(), - demoted_evidence: evidence.contradicted_by_lifecycle_evidence_ids.clone(), - distractor_evidence: evidence.selected_but_not_narrated_evidence_ids.clone(), - notes: - "Conflict candidates record selected, dropped, non-narrated, and lifecycle-demoted evidence." - .to_string(), - }, - ] -} - -fn unselected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { - ids.iter().filter(|id| !selected.contains(id.as_str())).cloned().collect() -} - -fn live_required_evidence_ids(loaded: &LoadedJob, ingested: &IngestedCorpus) -> Vec { - let mut selected = Vec::new(); - - for evidence in &loaded.job.required_evidence { - if ingested.note_ids_by_evidence.contains_key(&evidence.evidence_id) { - push_unique(&mut selected, evidence.evidence_id.clone()); - } - } - - if selected.is_empty() { - for evidence_id in ingested.note_ids_by_evidence.keys() { - push_unique(&mut selected, evidence_id.clone()); - } - - selected.sort(); - } - - selected -} - -fn expected_claim_text(loaded: &LoadedJob, evidence_ids: &[String]) -> SelectedEvidenceText { - let content = loaded - .job - .expected_answer - .must_include - .iter() - .map(LiveExpectedClaim::text) - .collect::>() - .join(" "); - - SelectedEvidenceText { content, evidence_ids: evidence_ids.to_vec() } -} - -fn capture_runtime_evidence_from_search_items(items: &[SearchItem]) -> CaptureRuntimeEvidence { - let source_refs = items.iter().map(|item| &item.source_ref); - - capture_runtime_evidence_from_source_refs(source_refs) -} - -fn capture_runtime_evidence_from_source_refs<'a>( - source_refs: impl IntoIterator, -) -> CaptureRuntimeEvidence { - let mut runtime = CaptureRuntimeEvidence::default(); - - for source_ref in source_refs { - let Some(evidence_id) = source_ref.get("evidence_id").and_then(serde_json::Value::as_str) - else { - continue; - }; - - if runtime.items.iter().any(|item| item.evidence_id == evidence_id) { - continue; - } - - runtime.items.push(CaptureRuntimeEvidenceItem { - evidence_id: evidence_id.to_string(), - source_id: source_ref - .get("source_id") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string), - evidence_binding: source_ref - .get("evidence_binding") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string), - write_policy_applied: source_ref - .get("write_policy_applied") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), - capture_action: source_ref - .get("capture_action") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string), - source_ref: source_ref.clone(), - }); - } - - runtime -} - -fn capture_with_runtime_source_refs( - mut capture: CaptureMaterializationEvidence, - runtime: &CaptureRuntimeEvidence, -) -> CaptureMaterializationEvidence { - capture.source_ids.clear(); - capture.runtime_source_refs.clear(); - - for item in &runtime.items { - if let Some(source_id) = item.source_id.as_deref() { - push_unique(&mut capture.source_ids, source_id.to_string()); - } - - capture.runtime_source_refs.push(CaptureRuntimeSourceRefEvidence { - evidence_id: item.evidence_id.clone(), - source_ref: item.source_ref.clone(), - }); - } - - capture -} - -fn validate_capture_runtime_evidence( - suite: &str, - corpus: &[CorpusText], - capture: &CaptureMaterializationEvidence, - runtime: &CaptureRuntimeEvidence, -) -> Option { - if suite != "capture_integration" { - return None; - } - - let mut failures = Vec::new(); - let mut expected_redactions = 0_usize; - let mut expected_exclusions = 0_usize; - - for item in corpus { - match item.capture.action { - LiveCaptureAction::Exclude => { - if runtime.item_for(item.evidence_id.as_str()).is_some() { - failures.push(format!( - "excluded evidence {} was returned by live search", - item.evidence_id - )); - } - if capture.stored_evidence_ids.iter().any(|id| id == &item.evidence_id) { - failures.push(format!( - "excluded evidence {} was stored by live ingestion", - item.evidence_id - )); - } - if !capture.excluded_evidence_ids.iter().any(|id| id == &item.evidence_id) { - failures.push(format!( - "excluded evidence {} was not recorded as excluded", - item.evidence_id - )); - } - }, - LiveCaptureAction::Store => { - let runtime_item = runtime.item_for(item.evidence_id.as_str()); - - if let Some(expected_source_id) = item.capture.source_id.as_deref() { - match runtime_item.and_then(|observed| observed.source_id.as_deref()) { - Some(observed) if observed == expected_source_id => {}, - Some(observed) => failures.push(format!( - "evidence {} returned source_id {observed}, expected {expected_source_id}", - item.evidence_id - )), - None => failures.push(format!( - "evidence {} did not return expected source_id {expected_source_id}", - item.evidence_id - )), - } - } - if let Some(expected_binding) = item.capture.evidence_binding.as_deref() { - match runtime_item.and_then(|observed| observed.evidence_binding.as_deref()) { - Some(observed) if observed == expected_binding => {}, - Some(observed) => failures.push(format!( - "evidence {} returned evidence_binding {observed}, expected {expected_binding}", - item.evidence_id - )), - None => failures.push(format!( - "evidence {} did not return expected evidence_binding {expected_binding}", - item.evidence_id - )), - } - } - if let Some(policy_value) = &item.capture.write_policy { - match write_policy_from_value(policy_value, item.evidence_id.as_str()) { - Ok(policy) => { - expected_exclusions += policy.exclusions.len(); - expected_redactions += policy.redactions.len(); - }, - Err(err) => failures.push(err.to_string()), - } - - if !runtime_item.is_some_and(|observed| observed.write_policy_applied) { - failures.push(format!( - "evidence {} did not return write_policy_applied=true", - item.evidence_id - )); - } - } - if let Some(observed) = - runtime_item.and_then(|observed| observed.capture_action.as_deref()) - && observed != capture_action_str(item.capture.action) - { - failures.push(format!( - "evidence {} returned capture_action {observed}, expected {}", - item.evidence_id, - capture_action_str(item.capture.action) - )); - } - }, - } - } - - if capture.write_policy_exclusion_count < expected_exclusions { - failures.push(format!( - "write-policy exclusion count {} was below expected {expected_exclusions}", - capture.write_policy_exclusion_count - )); - } - if capture.write_policy_redaction_count < expected_redactions { - failures.push(format!( - "write-policy redaction count {} was below expected {expected_redactions}", - capture.write_policy_redaction_count - )); - } - if expected_exclusions + expected_redactions > 0 && capture.write_policy_audit_count == 0 { - failures - .push("write-policy audit count was zero despite expected policy effects".to_string()); - } - if failures.is_empty() { - None - } else { - Some(format!("Capture runtime validation failed: {}", failures.join("; "))) - } -} - -fn elf_stored_corpus_texts(corpus: &[CorpusText]) -> color_eyre::Result> { - let mut stored = Vec::new(); - - for item in corpus { - if item.capture.action == LiveCaptureAction::Exclude { - continue; - } - - stored.push(CorpusText { - evidence_id: item.evidence_id.clone(), - text: transformed_capture_text(item)?.trim().to_string(), - capture: item.capture.clone(), - }); - } - - Ok(stored) -} - -fn transformed_capture_text(item: &CorpusText) -> color_eyre::Result { - let Some(policy_value) = &item.capture.write_policy else { - return Ok(item.text.clone()); - }; - let policy = write_policy_from_value(policy_value, item.evidence_id.as_str())?; - let result = - writegate::apply_write_policy(item.text.as_str(), Some(&policy)).map_err(|err| { - eyre::eyre!("Invalid write_policy for evidence {}: {err:?}", item.evidence_id) - })?; - - Ok(result.transformed) -} - -fn write_policy_from_value( - value: &serde_json::Value, - evidence_id: &str, -) -> color_eyre::Result { - serde_json::from_value::(value.clone()).map_err(|err| { - eyre::eyre!("Failed to parse write_policy for evidence {evidence_id}: {err}") - }) -} - -fn failure_jobs( - adapter_id: &str, - jobs: &[LoadedJob], - stage: &str, - reason: String, -) -> Vec { - jobs.iter() - .map(|job| { - materialized_job( - job, - adapter_id, - MaterializedJobInput { - content: String::new(), - evidence_ids: Vec::new(), - pages: Vec::new(), - latency_ms: 0.0, - indexing_latency_ms: None, - returned_count: 0, - trace_id: None, - failure: Some(format!("{stage}: {reason}")), - source_mappings: Vec::new(), - operator_debug: None, - operator_debug_evidence: None, - capture: None, - capture_failure: None, - consolidation_response: None, - consolidation: None, - knowledge: None, - temporal_reconciliation: None, - dreaming_readback: None, - memory_summaries: Vec::new(), - proactive_briefs: Vec::new(), - scheduled_tasks: Vec::new(), - trace_stages: None, - }, - ) - }) - .collect() -} - -fn write_materialized_output(output: MaterializedOutput<'_>) -> color_eyre::Result<()> { - if output.out_fixtures.exists() { - fs::remove_dir_all(output.out_fixtures)?; - } - - fs::create_dir_all(output.out_fixtures)?; - - for (loaded, materialized) in output.jobs.iter().zip(output.materialized) { - let mut value = loaded.value.clone(); - let mut adapter_response = - value["corpus"]["adapter_response"].as_object().cloned().unwrap_or_default(); - - adapter_response.insert( - "adapter_id".to_string(), - serde_json::to_value(&materialized.response.adapter_id)?, - ); - adapter_response - .insert("answer".to_string(), serde_json::to_value(&materialized.response.answer)?); - - if let Some(consolidation) = &materialized.response.consolidation { - adapter_response.insert("consolidation".to_string(), consolidation.clone()); - } else if loaded.job.suite == "consolidation" { - adapter_response.remove("consolidation"); - } - - value["corpus"]["adapter_response"] = serde_json::Value::Object(adapter_response); - - if let Some(operator_debug) = &materialized.operator_debug { - value["operator_debug"] = operator_debug.clone(); - } - if let Some(capture) = &materialized.evidence.capture { - apply_capture_runtime_source_refs(&mut value, capture); - - value["capture_materialization"] = serde_json::to_value(capture)?; - } - - if matches!( - materialized.evidence.status, - MaterializationStatus::Blocked - | MaterializationStatus::Incomplete - | MaterializationStatus::NotEncoded - ) { - value["encoding"] = serde_json::json!({ - "status": materialization_status_str(materialized.evidence.status), - "reason": materialized.evidence.failure.clone().unwrap_or_else(|| { - "Live adapter did not complete this job as a pass/fail check.".to_string() - }), - }); - } - - let output_path = output_fixture_path(output.fixtures, output.out_fixtures, &loaded.path)?; - - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; - } - - fs::write(output_path, serde_json::to_string_pretty(&value)?)?; - } - - let evidence = MaterializationEvidence { - schema: EVIDENCE_SCHEMA, - adapter_id: output.adapter_id.to_string(), - adapter_kind: output.adapter_kind, - status: aggregate_status(output.materialized), - fixtures: output.fixtures.display().to_string(), - generated_fixtures: output.out_fixtures.display().to_string(), - command_evidence: output.command_evidence, - jobs: output.materialized.iter().map(|job| clone_job_evidence(&job.evidence)).collect(), - metadata: output.metadata, - }; - - if let Some(parent) = output.evidence_out.parent() { - fs::create_dir_all(parent)?; - } - - fs::write(output.evidence_out, serde_json::to_string_pretty(&evidence)?)?; - - Ok(()) -} - -fn apply_capture_runtime_source_refs( - value: &mut serde_json::Value, - capture: &CaptureMaterializationEvidence, -) { - let Some(items) = value.pointer_mut("/corpus/items").and_then(serde_json::Value::as_array_mut) - else { - return; - }; - - for item in items { - let Some(evidence_id) = item.get("evidence_id").and_then(serde_json::Value::as_str) else { - continue; - }; - let Some(source_ref) = capture - .runtime_source_refs - .iter() - .find(|source_ref| source_ref.evidence_id == evidence_id) - else { - continue; - }; - - item["source_ref"] = source_ref.source_ref.clone(); - } -} - -fn clone_job_evidence(evidence: &MaterializedJobEvidence) -> MaterializedJobEvidence { - MaterializedJobEvidence { - job_id: evidence.job_id.clone(), - suite: evidence.suite.clone(), - title: evidence.title.clone(), - status: evidence.status, - query: evidence.query.clone(), - evidence_ids: evidence.evidence_ids.clone(), - returned_count: evidence.returned_count, - indexing_latency_ms: evidence.indexing_latency_ms, - latency_ms: evidence.latency_ms, - trace_id: evidence.trace_id, - failure: evidence.failure.clone(), - source_mappings: evidence.source_mappings.clone(), - operator_debug: evidence.operator_debug.clone(), - capture: evidence.capture.clone(), - consolidation: evidence.consolidation.clone(), - knowledge: evidence.knowledge.clone(), - temporal_reconciliation: evidence.temporal_reconciliation.clone(), - dreaming_readback: evidence.dreaming_readback.clone(), - } -} - -fn aggregate_status(jobs: &[MaterializedJob]) -> MaterializationStatus { - if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::Incomplete) { - MaterializationStatus::Incomplete - } else if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::Blocked) { - MaterializationStatus::Blocked - } else if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::WrongResult) { - MaterializationStatus::WrongResult - } else if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::NotEncoded) { - MaterializationStatus::NotEncoded - } else { - MaterializationStatus::Pass - } -} - -fn materialization_status_str(status: MaterializationStatus) -> &'static str { - match status { - MaterializationStatus::Pass => "pass", - MaterializationStatus::WrongResult => "wrong_result", - MaterializationStatus::Blocked => "blocked", - MaterializationStatus::Incomplete => "incomplete", - MaterializationStatus::NotEncoded => "not_encoded", - } -} - -fn output_fixture_path( - fixtures: &Path, - out_fixtures: &Path, - fixture: &Path, -) -> color_eyre::Result { - if fixtures.is_dir() { - let relative = fixture.strip_prefix(fixtures).map_err(|err| { - eyre::eyre!( - "Fixture path {} is not under fixture root {}: {err}", - fixture.display(), - fixtures.display() - ) - })?; - - return Ok(out_fixtures.join(relative)); - } - - let file_name = fixture - .file_name() - .ok_or_else(|| eyre::eyre!("Fixture path {} has no file name.", fixture.display()))?; - - Ok(out_fixtures.join(file_name)) -} - -fn load_jobs(path: &Path) -> color_eyre::Result> { - let paths = fixture_paths(path)?; - let mut jobs = Vec::with_capacity(paths.len()); - - for fixture in paths { - let raw = fs::read_to_string(&fixture)?; - let value = serde_json::from_str::(&raw) - .map_err(|err| eyre::eyre!("Failed to parse {} as JSON: {err}", fixture.display()))?; - let job = serde_json::from_value::(value.clone()).map_err(|err| { - eyre::eyre!("Failed to parse {} as real_world_job: {err}", fixture.display()) - })?; - - if job.schema != JOB_SCHEMA { - return Err(eyre::eyre!( - "{} has schema {}, expected {JOB_SCHEMA}.", - fixture.display(), - job.schema - )); - } - if job.corpus.items.is_empty() { - return Err(eyre::eyre!("{} has no corpus items.", fixture.display())); - } - - jobs.push(LoadedJob { path: fixture, value, job }); - } - - Ok(jobs) -} - -fn fixture_paths(path: &Path) -> color_eyre::Result> { - let mut paths = Vec::new(); - - collect_fixture_paths(path, &mut paths)?; - - paths.sort(); - - Ok(paths) -} - -fn collect_fixture_paths(path: &Path, paths: &mut Vec) -> color_eyre::Result<()> { - if path.is_dir() { - for entry in fs::read_dir(path)? { - let entry_path = entry?.path(); - - collect_fixture_paths(entry_path.as_path(), paths)?; - } - - return Ok(()); - } - if path.extension().and_then(|ext| ext.to_str()) == Some("json") { - paths.push(path.to_path_buf()); - } - - Ok(()) -} - -fn corpus_texts(loaded: &LoadedJob) -> color_eyre::Result> { - loaded - .job - .corpus - .items - .iter() - .map(|item| { - let text = match (&item.text, &item.local_ref) { - (Some(text), _) => text.clone(), - (None, Some(local_ref)) => { - let base = loaded.path.parent().unwrap_or_else(|| Path::new(".")); - - fs::read_to_string(base.join(local_ref))? - }, - (None, None) => { - return Err(eyre::eyre!( - "{} item {} has no text or local_ref.", - loaded.path.display(), - item.evidence_id - )); - }, - }; - - Ok(CorpusText { - evidence_id: item.evidence_id.clone(), - text: text.trim().to_string(), - capture: item.capture.clone(), - }) - }) - .collect() -} - -fn read_dir_paths(path: &Path) -> color_eyre::Result> { - if !path.exists() { - return Ok(Vec::new()); - } - - let mut paths = Vec::new(); - - for entry in fs::read_dir(path)? { - paths.push(entry?.path()); - } - - Ok(paths) -} - -fn runtime_config(runtime: &BaselineRuntime) -> color_eyre::Result { - let mut cfg = elf_config::load(&runtime.config_path)?; - - cfg.storage.postgres.dsn = runtime.dsn.clone(); - cfg.storage.postgres.pool_max_conns = 12; - cfg.storage.qdrant.url = runtime.qdrant_url.clone(); - cfg.storage.qdrant.collection = runtime.collection.clone(); - cfg.storage.qdrant.docs_collection = runtime.docs_collection.clone(); - cfg.providers.embedding.provider_id = "local".to_string(); - cfg.providers.embedding.model = "local-hash".to_string(); - cfg.providers.embedding.dimensions = cfg.storage.qdrant.vector_dim; - cfg.providers.rerank.provider_id = "local".to_string(); - cfg.providers.rerank.model = "local-token-overlap".to_string(); - cfg.providers.llm_extractor.provider_id = "disabled".to_string(); - cfg.providers.llm_extractor.model = "disabled".to_string(); - cfg.context = None; - - Ok(cfg) -} - -fn deterministic_providers(vector_dim: u32) -> Providers { - Providers::new( - Arc::new(DeterministicEmbedding { vector_dim }), - Arc::new(TokenOverlapRerank), - Arc::new(NoopExtractor), - ) -} - -fn run_qmd_command( - label: &str, - args: &QmdArgs, - home_dir: &Path, - qmd_args: &[&str], - log_path: &Path, -) -> color_eyre::Result { - let mut command = Command::new("npx"); - - command - .current_dir(&args.qmd_dir) - .env("HOME", home_dir) - .env("XDG_CACHE_HOME", "/root/.cache") - .env("QMD_FORCE_CPU", "1") - .arg("tsx") - .arg("src/cli/qmd.ts"); - - for arg in qmd_args { - command.arg(arg); - } - - run_logged_command(label, &mut command, log_path) -} - -fn run_logged_shell( - label: &str, - cwd: &Path, - script: &str, - log_path: &Path, -) -> color_eyre::Result<()> { - let mut command = Command::new("bash"); - - command.current_dir(cwd).arg("-lc").arg(script); - - run_logged_command(label, &mut command, log_path).map(|_| ()) -} - -fn run_logged_command( - label: &str, - command: &mut Command, - log_path: &Path, -) -> color_eyre::Result { - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent)?; - } - - let command_debug = format!("{command:?}"); - let output = command.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let mut log = OpenOptions::new().create(true).append(true).open(log_path)?; - - writeln!(log, "## {label}")?; - writeln!(log, "$ {command_debug}")?; - - if !stdout.trim().is_empty() { - writeln!(log, "\nstdout:\n{stdout}")?; - } - if !stderr.trim().is_empty() { - writeln!(log, "\nstderr:\n{stderr}")?; - } - if !output.status.success() { - return Err(eyre::eyre!( - "{label} failed with status {}. Inspect {}.", - output.status, - log_path.display() - )); - } - - Ok(stdout) -} - -fn project_id_for_job(job_id: &str) -> String { - format!("job-{}", slug(job_id)) -} - -fn slug(value: &str) -> String { - let mut out = String::new(); - let mut last_dash = false; - - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - - last_dash = false; - } else if !last_dash && !out.is_empty() { - out.push('-'); - - last_dash = true; - } - } - - while out.ends_with('-') { - out.pop(); - } - - if out.is_empty() { "item".to_string() } else { out } -} - -fn short_hash(value: &str) -> String { - let mut hasher = Hasher::new(); - - hasher.update(value.as_bytes()); - - hasher.finalize().to_hex().chars().take(12).collect() -} - -fn push_unique(values: &mut Vec, value: String) { - if !values.iter().any(|existing| existing == &value) { - values.push(value); - } -} - -fn embed_text(text: &str, vector_dim: u32) -> Vec { - let dim = vector_dim as usize; - let mut vector = vec![0.0_f32; dim]; - - if dim == 0 { - return vector; - } - - let normalized = normalize_ascii_alnum_lowercase(text); - - for term in normalized.split_whitespace() { - if term.len() < 2 { - continue; - } - - let hash = blake3::hash(term.as_bytes()); - let bytes = hash.as_bytes(); - let idx = (u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) % dim; - - vector[idx] += 1.0; - } - - let norm = vector.iter().map(|value| value * value).sum::().sqrt(); - - if norm > 0.0 { - for value in &mut vector { - *value /= norm; - } - } - - vector -} - -fn terms(text: &str) -> BTreeSet { - normalize_ascii_alnum_lowercase(text) - .split_whitespace() - .filter(|term| term.len() >= 2) - .map(ToString::to_string) - .collect() -} - -fn normalize_ascii_alnum_lowercase(text: &str) -> String { - text.chars() - .map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { ' ' }) - .collect() -} - -fn note_text_chunks(text: &str) -> Vec { - let normalized = text.split_whitespace().collect::>().join(" "); - - if normalized.chars().count() <= ELF_NOTE_CHUNK_CHARS { - return vec![normalized]; - } - - let mut chunks = Vec::new(); - let mut current = String::new(); - - for word in normalized.split_whitespace() { - if word.chars().count() > ELF_NOTE_CHUNK_CHARS { - if !current.is_empty() { - chunks.push(current); - - current = String::new(); - } - - chunks.extend(split_long_token(word)); - - continue; - } - - let separator = usize::from(!current.is_empty()); - - if current.chars().count() + separator + word.chars().count() > ELF_NOTE_CHUNK_CHARS - && !current.is_empty() - { - chunks.push(current); - - current = String::new(); - } - if !current.is_empty() { - current.push(' '); - } - - current.push_str(word); - } - - if !current.is_empty() { - chunks.push(current); - } - - chunks -} - -fn split_long_token(token: &str) -> Vec { - let mut chunks = Vec::new(); - let mut current = String::new(); - - for ch in token.chars() { - if current.chars().count() >= ELF_NOTE_CHUNK_CHARS { - chunks.push(current); - - current = String::new(); - } - - current.push(ch); - } - - if !current.is_empty() { - chunks.push(current); - } - - chunks -} - -fn capture_for_job( - loaded: &LoadedJob, - capture: CaptureMaterializationEvidence, -) -> Option { - if loaded.job.suite == "capture_integration" { Some(capture) } else { None } -} - -fn capture_action_str(action: LiveCaptureAction) -> &'static str { - match action { - LiveCaptureAction::Store => "store", - LiveCaptureAction::Exclude => "exclude", - } -} - -fn live_consolidation_fixture(loaded: &LoadedJob) -> color_eyre::Result { - let value = - loaded.value.pointer("/corpus/adapter_response/consolidation").cloned().ok_or_else( - || { - eyre::eyre!( - "{} does not contain adapter_response.consolidation.", - loaded.path.display() - ) - }, - )?; - - serde_json::from_value(value).map_err(|err| { - eyre::eyre!("Failed to parse consolidation fixture {}: {err}", loaded.path.display()) - }) -} - -fn prepare_consolidation_run( - loaded: &LoadedJob, - adapter_id: &str, - ingested: &IngestedCorpus, - fixture: &LiveConsolidationFixture, - corpus: &[CorpusText], -) -> color_eyre::Result { - let mut input_refs = Vec::new(); - let mut proposals = Vec::new(); - - for proposal in &fixture.proposals { - let source_refs = consolidation_input_refs( - loaded, - adapter_id, - proposal.source_refs.as_slice(), - ingested, - corpus, - )?; - - for source_ref in &source_refs { - push_unique_input_ref(&mut input_refs, source_ref.clone()); - } - - proposals.push(consolidation_proposal_input( - loaded, - adapter_id, - ingested, - corpus, - proposal, - source_refs, - &input_refs, - )?); - } - - if proposals.is_empty() { - return Err(eyre::eyre!("{} has no consolidation proposals.", loaded.job.job_id)); - } - - Ok(PreparedConsolidationRun { input_refs, proposals }) -} - -fn consolidation_proposal_input( - loaded: &LoadedJob, - adapter_id: &str, - ingested: &IngestedCorpus, - corpus: &[CorpusText], - proposal: &LiveConsolidationProposal, - source_refs: Vec, - input_refs: &[ConsolidationInputRef], -) -> color_eyre::Result { - let unsupported_claim_flags = - consolidation_unsupported_claim_flags(loaded, adapter_id, proposal, ingested, corpus)?; - let diff = consolidation_diff(proposal.diff.clone())?; - let proposed_payload = object_or_empty(diff.after.clone()); - let lineage = ConsolidationLineage { - source_refs: source_refs.clone(), - parent_run_id: None, - parent_proposal_ids: Vec::new(), - }; - - Ok(ConsolidationProposalInput { - proposal_kind: proposal.proposal_kind.clone(), - apply_intent: consolidation_apply_intent(proposal.actual_review_action.as_str()), - source_refs, - source_snapshot: serde_json::json!({ - "schema": "real_world_live_consolidation_source_snapshot/v1", - "adapter_id": adapter_id, - "job_id": loaded.job.job_id, - "proposal_id": proposal.proposal_id - }), - lineage, - confidence: proposal.usefulness_score as f32, - unsupported_claim_flags, - markers: consolidation_markers(proposal, input_refs), - diff, - target_ref: serde_json::json!({ - "schema": "real_world_live_consolidation_target/v1", - "proposal_id": proposal.proposal_id - }), - proposed_payload, - }) -} - -fn validate_reviewed_consolidation_count( - loaded: &LoadedJob, - fixture: &LiveConsolidationFixture, - reviewed: &[ConsolidationProposalResponse], -) -> color_eyre::Result<()> { - if reviewed.len() == fixture.proposals.len() { - return Ok(()); - } - - Err(eyre::eyre!( - "ELF consolidation materialized {} proposals for {} fixture proposals in {}.", - reviewed.len(), - fixture.proposals.len(), - loaded.job.job_id - )) -} - -fn consolidation_materialization_evidence( - run_id: Uuid, - fixture: &LiveConsolidationFixture, - input_refs: &[ConsolidationInputRef], - reviewed: &[ConsolidationProposalResponse], -) -> ConsolidationMaterializationEvidence { - let review_actions = reviewed - .iter() - .flat_map(|proposal| proposal.review_events.iter().map(|event| event.action.clone())) - .collect::>(); - let final_review_states = - reviewed.iter().map(|proposal| proposal.review_state.clone()).collect::>(); - let unsupported_claim_flag_count = fixture - .proposals - .iter() - .map(|proposal| { - proposal.unsupported_claim_count.max(proposal.unsupported_claim_flags.len()) - }) - .sum(); - let review_event_count = - reviewed.iter().map(|proposal| proposal.review_events.len()).sum::(); - - ConsolidationMaterializationEvidence { - run_id: Some(run_id), - proposal_ids: reviewed.iter().map(|proposal| proposal.proposal_id).collect(), - source_lineage_count: input_refs.len(), - unsupported_claim_flag_count, - review_event_count, - review_actions, - final_review_states, - } -} - -fn consolidation_input_refs( - loaded: &LoadedJob, - adapter_id: &str, - evidence_ids: &[String], - ingested: &IngestedCorpus, - corpus: &[CorpusText], -) -> color_eyre::Result> { - evidence_ids - .iter() - .map(|evidence_id| { - let note_id = ingested - .note_ids_by_evidence - .get(evidence_id) - .and_then(|ids| ids.first().copied()) - .ok_or_else(|| { - eyre::eyre!( - "No live note id mapped for consolidation evidence {} in {}.", - evidence_id, - loaded.job.job_id - ) - })?; - let text = corpus - .iter() - .find(|item| item.evidence_id == *evidence_id) - .map(|item| item.text.as_str()) - .unwrap_or(evidence_id.as_str()); - let content_hash = format!("blake3:{}", blake3::hash(text.as_bytes()).to_hex()); - - Ok(ConsolidationInputRef { - kind: ConsolidationSourceKind::Note, - id: note_id, - snapshot: ConsolidationSourceSnapshot { - status: Some("active".to_string()), - updated_at: Some(OffsetDateTime::now_utc()), - content_hash: Some(content_hash), - embedding_version: None, - trace_version: None, - source_ref: serde_json::json!({ - "schema": "real_world_live_adapter/v1", - "adapter": adapter_id, - "job_id": loaded.job.job_id, - "evidence_id": evidence_id - }), - metadata: serde_json::json!({ - "evidence_id": evidence_id, - "source": "memory_notes" - }), - }, - }) - }) - .collect() -} - -fn push_unique_input_ref(values: &mut Vec, value: ConsolidationInputRef) { - if !values.iter().any(|existing| existing.id == value.id) { - values.push(value); - } -} - -fn consolidation_unsupported_claim_flags( - loaded: &LoadedJob, - adapter_id: &str, - proposal: &LiveConsolidationProposal, - ingested: &IngestedCorpus, - corpus: &[CorpusText], -) -> color_eyre::Result> { - proposal - .unsupported_claim_flags - .iter() - .map(|flag| { - let source = flag - .source_ref - .as_deref() - .map(|source_ref| { - consolidation_input_refs( - loaded, - adapter_id, - &[source_ref.to_string()], - ingested, - corpus, - ) - .and_then(|refs| { - refs.into_iter().next().ok_or_else(|| { - eyre::eyre!( - "Unsupported claim source {} did not map to a live source.", - source_ref - ) - }) - }) - }) - .transpose()?; - - Ok(ConsolidationUnsupportedClaimFlag { - claim_id: flag.claim_id.clone(), - message: flag.message.clone(), - source, - }) - }) - .collect() -} - -fn consolidation_diff(value: serde_json::Value) -> color_eyre::Result { - let summary = value - .get("summary") - .and_then(serde_json::Value::as_str) - .unwrap_or("Live consolidation proposal.") - .to_string(); - - Ok(ConsolidationProposalDiff { - summary, - before: object_or_empty(value.get("before").cloned().unwrap_or(serde_json::Value::Null)), - after: object_or_empty(value.get("after").cloned().unwrap_or(serde_json::Value::Null)), - }) -} - -fn object_or_empty(value: serde_json::Value) -> serde_json::Value { - if matches!(value, serde_json::Value::Object(_)) { value } else { serde_json::json!({}) } -} - -fn consolidation_apply_intent(action: &str) -> ConsolidationApplyIntent { - if action == "apply" { - ConsolidationApplyIntent::CreateDerivedNote - } else { - ConsolidationApplyIntent::NoOp - } -} - -fn consolidation_review_action(raw: &str) -> color_eyre::Result { - match raw { - "apply" => Ok(ConsolidationReviewAction::Apply), - "discard" => Ok(ConsolidationReviewAction::Discard), - "defer" => Ok(ConsolidationReviewAction::Defer), - "approve" => Ok(ConsolidationReviewAction::Approve), - _ => Err(eyre::eyre!("Unknown consolidation review action {raw}.")), - } -} - -fn consolidation_markers( - proposal: &LiveConsolidationProposal, - input_refs: &[ConsolidationInputRef], -) -> ConsolidationMarkers { - if !proposal.proposal_kind.contains("contradiction") { - return ConsolidationMarkers::default(); - } - - let marker = ConsolidationMarker { - severity: ConsolidationMarkerSeverity::High, - message: - "Live adapter materialized a contradiction-oriented proposal for reviewer inspection." - .to_string(), - source: input_refs.first().cloned(), - }; - - ConsolidationMarkers { contradictions: vec![marker], staleness: Vec::new() } -} - -fn live_consolidation_response( - fixture: &LiveConsolidationFixture, - reviewed: &[ConsolidationProposalResponse], -) -> color_eyre::Result { - let proposals = fixture - .proposals - .iter() - .zip(reviewed) - .map(|(fixture_proposal, reviewed_proposal)| { - serde_json::json!({ - "proposal_id": reviewed_proposal.proposal_id.to_string(), - "proposal_kind": fixture_proposal.proposal_kind.clone(), - "source_refs": fixture_proposal.source_refs.clone(), - "expected_source_refs": if fixture_proposal.expected_source_refs.is_empty() { - fixture_proposal.source_refs.clone() - } else { - fixture_proposal.expected_source_refs.clone() - }, - "usefulness_score": fixture_proposal.usefulness_score, - "min_usefulness_score": fixture_proposal.min_usefulness_score, - "expected_review_action": fixture_proposal.expected_review_action.clone(), - "actual_review_action": fixture_proposal.actual_review_action.clone(), - "source_mutations": fixture_proposal.source_mutations.clone(), - "unsupported_claim_count": fixture_proposal - .unsupported_claim_count - .max(fixture_proposal.unsupported_claim_flags.len()), - "unsupported_claim_flags": fixture_proposal.unsupported_claim_flags.clone(), - "diff": fixture_proposal.diff.clone(), - "live_review_state": reviewed_proposal.review_state.clone(), - "live_review_event_count": reviewed_proposal.review_events.len() - }) - }) - .collect::>(); - - Ok(serde_json::json!({ "proposals": proposals, "executable_gaps": [] })) -} - -fn live_note_ids(ingested: &IngestedCorpus) -> Vec { - let mut note_ids = Vec::new(); - - for ids in ingested.note_ids_by_evidence.values() { - for note_id in ids { - if !note_ids.iter().any(|existing| existing == note_id) { - note_ids.push(*note_id); - } - } - } - - note_ids -} - -fn knowledge_page_artifact( - loaded: &LoadedJob, - ingested: &IngestedCorpus, - first: &KnowledgePageResponse, - second: &KnowledgePageResponse, - lint: &KnowledgePageLintResponse, -) -> color_eyre::Result { - let reverse = note_id_to_evidence_id(ingested); - let mut sections = second - .sections - .iter() - .map(|section| { - let evidence_ids = section - .source_backlinks - .iter() - .filter_map(|source| reverse.get(&source.source_id).cloned()) - .collect::>(); - - serde_json::json!({ - "section_id": section.section_key.clone(), - "heading": section.heading.clone(), - "role": section.role.clone(), - "content": section.content.clone(), - "evidence_ids": evidence_ids, - "timeline_event_ids": [] - }) - }) - .collect::>(); - - sections.extend(unsupported_sections_from_fixture(loaded)); - - Ok(serde_json::json!({ - "page_id": second.page.page_id.to_string(), - "page_type": second.page.page_kind.clone(), - "title": second.page.title.clone(), - "sections": sections, - "backlinks": source_backlinks(ingested), - "lint_findings": lint_findings_for_page(loaded, ingested, lint), - "page_version_diff": second.page.previous_version_diff.clone(), - "rebuild": { - "first_hash": first.page.content_hash.clone(), - "second_hash": second.page.content_hash.clone(), - "deterministic": first.page.content_hash == second.page.content_hash, - "allowed_variance": [] - } - })) -} - -fn knowledge_materialization_evidence( - page: &KnowledgePageResponse, - lint: &KnowledgePageLintResponse, - search_result_count: usize, -) -> KnowledgeMaterializationEvidence { - let unsupported_claim_count = - lint.findings.iter().filter(|finding| finding.finding_type == "unsupported_claim").count() - + page.sections.iter().filter(|section| section.unsupported_reason.is_some()).count(); - - KnowledgeMaterializationEvidence { - page_ids: vec![page.page.page_id], - search_result_count, - lint_finding_count: lint.findings.len(), - stale_source_finding_count: lint - .findings - .iter() - .filter(|finding| finding.finding_type == "stale_source_ref") - .count(), - unsupported_claim_count, - citation_count: page.sections.iter().map(|section| section.citation_count).sum(), - source_ref_count: page.source_refs.len(), - version_diff_available: page - .page - .previous_version_diff - .as_ref() - .and_then(|diff| diff.get("available")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), - } -} - -fn note_id_to_evidence_id(ingested: &IngestedCorpus) -> HashMap { - let mut out = HashMap::new(); - - for (evidence_id, note_ids) in &ingested.note_ids_by_evidence { - for note_id in note_ids { - out.insert(*note_id, evidence_id.clone()); - } - } - - out -} - -fn source_backlinks(ingested: &IngestedCorpus) -> Vec { - let mut backlinks = ingested - .note_ids_by_evidence - .keys() - .map(|evidence_id| format!("source:{evidence_id}")) - .collect::>(); - - backlinks.sort(); - - backlinks -} - -fn lint_findings_for_page( - loaded: &LoadedJob, - ingested: &IngestedCorpus, - lint: &KnowledgePageLintResponse, -) -> Vec { - let reverse = note_id_to_evidence_id(ingested); - - lint.findings - .iter() - .map(|finding| { - let evidence_ids = finding - .source_id - .and_then(|source_id| reverse.get(&source_id).cloned()) - .into_iter() - .collect::>(); - let trap_id = evidence_ids - .first() - .and_then(|evidence_id| trap_id_for_evidence(loaded, evidence_id)); - - serde_json::json!({ - "finding_id": finding.finding_id.to_string(), - "finding_type": finding.finding_type.clone(), - "severity": finding.severity.clone(), - "text": finding.message.clone(), - "evidence_ids": evidence_ids, - "trap_id": trap_id - }) - }) - .collect() -} - -fn unsupported_sections_from_fixture(loaded: &LoadedJob) -> Vec { - let Some(pages) = loaded - .value - .pointer("/corpus/adapter_response/answer/pages") - .and_then(serde_json::Value::as_array) - else { - return Vec::new(); - }; - let mut sections = Vec::new(); - - for page in pages { - let Some(page_sections) = page.get("sections").and_then(serde_json::Value::as_array) else { - continue; - }; - - for section in page_sections { - let Some(reason) = - section.get("unsupported_reason").and_then(serde_json::Value::as_str) - else { - continue; - }; - - sections.push(serde_json::json!({ - "section_id": section - .get("section_id") - .and_then(serde_json::Value::as_str) - .unwrap_or("unsupported-summary"), - "heading": section - .get("heading") - .and_then(serde_json::Value::as_str) - .unwrap_or("Unsupported Summary"), - "role": section.get("role").and_then(serde_json::Value::as_str).unwrap_or("summary"), - "content": section.get("content").and_then(serde_json::Value::as_str).unwrap_or(reason), - "evidence_ids": [], - "timeline_event_ids": [], - "unsupported_reason": reason - })); - } - } - - sections -} - -fn stale_trap_evidence_ids(loaded: &LoadedJob) -> Vec { - loaded - .value - .get("negative_traps") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter(|trap| { - trap.get("type").and_then(serde_json::Value::as_str) == Some("stale_fact") - && trap.get("failure_if_used").and_then(serde_json::Value::as_bool).unwrap_or(false) - }) - .flat_map(|trap| { - trap.get("evidence_ids") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - .map(ToString::to_string) - .collect::>() - }) - .collect() -} - -fn trap_id_for_evidence(loaded: &LoadedJob, evidence_id: &str) -> Option { - loaded - .value - .get("negative_traps") - .and_then(serde_json::Value::as_array)? - .iter() - .find(|trap| { - trap.get("evidence_ids") - .and_then(serde_json::Value::as_array) - .is_some_and(|ids| ids.iter().any(|id| id.as_str() == Some(evidence_id))) - }) - .and_then(|trap| trap.get("trap_id").and_then(serde_json::Value::as_str)) - .map(ToString::to_string) -} - -fn elf_selected_evidence_text( - loaded: &LoadedJob, - stored_corpus: &[CorpusText], - evidence_ids: &[String], - ingested: &IngestedCorpus, - capture_failure: &Option, -) -> ( - SelectedEvidenceText, - Option, - Option>, -) { - if let Some(failure) = capture_failure { - return ( - SelectedEvidenceText { content: failure.clone(), evidence_ids: Vec::new() }, - None, - None, - ); - } - if let Some(selection) = - temporal_reconciliation_selection(loaded, stored_corpus, evidence_ids, ingested) - { - return (selection.selected, Some(selection.evidence), Some(selection.trace_stages)); - } - - (selected_required_corpus_texts(loaded, stored_corpus, evidence_ids), None, None) -} - -fn dreaming_readback_template_artifacts( - loaded: &LoadedJob, -) -> color_eyre::Result> { - let pointer = match loaded.job.suite.as_str() { - "memory_summary" => "/corpus/adapter_response/answer/memory_summaries", - "proactive_brief" => "/corpus/adapter_response/answer/proactive_briefs", - "scheduled_memory" => "/corpus/adapter_response/answer/scheduled_tasks", - _ => return Ok(Vec::new()), - }; - let artifacts = - loaded.value.pointer(pointer).and_then(serde_json::Value::as_array).cloned().ok_or_else( - || { - eyre::eyre!( - "{} missing service-native readback template at {pointer}.", - loaded.job.job_id - ) - }, - )?; - - if artifacts.is_empty() { - return Err(eyre::eyre!( - "{} has no service-native readback template artifacts.", - loaded.job.job_id - )); - } - - Ok(artifacts) -} - -fn dreaming_readback_scoring_evidence_ids( - loaded: &LoadedJob, - service_evidence_ids: &[String], -) -> Vec { - let selected = service_evidence_ids.iter().map(String::as_str).collect::>(); - let trap_ids = negative_trap_evidence_ids(loaded); - let mut evidence_ids = Vec::new(); - - for evidence in &loaded.job.required_evidence { - if selected.contains(evidence.evidence_id.as_str()) - && !trap_ids.contains(evidence.evidence_id.as_str()) - { - push_unique(&mut evidence_ids, evidence.evidence_id.clone()); - } - } - - if evidence_ids.is_empty() { - for evidence_id in service_evidence_ids { - if !trap_ids.contains(evidence_id.as_str()) { - push_unique(&mut evidence_ids, evidence_id.clone()); - } - } - } - - evidence_ids -} - -fn negative_trap_evidence_ids(loaded: &LoadedJob) -> BTreeSet<&str> { - loaded - .value - .get("negative_traps") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter(|trap| { - trap.get("failure_if_used").and_then(serde_json::Value::as_bool).unwrap_or(false) - }) - .flat_map(|trap| { - trap.get("evidence_ids") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - }) - .collect() -} - -fn stamp_dreaming_readback_artifact( - artifact: &mut serde_json::Value, - loaded: &LoadedJob, - project_id: &str, - trace_id: Uuid, - generated_at: &str, -) { - artifact["generated_at"] = serde_json::json!(generated_at); - artifact["tenant_id"] = serde_json::json!(TENANT_ID); - artifact["project_id"] = serde_json::json!(project_id); - artifact["agent_id"] = serde_json::json!(AGENT_ID); - artifact["read_profile"] = serde_json::json!("private_only"); - artifact["service_readback"] = serde_json::json!({ - "schema": "elf.service_native_dreaming_readback/v1", - "job_id": loaded.job.job_id, - "suite": loaded.job.suite, - "runtime_path": "ElfService::list", - "search_trace_id": trace_id, - "source_mutation_count": 0 - }); - - if loaded.job.suite == "scheduled_memory" { - let trace = artifact - .as_object_mut() - .map(|object| object.entry("execution_trace").or_insert_with(|| serde_json::json!({}))); - - if let Some(trace) = trace { - trace["trace_id"] = serde_json::json!(format!("service-native-{trace_id}")); - trace["trigger_kind"] = serde_json::json!("service_native_readback"); - trace["status"] = serde_json::json!("completed"); - } - - artifact["source_mutations"] = serde_json::json!([]); - } -} - -fn collect_dreaming_artifact_source_refs(value: &serde_json::Value, refs: &mut Vec) { - match value { - serde_json::Value::Array(items) => - for item in items { - collect_dreaming_artifact_source_refs(item, refs); - }, - serde_json::Value::Object(map) => - for (key, value) in map { - if matches!(key.as_str(), "source_refs" | "evidence_refs" | "evidence_ids") - && let Some(items) = value.as_array() - { - for item in items { - if let Some(source_ref) = item.as_str() { - push_unique(refs, source_ref.to_string()); - } - } - } - if key == "evidence_id" - && let Some(source_ref) = value.as_str() - { - push_unique(refs, source_ref.to_string()); - } - - collect_dreaming_artifact_source_refs(value, refs); - }, - _ => {}, - } -} - -fn dreaming_readback_content(suite: &str, artifacts: &[serde_json::Value]) -> String { - let mut parts = Vec::new(); - - for artifact in artifacts { - match suite { - "memory_summary" => { - for entry in artifact - .get("entries") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - { - if let Some(text) = entry.get("text").and_then(serde_json::Value::as_str) { - parts.push(text.to_string()); - } - } - }, - "proactive_brief" => { - for suggestion in artifact - .get("suggestions") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - { - if let Some(title) = suggestion.get("title").and_then(serde_json::Value::as_str) - { - parts.push(title.to_string()); - } - if let Some(body) = suggestion.get("body").and_then(serde_json::Value::as_str) { - parts.push(body.to_string()); - } - } - }, - "scheduled_memory" => { - for output in artifact - .get("outputs") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - { - if let Some(text) = output.get("text").and_then(serde_json::Value::as_str) { - parts.push(text.to_string()); - } - } - }, - _ => {}, - } - } - - if parts.is_empty() { - "Service-native Dreaming readback produced no artifact text.".to_string() - } else { - parts.join(" ") - } -} - -fn dreaming_readback_trace_stages( - loaded: &LoadedJob, - evidence: &DreamingReadbackMaterializationEvidence, -) -> Vec { - vec![ - TraceStageOutput { - stage_name: "dreaming_readback.service_list".to_string(), - kept_evidence: evidence.selected_source_refs.clone(), - dropped_evidence: evidence.missing_source_refs.clone(), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: format!( - "Read {} source refs from ElfService::list for {}.", - evidence.selected_source_refs.len(), - loaded.job.suite - ), - }, - TraceStageOutput { - stage_name: "dreaming_readback.source_mutation_guard".to_string(), - kept_evidence: evidence.selected_source_refs.clone(), - dropped_evidence: Vec::new(), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Generated readback artifacts without mutating source notes.".to_string(), - }, - ] -} - -fn search_response_evidence_ids(response: &SearchResponse) -> Vec { - let mut evidence_ids = Vec::new(); - - for item in &response.items { - if let Some(evidence_id) = - item.source_ref.get("evidence_id").and_then(serde_json::Value::as_str) - { - push_unique(&mut evidence_ids, evidence_id.to_string()); - } - } - - evidence_ids -} - -fn suite_materialization_selection( - input: SuiteMaterializationSelectionInput<'_>, -) -> SuiteMaterializationSelection { - let suite_claims_materialized = input.capture_failure.is_none() - && ((input.loaded.job.suite == "knowledge_compilation" && input.knowledge.is_some()) - || (input.loaded.job.suite == "consolidation" && input.consolidation.is_some()) - || input.dreaming_readback.is_some()); - let selected = if let Some(output) = &input.dreaming_readback { - SelectedEvidenceText { - content: output.content.clone(), - evidence_ids: output.evidence_ids.clone(), - } - } else if suite_claims_materialized { - expected_claim_text( - input.loaded, - live_required_evidence_ids(input.loaded, input.ingested).as_slice(), - ) - } else { - input.selected - }; - let trace_stages = input - .dreaming_readback - .as_ref() - .map(|output| output.trace_stages.clone()) - .or(input.trace_stages); - let memory_summaries = input - .dreaming_readback - .as_ref() - .map(|output| output.memory_summaries.clone()) - .unwrap_or_default(); - let proactive_briefs = input - .dreaming_readback - .as_ref() - .map(|output| output.proactive_briefs.clone()) - .unwrap_or_default(); - let scheduled_tasks = input - .dreaming_readback - .as_ref() - .map(|output| output.scheduled_tasks.clone()) - .unwrap_or_default(); - let dreaming_readback = - input.dreaming_readback.as_ref().map(|output| output.materialization.clone()); - - SuiteMaterializationSelection { - selected, - trace_stages, - dreaming_readback, - memory_summaries, - proactive_briefs, - scheduled_tasks, - } -} - -async fn materialize_elf_dreaming_readback( - service: &ElfService, - loaded: &LoadedJob, - project_id: &str, - trace_id: Uuid, - adapter_id: &str, -) -> color_eyre::Result> { - if !is_elf_dreaming_readback_live_adapter(adapter_id, loaded.job.suite.as_str()) { - return Ok(None); - } - - let generated_at = OffsetDateTime::now_utc().format(&Rfc3339)?; - let service_evidence_ids = service_readback_evidence_ids(service, project_id).await?; - let mut artifacts = dreaming_readback_template_artifacts(loaded)?; - - for artifact in &mut artifacts { - stamp_dreaming_readback_artifact( - artifact, - loaded, - project_id, - trace_id, - generated_at.as_str(), - ); - } - - let mut artifact_source_refs = Vec::new(); - - for artifact in &artifacts { - collect_dreaming_artifact_source_refs(artifact, &mut artifact_source_refs); - } - - artifact_source_refs.sort(); - artifact_source_refs.dedup(); - - let missing_source_refs = artifact_source_refs - .iter() - .filter(|source_ref| !service_evidence_ids.contains(*source_ref)) - .cloned() - .collect::>(); - let returned_source_refs = artifact_source_refs - .iter() - .filter(|source_ref| service_evidence_ids.contains(*source_ref)) - .cloned() - .collect::>(); - let scoring_evidence_ids = - dreaming_readback_scoring_evidence_ids(loaded, &service_evidence_ids); - let artifact_kind = match loaded.job.suite.as_str() { - "memory_summary" => "elf.memory_summary/v1", - "proactive_brief" => "elf.proactive_project_brief/v1", - "scheduled_memory" => "elf.scheduled_memory_task/v1", - _ => "elf.dreaming_readback/v1", - }; - let materialization = DreamingReadbackMaterializationEvidence { - artifact_kind: artifact_kind.to_string(), - runtime_path: "ElfService::add_note -> ElfService::list -> derived readback artifact" - .to_string(), - service_list_count: service_evidence_ids.len(), - trace_id: Some(trace_id), - generated_artifact_count: artifacts.len(), - selected_source_refs: returned_source_refs.clone(), - missing_source_refs, - source_mutation_count: 0, - no_source_mutation_checked: true, - }; - let trace_stages = dreaming_readback_trace_stages(loaded, &materialization); - let content = dreaming_readback_content(loaded.job.suite.as_str(), &artifacts); - let (memory_summaries, proactive_briefs, scheduled_tasks) = match loaded.job.suite.as_str() { - "memory_summary" => (artifacts, Vec::new(), Vec::new()), - "proactive_brief" => (Vec::new(), artifacts, Vec::new()), - "scheduled_memory" => (Vec::new(), Vec::new(), artifacts), - _ => (Vec::new(), Vec::new(), Vec::new()), - }; - - Ok(Some(DreamingReadbackOutput { - content, - evidence_ids: scoring_evidence_ids, - memory_summaries, - proactive_briefs, - scheduled_tasks, - materialization, - trace_stages, - })) -} - -async fn service_readback_evidence_ids( - service: &ElfService, - project_id: &str, -) -> color_eyre::Result> { - let response = service - .list(ListRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.to_string(), - agent_id: Some(AGENT_ID.to_string()), - scope: Some(SCOPE.to_string()), - status: Some("active".to_string()), - r#type: None, - }) - .await - .map_err(|err| eyre::eyre!("ELF service-native readback list failed: {err}"))?; - let mut evidence_ids = Vec::new(); - - for item in response.items { - if let Some(evidence_id) = - item.source_ref.get("evidence_id").and_then(serde_json::Value::as_str) - { - push_unique(&mut evidence_ids, evidence_id.to_string()); - } - } - - Ok(evidence_ids) -} - -async fn run_lightrag_async(args: LightragArgs) -> color_eyre::Result<()> { - let jobs = load_jobs(&args.fixtures)?; - let run_slug = short_hash(format!("{}:{}", args.adapter_id, Uuid::new_v4()).as_str()); - let result = materialize_lightrag_jobs(&args, &jobs, &run_slug).await; - let materialized = match result { - Ok(jobs) => jobs, - Err(err) => lightrag_failure_jobs( - &args.adapter_id, - &jobs, - "lightrag_api_context_export", - err.to_string(), - ), - }; - let status = aggregate_status(&materialized); - - write_materialized_output(MaterializedOutput { - adapter_id: &args.adapter_id, - adapter_kind: AdapterKind::LightragApiContextExport, - fixtures: &args.fixtures, - out_fixtures: &args.out_fixtures, - evidence_out: &args.evidence_out, - jobs: &jobs, - materialized: &materialized, - command_evidence: vec![CommandEvidence { - label: "lightrag_api_context_export".to_string(), - status, - command: "cargo run -p elf-eval --bin real_world_live_adapter -- lightrag" - .to_string(), - artifact: Some(args.evidence_out.display().to_string()), - reason: "LightRAG adapter used /documents/texts, /documents/track_status, and /query with only_need_context plus chunk references.".to_string(), - }], - metadata: Some(lightrag_metadata(&args, &run_slug)), - }) -} - -async fn materialize_lightrag_jobs( - args: &LightragArgs, - jobs: &[LoadedJob], - run_slug: &str, -) -> color_eyre::Result> { - fs::create_dir_all(&args.work_dir)?; - - let client = reqwest::Client::builder().timeout(Duration::from_secs(180)).build()?; - - wait_for_lightrag(args, &client).await?; - - let mut out = Vec::with_capacity(jobs.len()); - - for loaded in jobs { - out.push(materialize_lightrag_job(args, &client, loaded, run_slug).await?); - } - - Ok(out) -} - -async fn wait_for_lightrag( - args: &LightragArgs, - client: &reqwest::Client, -) -> color_eyre::Result<()> { - let mut last_error = String::new(); - - for _attempt in 1..=args.startup_attempts { - match lightrag_get_json(args, client, "/health").await { - Ok(_) => return Ok(()), - Err(err) => last_error = err.to_string(), - } - - time::sleep(Duration::from_secs(args.startup_interval_seconds)).await; - } - - Err(eyre::eyre!( - "LightRAG API did not become healthy at {} after {} attempts: {}", - lightrag_api_base(args), - args.startup_attempts, - last_error - )) -} - -async fn materialize_lightrag_job( - args: &LightragArgs, - client: &reqwest::Client, - loaded: &LoadedJob, - run_slug: &str, -) -> color_eyre::Result { - if let Some(job) = declared_encoding_job(&args.adapter_id, loaded) { - return Ok(job); - } - if let Some(job) = lightrag_not_encoded_job(&args.adapter_id, loaded) { - return Ok(job); - } - - let corpus = corpus_texts(loaded)?; - let sources = write_lightrag_corpus(args, loaded, &corpus, run_slug)?; - let indexed_at = Instant::now(); - let insert_response = insert_lightrag_texts(args, client, &corpus, &sources).await?; - - wait_for_lightrag_index(args, client, &insert_response, corpus.len()).await?; - - let indexing_latency_ms = indexed_at.elapsed().as_secs_f64() * 1_000.0; - let queried_at = Instant::now(); - let query_response = query_lightrag_context(args, client, loaded).await?; - let latency_ms = queried_at.elapsed().as_secs_f64() * 1_000.0; - let source_mappings = lightrag_source_mappings(&corpus, &sources, &query_response); - let evidence_ids = lightrag_mapped_evidence_ids(&source_mappings); - let selected = selected_required_corpus_texts(loaded, &corpus, &evidence_ids); - - Ok(materialized_job( - loaded, - &args.adapter_id, - MaterializedJobInput { - content: selected.content, - evidence_ids: selected.evidence_ids, - pages: Vec::new(), - latency_ms, - indexing_latency_ms: Some(indexing_latency_ms), - returned_count: source_mappings.len(), - trace_id: None, - failure: None, - source_mappings, - operator_debug: None, - operator_debug_evidence: None, - capture: None, - capture_failure: None, - consolidation_response: None, - consolidation: None, - knowledge: None, - temporal_reconciliation: None, - dreaming_readback: None, - memory_summaries: Vec::new(), - proactive_briefs: Vec::new(), - scheduled_tasks: Vec::new(), - trace_stages: None, - }, - )) -} - -async fn insert_lightrag_texts( - args: &LightragArgs, - client: &reqwest::Client, - corpus: &[CorpusText], - sources: &[LightragSource], -) -> color_eyre::Result { - let request = serde_json::json!({ - "texts": corpus.iter().map(|item| item.text.as_str()).collect::>(), - "file_sources": sources.iter().map(|source| source.file_source.as_str()).collect::>(), - "chunking": { - "strategy": "fixed_token", - "params": { - "chunk_token_size": 320, - "chunk_overlap_token_size": 32 - } - } - }); - - lightrag_post_json(args, client, "/documents/texts", &request).await -} - -async fn wait_for_lightrag_index( - args: &LightragArgs, - client: &reqwest::Client, - insert_response: &serde_json::Value, - expected_docs: usize, -) -> color_eyre::Result<()> { - let track_id = insert_response - .get("track_id") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| eyre::eyre!("LightRAG text insert response did not include track_id."))?; - let mut last_status = serde_json::Value::Null; - - for _attempt in 1..=args.index_attempts { - let status = - lightrag_get_json(args, client, format!("/documents/track_status/{track_id}")).await?; - - if lightrag_index_failed(&status) { - return Err(eyre::eyre!( - "LightRAG document indexing failed for track_id {track_id}: {}", - serde_json::to_string(&status)? - )); - } - if lightrag_index_processed(&status, expected_docs) { - return Ok(()); - } - - last_status = status; - - time::sleep(Duration::from_secs(args.index_interval_seconds)).await; - } - - Err(eyre::eyre!( - "LightRAG document indexing did not finish for track_id {} after {} attempts: {}", - track_id, - args.index_attempts, - serde_json::to_string(&last_status)? - )) -} - -async fn query_lightrag_context( - args: &LightragArgs, - client: &reqwest::Client, - loaded: &LoadedJob, -) -> color_eyre::Result { - let keywords = lightrag_keywords(loaded.job.prompt.content.as_str()); - let request = serde_json::json!({ - "query": loaded.job.prompt.content, - "mode": args.query_mode, - "only_need_context": true, - "include_references": true, - "include_chunk_content": true, - "enable_rerank": false, - "top_k": args.top_k, - "chunk_top_k": args.chunk_top_k, - "hl_keywords": keywords, - "ll_keywords": keywords, - "stream": false - }); - - lightrag_post_json(args, client, "/query", &request).await -} - -async fn lightrag_get_json( - args: &LightragArgs, - client: &reqwest::Client, - path: impl AsRef, -) -> color_eyre::Result { - let url = format!("{}{}", lightrag_api_base(args), path.as_ref()); - let mut request = client.get(url); - - if let Some(api_key) = args.api_key.as_deref().filter(|key| !key.is_empty()) { - request = request.bearer_auth(api_key); - } - - lightrag_send_json(request).await -} - -async fn lightrag_post_json( - args: &LightragArgs, - client: &reqwest::Client, - path: &str, - body: &serde_json::Value, -) -> color_eyre::Result { - let url = format!("{}{}", lightrag_api_base(args), path); - let mut request = client.post(url).json(body); - - if let Some(api_key) = args.api_key.as_deref().filter(|key| !key.is_empty()) { - request = request.bearer_auth(api_key); - } - - lightrag_send_json(request).await -} - -async fn lightrag_send_json(request: RequestBuilder) -> color_eyre::Result { - let response = request.send().await?; - let status = response.status(); - let body = response.text().await?; - - if !status.is_success() { - return Err(eyre::eyre!("LightRAG API returned HTTP {status}: {body}")); - } - - serde_json::from_str(&body) - .map_err(|err| eyre::eyre!("LightRAG API returned invalid JSON: {err}; body={body}")) -} - -#[tokio::main] -async fn main() -> color_eyre::Result<()> { - color_eyre::install()?; - - match Args::parse().command { - CommandArgs::Elf(args) => run_elf(args).await, - CommandArgs::Qmd(args) => run_qmd(args), - CommandArgs::Lightrag(args) => run_lightrag_async(args).await, - } -} - -async fn run_elf(args: ElfArgs) -> color_eyre::Result<()> { - let jobs = load_jobs(&args.fixtures)?; - let result = materialize_elf_jobs(&args, &jobs).await; - let materialized = match result { - Ok(jobs) => jobs, - Err(err) => failure_jobs(&args.adapter_id, &jobs, "elf_service_runtime", err.to_string()), - }; - - write_materialized_output(MaterializedOutput { - adapter_id: &args.adapter_id, - adapter_kind: AdapterKind::ElfServiceRuntime, - fixtures: &args.fixtures, - out_fixtures: &args.out_fixtures, - evidence_out: &args.evidence_out, - jobs: &jobs, - materialized: &materialized, - command_evidence: vec![CommandEvidence { - label: "elf_service_runtime".to_string(), - status: aggregate_status(&materialized), - command: "cargo run -p elf-eval --bin real_world_live_adapter -- elf".to_string(), - artifact: Some(args.evidence_out.display().to_string()), - reason: "ELF live adapter used ElfService, worker indexing, and search_raw." - .to_string(), - }], - metadata: None, - }) -} - -async fn materialize_elf_jobs( - args: &ElfArgs, - jobs: &[LoadedJob], -) -> color_eyre::Result> { - let base_dsn = env::var("ELF_PG_DSN") - .map_err(|_| eyre::eyre!("ELF_PG_DSN must be set for ELF live real-world adapter."))?; - let qdrant_url = env::var("ELF_QDRANT_GRPC_URL") - .or_else(|_| env::var("ELF_QDRANT_URL")) - .map_err(|_| eyre::eyre!("ELF_QDRANT_GRPC_URL or ELF_QDRANT_URL must be set."))?; - let test_db = TestDatabase::new(&base_dsn).await?; - let run_suffix = short_hash(format!("{}:{}", args.adapter_id, Uuid::new_v4()).as_str()); - let runtime = BaselineRuntime { - config_path: args.config.clone(), - dsn: test_db.dsn().to_string(), - qdrant_url, - collection: format!("elf_live_real_world_{run_suffix}"), - docs_collection: format!("elf_live_real_world_docs_{run_suffix}"), - }; - let service = build_service(&runtime).await?; - let mut out = Vec::with_capacity(jobs.len()); - - for loaded in jobs { - out.push(materialize_elf_job(&runtime, &service, loaded, &args.adapter_id).await?); - } - - drop(service); - - test_db.cleanup().await?; - - Ok(out) -} - -async fn materialize_elf_job( - runtime: &BaselineRuntime, - service: &ElfService, - loaded: &LoadedJob, - adapter_id: &str, -) -> color_eyre::Result { - if let Some(job) = declared_encoding_job(adapter_id, loaded) { - return Ok(job); - } - if let Some(job) = not_encoded_job(adapter_id, loaded) { - return Ok(job); - } - - let corpus = corpus_texts(loaded)?; - let stored_corpus = elf_stored_corpus_texts(&corpus)?; - let project_id = project_id_for_job(&loaded.job.job_id); - let ingested = - ingest_elf_corpus(service, loaded, adapter_id, project_id.as_str(), &corpus).await?; - - run_worker(runtime).await?; - - let (response, latency_ms) = search_elf_job(service, loaded, &project_id).await?; - let evidence_ids = search_response_evidence_ids(&response); - let runtime_capture = capture_runtime_evidence_from_search_items(&response.items); - let capture = capture_with_runtime_source_refs(ingested.capture.clone(), &runtime_capture); - let capture_failure = validate_capture_runtime_evidence( - loaded.job.suite.as_str(), - &corpus, - &capture, - &runtime_capture, - ); - let (selected, temporal_reconciliation, trace_stages) = elf_selected_evidence_text( - loaded, - &stored_corpus, - &evidence_ids, - &ingested, - &capture_failure, - ); - let replay_command = elf_replay_command(response.trace_id, project_id.as_str()); - let (operator_debug, operator_debug_evidence) = operator_debug_output( - AdapterKind::ElfServiceRuntime, - loaded, - Some(response.trace_id), - replay_command, - format!( - "/v2/admin/traces/{}/bundle?mode=full&stage_items_limit=128&candidates_limit=200", - response.trace_id - ), - ); - let (pages, knowledge, knowledge_failure) = - match materialize_elf_knowledge(service, loaded, &ingested, adapter_id).await { - Ok(output) => output, - Err(err) if loaded.job.suite == "knowledge_compilation" => - (Vec::new(), None, Some(format!("live_adapter.knowledge: {err}"))), - Err(_) => (Vec::new(), None, None), - }; - let (consolidation_response, consolidation, consolidation_failure) = - match materialize_elf_consolidation(runtime, service, loaded, &ingested, adapter_id).await { - Ok(output) => output, - Err(err) if loaded.job.suite == "consolidation" => - (None, None, Some(format!("live_adapter.consolidation: {err}"))), - Err(_) => (None, None, None), - }; - let dreaming_readback = materialize_elf_dreaming_readback( - service, - loaded, - project_id.as_str(), - response.trace_id, - adapter_id, - ) - .await?; - let dreaming_failure = dreaming_readback.as_ref().and_then(|output| { - if output.materialization.missing_source_refs.is_empty() { - None - } else { - Some(format!( - "live_adapter.dreaming_readback missing source refs: {}", - output.materialization.missing_source_refs.join(", ") - )) - } - }); - let failure = knowledge_failure.or(consolidation_failure).or(dreaming_failure); - let suite_selection = suite_materialization_selection(SuiteMaterializationSelectionInput { - loaded, - ingested: &ingested, - capture_failure: &capture_failure, - selected, - trace_stages, - knowledge: &knowledge, - consolidation: &consolidation, - dreaming_readback, - }); - - Ok(materialized_job( - loaded, - adapter_id, - MaterializedJobInput { - content: suite_selection.selected.content, - evidence_ids: suite_selection.selected.evidence_ids, - pages, - latency_ms, - indexing_latency_ms: None, - returned_count: response.items.len(), - trace_id: Some(response.trace_id), - failure, - source_mappings: Vec::new(), - operator_debug, - operator_debug_evidence, - capture: capture_for_job(loaded, capture), - capture_failure, - consolidation_response, - consolidation, - knowledge, - temporal_reconciliation, - dreaming_readback: suite_selection.dreaming_readback, - memory_summaries: suite_selection.memory_summaries, - proactive_briefs: suite_selection.proactive_briefs, - scheduled_tasks: suite_selection.scheduled_tasks, - trace_stages: suite_selection.trace_stages, - }, - )) -} - -async fn search_elf_job( - service: &ElfService, - loaded: &LoadedJob, - project_id: &str, -) -> color_eyre::Result<(SearchResponse, f64)> { - let started_at = Instant::now(); - let response = service - .search_raw(SearchRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.to_string(), - agent_id: AGENT_ID.to_string(), - token_id: None, - payload_level: PayloadLevel::L2, - read_profile: "private_only".to_string(), - query: loaded.job.prompt.content.clone(), - top_k: Some(5), - candidate_k: Some(20), - filter: None, - record_hits: Some(false), - ranking: None, - }) - .await - .map_err(|err| eyre::eyre!("ELF search_raw failed for {}: {err}", loaded.job.job_id))?; - - Ok((response, started_at.elapsed().as_secs_f64() * 1_000.0)) -} - -async fn materialize_elf_consolidation( - runtime: &BaselineRuntime, - service: &ElfService, - loaded: &LoadedJob, - ingested: &IngestedCorpus, - adapter_id: &str, -) -> color_eyre::Result<( - Option, - Option, - Option, -)> { - if loaded.job.suite != "consolidation" { - return Ok((None, None, None)); - } - - let project_id = project_id_for_job(&loaded.job.job_id); - let fixture = live_consolidation_fixture(loaded)?; - let corpus = corpus_texts(loaded)?; - let prepared = prepare_consolidation_run(loaded, adapter_id, ingested, &fixture, &corpus)?; - let run = service - .consolidation_run_create(ConsolidationRunCreateRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.clone(), - agent_id: AGENT_ID.to_string(), - job_kind: "fixture".to_string(), - input_refs: prepared.input_refs.clone(), - source_snapshot: serde_json::json!({ - "schema": "real_world_live_consolidation_run_snapshot/v1", - "adapter_id": adapter_id, - "job_id": loaded.job.job_id, - "source_ref_count": prepared.input_refs.len() - }), - lineage: ConsolidationLineage { - source_refs: prepared.input_refs.clone(), - parent_run_id: None, - parent_proposal_ids: Vec::new(), - }, - proposals: prepared.proposals, - }) - .await - .map_err(|err| { - eyre::eyre!("ELF consolidation_run_create failed for {}: {err}", loaded.job.job_id) - })?; - - run_worker(runtime).await?; - - let reviewed = review_live_consolidation_proposals( - service, - loaded, - project_id.as_str(), - run.run.run_id, - &fixture, - ) - .await?; - let consolidation_response = live_consolidation_response(&fixture, &reviewed)?; - let evidence = consolidation_materialization_evidence( - run.run.run_id, - &fixture, - &prepared.input_refs, - &reviewed, - ); - - Ok((Some(consolidation_response), Some(evidence), None)) -} - -async fn materialize_elf_knowledge( - service: &ElfService, - loaded: &LoadedJob, - ingested: &IngestedCorpus, - adapter_id: &str, -) -> color_eyre::Result<( - Vec, - Option, - Option, -)> { - if loaded.job.suite != "knowledge_compilation" { - return Ok((Vec::new(), None, None)); - } - - let project_id = project_id_for_job(&loaded.job.job_id); - let note_ids = live_note_ids(ingested); - - if note_ids.is_empty() { - return Err(eyre::eyre!( - "{} has no live note sources for knowledge rebuild.", - loaded.job.job_id - )); - } - - let page_key = slug(&loaded.job.job_id); - let request = KnowledgePageRebuildRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.clone(), - agent_id: AGENT_ID.to_string(), - page_kind: KnowledgePageKind::Project, - page_key, - title: Some(loaded.job.title.clone()), - doc_ids: Vec::new(), - doc_chunk_ids: Vec::new(), - note_ids: note_ids.clone(), - event_ids: Vec::new(), - relation_ids: Vec::new(), - proposal_ids: Vec::new(), - provider_metadata: serde_json::json!({ - "adapter_id": adapter_id, - "job_id": loaded.job.job_id, - "llm_derived": false, - "runtime_path": "ElfService::knowledge_page_rebuild" - }), - }; - let first = service.knowledge_page_rebuild(request.clone()).await.map_err(|err| { - eyre::eyre!("ELF knowledge_page_rebuild failed for {}: {err}", loaded.job.job_id) - })?; - let second = service.knowledge_page_rebuild(request).await.map_err(|err| { - eyre::eyre!("ELF second knowledge_page_rebuild failed for {}: {err}", loaded.job.job_id) - })?; - - update_stale_trap_sources(service, loaded, adapter_id, project_id.as_str()).await?; - - let lint = service - .knowledge_page_lint(KnowledgePageLintRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.clone(), - page_id: second.page.page.page_id, - }) - .await - .map_err(|err| { - eyre::eyre!("ELF knowledge_page_lint failed for {}: {err}", loaded.job.job_id) - })?; - let search = service - .knowledge_pages_search(KnowledgePageSearchRequest { - tenant_id: TENANT_ID.to_string(), - project_id, - agent_id: AGENT_ID.to_string(), - read_profile: "private_only".to_string(), - query: "source notes".to_string(), - page_kind: Some(KnowledgePageKind::Project), - limit: Some(10), - }) - .await - .map_err(|err| { - eyre::eyre!("ELF knowledge_pages_search failed for {}: {err}", loaded.job.job_id) - })?; - let page = knowledge_page_artifact(loaded, ingested, &first.page, &second.page, &lint)?; - let evidence = knowledge_materialization_evidence(&second.page, &lint, search.items.len()); - - Ok((vec![page], Some(evidence), None)) -} - -async fn ingest_elf_corpus( - service: &ElfService, - loaded: &LoadedJob, - adapter_id: &str, - project_id: &str, - corpus: &[CorpusText], -) -> color_eyre::Result { - let mut ingested = IngestedCorpus::default(); - - for item in corpus { - if item.capture.action == LiveCaptureAction::Exclude { - push_unique(&mut ingested.capture.excluded_evidence_ids, item.evidence_id.clone()); - - continue; - } - - push_unique(&mut ingested.capture.stored_evidence_ids, item.evidence_id.clone()); - - if let Some(source_id) = item.capture.source_id.as_deref() { - push_unique(&mut ingested.capture.source_ids, source_id.to_string()); - } - - if item.capture.write_policy.is_some() { - let note_id = ingest_elf_corpus_item( - service, - loaded, - adapter_id, - project_id, - item, - item.evidence_id.clone(), - item.text.clone(), - 0, - 1, - &mut ingested.capture, - ) - .await?; - - ingested - .note_ids_by_evidence - .entry(item.evidence_id.clone()) - .or_default() - .push(note_id); - - continue; - } - - let chunks = note_text_chunks(item.text.as_str()); - let chunk_count = chunks.len(); - - for (chunk_index, text) in chunks.into_iter().enumerate() { - let key = if chunk_count == 1 { - item.evidence_id.clone() - } else { - format!("{}:chunk-{chunk_index:03}", item.evidence_id) - }; - let note_id = ingest_elf_corpus_item( - service, - loaded, - adapter_id, - project_id, - item, - key, - text, - chunk_index, - chunk_count, - &mut ingested.capture, - ) - .await?; - - ingested - .note_ids_by_evidence - .entry(item.evidence_id.clone()) - .or_default() - .push(note_id); - } - } - - Ok(ingested) -} - -#[allow(clippy::too_many_arguments)] -async fn ingest_elf_corpus_item( - service: &ElfService, - loaded: &LoadedJob, - adapter_id: &str, - project_id: &str, - item: &CorpusText, - key: String, - text: String, - chunk_index: usize, - chunk_count: usize, - capture: &mut CaptureMaterializationEvidence, -) -> color_eyre::Result { - let write_policy = item - .capture - .write_policy - .as_ref() - .map(|policy| write_policy_from_value(policy, item.evidence_id.as_str())) - .transpose()?; - let response = service - .add_note(AddNoteRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.to_string(), - agent_id: AGENT_ID.to_string(), - scope: SCOPE.to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some(key), - text, - structured: None, - importance: 0.9, - confidence: 0.95, - ttl_days: None, - source_ref: serde_json::json!({ - "schema": "real_world_live_adapter/v1", - "adapter": adapter_id, - "job_id": loaded.job.job_id, - "evidence_id": item.evidence_id, - "source_id": item.capture.source_id.as_deref(), - "capture_action": capture_action_str(item.capture.action), - "evidence_binding": item.capture.evidence_binding.as_deref(), - "write_policy_applied": item.capture.write_policy.is_some(), - "chunk_index": chunk_index, - "chunk_count": chunk_count, - }), - write_policy, - }], - }) - .await - .map_err(|err| eyre::eyre!("ELF add_note failed for {}: {err}", loaded.job.job_id))?; - - for result in &response.results { - if let Some(audit) = &result.write_policy_audit - && (!audit.exclusions.is_empty() || !audit.redactions.is_empty()) - { - capture.write_policy_audit_count += 1; - capture.write_policy_exclusion_count += audit.exclusions.len(); - capture.write_policy_redaction_count += audit.redactions.len(); - } - } - - response.results.iter().find_map(|result| result.note_id).ok_or_else(|| { - eyre::eyre!( - "ELF add_note did not persist evidence {} chunk {} for {}.", - item.evidence_id, - chunk_index, - loaded.job.job_id - ) - }) -} - -async fn review_live_consolidation_proposals( - service: &ElfService, - loaded: &LoadedJob, - project_id: &str, - run_id: Uuid, - fixture: &LiveConsolidationFixture, -) -> color_eyre::Result> { - let listed = service - .consolidation_proposals_list(ConsolidationProposalsListRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.to_string(), - run_id: Some(run_id), - review_state: None, - limit: Some(100), - }) - .await - .map_err(|err| { - eyre::eyre!("ELF consolidation proposal list failed for {}: {err}", loaded.job.job_id) - })?; - let mut reviewed = Vec::new(); - - for (index, proposal) in listed.proposals.into_iter().enumerate() { - let fixture_proposal = fixture.proposals.get(index).ok_or_else(|| { - eyre::eyre!( - "ELF consolidation materialized extra proposal {} for {}.", - proposal.proposal_id, - loaded.job.job_id - ) - })?; - let review_action = - consolidation_review_action(fixture_proposal.actual_review_action.as_str())?; - - reviewed.push( - service - .consolidation_proposal_review(ConsolidationProposalReviewRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.to_string(), - reviewer_agent_id: AGENT_ID.to_string(), - proposal_id: proposal.proposal_id, - review_action, - review_comment: Some( - "Live adapter review transition for real-world benchmark evidence." - .to_string(), - ), - }) - .await - .map_err(|err| { - eyre::eyre!( - "ELF consolidation proposal review failed for {}: {err}", - loaded.job.job_id - ) - })?, - ); - } - - validate_reviewed_consolidation_count(loaded, fixture, &reviewed)?; - - Ok(reviewed) -} - -async fn update_stale_trap_sources( - service: &ElfService, - loaded: &LoadedJob, - adapter_id: &str, - project_id: &str, -) -> color_eyre::Result<()> { - for evidence_id in stale_trap_evidence_ids(loaded) { - service - .add_note(AddNoteRequest { - tenant_id: TENANT_ID.to_string(), - project_id: project_id.to_string(), - agent_id: AGENT_ID.to_string(), - scope: SCOPE.to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some(evidence_id.clone()), - text: format!( - "Current lint probe: evidence {evidence_id} changed after the knowledge page rebuild and should mark the derived page source snapshot stale." - ), - structured: None, - importance: 0.9, - confidence: 0.95, - ttl_days: None, - source_ref: serde_json::json!({ - "schema": "real_world_live_adapter/v1", - "adapter": adapter_id, - "job_id": loaded.job.job_id, - "evidence_id": evidence_id, - "lint_probe": "stale_source_ref" - }), - write_policy: None, - }], - }) - .await - .map_err(|err| { - eyre::eyre!( - "ELF add_note stale-source update failed for {}: {err}", - loaded.job.job_id - ) - })?; - } - - Ok(()) -} - -async fn build_service(runtime: &BaselineRuntime) -> color_eyre::Result { - let cfg = runtime_config(runtime)?; - let vector_dim = cfg.storage.qdrant.vector_dim; - let db = Db::connect(&cfg.storage.postgres).await?; - - db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; - - let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; - - qdrant.ensure_collection().await?; - - Ok(ElfService::with_providers(cfg, db, qdrant, deterministic_providers(vector_dim))) -} - -async fn build_worker_state(runtime: &BaselineRuntime) -> color_eyre::Result { - let cfg = runtime_config(runtime)?; - let db = Db::connect(&cfg.storage.postgres).await?; - - db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; - - let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; - - qdrant.ensure_collection().await?; - - let docs_qdrant = - QdrantStore::new_with_collection(&cfg.storage.qdrant, &cfg.storage.qdrant.docs_collection)?; - - docs_qdrant.ensure_collection().await?; - - let tokenizer = elf_chunking::load_tokenizer(&cfg.chunking.tokenizer_repo) - .map_err(|err| eyre::eyre!("Failed to load tokenizer for live adapter worker: {err}"))?; - let chunking = ChunkingConfig { - max_tokens: cfg.chunking.max_tokens, - overlap_tokens: cfg.chunking.overlap_tokens, - }; - - Ok(WorkerState { - db, - qdrant, - docs_qdrant, - embedding: cfg.providers.embedding, - chunking, - tokenizer, - }) -} - -async fn run_worker(runtime: &BaselineRuntime) -> color_eyre::Result<()> { - let state = Arc::new(build_worker_state(runtime).await?); - - for _ in 0..8 { - let state = Arc::clone(&state); - let mut set = JoinSet::new(); - - set.spawn(async move { - worker::process_once(&state) - .await - .map_err(|err| eyre::eyre!("Worker process_once failed: {err}")) - }); - - while let Some(joined) = set.join_next().await { - joined??; - } - } - - Ok(()) } #[cfg(test)] -mod tests { - use serde_json::Value; - - fn capture_item( - evidence_id: &str, - action: super::LiveCaptureAction, - source_id: Option<&str>, - evidence_binding: Option<&str>, - write_policy: Option, - ) -> super::CorpusText { - super::CorpusText { - evidence_id: evidence_id.to_string(), - text: "Public capture text.".to_string(), - capture: super::LiveCapturePolicy { - action, - source_id: source_id.map(ToString::to_string), - evidence_binding: evidence_binding.map(ToString::to_string), - write_policy, - }, - } - } - - fn capture_evidence( - stored: &[&str], - excluded: &[&str], - ) -> super::CaptureMaterializationEvidence { - super::CaptureMaterializationEvidence { - stored_evidence_ids: stored.iter().map(|id| (*id).to_string()).collect(), - excluded_evidence_ids: excluded.iter().map(|id| (*id).to_string()).collect(), - source_ids: Vec::new(), - write_policy_audit_count: 0, - write_policy_exclusion_count: 0, - write_policy_redaction_count: 0, - runtime_source_refs: Vec::new(), - } - } - - #[test] - fn capture_runtime_validation_requires_returned_source_id() { - let corpus = vec![capture_item( - "source-a", - super::LiveCaptureAction::Store, - Some("capture:a"), - None, - None, - )]; - let capture = capture_evidence(&["source-a"], &[]); - let runtime = super::capture_runtime_evidence_from_source_refs([&serde_json::json!({ - "evidence_id": "source-a", - "capture_action": "store" - })]); - let failure = super::validate_capture_runtime_evidence( - "capture_integration", - &corpus, - &capture, - &runtime, - ) - .expect("missing runtime source_id should fail capture validation"); - - assert!(failure.contains("did not return expected source_id capture:a")); - } - - #[test] - fn capture_runtime_validation_rejects_returned_excluded_evidence() { - let corpus = vec![capture_item( - "private-trap", - super::LiveCaptureAction::Exclude, - Some("capture:private"), - Some("negative_trap"), - None, - )]; - let capture = capture_evidence(&[], &["private-trap"]); - let runtime = super::capture_runtime_evidence_from_source_refs([&serde_json::json!({ - "evidence_id": "private-trap", - "source_id": "capture:private", - "capture_action": "store" - })]); - let failure = super::validate_capture_runtime_evidence( - "capture_integration", - &corpus, - &capture, - &runtime, - ) - .expect("returned excluded evidence should fail capture validation"); - - assert!(failure.contains("excluded evidence private-trap was returned by live search")); - } - - #[test] - fn capture_runtime_source_refs_are_written_into_generated_fixture() { - let mut value = serde_json::json!({ - "corpus": { - "items": [ - { - "evidence_id": "source-a", - "source_ref": { - "schema": "source_ref/v1", - "resolver": "fixture" - } - } - ] - } - }); - let mut capture = capture_evidence(&["source-a"], &[]); - - capture.runtime_source_refs.push(super::CaptureRuntimeSourceRefEvidence { - evidence_id: "source-a".to_string(), - source_ref: serde_json::json!({ - "schema": "real_world_live_adapter/v1", - "evidence_id": "source-a", - "source_id": "capture:a", - "capture_action": "store", - "evidence_binding": "source_ref" - }), - }); - - super::apply_capture_runtime_source_refs(&mut value, &capture); - - assert_eq!( - value - .pointer("/corpus/items/0/source_ref/source_id") - .and_then(serde_json::Value::as_str), - Some("capture:a") - ); - assert_eq!( - value - .pointer("/corpus/items/0/source_ref/evidence_binding") - .and_then(serde_json::Value::as_str), - Some("source_ref") - ); - } -} +#[path = "real_world_live_adapter/tests.rs"] +mod tests; diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/capture.rs b/apps/elf-eval/src/bin/real_world_live_adapter/capture.rs new file mode 100644 index 00000000..5e34fb20 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/capture.rs @@ -0,0 +1,272 @@ +use crate::{ + CaptureMaterializationEvidence, CaptureRuntimeEvidence, CaptureRuntimeEvidenceItem, + CaptureRuntimeSourceRefEvidence, CorpusText, LiveCaptureAction, LoadedJob, Result, SearchItem, + eyre, serde_json, +}; +use elf_domain::writegate::{self, WritePolicy}; + +pub(super) fn capture_runtime_evidence_from_search_items( + items: &[SearchItem], +) -> CaptureRuntimeEvidence { + let source_refs = items.iter().map(|item| &item.source_ref); + + capture_runtime_evidence_from_source_refs(source_refs) +} + +pub(super) fn capture_runtime_evidence_from_source_refs<'a>( + source_refs: impl IntoIterator, +) -> CaptureRuntimeEvidence { + let mut runtime = CaptureRuntimeEvidence::default(); + + for source_ref in source_refs { + let Some(evidence_id) = source_ref.get("evidence_id").and_then(serde_json::Value::as_str) + else { + continue; + }; + + if runtime.items.iter().any(|item| item.evidence_id == evidence_id) { + continue; + } + + runtime.items.push(CaptureRuntimeEvidenceItem { + evidence_id: evidence_id.to_string(), + source_id: source_ref + .get("source_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + evidence_binding: source_ref + .get("evidence_binding") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + write_policy_applied: source_ref + .get("write_policy_applied") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + capture_action: source_ref + .get("capture_action") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + source_ref: source_ref.clone(), + }); + } + + runtime +} + +pub(super) fn capture_with_runtime_source_refs( + mut capture: CaptureMaterializationEvidence, + runtime: &CaptureRuntimeEvidence, +) -> CaptureMaterializationEvidence { + capture.source_ids.clear(); + capture.runtime_source_refs.clear(); + + for item in &runtime.items { + if let Some(source_id) = item.source_id.as_deref() { + crate::push_unique(&mut capture.source_ids, source_id.to_string()); + } + + capture.runtime_source_refs.push(CaptureRuntimeSourceRefEvidence { + evidence_id: item.evidence_id.clone(), + source_ref: item.source_ref.clone(), + }); + } + + capture +} + +pub(super) fn validate_capture_runtime_evidence( + suite: &str, + corpus: &[CorpusText], + capture: &CaptureMaterializationEvidence, + runtime: &CaptureRuntimeEvidence, +) -> Option { + if suite != "capture_integration" { + return None; + } + + let mut failures = Vec::new(); + let mut expected_redactions = 0_usize; + let mut expected_exclusions = 0_usize; + + for item in corpus { + match item.capture.action { + LiveCaptureAction::Exclude => { + if runtime.item_for(item.evidence_id.as_str()).is_some() { + failures.push(format!( + "excluded evidence {} was returned by live search", + item.evidence_id + )); + } + if capture.stored_evidence_ids.iter().any(|id| id == &item.evidence_id) { + failures.push(format!( + "excluded evidence {} was stored by live ingestion", + item.evidence_id + )); + } + if !capture.excluded_evidence_ids.iter().any(|id| id == &item.evidence_id) { + failures.push(format!( + "excluded evidence {} was not recorded as excluded", + item.evidence_id + )); + } + }, + LiveCaptureAction::Store => { + let runtime_item = runtime.item_for(item.evidence_id.as_str()); + + if let Some(expected_source_id) = item.capture.source_id.as_deref() { + match runtime_item.and_then(|observed| observed.source_id.as_deref()) { + Some(observed) if observed == expected_source_id => {}, + Some(observed) => failures.push(format!( + "evidence {} returned source_id {observed}, expected {expected_source_id}", + item.evidence_id + )), + None => failures.push(format!( + "evidence {} did not return expected source_id {expected_source_id}", + item.evidence_id + )), + } + } + if let Some(expected_binding) = item.capture.evidence_binding.as_deref() { + match runtime_item.and_then(|observed| observed.evidence_binding.as_deref()) { + Some(observed) if observed == expected_binding => {}, + Some(observed) => failures.push(format!( + "evidence {} returned evidence_binding {observed}, expected {expected_binding}", + item.evidence_id + )), + None => failures.push(format!( + "evidence {} did not return expected evidence_binding {expected_binding}", + item.evidence_id + )), + } + } + if let Some(policy_value) = &item.capture.write_policy { + match write_policy_from_value(policy_value, item.evidence_id.as_str()) { + Ok(policy) => { + expected_exclusions += policy.exclusions.len(); + expected_redactions += policy.redactions.len(); + }, + Err(err) => failures.push(err.to_string()), + } + + if !runtime_item.is_some_and(|observed| observed.write_policy_applied) { + failures.push(format!( + "evidence {} did not return write_policy_applied=true", + item.evidence_id + )); + } + } + if let Some(observed) = + runtime_item.and_then(|observed| observed.capture_action.as_deref()) + && observed != capture_action_str(item.capture.action) + { + failures.push(format!( + "evidence {} returned capture_action {observed}, expected {}", + item.evidence_id, + capture_action_str(item.capture.action) + )); + } + }, + } + } + + if capture.write_policy_exclusion_count < expected_exclusions { + failures.push(format!( + "write-policy exclusion count {} was below expected {expected_exclusions}", + capture.write_policy_exclusion_count + )); + } + if capture.write_policy_redaction_count < expected_redactions { + failures.push(format!( + "write-policy redaction count {} was below expected {expected_redactions}", + capture.write_policy_redaction_count + )); + } + if expected_exclusions + expected_redactions > 0 && capture.write_policy_audit_count == 0 { + failures + .push("write-policy audit count was zero despite expected policy effects".to_string()); + } + if failures.is_empty() { + None + } else { + Some(format!("Capture runtime validation failed: {}", failures.join("; "))) + } +} + +pub(super) fn elf_stored_corpus_texts(corpus: &[CorpusText]) -> Result> { + let mut stored = Vec::new(); + + for item in corpus { + if item.capture.action == LiveCaptureAction::Exclude { + continue; + } + + stored.push(CorpusText { + evidence_id: item.evidence_id.clone(), + text: transformed_capture_text(item)?.trim().to_string(), + capture: item.capture.clone(), + }); + } + + Ok(stored) +} + +pub(super) fn write_policy_from_value( + value: &serde_json::Value, + evidence_id: &str, +) -> Result { + serde_json::from_value::(value.clone()).map_err(|err| { + eyre::eyre!("Failed to parse write_policy for evidence {evidence_id}: {err}") + }) +} + +pub(super) fn apply_capture_runtime_source_refs( + value: &mut serde_json::Value, + capture: &CaptureMaterializationEvidence, +) { + let Some(items) = value.pointer_mut("/corpus/items").and_then(serde_json::Value::as_array_mut) + else { + return; + }; + + for item in items { + let Some(evidence_id) = item.get("evidence_id").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(source_ref) = capture + .runtime_source_refs + .iter() + .find(|source_ref| source_ref.evidence_id == evidence_id) + else { + continue; + }; + + item["source_ref"] = source_ref.source_ref.clone(); + } +} + +pub(super) fn capture_for_job( + loaded: &LoadedJob, + capture: CaptureMaterializationEvidence, +) -> Option { + if loaded.job.suite == "capture_integration" { Some(capture) } else { None } +} + +pub(super) fn capture_action_str(action: LiveCaptureAction) -> &'static str { + match action { + LiveCaptureAction::Store => "store", + LiveCaptureAction::Exclude => "exclude", + } +} + +fn transformed_capture_text(item: &CorpusText) -> Result { + let Some(policy_value) = &item.capture.write_policy else { + return Ok(item.text.clone()); + }; + let policy = write_policy_from_value(policy_value, item.evidence_id.as_str())?; + let result = + writegate::apply_write_policy(item.text.as_str(), Some(&policy)).map_err(|err| { + eyre::eyre!("Invalid write_policy for evidence {}: {err:?}", item.evidence_id) + })?; + + Ok(result.transformed) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/consolidation_adapter.rs b/apps/elf-eval/src/bin/real_world_live_adapter/consolidation_adapter.rs new file mode 100644 index 00000000..66878b5e --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/consolidation_adapter.rs @@ -0,0 +1,365 @@ +use crate::{ + ConsolidationApplyIntent, ConsolidationInputRef, ConsolidationLineage, ConsolidationMarker, + ConsolidationMarkerSeverity, ConsolidationMarkers, ConsolidationMaterializationEvidence, + ConsolidationProposalDiff, ConsolidationProposalInput, ConsolidationProposalResponse, + ConsolidationReviewAction, ConsolidationSourceKind, ConsolidationSourceSnapshot, + ConsolidationUnsupportedClaimFlag, CorpusText, IngestedCorpus, LiveConsolidationFixture, + LiveConsolidationProposal, LoadedJob, OffsetDateTime, PreparedConsolidationRun, Result, Uuid, + eyre, serde_json, +}; + +pub(super) fn live_consolidation_fixture(loaded: &LoadedJob) -> Result { + let value = + loaded.value.pointer("/corpus/adapter_response/consolidation").cloned().ok_or_else( + || { + eyre::eyre!( + "{} does not contain adapter_response.consolidation.", + loaded.path.display() + ) + }, + )?; + + serde_json::from_value(value).map_err(|err| { + eyre::eyre!("Failed to parse consolidation fixture {}: {err}", loaded.path.display()) + }) +} + +pub(super) fn prepare_consolidation_run( + loaded: &LoadedJob, + adapter_id: &str, + ingested: &IngestedCorpus, + fixture: &LiveConsolidationFixture, + corpus: &[CorpusText], +) -> Result { + let mut input_refs = Vec::new(); + let mut proposals = Vec::new(); + + for proposal in &fixture.proposals { + let source_refs = consolidation_input_refs( + loaded, + adapter_id, + proposal.source_refs.as_slice(), + ingested, + corpus, + )?; + + for source_ref in &source_refs { + push_unique_input_ref(&mut input_refs, source_ref.clone()); + } + + proposals.push(consolidation_proposal_input( + loaded, + adapter_id, + ingested, + corpus, + proposal, + source_refs, + &input_refs, + )?); + } + + if proposals.is_empty() { + return Err(eyre::eyre!("{} has no consolidation proposals.", loaded.job.job_id)); + } + + Ok(PreparedConsolidationRun { input_refs, proposals }) +} + +pub(super) fn validate_reviewed_consolidation_count( + loaded: &LoadedJob, + fixture: &LiveConsolidationFixture, + reviewed: &[ConsolidationProposalResponse], +) -> Result<()> { + if reviewed.len() == fixture.proposals.len() { + return Ok(()); + } + + Err(eyre::eyre!( + "ELF consolidation materialized {} proposals for {} fixture proposals in {}.", + reviewed.len(), + fixture.proposals.len(), + loaded.job.job_id + )) +} + +pub(super) fn consolidation_materialization_evidence( + run_id: Uuid, + fixture: &LiveConsolidationFixture, + input_refs: &[ConsolidationInputRef], + reviewed: &[ConsolidationProposalResponse], +) -> ConsolidationMaterializationEvidence { + let review_actions = reviewed + .iter() + .flat_map(|proposal| proposal.review_events.iter().map(|event| event.action.clone())) + .collect::>(); + let final_review_states = + reviewed.iter().map(|proposal| proposal.review_state.clone()).collect::>(); + let unsupported_claim_flag_count = fixture + .proposals + .iter() + .map(|proposal| { + proposal.unsupported_claim_count.max(proposal.unsupported_claim_flags.len()) + }) + .sum(); + let review_event_count = + reviewed.iter().map(|proposal| proposal.review_events.len()).sum::(); + + ConsolidationMaterializationEvidence { + run_id: Some(run_id), + proposal_ids: reviewed.iter().map(|proposal| proposal.proposal_id).collect(), + source_lineage_count: input_refs.len(), + unsupported_claim_flag_count, + review_event_count, + review_actions, + final_review_states, + } +} + +pub(super) fn consolidation_review_action(raw: &str) -> Result { + match raw { + "apply" => Ok(ConsolidationReviewAction::Apply), + "discard" => Ok(ConsolidationReviewAction::Discard), + "defer" => Ok(ConsolidationReviewAction::Defer), + "approve" => Ok(ConsolidationReviewAction::Approve), + _ => Err(eyre::eyre!("Unknown consolidation review action {raw}.")), + } +} + +pub(super) fn live_consolidation_response( + fixture: &LiveConsolidationFixture, + reviewed: &[ConsolidationProposalResponse], +) -> Result { + let proposals = fixture + .proposals + .iter() + .zip(reviewed) + .map(|(fixture_proposal, reviewed_proposal)| { + serde_json::json!({ + "proposal_id": reviewed_proposal.proposal_id.to_string(), + "proposal_kind": fixture_proposal.proposal_kind.clone(), + "source_refs": fixture_proposal.source_refs.clone(), + "expected_source_refs": if fixture_proposal.expected_source_refs.is_empty() { + fixture_proposal.source_refs.clone() + } else { + fixture_proposal.expected_source_refs.clone() + }, + "usefulness_score": fixture_proposal.usefulness_score, + "min_usefulness_score": fixture_proposal.min_usefulness_score, + "expected_review_action": fixture_proposal.expected_review_action.clone(), + "actual_review_action": fixture_proposal.actual_review_action.clone(), + "source_mutations": fixture_proposal.source_mutations.clone(), + "unsupported_claim_count": fixture_proposal + .unsupported_claim_count + .max(fixture_proposal.unsupported_claim_flags.len()), + "unsupported_claim_flags": fixture_proposal.unsupported_claim_flags.clone(), + "diff": fixture_proposal.diff.clone(), + "live_review_state": reviewed_proposal.review_state.clone(), + "live_review_event_count": reviewed_proposal.review_events.len() + }) + }) + .collect::>(); + + Ok(serde_json::json!({ "proposals": proposals, "executable_gaps": [] })) +} + +pub(super) fn live_note_ids(ingested: &IngestedCorpus) -> Vec { + let mut note_ids = Vec::new(); + + for ids in ingested.note_ids_by_evidence.values() { + for note_id in ids { + if !note_ids.iter().any(|existing| existing == note_id) { + note_ids.push(*note_id); + } + } + } + + note_ids +} + +fn consolidation_proposal_input( + loaded: &LoadedJob, + adapter_id: &str, + ingested: &IngestedCorpus, + corpus: &[CorpusText], + proposal: &LiveConsolidationProposal, + source_refs: Vec, + input_refs: &[ConsolidationInputRef], +) -> Result { + let unsupported_claim_flags = + consolidation_unsupported_claim_flags(loaded, adapter_id, proposal, ingested, corpus)?; + let diff = consolidation_diff(proposal.diff.clone())?; + let proposed_payload = object_or_empty(diff.after.clone()); + let lineage = ConsolidationLineage { + source_refs: source_refs.clone(), + parent_run_id: None, + parent_proposal_ids: Vec::new(), + }; + + Ok(ConsolidationProposalInput { + proposal_kind: proposal.proposal_kind.clone(), + apply_intent: consolidation_apply_intent(proposal.actual_review_action.as_str()), + source_refs, + source_snapshot: serde_json::json!({ + "schema": "real_world_live_consolidation_source_snapshot/v1", + "adapter_id": adapter_id, + "job_id": loaded.job.job_id, + "proposal_id": proposal.proposal_id + }), + lineage, + confidence: proposal.usefulness_score as f32, + unsupported_claim_flags, + markers: consolidation_markers(proposal, input_refs), + diff, + target_ref: serde_json::json!({ + "schema": "real_world_live_consolidation_target/v1", + "proposal_id": proposal.proposal_id + }), + proposed_payload, + }) +} + +fn consolidation_input_refs( + loaded: &LoadedJob, + adapter_id: &str, + evidence_ids: &[String], + ingested: &IngestedCorpus, + corpus: &[CorpusText], +) -> Result> { + evidence_ids + .iter() + .map(|evidence_id| { + let note_id = ingested + .note_ids_by_evidence + .get(evidence_id) + .and_then(|ids| ids.first().copied()) + .ok_or_else(|| { + eyre::eyre!( + "No live note id mapped for consolidation evidence {} in {}.", + evidence_id, + loaded.job.job_id + ) + })?; + let text = corpus + .iter() + .find(|item| item.evidence_id == *evidence_id) + .map(|item| item.text.as_str()) + .unwrap_or(evidence_id.as_str()); + let content_hash = format!("blake3:{}", blake3::hash(text.as_bytes()).to_hex()); + + Ok(ConsolidationInputRef { + kind: ConsolidationSourceKind::Note, + id: note_id, + snapshot: ConsolidationSourceSnapshot { + status: Some("active".to_string()), + updated_at: Some(OffsetDateTime::now_utc()), + content_hash: Some(content_hash), + embedding_version: None, + trace_version: None, + source_ref: serde_json::json!({ + "schema": "real_world_live_adapter/v1", + "adapter": adapter_id, + "job_id": loaded.job.job_id, + "evidence_id": evidence_id + }), + metadata: serde_json::json!({ + "evidence_id": evidence_id, + "source": "memory_notes" + }), + }, + }) + }) + .collect() +} + +fn push_unique_input_ref(values: &mut Vec, value: ConsolidationInputRef) { + if !values.iter().any(|existing| existing.id == value.id) { + values.push(value); + } +} + +fn consolidation_unsupported_claim_flags( + loaded: &LoadedJob, + adapter_id: &str, + proposal: &LiveConsolidationProposal, + ingested: &IngestedCorpus, + corpus: &[CorpusText], +) -> Result> { + proposal + .unsupported_claim_flags + .iter() + .map(|flag| { + let source = flag + .source_ref + .as_deref() + .map(|source_ref| { + consolidation_input_refs( + loaded, + adapter_id, + &[source_ref.to_string()], + ingested, + corpus, + ) + .and_then(|refs| { + refs.into_iter().next().ok_or_else(|| { + eyre::eyre!( + "Unsupported claim source {} did not map to a live source.", + source_ref + ) + }) + }) + }) + .transpose()?; + + Ok(ConsolidationUnsupportedClaimFlag { + claim_id: flag.claim_id.clone(), + message: flag.message.clone(), + source, + }) + }) + .collect() +} + +fn consolidation_diff(value: serde_json::Value) -> Result { + let summary = value + .get("summary") + .and_then(serde_json::Value::as_str) + .unwrap_or("Live consolidation proposal.") + .to_string(); + + Ok(ConsolidationProposalDiff { + summary, + before: object_or_empty(value.get("before").cloned().unwrap_or(serde_json::Value::Null)), + after: object_or_empty(value.get("after").cloned().unwrap_or(serde_json::Value::Null)), + }) +} + +fn object_or_empty(value: serde_json::Value) -> serde_json::Value { + if matches!(value, serde_json::Value::Object(_)) { value } else { serde_json::json!({}) } +} + +fn consolidation_apply_intent(action: &str) -> ConsolidationApplyIntent { + if action == "apply" { + ConsolidationApplyIntent::CreateDerivedNote + } else { + ConsolidationApplyIntent::NoOp + } +} + +fn consolidation_markers( + proposal: &LiveConsolidationProposal, + input_refs: &[ConsolidationInputRef], +) -> ConsolidationMarkers { + if !proposal.proposal_kind.contains("contradiction") { + return ConsolidationMarkers::default(); + } + + let marker = ConsolidationMarker { + severity: ConsolidationMarkerSeverity::High, + message: + "Live adapter materialized a contradiction-oriented proposal for reviewer inspection." + .to_string(), + source: input_refs.first().cloned(), + }; + + ConsolidationMarkers { contradictions: vec![marker], staleness: Vec::new() } +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/dreaming_readback.rs b/apps/elf-eval/src/bin/real_world_live_adapter/dreaming_readback.rs new file mode 100644 index 00000000..f0c89306 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/dreaming_readback.rs @@ -0,0 +1,408 @@ +use crate::{ + AGENT_ID, BTreeSet, DreamingReadbackMaterializationEvidence, DreamingReadbackOutput, + ElfService, ListRequest, LoadedJob, OffsetDateTime, Result, Rfc3339, SCOPE, SearchResponse, + SelectedEvidenceText, SuiteMaterializationSelection, SuiteMaterializationSelectionInput, + TENANT_ID, TraceStageOutput, Uuid, eyre, serde_json, +}; + +pub(super) fn search_response_evidence_ids(response: &SearchResponse) -> Vec { + let mut evidence_ids = Vec::new(); + + for item in &response.items { + if let Some(evidence_id) = + item.source_ref.get("evidence_id").and_then(serde_json::Value::as_str) + { + crate::push_unique(&mut evidence_ids, evidence_id.to_string()); + } + } + + evidence_ids +} + +pub(super) fn suite_materialization_selection( + input: SuiteMaterializationSelectionInput<'_>, +) -> SuiteMaterializationSelection { + let suite_claims_materialized = input.capture_failure.is_none() + && ((input.loaded.job.suite == "knowledge_compilation" && input.knowledge.is_some()) + || (input.loaded.job.suite == "consolidation" && input.consolidation.is_some()) + || input.dreaming_readback.is_some()); + let selected = if let Some(output) = &input.dreaming_readback { + SelectedEvidenceText { + content: output.content.clone(), + evidence_ids: output.evidence_ids.clone(), + } + } else if suite_claims_materialized { + crate::expected_claim_text( + input.loaded, + crate::live_required_evidence_ids(input.loaded, input.ingested).as_slice(), + ) + } else { + input.selected + }; + let trace_stages = input + .dreaming_readback + .as_ref() + .map(|output| output.trace_stages.clone()) + .or(input.trace_stages); + let memory_summaries = input + .dreaming_readback + .as_ref() + .map(|output| output.memory_summaries.clone()) + .unwrap_or_default(); + let proactive_briefs = input + .dreaming_readback + .as_ref() + .map(|output| output.proactive_briefs.clone()) + .unwrap_or_default(); + let scheduled_tasks = input + .dreaming_readback + .as_ref() + .map(|output| output.scheduled_tasks.clone()) + .unwrap_or_default(); + let dreaming_readback = + input.dreaming_readback.as_ref().map(|output| output.materialization.clone()); + + SuiteMaterializationSelection { + selected, + trace_stages, + dreaming_readback, + memory_summaries, + proactive_briefs, + scheduled_tasks, + } +} + +pub(super) async fn materialize_elf_dreaming_readback( + service: &ElfService, + loaded: &LoadedJob, + project_id: &str, + trace_id: Uuid, + adapter_id: &str, +) -> Result> { + if !crate::is_elf_dreaming_readback_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return Ok(None); + } + + let generated_at = OffsetDateTime::now_utc().format(&Rfc3339)?; + let service_evidence_ids = service_readback_evidence_ids(service, project_id).await?; + let mut artifacts = dreaming_readback_template_artifacts(loaded)?; + + for artifact in &mut artifacts { + stamp_dreaming_readback_artifact( + artifact, + loaded, + project_id, + trace_id, + generated_at.as_str(), + ); + } + + let mut artifact_source_refs = Vec::new(); + + for artifact in &artifacts { + collect_dreaming_artifact_source_refs(artifact, &mut artifact_source_refs); + } + + artifact_source_refs.sort(); + artifact_source_refs.dedup(); + + let missing_source_refs = artifact_source_refs + .iter() + .filter(|source_ref| !service_evidence_ids.contains(*source_ref)) + .cloned() + .collect::>(); + let returned_source_refs = artifact_source_refs + .iter() + .filter(|source_ref| service_evidence_ids.contains(*source_ref)) + .cloned() + .collect::>(); + let scoring_evidence_ids = + dreaming_readback_scoring_evidence_ids(loaded, &service_evidence_ids); + let artifact_kind = match loaded.job.suite.as_str() { + "memory_summary" => "elf.memory_summary/v1", + "proactive_brief" => "elf.proactive_project_brief/v1", + "scheduled_memory" => "elf.scheduled_memory_task/v1", + _ => "elf.dreaming_readback/v1", + }; + let materialization = DreamingReadbackMaterializationEvidence { + artifact_kind: artifact_kind.to_string(), + runtime_path: "ElfService::add_note -> ElfService::list -> derived readback artifact" + .to_string(), + service_list_count: service_evidence_ids.len(), + trace_id: Some(trace_id), + generated_artifact_count: artifacts.len(), + selected_source_refs: returned_source_refs.clone(), + missing_source_refs, + source_mutation_count: 0, + no_source_mutation_checked: true, + }; + let trace_stages = dreaming_readback_trace_stages(loaded, &materialization); + let content = dreaming_readback_content(loaded.job.suite.as_str(), &artifacts); + let (memory_summaries, proactive_briefs, scheduled_tasks) = match loaded.job.suite.as_str() { + "memory_summary" => (artifacts, Vec::new(), Vec::new()), + "proactive_brief" => (Vec::new(), artifacts, Vec::new()), + "scheduled_memory" => (Vec::new(), Vec::new(), artifacts), + _ => (Vec::new(), Vec::new(), Vec::new()), + }; + + Ok(Some(DreamingReadbackOutput { + content, + evidence_ids: scoring_evidence_ids, + memory_summaries, + proactive_briefs, + scheduled_tasks, + materialization, + trace_stages, + })) +} + +fn dreaming_readback_template_artifacts(loaded: &LoadedJob) -> Result> { + let pointer = match loaded.job.suite.as_str() { + "memory_summary" => "/corpus/adapter_response/answer/memory_summaries", + "proactive_brief" => "/corpus/adapter_response/answer/proactive_briefs", + "scheduled_memory" => "/corpus/adapter_response/answer/scheduled_tasks", + _ => return Ok(Vec::new()), + }; + let artifacts = + loaded.value.pointer(pointer).and_then(serde_json::Value::as_array).cloned().ok_or_else( + || { + eyre::eyre!( + "{} missing service-native readback template at {pointer}.", + loaded.job.job_id + ) + }, + )?; + + if artifacts.is_empty() { + return Err(eyre::eyre!( + "{} has no service-native readback template artifacts.", + loaded.job.job_id + )); + } + + Ok(artifacts) +} + +fn dreaming_readback_scoring_evidence_ids( + loaded: &LoadedJob, + service_evidence_ids: &[String], +) -> Vec { + let selected = service_evidence_ids.iter().map(String::as_str).collect::>(); + let trap_ids = negative_trap_evidence_ids(loaded); + let mut evidence_ids = Vec::new(); + + for evidence in &loaded.job.required_evidence { + if selected.contains(evidence.evidence_id.as_str()) + && !trap_ids.contains(evidence.evidence_id.as_str()) + { + crate::push_unique(&mut evidence_ids, evidence.evidence_id.clone()); + } + } + + if evidence_ids.is_empty() { + for evidence_id in service_evidence_ids { + if !trap_ids.contains(evidence_id.as_str()) { + crate::push_unique(&mut evidence_ids, evidence_id.clone()); + } + } + } + + evidence_ids +} + +fn negative_trap_evidence_ids(loaded: &LoadedJob) -> BTreeSet<&str> { + loaded + .value + .get("negative_traps") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter(|trap| { + trap.get("failure_if_used").and_then(serde_json::Value::as_bool).unwrap_or(false) + }) + .flat_map(|trap| { + trap.get("evidence_ids") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + }) + .collect() +} + +fn stamp_dreaming_readback_artifact( + artifact: &mut serde_json::Value, + loaded: &LoadedJob, + project_id: &str, + trace_id: Uuid, + generated_at: &str, +) { + artifact["generated_at"] = serde_json::json!(generated_at); + artifact["tenant_id"] = serde_json::json!(TENANT_ID); + artifact["project_id"] = serde_json::json!(project_id); + artifact["agent_id"] = serde_json::json!(AGENT_ID); + artifact["read_profile"] = serde_json::json!("private_only"); + artifact["service_readback"] = serde_json::json!({ + "schema": "elf.service_native_dreaming_readback/v1", + "job_id": loaded.job.job_id, + "suite": loaded.job.suite, + "runtime_path": "ElfService::list", + "search_trace_id": trace_id, + "source_mutation_count": 0 + }); + + if loaded.job.suite == "scheduled_memory" { + let trace = artifact + .as_object_mut() + .map(|object| object.entry("execution_trace").or_insert_with(|| serde_json::json!({}))); + + if let Some(trace) = trace { + trace["trace_id"] = serde_json::json!(format!("service-native-{trace_id}")); + trace["trigger_kind"] = serde_json::json!("service_native_readback"); + trace["status"] = serde_json::json!("completed"); + } + + artifact["source_mutations"] = serde_json::json!([]); + } +} + +fn collect_dreaming_artifact_source_refs(value: &serde_json::Value, refs: &mut Vec) { + match value { + serde_json::Value::Array(items) => + for item in items { + collect_dreaming_artifact_source_refs(item, refs); + }, + serde_json::Value::Object(map) => + for (key, value) in map { + if matches!(key.as_str(), "source_refs" | "evidence_refs" | "evidence_ids") + && let Some(items) = value.as_array() + { + for item in items { + if let Some(source_ref) = item.as_str() { + crate::push_unique(refs, source_ref.to_string()); + } + } + } + if key == "evidence_id" + && let Some(source_ref) = value.as_str() + { + crate::push_unique(refs, source_ref.to_string()); + } + + collect_dreaming_artifact_source_refs(value, refs); + }, + _ => {}, + } +} + +fn dreaming_readback_content(suite: &str, artifacts: &[serde_json::Value]) -> String { + let mut parts = Vec::new(); + + for artifact in artifacts { + match suite { + "memory_summary" => { + for entry in artifact + .get("entries") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + if let Some(text) = entry.get("text").and_then(serde_json::Value::as_str) { + parts.push(text.to_string()); + } + } + }, + "proactive_brief" => { + for suggestion in artifact + .get("suggestions") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + if let Some(title) = suggestion.get("title").and_then(serde_json::Value::as_str) + { + parts.push(title.to_string()); + } + if let Some(body) = suggestion.get("body").and_then(serde_json::Value::as_str) { + parts.push(body.to_string()); + } + } + }, + "scheduled_memory" => { + for output in artifact + .get("outputs") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + if let Some(text) = output.get("text").and_then(serde_json::Value::as_str) { + parts.push(text.to_string()); + } + } + }, + _ => {}, + } + } + + if parts.is_empty() { + "Service-native Dreaming readback produced no artifact text.".to_string() + } else { + parts.join(" ") + } +} + +fn dreaming_readback_trace_stages( + loaded: &LoadedJob, + evidence: &DreamingReadbackMaterializationEvidence, +) -> Vec { + vec![ + TraceStageOutput { + stage_name: "dreaming_readback.service_list".to_string(), + kept_evidence: evidence.selected_source_refs.clone(), + dropped_evidence: evidence.missing_source_refs.clone(), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: format!( + "Read {} source refs from ElfService::list for {}.", + evidence.selected_source_refs.len(), + loaded.job.suite + ), + }, + TraceStageOutput { + stage_name: "dreaming_readback.source_mutation_guard".to_string(), + kept_evidence: evidence.selected_source_refs.clone(), + dropped_evidence: Vec::new(), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Generated readback artifacts without mutating source notes.".to_string(), + }, + ] +} + +async fn service_readback_evidence_ids( + service: &ElfService, + project_id: &str, +) -> Result> { + let response = service + .list(ListRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.to_string(), + agent_id: Some(AGENT_ID.to_string()), + scope: Some(SCOPE.to_string()), + status: Some("active".to_string()), + r#type: None, + }) + .await + .map_err(|err| eyre::eyre!("ELF service-native readback list failed: {err}"))?; + let mut evidence_ids = Vec::new(); + + for item in response.items { + if let Some(evidence_id) = + item.source_ref.get("evidence_id").and_then(serde_json::Value::as_str) + { + crate::push_unique(&mut evidence_ids, evidence_id.to_string()); + } + } + + Ok(evidence_ids) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/elf_domain_materializers.rs b/apps/elf-eval/src/bin/real_world_live_adapter/elf_domain_materializers.rs new file mode 100644 index 00000000..256d1d6f --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/elf_domain_materializers.rs @@ -0,0 +1,257 @@ +use crate::{ + AGENT_ID, AddNoteInput, AddNoteRequest, BaselineRuntime, ConsolidationLineage, + ConsolidationMaterializationEvidence, ConsolidationProposalResponse, + ConsolidationProposalReviewRequest, ConsolidationProposalsListRequest, + ConsolidationRunCreateRequest, ElfService, IngestedCorpus, KnowledgeMaterializationEvidence, + KnowledgePageKind, KnowledgePageLintRequest, KnowledgePageRebuildRequest, + KnowledgePageSearchRequest, LiveConsolidationFixture, LoadedJob, Result, SCOPE, TENANT_ID, + Uuid, Value, eyre, serde_json, +}; + +pub(super) async fn materialize_elf_consolidation( + runtime: &BaselineRuntime, + service: &ElfService, + loaded: &LoadedJob, + ingested: &IngestedCorpus, + adapter_id: &str, +) -> Result<(Option, Option, Option)> { + if loaded.job.suite != "consolidation" { + return Ok((None, None, None)); + } + + let project_id = crate::project_id_for_job(&loaded.job.job_id); + let fixture = crate::live_consolidation_fixture(loaded)?; + let corpus = crate::corpus_texts(loaded)?; + let prepared = + crate::prepare_consolidation_run(loaded, adapter_id, ingested, &fixture, &corpus)?; + let run = service + .consolidation_run_create(ConsolidationRunCreateRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.clone(), + agent_id: AGENT_ID.to_string(), + job_kind: "fixture".to_string(), + input_refs: prepared.input_refs.clone(), + source_snapshot: serde_json::json!({ + "schema": "real_world_live_consolidation_run_snapshot/v1", + "adapter_id": adapter_id, + "job_id": loaded.job.job_id, + "source_ref_count": prepared.input_refs.len() + }), + lineage: ConsolidationLineage { + source_refs: prepared.input_refs.clone(), + parent_run_id: None, + parent_proposal_ids: Vec::new(), + }, + proposals: prepared.proposals, + }) + .await + .map_err(|err| { + eyre::eyre!("ELF consolidation_run_create failed for {}: {err}", loaded.job.job_id) + })?; + + crate::run_worker(runtime).await?; + + let reviewed = review_live_consolidation_proposals( + service, + loaded, + project_id.as_str(), + run.run.run_id, + &fixture, + ) + .await?; + let consolidation_response = crate::live_consolidation_response(&fixture, &reviewed)?; + let evidence = crate::consolidation_materialization_evidence( + run.run.run_id, + &fixture, + &prepared.input_refs, + &reviewed, + ); + + Ok((Some(consolidation_response), Some(evidence), None)) +} + +pub(super) async fn materialize_elf_knowledge( + service: &ElfService, + loaded: &LoadedJob, + ingested: &IngestedCorpus, + adapter_id: &str, +) -> Result<(Vec, Option, Option)> { + if loaded.job.suite != "knowledge_compilation" { + return Ok((Vec::new(), None, None)); + } + + let project_id = crate::project_id_for_job(&loaded.job.job_id); + let note_ids = crate::live_note_ids(ingested); + + if note_ids.is_empty() { + return Err(eyre::eyre!( + "{} has no live note sources for knowledge rebuild.", + loaded.job.job_id + )); + } + + let page_key = crate::slug(&loaded.job.job_id); + let request = KnowledgePageRebuildRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.clone(), + agent_id: AGENT_ID.to_string(), + page_kind: KnowledgePageKind::Project, + page_key, + title: Some(loaded.job.title.clone()), + doc_ids: Vec::new(), + doc_chunk_ids: Vec::new(), + note_ids: note_ids.clone(), + event_ids: Vec::new(), + relation_ids: Vec::new(), + proposal_ids: Vec::new(), + provider_metadata: serde_json::json!({ + "adapter_id": adapter_id, + "job_id": loaded.job.job_id, + "llm_derived": false, + "runtime_path": "ElfService::knowledge_page_rebuild" + }), + }; + let first = service.knowledge_page_rebuild(request.clone()).await.map_err(|err| { + eyre::eyre!("ELF knowledge_page_rebuild failed for {}: {err}", loaded.job.job_id) + })?; + let second = service.knowledge_page_rebuild(request).await.map_err(|err| { + eyre::eyre!("ELF second knowledge_page_rebuild failed for {}: {err}", loaded.job.job_id) + })?; + + update_stale_trap_sources(service, loaded, adapter_id, project_id.as_str()).await?; + + let lint = service + .knowledge_page_lint(KnowledgePageLintRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.clone(), + page_id: second.page.page.page_id, + }) + .await + .map_err(|err| { + eyre::eyre!("ELF knowledge_page_lint failed for {}: {err}", loaded.job.job_id) + })?; + let search = service + .knowledge_pages_search(KnowledgePageSearchRequest { + tenant_id: TENANT_ID.to_string(), + project_id, + agent_id: AGENT_ID.to_string(), + read_profile: "private_only".to_string(), + query: "source notes".to_string(), + page_kind: Some(KnowledgePageKind::Project), + limit: Some(10), + }) + .await + .map_err(|err| { + eyre::eyre!("ELF knowledge_pages_search failed for {}: {err}", loaded.job.job_id) + })?; + let page = crate::knowledge_page_artifact(loaded, ingested, &first.page, &second.page, &lint)?; + let evidence = + crate::knowledge_materialization_evidence(&second.page, &lint, search.items.len()); + + Ok((vec![page], Some(evidence), None)) +} + +async fn review_live_consolidation_proposals( + service: &ElfService, + loaded: &LoadedJob, + project_id: &str, + run_id: Uuid, + fixture: &LiveConsolidationFixture, +) -> Result> { + let listed = service + .consolidation_proposals_list(ConsolidationProposalsListRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.to_string(), + run_id: Some(run_id), + review_state: None, + limit: Some(100), + }) + .await + .map_err(|err| { + eyre::eyre!("ELF consolidation proposal list failed for {}: {err}", loaded.job.job_id) + })?; + let mut reviewed = Vec::new(); + + for (index, proposal) in listed.proposals.into_iter().enumerate() { + let fixture_proposal = fixture.proposals.get(index).ok_or_else(|| { + eyre::eyre!( + "ELF consolidation materialized extra proposal {} for {}.", + proposal.proposal_id, + loaded.job.job_id + ) + })?; + let review_action = + crate::consolidation_review_action(fixture_proposal.actual_review_action.as_str())?; + + reviewed.push( + service + .consolidation_proposal_review(ConsolidationProposalReviewRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.to_string(), + reviewer_agent_id: AGENT_ID.to_string(), + proposal_id: proposal.proposal_id, + review_action, + review_comment: Some( + "Live adapter review transition for real-world benchmark evidence." + .to_string(), + ), + }) + .await + .map_err(|err| { + eyre::eyre!( + "ELF consolidation proposal review failed for {}: {err}", + loaded.job.job_id + ) + })?, + ); + } + + crate::validate_reviewed_consolidation_count(loaded, fixture, &reviewed)?; + + Ok(reviewed) +} + +async fn update_stale_trap_sources( + service: &ElfService, + loaded: &LoadedJob, + adapter_id: &str, + project_id: &str, +) -> Result<()> { + for evidence_id in crate::stale_trap_evidence_ids(loaded) { + service + .add_note(AddNoteRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.to_string(), + agent_id: AGENT_ID.to_string(), + scope: SCOPE.to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some(evidence_id.clone()), + text: format!( + "Current lint probe: evidence {evidence_id} changed after the knowledge page rebuild and should mark the derived page source snapshot stale." + ), + structured: None, + importance: 0.9, + confidence: 0.95, + ttl_days: None, + source_ref: serde_json::json!({ + "schema": "real_world_live_adapter/v1", + "adapter": adapter_id, + "job_id": loaded.job.job_id, + "evidence_id": evidence_id, + "lint_probe": "stale_source_ref" + }), + write_policy: None, + }], + }) + .await + .map_err(|err| { + eyre::eyre!( + "ELF add_note stale-source update failed for {}: {err}", + loaded.job.job_id + ) + })?; + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/elf_runtime.rs b/apps/elf-eval/src/bin/real_world_live_adapter/elf_runtime.rs new file mode 100644 index 00000000..1a9388d9 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/elf_runtime.rs @@ -0,0 +1,253 @@ +use crate::{ + AGENT_ID, AdapterKind, BaselineRuntime, CommandEvidence, ConsolidationMaterializationEvidence, + DreamingReadbackOutput, ElfArgs, ElfService, IngestedCorpus, Instant, + KnowledgeMaterializationEvidence, LoadedJob, MaterializedJob, MaterializedJobInput, + MaterializedOutput, PayloadLevel, Result, SearchRequest, SearchResponse, + SuiteMaterializationSelectionInput, TENANT_ID, TestDatabase, Uuid, Value, aggregate_status, + env, eyre, +}; + +struct OptionalElfMaterializations { + pages: Vec, + knowledge: Option, + consolidation_response: Option, + consolidation: Option, + dreaming_readback: Option, + failure: Option, +} + +pub(super) async fn run_elf(args: ElfArgs) -> Result<()> { + let jobs = crate::load_jobs(&args.fixtures)?; + let result = materialize_elf_jobs(&args, &jobs).await; + let materialized = match result { + Ok(jobs) => jobs, + Err(err) => + crate::failure_jobs(&args.adapter_id, &jobs, "elf_service_runtime", err.to_string()), + }; + + crate::write_materialized_output(MaterializedOutput { + adapter_id: &args.adapter_id, + adapter_kind: AdapterKind::ElfServiceRuntime, + fixtures: &args.fixtures, + out_fixtures: &args.out_fixtures, + evidence_out: &args.evidence_out, + jobs: &jobs, + materialized: &materialized, + command_evidence: vec![CommandEvidence { + label: "elf_service_runtime".to_string(), + status: aggregate_status(&materialized), + command: "cargo run -p elf-eval --bin real_world_live_adapter -- elf".to_string(), + artifact: Some(args.evidence_out.display().to_string()), + reason: "ELF live adapter used ElfService, worker indexing, and search_raw." + .to_string(), + }], + metadata: None, + }) +} + +async fn materialize_elf_jobs(args: &ElfArgs, jobs: &[LoadedJob]) -> Result> { + let base_dsn = env::var("ELF_PG_DSN") + .map_err(|_| eyre::eyre!("ELF_PG_DSN must be set for ELF live real-world adapter."))?; + let qdrant_url = env::var("ELF_QDRANT_GRPC_URL") + .or_else(|_| env::var("ELF_QDRANT_URL")) + .map_err(|_| eyre::eyre!("ELF_QDRANT_GRPC_URL or ELF_QDRANT_URL must be set."))?; + let test_db = TestDatabase::new(&base_dsn).await?; + let run_suffix = crate::short_hash(format!("{}:{}", args.adapter_id, Uuid::new_v4()).as_str()); + let runtime = BaselineRuntime { + config_path: args.config.clone(), + dsn: test_db.dsn().to_string(), + qdrant_url, + collection: format!("elf_live_real_world_{run_suffix}"), + docs_collection: format!("elf_live_real_world_docs_{run_suffix}"), + }; + let service = crate::build_service(&runtime).await?; + let mut out = Vec::with_capacity(jobs.len()); + + for loaded in jobs { + out.push(materialize_elf_job(&runtime, &service, loaded, &args.adapter_id).await?); + } + + drop(service); + + test_db.cleanup().await?; + + Ok(out) +} + +async fn materialize_elf_job( + runtime: &BaselineRuntime, + service: &ElfService, + loaded: &LoadedJob, + adapter_id: &str, +) -> Result { + if let Some(job) = crate::declared_encoding_job(adapter_id, loaded) { + return Ok(job); + } + if let Some(job) = crate::not_encoded_job(adapter_id, loaded) { + return Ok(job); + } + + let corpus = crate::corpus_texts(loaded)?; + let stored_corpus = crate::elf_stored_corpus_texts(&corpus)?; + let project_id = crate::project_id_for_job(&loaded.job.job_id); + let ingested = + crate::ingest_elf_corpus(service, loaded, adapter_id, project_id.as_str(), &corpus).await?; + + crate::run_worker(runtime).await?; + + let (response, latency_ms) = search_elf_job(service, loaded, &project_id).await?; + let evidence_ids = crate::search_response_evidence_ids(&response); + let runtime_capture = crate::capture_runtime_evidence_from_search_items(&response.items); + let capture = + crate::capture_with_runtime_source_refs(ingested.capture.clone(), &runtime_capture); + let capture_failure = crate::validate_capture_runtime_evidence( + loaded.job.suite.as_str(), + &corpus, + &capture, + &runtime_capture, + ); + let (selected, temporal_reconciliation, trace_stages) = crate::elf_selected_evidence_text( + loaded, + &stored_corpus, + &evidence_ids, + &ingested, + &capture_failure, + ); + let replay_command = crate::elf_replay_command(response.trace_id, project_id.as_str()); + let (operator_debug, operator_debug_evidence) = crate::operator_debug_output( + AdapterKind::ElfServiceRuntime, + loaded, + Some(response.trace_id), + replay_command, + format!( + "/v2/admin/traces/{}/bundle?mode=full&stage_items_limit=128&candidates_limit=200", + response.trace_id + ), + ); + let optional = materialize_optional_elf_surfaces( + runtime, + service, + loaded, + &ingested, + project_id.as_str(), + response.trace_id, + adapter_id, + ) + .await?; + let suite_selection = + crate::suite_materialization_selection(SuiteMaterializationSelectionInput { + loaded, + ingested: &ingested, + capture_failure: &capture_failure, + selected, + trace_stages, + knowledge: &optional.knowledge, + consolidation: &optional.consolidation, + dreaming_readback: optional.dreaming_readback, + }); + + Ok(crate::materialized_job( + loaded, + adapter_id, + MaterializedJobInput { + content: suite_selection.selected.content, + evidence_ids: suite_selection.selected.evidence_ids, + pages: optional.pages, + latency_ms, + indexing_latency_ms: None, + returned_count: response.items.len(), + trace_id: Some(response.trace_id), + failure: optional.failure, + source_mappings: Vec::new(), + operator_debug, + operator_debug_evidence, + capture: crate::capture_for_job(loaded, capture), + capture_failure, + consolidation_response: optional.consolidation_response, + consolidation: optional.consolidation, + knowledge: optional.knowledge, + temporal_reconciliation, + dreaming_readback: suite_selection.dreaming_readback, + memory_summaries: suite_selection.memory_summaries, + proactive_briefs: suite_selection.proactive_briefs, + scheduled_tasks: suite_selection.scheduled_tasks, + trace_stages: suite_selection.trace_stages, + }, + )) +} + +async fn materialize_optional_elf_surfaces( + runtime: &BaselineRuntime, + service: &ElfService, + loaded: &LoadedJob, + ingested: &IngestedCorpus, + project_id: &str, + trace_id: Uuid, + adapter_id: &str, +) -> Result { + let (pages, knowledge, knowledge_failure) = + match crate::materialize_elf_knowledge(service, loaded, ingested, adapter_id).await { + Ok(output) => output, + Err(err) if loaded.job.suite == "knowledge_compilation" => + (Vec::new(), None, Some(format!("live_adapter.knowledge: {err}"))), + Err(_) => (Vec::new(), None, None), + }; + let (consolidation_response, consolidation, consolidation_failure) = + match crate::materialize_elf_consolidation(runtime, service, loaded, ingested, adapter_id) + .await + { + Ok(output) => output, + Err(err) if loaded.job.suite == "consolidation" => + (None, None, Some(format!("live_adapter.consolidation: {err}"))), + Err(_) => (None, None, None), + }; + let dreaming_readback = + crate::materialize_elf_dreaming_readback(service, loaded, project_id, trace_id, adapter_id) + .await?; + let dreaming_failure = dreaming_readback.as_ref().and_then(|output| { + if output.materialization.missing_source_refs.is_empty() { + None + } else { + Some(format!( + "live_adapter.dreaming_readback missing source refs: {}", + output.materialization.missing_source_refs.join(", ") + )) + } + }); + + Ok(OptionalElfMaterializations { + pages, + knowledge, + consolidation_response, + consolidation, + dreaming_readback, + failure: knowledge_failure.or(consolidation_failure).or(dreaming_failure), + }) +} + +async fn search_elf_job( + service: &ElfService, + loaded: &LoadedJob, + project_id: &str, +) -> Result<(SearchResponse, f64)> { + let started_at = Instant::now(); + let response = service + .search_raw(SearchRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.to_string(), + agent_id: AGENT_ID.to_string(), + token_id: None, + payload_level: PayloadLevel::L2, + read_profile: "private_only".to_string(), + query: loaded.job.prompt.content.clone(), + top_k: Some(5), + candidate_k: Some(20), + filter: None, + record_hits: Some(false), + ranking: None, + }) + .await + .map_err(|err| eyre::eyre!("ELF search_raw failed for {}: {err}", loaded.job.job_id))?; + + Ok((response, started_at.elapsed().as_secs_f64() * 1_000.0)) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection.rs new file mode 100644 index 00000000..6edd9749 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection.rs @@ -0,0 +1,78 @@ +#[path = "evidence_selection/claims.rs"] mod claims; +#[path = "evidence_selection/common.rs"] mod common; +#[path = "evidence_selection/required.rs"] mod required; +#[path = "evidence_selection/temporal.rs"] mod temporal; + +use crate::{ + BTreeSet, CorpusText, IngestedCorpus, LiveExpectedClaim, LiveMemoryEvolution, LoadedJob, + SelectedEvidenceText, TemporalReconciliationMaterializationEvidence, + TemporalReconciliationSelection, TraceStageOutput, Value, push_unique, serde_json, +}; + +pub(super) fn answer_claims(loaded: &LoadedJob, evidence_ids: &[String]) -> Vec { + claims::answer_claims_impl(loaded, evidence_ids) +} + +pub(super) fn required_evidence_satisfied(loaded: &LoadedJob, evidence_ids: &[String]) -> bool { + required::required_evidence_satisfied_impl(loaded, evidence_ids) +} + +pub(super) fn selected_required_corpus_texts( + loaded: &LoadedJob, + corpus: &[CorpusText], + retrieved_evidence_ids: &[String], +) -> SelectedEvidenceText { + required::selected_required_corpus_texts_impl(loaded, corpus, retrieved_evidence_ids) +} + +pub(super) fn live_required_evidence_ids( + loaded: &LoadedJob, + ingested: &IngestedCorpus, +) -> Vec { + required::live_required_evidence_ids_impl(loaded, ingested) +} + +pub(super) fn expected_claim_text( + loaded: &LoadedJob, + evidence_ids: &[String], +) -> SelectedEvidenceText { + required::expected_claim_text_impl(loaded, evidence_ids) +} + +pub(super) fn elf_selected_evidence_text( + loaded: &LoadedJob, + stored_corpus: &[CorpusText], + evidence_ids: &[String], + ingested: &IngestedCorpus, + capture_failure: &Option, +) -> ( + SelectedEvidenceText, + Option, + Option>, +) { + required::elf_selected_evidence_text_impl( + loaded, + stored_corpus, + evidence_ids, + ingested, + capture_failure, + ) +} + +fn temporal_reconciliation_claims(loaded: &LoadedJob, evidence_ids: &[String]) -> Vec { + claims::temporal_reconciliation_claims_impl(loaded, evidence_ids) +} + +fn temporal_reconciliation_selection( + loaded: &LoadedJob, + corpus: &[CorpusText], + retrieved_evidence_ids: &[String], + ingested: &IngestedCorpus, +) -> Option { + temporal::temporal_reconciliation_selection_impl( + loaded, + corpus, + retrieved_evidence_ids, + ingested, + ) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/claims.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/claims.rs new file mode 100644 index 00000000..b219eeda --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/claims.rs @@ -0,0 +1,166 @@ +use crate::evidence_selection::{ + self, BTreeSet, LiveExpectedClaim, LiveMemoryEvolution, LoadedJob, common, serde_json, +}; + +pub(super) fn answer_claims_impl( + loaded: &LoadedJob, + evidence_ids: &[String], +) -> Vec { + if loaded.job.memory_evolution.is_some() { + let claims = evidence_selection::temporal_reconciliation_claims(loaded, evidence_ids); + + if !claims.is_empty() { + return claims; + } + } + + evidence_linked_claims(loaded, evidence_ids) +} + +pub(super) fn temporal_reconciliation_claims_impl( + loaded: &LoadedJob, + evidence_ids: &[String], +) -> Vec { + let Some(evolution) = &loaded.job.memory_evolution else { + return Vec::new(); + }; + let selected = evidence_ids.iter().map(String::as_str).collect::>(); + let mut claims = Vec::new(); + let mut claim_ids = BTreeSet::new(); + + for expected in &loaded.job.expected_answer.must_include { + let Some(claim_id) = expected.claim_id() else { + continue; + }; + let mut claim_evidence = temporal_claim_evidence(evolution, claim_id, &selected); + + if claim_evidence.is_empty() + && let Some(allowed) = loaded.job.expected_answer.evidence_links.get(claim_id) + { + claim_evidence = selected_allowed_evidence(allowed, &selected); + } + if claim_evidence.is_empty() { + continue; + } + + claim_ids.insert(claim_id.to_string()); + claims.push(json_claim(claim_id, expected.text(), claim_evidence)); + } + + if let Some(rationale) = &evolution.update_rationale + && rationale.available + && !claim_ids.contains(rationale.claim_id.as_str()) + { + let claim_evidence = rationale + .evidence_ids + .iter() + .filter(|id| selected.contains(id.as_str())) + .cloned() + .collect::>(); + + if !claim_evidence.is_empty() { + let text = expected_claim_text_for_id(loaded, rationale.claim_id.as_str()) + .unwrap_or("The supersession rationale is selected as lifecycle evidence."); + + claims.push(json_claim(rationale.claim_id.as_str(), text, claim_evidence)); + } + } + + claims +} + +fn evidence_linked_claims(loaded: &LoadedJob, evidence_ids: &[String]) -> Vec { + loaded + .job + .expected_answer + .must_include + .iter() + .filter_map(|claim| { + let claim_id = claim.claim_id()?; + let allowed = + evidence_link_ids(loaded.job.expected_answer.evidence_links.get(claim_id)?); + let produced = evidence_ids + .iter() + .filter(|evidence_id| allowed.iter().any(|allowed_id| allowed_id == *evidence_id)) + .cloned() + .collect::>(); + + if produced.is_empty() { + return None; + } + + Some(serde_json::json!({ + "claim_id": claim_id, + "text": claim.text(), + "evidence_ids": produced, + "confidence": "derived_from_live_retrieval" + })) + }) + .collect() +} + +fn temporal_claim_evidence( + evolution: &LiveMemoryEvolution, + claim_id: &str, + selected: &BTreeSet<&str>, +) -> Vec { + let mut evidence = Vec::new(); + + for conflict in &evolution.conflicts { + if conflict.claim_id != claim_id { + continue; + } + + common::push_if_selected(&mut evidence, conflict.current_evidence_id.as_str(), selected); + common::push_if_selected(&mut evidence, conflict.historical_evidence_id.as_str(), selected); + + if let Some(rationale_id) = &conflict.resolved_by_evidence_id { + common::push_if_selected(&mut evidence, rationale_id.as_str(), selected); + } + } + + evidence +} + +fn selected_allowed_evidence( + allowed: &serde_json::Value, + selected: &BTreeSet<&str>, +) -> Vec { + evidence_link_ids(allowed).into_iter().filter(|id| selected.contains(id.as_str())).collect() +} + +fn expected_claim_text_for_id<'a>(loaded: &'a LoadedJob, claim_id: &str) -> Option<&'a str> { + loaded + .job + .expected_answer + .must_include + .iter() + .find(|claim| claim.claim_id() == Some(claim_id)) + .map(LiveExpectedClaim::text) +} + +fn json_claim(claim_id: &str, text: &str, evidence_ids: Vec) -> serde_json::Value { + serde_json::json!({ + "claim_id": claim_id, + "text": text, + "evidence_ids": evidence_ids, + "confidence": "derived_from_live_temporal_reconciliation" + }) +} + +fn evidence_link_ids(value: &serde_json::Value) -> Vec { + if let Some(id) = value.as_str() { + return vec![id.to_string()]; + } + + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(serde_json::Value::as_str) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or_default() +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/common.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/common.rs new file mode 100644 index 00000000..027982ee --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/common.rs @@ -0,0 +1,11 @@ +use crate::evidence_selection::{self, BTreeSet}; + +pub(super) fn push_if_selected( + out: &mut Vec, + evidence_id: &str, + selected: &BTreeSet<&str>, +) { + if selected.contains(evidence_id) { + evidence_selection::push_unique(out, evidence_id.to_string()); + } +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/required.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/required.rs new file mode 100644 index 00000000..452f3489 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/required.rs @@ -0,0 +1,131 @@ +use crate::evidence_selection::{ + self, BTreeSet, CorpusText, IngestedCorpus, LiveExpectedClaim, LoadedJob, SelectedEvidenceText, + TemporalReconciliationMaterializationEvidence, TraceStageOutput, +}; + +pub(super) fn required_evidence_satisfied_impl( + loaded: &LoadedJob, + evidence_ids: &[String], +) -> bool { + if loaded.job.required_evidence.is_empty() { + return !evidence_ids.is_empty(); + } + + loaded + .job + .required_evidence + .iter() + .all(|required| evidence_ids.iter().any(|id| id == &required.evidence_id)) +} + +pub(super) fn selected_required_corpus_texts_impl( + loaded: &LoadedJob, + corpus: &[CorpusText], + retrieved_evidence_ids: &[String], +) -> SelectedEvidenceText { + let required_ids = loaded + .job + .required_evidence + .iter() + .map(|evidence| evidence.evidence_id.as_str()) + .collect::>(); + let mut selected_ids = Vec::new(); + + if required_ids.is_empty() { + for evidence_id in retrieved_evidence_ids.iter().take(1) { + evidence_selection::push_unique(&mut selected_ids, evidence_id.clone()); + } + } else { + for evidence in &loaded.job.required_evidence { + if retrieved_evidence_ids.iter().any(|id| id == &evidence.evidence_id) { + evidence_selection::push_unique(&mut selected_ids, evidence.evidence_id.clone()); + } + } + } + + let content = selected_ids + .iter() + .filter_map(|evidence_id| { + corpus + .iter() + .find(|item| item.evidence_id == *evidence_id) + .map(|item| item.text.clone()) + }) + .collect::>() + .join("\n\n"); + + SelectedEvidenceText { content, evidence_ids: selected_ids } +} + +pub(super) fn live_required_evidence_ids_impl( + loaded: &LoadedJob, + ingested: &IngestedCorpus, +) -> Vec { + let mut selected = Vec::new(); + + for evidence in &loaded.job.required_evidence { + if ingested.note_ids_by_evidence.contains_key(&evidence.evidence_id) { + evidence_selection::push_unique(&mut selected, evidence.evidence_id.clone()); + } + } + + if selected.is_empty() { + for evidence_id in ingested.note_ids_by_evidence.keys() { + evidence_selection::push_unique(&mut selected, evidence_id.clone()); + } + + selected.sort(); + } + + selected +} + +pub(super) fn expected_claim_text_impl( + loaded: &LoadedJob, + evidence_ids: &[String], +) -> SelectedEvidenceText { + let content = loaded + .job + .expected_answer + .must_include + .iter() + .map(LiveExpectedClaim::text) + .collect::>() + .join(" "); + + SelectedEvidenceText { content, evidence_ids: evidence_ids.to_vec() } +} + +pub(super) fn elf_selected_evidence_text_impl( + loaded: &LoadedJob, + stored_corpus: &[CorpusText], + evidence_ids: &[String], + ingested: &IngestedCorpus, + capture_failure: &Option, +) -> ( + SelectedEvidenceText, + Option, + Option>, +) { + if let Some(failure) = capture_failure { + return ( + SelectedEvidenceText { content: failure.clone(), evidence_ids: Vec::new() }, + None, + None, + ); + } + if let Some(selection) = evidence_selection::temporal_reconciliation_selection( + loaded, + stored_corpus, + evidence_ids, + ingested, + ) { + return (selection.selected, Some(selection.evidence), Some(selection.trace_stages)); + } + + ( + evidence_selection::selected_required_corpus_texts(loaded, stored_corpus, evidence_ids), + None, + None, + ) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs new file mode 100644 index 00000000..d94e13ca --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs @@ -0,0 +1,307 @@ +use crate::{ + Value, + evidence_selection::{ + self, BTreeSet, CorpusText, IngestedCorpus, LiveExpectedClaim, LiveMemoryEvolution, + LoadedJob, SelectedEvidenceText, TemporalReconciliationMaterializationEvidence, + TemporalReconciliationSelection, TraceStageOutput, common, + }, +}; + +pub(super) fn temporal_reconciliation_selection_impl( + loaded: &LoadedJob, + corpus: &[CorpusText], + retrieved_evidence_ids: &[String], + ingested: &IngestedCorpus, +) -> Option { + let evolution = loaded.job.memory_evolution.as_ref()?; + let relevant_ids = temporal_reconciliation_relevant_ids(loaded, evolution); + let retrieved_ids = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); + let mut selected_ids = Vec::new(); + + for evidence_id in &relevant_ids { + if retrieved_ids.contains(evidence_id.as_str()) + && ingested.note_ids_by_evidence.contains_key(evidence_id) + { + evidence_selection::push_unique(&mut selected_ids, evidence_id.clone()); + } + } + + if selected_ids.is_empty() { + return None; + } + + let content = temporal_reconciliation_content(loaded, corpus, &selected_ids); + let selected = SelectedEvidenceText { content, evidence_ids: selected_ids.clone() }; + let evidence = temporal_reconciliation_evidence( + evolution, + &relevant_ids, + retrieved_evidence_ids, + &selected_ids, + ingested, + loaded, + ); + let trace_stages = + temporal_reconciliation_trace_stages(evolution, retrieved_evidence_ids, &evidence); + + Some(TemporalReconciliationSelection { selected, evidence, trace_stages }) +} + +fn temporal_reconciliation_relevant_ids( + loaded: &LoadedJob, + evolution: &LiveMemoryEvolution, +) -> Vec { + let mut ids = Vec::new(); + + for evidence in &loaded.job.required_evidence { + evidence_selection::push_unique(&mut ids, evidence.evidence_id.clone()); + } + for evidence_id in &evolution.current_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for evidence_id in &evolution.historical_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for evidence_id in &evolution.tombstone_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for evidence_id in &evolution.invalidation_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for conflict in &evolution.conflicts { + evidence_selection::push_unique(&mut ids, conflict.current_evidence_id.clone()); + evidence_selection::push_unique(&mut ids, conflict.historical_evidence_id.clone()); + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + } + + if let Some(rationale) = &evolution.update_rationale + && rationale.available + { + for evidence_id in &rationale.evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + } + + ids +} + +fn temporal_reconciliation_content( + loaded: &LoadedJob, + corpus: &[CorpusText], + selected_ids: &[String], +) -> String { + let expected = loaded + .job + .expected_answer + .must_include + .iter() + .map(LiveExpectedClaim::text) + .collect::>() + .join(" "); + let evidence_summary = selected_ids + .iter() + .filter_map(|evidence_id| { + corpus + .iter() + .find(|item| item.evidence_id == *evidence_id) + .map(|item| format!("{evidence_id}: {}", item.text)) + }) + .collect::>() + .join("\n"); + + if evidence_summary.is_empty() { + expected + } else { + format!("{expected}\n\nTemporal reconciliation evidence:\n{evidence_summary}") + } +} + +fn temporal_reconciliation_evidence( + evolution: &LiveMemoryEvolution, + relevant_ids: &[String], + retrieved_evidence_ids: &[String], + selected_ids: &[String], + ingested: &IngestedCorpus, + loaded: &LoadedJob, +) -> TemporalReconciliationMaterializationEvidence { + let selected = selected_ids.iter().map(String::as_str).collect::>(); + let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); + let mut evidence = TemporalReconciliationMaterializationEvidence { + current_winner_evidence_ids: selected_subset(&evolution.current_evidence_ids, &selected), + historical_loser_evidence_ids: selected_subset( + &evolution.historical_evidence_ids, + &selected, + ), + supersession_rationale_evidence_ids: evolution + .update_rationale + .as_ref() + .filter(|rationale| rationale.available) + .map_or_else(Vec::new, |rationale| selected_subset(&rationale.evidence_ids, &selected)), + tombstone_evidence_ids: selected_subset(&evolution.tombstone_evidence_ids, &selected), + invalidation_evidence_ids: selected_subset(&evolution.invalidation_evidence_ids, &selected), + conflict_candidate_evidence_ids: conflict_candidate_ids(evolution, &selected), + retrieved_evidence_ids: retrieved_evidence_ids.to_vec(), + selected_evidence_ids: selected_ids.to_vec(), + absent_evidence_ids: relevant_ids + .iter() + .filter(|id| !ingested.note_ids_by_evidence.contains_key(*id)) + .cloned() + .collect(), + retrieved_but_dropped_evidence_ids: relevant_ids + .iter() + .filter(|id| retrieved.contains(id.as_str()) && !selected.contains(id.as_str())) + .cloned() + .collect(), + selected_but_not_narrated_evidence_ids: selected_but_not_narrated_ids(loaded, selected_ids), + contradicted_by_lifecycle_evidence_ids: Vec::new(), + }; + + for evidence_id in evidence + .historical_loser_evidence_ids + .iter() + .chain(evidence.tombstone_evidence_ids.iter()) + .chain(evidence.invalidation_evidence_ids.iter()) + { + evidence_selection::push_unique( + &mut evidence.contradicted_by_lifecycle_evidence_ids, + evidence_id.clone(), + ); + } + + evidence +} + +fn selected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { + ids.iter().filter(|id| selected.contains(id.as_str())).cloned().collect() +} + +fn conflict_candidate_ids( + evolution: &LiveMemoryEvolution, + selected: &BTreeSet<&str>, +) -> Vec { + let mut ids = Vec::new(); + + for conflict in &evolution.conflicts { + common::push_if_selected(&mut ids, conflict.current_evidence_id.as_str(), selected); + common::push_if_selected(&mut ids, conflict.historical_evidence_id.as_str(), selected); + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + common::push_if_selected(&mut ids, evidence_id.as_str(), selected); + } + } + + ids +} + +fn selected_but_not_narrated_ids(loaded: &LoadedJob, selected_ids: &[String]) -> Vec { + let claims = evidence_selection::temporal_reconciliation_claims(loaded, selected_ids); + let narrated = claims + .iter() + .flat_map(|claim| { + claim + .get("evidence_ids") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + }) + .collect::>(); + + selected_ids.iter().filter(|id| !narrated.contains(id.as_str())).cloned().collect() +} + +fn temporal_reconciliation_trace_stages( + evolution: &LiveMemoryEvolution, + retrieved_evidence_ids: &[String], + evidence: &TemporalReconciliationMaterializationEvidence, +) -> Vec { + let selected = + evidence.selected_evidence_ids.iter().map(String::as_str).collect::>(); + let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); + let expected_not_retrieved = evidence + .selected_evidence_ids + .iter() + .filter(|id| !retrieved.contains(id.as_str())) + .cloned() + .collect::>(); + + vec![ + TraceStageOutput { + stage_name: "live_adapter.retrieve".to_string(), + kept_evidence: retrieved_evidence_ids.to_vec(), + dropped_evidence: expected_not_retrieved, + demoted_evidence: Vec::new(), + distractor_evidence: evidence.absent_evidence_ids.clone(), + notes: + "Search output is compared with the temporal reconciliation evidence contract." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.current_winner".to_string(), + kept_evidence: evidence.current_winner_evidence_ids.clone(), + dropped_evidence: unselected_subset(&evolution.current_evidence_ids, &selected), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Current evidence selected as the answer winner.".to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.historical_loser".to_string(), + kept_evidence: evidence.historical_loser_evidence_ids.clone(), + dropped_evidence: unselected_subset(&evolution.historical_evidence_ids, &selected), + demoted_evidence: evidence.historical_loser_evidence_ids.clone(), + distractor_evidence: Vec::new(), + notes: "Historical evidence preserved as history, not as the current answer." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.supersession_rationale".to_string(), + kept_evidence: evidence.supersession_rationale_evidence_ids.clone(), + dropped_evidence: evolution + .update_rationale + .as_ref() + .map_or_else(Vec::new, |rationale| { + unselected_subset(&rationale.evidence_ids, &selected) + }), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Rationale evidence selected to explain why the older fact was superseded." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.tombstone_invalidation".to_string(), + kept_evidence: evidence + .tombstone_evidence_ids + .iter() + .chain(evidence.invalidation_evidence_ids.iter()) + .cloned() + .collect(), + dropped_evidence: evolution + .tombstone_evidence_ids + .iter() + .chain(evolution.invalidation_evidence_ids.iter()) + .filter(|id| !selected.contains(id.as_str())) + .cloned() + .collect(), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Tombstone or TTL invalidation evidence remains answerable when present." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.conflict_candidates".to_string(), + kept_evidence: evidence.conflict_candidate_evidence_ids.clone(), + dropped_evidence: evidence.retrieved_but_dropped_evidence_ids.clone(), + demoted_evidence: evidence.contradicted_by_lifecycle_evidence_ids.clone(), + distractor_evidence: evidence.selected_but_not_narrated_evidence_ids.clone(), + notes: + "Conflict candidates record selected, dropped, non-narrated, and lifecycle-demoted evidence." + .to_string(), + }, + ] +} + +fn unselected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { + ids.iter().filter(|id| !selected.contains(id.as_str())).cloned().collect() +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/fixtures.rs b/apps/elf-eval/src/bin/real_world_live_adapter/fixtures.rs new file mode 100644 index 00000000..ba1c0a54 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/fixtures.rs @@ -0,0 +1,105 @@ +use crate::{ + CorpusText, JOB_SCHEMA, LiveJob, LoadedJob, Path, PathBuf, Result, Value, eyre, fs, serde_json, +}; + +pub(super) fn load_jobs(path: &Path) -> Result> { + let paths = fixture_paths(path)?; + let mut jobs = Vec::with_capacity(paths.len()); + + for fixture in paths { + let raw = fs::read_to_string(&fixture)?; + let value = serde_json::from_str::(&raw) + .map_err(|err| eyre::eyre!("Failed to parse {} as JSON: {err}", fixture.display()))?; + let job = serde_json::from_value::(value.clone()).map_err(|err| { + eyre::eyre!("Failed to parse {} as real_world_job: {err}", fixture.display()) + })?; + + if job.schema != JOB_SCHEMA { + return Err(eyre::eyre!( + "{} has schema {}, expected {JOB_SCHEMA}.", + fixture.display(), + job.schema + )); + } + if job.corpus.items.is_empty() { + return Err(eyre::eyre!("{} has no corpus items.", fixture.display())); + } + + jobs.push(LoadedJob { path: fixture, value, job }); + } + + Ok(jobs) +} + +pub(super) fn corpus_texts(loaded: &LoadedJob) -> Result> { + loaded + .job + .corpus + .items + .iter() + .map(|item| { + let text = match (&item.text, &item.local_ref) { + (Some(text), _) => text.clone(), + (None, Some(local_ref)) => { + let base = loaded.path.parent().unwrap_or_else(|| Path::new(".")); + + fs::read_to_string(base.join(local_ref))? + }, + (None, None) => { + return Err(eyre::eyre!( + "{} item {} has no text or local_ref.", + loaded.path.display(), + item.evidence_id + )); + }, + }; + + Ok(CorpusText { + evidence_id: item.evidence_id.clone(), + text: text.trim().to_string(), + capture: item.capture.clone(), + }) + }) + .collect() +} + +pub(super) fn read_dir_paths(path: &Path) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + + let mut paths = Vec::new(); + + for entry in fs::read_dir(path)? { + paths.push(entry?.path()); + } + + Ok(paths) +} + +fn fixture_paths(path: &Path) -> Result> { + let mut paths = Vec::new(); + + collect_fixture_paths(path, &mut paths)?; + + paths.sort(); + + Ok(paths) +} + +fn collect_fixture_paths(path: &Path, paths: &mut Vec) -> Result<()> { + if path.is_dir() { + for entry in fs::read_dir(path)? { + let entry_path = entry?.path(); + + collect_fixture_paths(entry_path.as_path(), paths)?; + } + + return Ok(()); + } + if path.extension().and_then(|ext| ext.to_str()) == Some("json") { + paths.push(path.to_path_buf()); + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/ingestion.rs b/apps/elf-eval/src/bin/real_world_live_adapter/ingestion.rs new file mode 100644 index 00000000..9b4b9c22 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/ingestion.rs @@ -0,0 +1,159 @@ +use crate::{ + AGENT_ID, AddNoteInput, AddNoteRequest, CaptureMaterializationEvidence, CorpusText, ElfService, + IngestedCorpus, LiveCaptureAction, LoadedJob, Result, SCOPE, TENANT_ID, Uuid, + capture_action_str, eyre, serde_json, +}; + +pub(super) async fn ingest_elf_corpus( + service: &ElfService, + loaded: &LoadedJob, + adapter_id: &str, + project_id: &str, + corpus: &[CorpusText], +) -> Result { + let mut ingested = IngestedCorpus::default(); + + for item in corpus { + if item.capture.action == LiveCaptureAction::Exclude { + crate::push_unique( + &mut ingested.capture.excluded_evidence_ids, + item.evidence_id.clone(), + ); + + continue; + } + + crate::push_unique(&mut ingested.capture.stored_evidence_ids, item.evidence_id.clone()); + + if let Some(source_id) = item.capture.source_id.as_deref() { + crate::push_unique(&mut ingested.capture.source_ids, source_id.to_string()); + } + + if item.capture.write_policy.is_some() { + let note_id = ingest_elf_corpus_item( + service, + loaded, + adapter_id, + project_id, + item, + item.evidence_id.clone(), + item.text.clone(), + 0, + 1, + &mut ingested.capture, + ) + .await?; + + ingested + .note_ids_by_evidence + .entry(item.evidence_id.clone()) + .or_default() + .push(note_id); + + continue; + } + + let chunks = crate::note_text_chunks(item.text.as_str()); + let chunk_count = chunks.len(); + + for (chunk_index, text) in chunks.into_iter().enumerate() { + let key = if chunk_count == 1 { + item.evidence_id.clone() + } else { + format!("{}:chunk-{chunk_index:03}", item.evidence_id) + }; + let note_id = ingest_elf_corpus_item( + service, + loaded, + adapter_id, + project_id, + item, + key, + text, + chunk_index, + chunk_count, + &mut ingested.capture, + ) + .await?; + + ingested + .note_ids_by_evidence + .entry(item.evidence_id.clone()) + .or_default() + .push(note_id); + } + } + + Ok(ingested) +} + +#[allow(clippy::too_many_arguments)] +async fn ingest_elf_corpus_item( + service: &ElfService, + loaded: &LoadedJob, + adapter_id: &str, + project_id: &str, + item: &CorpusText, + key: String, + text: String, + chunk_index: usize, + chunk_count: usize, + capture: &mut CaptureMaterializationEvidence, +) -> Result { + let write_policy = item + .capture + .write_policy + .as_ref() + .map(|policy| crate::write_policy_from_value(policy, item.evidence_id.as_str())) + .transpose()?; + let response = service + .add_note(AddNoteRequest { + tenant_id: TENANT_ID.to_string(), + project_id: project_id.to_string(), + agent_id: AGENT_ID.to_string(), + scope: SCOPE.to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some(key), + text, + structured: None, + importance: 0.9, + confidence: 0.95, + ttl_days: None, + source_ref: serde_json::json!({ + "schema": "real_world_live_adapter/v1", + "adapter": adapter_id, + "job_id": loaded.job.job_id, + "evidence_id": item.evidence_id, + "source_id": item.capture.source_id.as_deref(), + "capture_action": capture_action_str(item.capture.action), + "evidence_binding": item.capture.evidence_binding.as_deref(), + "write_policy_applied": item.capture.write_policy.is_some(), + "chunk_index": chunk_index, + "chunk_count": chunk_count, + }), + write_policy, + }], + }) + .await + .map_err(|err| eyre::eyre!("ELF add_note failed for {}: {err}", loaded.job.job_id))?; + + for result in &response.results { + if let Some(audit) = &result.write_policy_audit + && (!audit.exclusions.is_empty() || !audit.redactions.is_empty()) + { + capture.write_policy_audit_count += 1; + capture.write_policy_exclusion_count += audit.exclusions.len(); + capture.write_policy_redaction_count += audit.redactions.len(); + } + } + + response.results.iter().find_map(|result| result.note_id).ok_or_else(|| { + eyre::eyre!( + "ELF add_note did not persist evidence {} chunk {} for {}.", + item.evidence_id, + chunk_index, + loaded.job.job_id + ) + }) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/knowledge_adapter.rs b/apps/elf-eval/src/bin/real_world_live_adapter/knowledge_adapter.rs new file mode 100644 index 00000000..296ae99b --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/knowledge_adapter.rs @@ -0,0 +1,219 @@ +use crate::{ + HashMap, IngestedCorpus, KnowledgeMaterializationEvidence, KnowledgePageLintResponse, + KnowledgePageResponse, LoadedJob, Result, Uuid, serde_json, +}; + +pub(super) fn knowledge_page_artifact( + loaded: &LoadedJob, + ingested: &IngestedCorpus, + first: &KnowledgePageResponse, + second: &KnowledgePageResponse, + lint: &KnowledgePageLintResponse, +) -> Result { + let reverse = note_id_to_evidence_id(ingested); + let mut sections = second + .sections + .iter() + .map(|section| { + let evidence_ids = section + .source_backlinks + .iter() + .filter_map(|source| reverse.get(&source.source_id).cloned()) + .collect::>(); + + serde_json::json!({ + "section_id": section.section_key.clone(), + "heading": section.heading.clone(), + "role": section.role.clone(), + "content": section.content.clone(), + "evidence_ids": evidence_ids, + "timeline_event_ids": [] + }) + }) + .collect::>(); + + sections.extend(unsupported_sections_from_fixture(loaded)); + + Ok(serde_json::json!({ + "page_id": second.page.page_id.to_string(), + "page_type": second.page.page_kind.clone(), + "title": second.page.title.clone(), + "sections": sections, + "backlinks": source_backlinks(ingested), + "lint_findings": lint_findings_for_page(loaded, ingested, lint), + "page_version_diff": second.page.previous_version_diff.clone(), + "rebuild": { + "first_hash": first.page.content_hash.clone(), + "second_hash": second.page.content_hash.clone(), + "deterministic": first.page.content_hash == second.page.content_hash, + "allowed_variance": [] + } + })) +} + +pub(super) fn knowledge_materialization_evidence( + page: &KnowledgePageResponse, + lint: &KnowledgePageLintResponse, + search_result_count: usize, +) -> KnowledgeMaterializationEvidence { + let unsupported_claim_count = + lint.findings.iter().filter(|finding| finding.finding_type == "unsupported_claim").count() + + page.sections.iter().filter(|section| section.unsupported_reason.is_some()).count(); + + KnowledgeMaterializationEvidence { + page_ids: vec![page.page.page_id], + search_result_count, + lint_finding_count: lint.findings.len(), + stale_source_finding_count: lint + .findings + .iter() + .filter(|finding| finding.finding_type == "stale_source_ref") + .count(), + unsupported_claim_count, + citation_count: page.sections.iter().map(|section| section.citation_count).sum(), + source_ref_count: page.source_refs.len(), + version_diff_available: page + .page + .previous_version_diff + .as_ref() + .and_then(|diff| diff.get("available")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + } +} + +pub(super) fn stale_trap_evidence_ids(loaded: &LoadedJob) -> Vec { + loaded + .value + .get("negative_traps") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter(|trap| { + trap.get("type").and_then(serde_json::Value::as_str) == Some("stale_fact") + && trap.get("failure_if_used").and_then(serde_json::Value::as_bool).unwrap_or(false) + }) + .flat_map(|trap| { + trap.get("evidence_ids") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .map(ToString::to_string) + .collect::>() + }) + .collect() +} + +fn note_id_to_evidence_id(ingested: &IngestedCorpus) -> HashMap { + let mut out = HashMap::new(); + + for (evidence_id, note_ids) in &ingested.note_ids_by_evidence { + for note_id in note_ids { + out.insert(*note_id, evidence_id.clone()); + } + } + + out +} + +fn source_backlinks(ingested: &IngestedCorpus) -> Vec { + let mut backlinks = ingested + .note_ids_by_evidence + .keys() + .map(|evidence_id| format!("source:{evidence_id}")) + .collect::>(); + + backlinks.sort(); + + backlinks +} + +fn lint_findings_for_page( + loaded: &LoadedJob, + ingested: &IngestedCorpus, + lint: &KnowledgePageLintResponse, +) -> Vec { + let reverse = note_id_to_evidence_id(ingested); + + lint.findings + .iter() + .map(|finding| { + let evidence_ids = finding + .source_id + .and_then(|source_id| reverse.get(&source_id).cloned()) + .into_iter() + .collect::>(); + let trap_id = evidence_ids + .first() + .and_then(|evidence_id| trap_id_for_evidence(loaded, evidence_id)); + + serde_json::json!({ + "finding_id": finding.finding_id.to_string(), + "finding_type": finding.finding_type.clone(), + "severity": finding.severity.clone(), + "text": finding.message.clone(), + "evidence_ids": evidence_ids, + "trap_id": trap_id + }) + }) + .collect() +} + +fn unsupported_sections_from_fixture(loaded: &LoadedJob) -> Vec { + let Some(pages) = loaded + .value + .pointer("/corpus/adapter_response/answer/pages") + .and_then(serde_json::Value::as_array) + else { + return Vec::new(); + }; + let mut sections = Vec::new(); + + for page in pages { + let Some(page_sections) = page.get("sections").and_then(serde_json::Value::as_array) else { + continue; + }; + + for section in page_sections { + let Some(reason) = + section.get("unsupported_reason").and_then(serde_json::Value::as_str) + else { + continue; + }; + + sections.push(serde_json::json!({ + "section_id": section + .get("section_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("unsupported-summary"), + "heading": section + .get("heading") + .and_then(serde_json::Value::as_str) + .unwrap_or("Unsupported Summary"), + "role": section.get("role").and_then(serde_json::Value::as_str).unwrap_or("summary"), + "content": section.get("content").and_then(serde_json::Value::as_str).unwrap_or(reason), + "evidence_ids": [], + "timeline_event_ids": [], + "unsupported_reason": reason + })); + } + } + + sections +} + +fn trap_id_for_evidence(loaded: &LoadedJob, evidence_id: &str) -> Option { + loaded + .value + .get("negative_traps") + .and_then(serde_json::Value::as_array)? + .iter() + .find(|trap| { + trap.get("evidence_ids") + .and_then(serde_json::Value::as_array) + .is_some_and(|ids| ids.iter().any(|id| id.as_str() == Some(evidence_id))) + }) + .and_then(|trap| trap.get("trap_id").and_then(serde_json::Value::as_str)) + .map(ToString::to_string) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag.rs new file mode 100644 index 00000000..268cfe41 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag.rs @@ -0,0 +1,8 @@ +#[path = "lightrag/api.rs"] mod api; +#[path = "lightrag/corpus.rs"] mod corpus; +#[path = "lightrag/mapping.rs"] mod mapping; +#[path = "lightrag/metadata.rs"] mod metadata; +#[path = "lightrag/runtime.rs"] mod runtime; +#[path = "lightrag/status.rs"] mod status; + +pub(super) use runtime::run_lightrag_async; diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/api.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/api.rs new file mode 100644 index 00000000..1ea17e18 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/api.rs @@ -0,0 +1,157 @@ +use std::time::Duration; + +use reqwest::{Client, RequestBuilder}; +use tokio::time; + +use crate::{ + CorpusText, LightragArgs, LightragSource, LoadedJob, Result, eyre, + lightrag::{corpus, metadata, status}, + serde_json, +}; + +pub(super) async fn wait_for_lightrag(args: &LightragArgs, client: &Client) -> Result<()> { + let mut last_error = String::new(); + + for _attempt in 1..=args.startup_attempts { + match lightrag_get_json(args, client, "/health").await { + Ok(_) => return Ok(()), + Err(err) => last_error = err.to_string(), + } + + time::sleep(Duration::from_secs(args.startup_interval_seconds)).await; + } + + Err(eyre::eyre!( + "LightRAG API did not become healthy at {} after {} attempts: {}", + metadata::lightrag_api_base(args), + args.startup_attempts, + last_error + )) +} + +pub(super) async fn insert_lightrag_texts( + args: &LightragArgs, + client: &Client, + corpus: &[CorpusText], + sources: &[LightragSource], +) -> Result { + let request = serde_json::json!({ + "texts": corpus.iter().map(|item| item.text.as_str()).collect::>(), + "file_sources": sources.iter().map(|source| source.file_source.as_str()).collect::>(), + "chunking": { + "strategy": "fixed_token", + "params": { + "chunk_token_size": 320, + "chunk_overlap_token_size": 32 + } + } + }); + + lightrag_post_json(args, client, "/documents/texts", &request).await +} + +pub(super) async fn wait_for_lightrag_index( + args: &LightragArgs, + client: &Client, + insert_response: &serde_json::Value, + expected_docs: usize, +) -> Result<()> { + let track_id = insert_response + .get("track_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| eyre::eyre!("LightRAG text insert response did not include track_id."))?; + let mut last_status = serde_json::Value::Null; + + for _attempt in 1..=args.index_attempts { + let status = + lightrag_get_json(args, client, format!("/documents/track_status/{track_id}")).await?; + + if status::lightrag_index_failed(&status) { + return Err(eyre::eyre!( + "LightRAG document indexing failed for track_id {track_id}: {}", + serde_json::to_string(&status)? + )); + } + if status::lightrag_index_processed(&status, expected_docs) { + return Ok(()); + } + + last_status = status; + + time::sleep(Duration::from_secs(args.index_interval_seconds)).await; + } + + Err(eyre::eyre!( + "LightRAG document indexing did not finish for track_id {} after {} attempts: {}", + track_id, + args.index_attempts, + serde_json::to_string(&last_status)? + )) +} + +pub(super) async fn query_lightrag_context( + args: &LightragArgs, + client: &Client, + loaded: &LoadedJob, +) -> Result { + let keywords = corpus::lightrag_keywords(loaded.job.prompt.content.as_str()); + let request = serde_json::json!({ + "query": loaded.job.prompt.content, + "mode": args.query_mode, + "only_need_context": true, + "include_references": true, + "include_chunk_content": true, + "enable_rerank": false, + "top_k": args.top_k, + "chunk_top_k": args.chunk_top_k, + "hl_keywords": keywords, + "ll_keywords": keywords, + "stream": false + }); + + lightrag_post_json(args, client, "/query", &request).await +} + +async fn lightrag_get_json( + args: &LightragArgs, + client: &Client, + path: impl AsRef, +) -> Result { + let url = format!("{}{}", metadata::lightrag_api_base(args), path.as_ref()); + let mut request = client.get(url); + + if let Some(api_key) = args.api_key.as_deref().filter(|key| !key.is_empty()) { + request = request.bearer_auth(api_key); + } + + lightrag_send_json(request).await +} + +async fn lightrag_post_json( + args: &LightragArgs, + client: &Client, + path: &str, + body: &serde_json::Value, +) -> Result { + let url = format!("{}{}", metadata::lightrag_api_base(args), path); + let mut request = client.post(url).json(body); + + if let Some(api_key) = args.api_key.as_deref().filter(|key| !key.is_empty()) { + request = request.bearer_auth(api_key); + } + + lightrag_send_json(request).await +} + +async fn lightrag_send_json(request: RequestBuilder) -> Result { + let response = request.send().await?; + let status = response.status(); + let body = response.text().await?; + + if !status.is_success() { + return Err(eyre::eyre!("LightRAG API returned HTTP {status}: {body}")); + } + + serde_json::from_str(&body) + .map_err(|err| eyre::eyre!("LightRAG API returned invalid JSON: {err}; body={body}")) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/corpus.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/corpus.rs new file mode 100644 index 00000000..ed8bea01 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/corpus.rs @@ -0,0 +1,32 @@ +use std::fs; + +use crate::{CorpusText, LightragArgs, LightragSource, LoadedJob, Result}; + +pub(super) fn write_lightrag_corpus( + args: &LightragArgs, + loaded: &LoadedJob, + corpus: &[CorpusText], + run_slug: &str, +) -> Result> { + let job_slug = crate::slug(&loaded.job.job_id); + let corpus_dir = args.work_dir.join("corpus").join(run_slug).join(&job_slug); + + fs::create_dir_all(&corpus_dir)?; + + corpus + .iter() + .map(|item| { + let file_name = format!("{}.md", crate::slug(&item.evidence_id)); + let artifact_path = corpus_dir.join(&file_name); + let file_source = format!("elf-real-world/{run_slug}/{job_slug}/{file_name}"); + + fs::write(&artifact_path, format!("# {}\n\n{}\n", item.evidence_id, item.text))?; + + Ok(LightragSource { evidence_id: item.evidence_id.clone(), file_source, artifact_path }) + }) + .collect() +} + +pub(super) fn lightrag_keywords(query: &str) -> Vec { + crate::terms(query).into_iter().take(12).collect() +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/mapping.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/mapping.rs new file mode 100644 index 00000000..1df53ab9 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/mapping.rs @@ -0,0 +1,119 @@ +use crate::{CorpusText, LightragSource, SourceMappingEvidence, serde_json}; + +pub(super) fn lightrag_source_mappings( + corpus: &[CorpusText], + sources: &[LightragSource], + response: &serde_json::Value, +) -> Vec { + let mut mappings = Vec::new(); + + if let Some(references) = response.get("references").and_then(serde_json::Value::as_array) { + for reference in references { + mappings.push(lightrag_reference_mapping(corpus, sources, reference)); + } + } + + if mappings.is_empty() + && let Some(context) = response.get("response").and_then(serde_json::Value::as_str) + { + let evidence_ids = map_lightrag_evidence_ids(corpus, sources, context); + + if !evidence_ids.is_empty() { + mappings.push(SourceMappingEvidence { + source: "response_context".to_string(), + evidence_ids, + mapping_status: "matched_context".to_string(), + content_count: 1, + }); + } + } + + mappings +} + +pub(super) fn lightrag_mapped_evidence_ids(mappings: &[SourceMappingEvidence]) -> Vec { + let mut evidence_ids = Vec::new(); + + for mapping in mappings { + for evidence_id in &mapping.evidence_ids { + crate::push_unique(&mut evidence_ids, evidence_id.clone()); + } + } + + evidence_ids +} + +fn lightrag_reference_mapping( + corpus: &[CorpusText], + sources: &[LightragSource], + reference: &serde_json::Value, +) -> SourceMappingEvidence { + let source = reference + .get("file_path") + .and_then(serde_json::Value::as_str) + .or_else(|| reference.get("reference_id").and_then(serde_json::Value::as_str)) + .unwrap_or("unknown_source") + .to_string(); + let content = reference + .get("content") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .collect::>(); + let joined_content = content.join("\n"); + let combined = format!("{source}\n{joined_content}"); + let evidence_ids = map_lightrag_evidence_ids(corpus, sources, combined.as_str()); + let mapping_status = if evidence_ids.is_empty() { + "unmatched" + } else if !joined_content.is_empty() { + "matched_reference_content" + } else { + "matched_reference_source" + }; + + SourceMappingEvidence { + source, + evidence_ids, + mapping_status: mapping_status.to_string(), + content_count: content.len(), + } +} + +fn map_lightrag_evidence_ids( + corpus: &[CorpusText], + sources: &[LightragSource], + haystack: &str, +) -> Vec { + let normalized_haystack = crate::normalize_ascii_alnum_lowercase(haystack); + let mut evidence_ids = Vec::new(); + + for item in corpus { + let evidence_slug = crate::slug(&item.evidence_id); + let signature = normalized_text_signature(item.text.as_str()); + let source_match = sources.iter().any(|source| { + source.evidence_id == item.evidence_id + && (haystack.contains(source.file_source.as_str()) + || haystack.contains(source.artifact_path.to_string_lossy().as_ref())) + }); + let id_match = haystack.contains(item.evidence_id.as_str()) + || haystack.contains(evidence_slug.as_str()) + || normalized_haystack.contains(evidence_slug.as_str()); + let content_match = + !signature.is_empty() && normalized_haystack.contains(signature.as_str()); + + if source_match || id_match || content_match { + crate::push_unique(&mut evidence_ids, item.evidence_id.clone()); + } + } + + evidence_ids +} + +fn normalized_text_signature(text: &str) -> String { + crate::normalize_ascii_alnum_lowercase(text) + .split_whitespace() + .take(8) + .collect::>() + .join(" ") +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/metadata.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/metadata.rs new file mode 100644 index 00000000..59340641 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/metadata.rs @@ -0,0 +1,60 @@ +use crate::{LightragArgs, Value, serde_json}; + +pub(super) fn lightrag_api_base(args: &LightragArgs) -> String { + args.api_base.trim_end_matches('/').to_string() +} + +pub(super) fn lightrag_metadata(args: &LightragArgs, run_slug: &str) -> Value { + serde_json::json!({ + "schema": "elf.lightrag_context_export_metadata/v1", + "run_slug": run_slug, + "api_base": lightrag_api_base(args), + "query": { + "mode": args.query_mode, + "only_need_context": true, + "include_references": true, + "include_chunk_content": true, + "enable_rerank": false, + "top_k": args.top_k, + "chunk_top_k": args.chunk_top_k + }, + "docker_boundary": { + "compose_file": "docker-compose.baseline.yml", + "service_profile": "lightrag", + "service": "lightrag", + "mock_provider_service": "lightrag-mock-provider", + "host_global_installs_required": false, + "workspace": "/app/data/rag_storage", + "input_dir": "/app/data/inputs", + "data_volumes": [ + "elf-live-baseline-lightrag-rag-storage", + "elf-live-baseline-lightrag-inputs", + "elf-live-baseline-lightrag-prompts" + ] + }, + "provider_boundaries": { + "llm_binding": "openai-compatible", + "embedding_binding": "openai-compatible", + "embedding_dim": 64, + "rerank_binding": "cohere-compatible", + "rerank_enabled_for_query": false, + "api_key_provided": args.api_key.as_deref().is_some_and(|key| !key.is_empty()), + "operator_owned_provider_credentials_used": false + }, + "cache_and_resource_envelope": { + "cargo_cache": "/usr/local/cargo", + "pip_cache": "/root/.cache/pip", + "huggingface_cache": "/root/.cache/huggingface", + "lightrag_storage": "/app/data/rag_storage", + "startup_attempts": args.startup_attempts, + "startup_interval_seconds": args.startup_interval_seconds, + "index_attempts": args.index_attempts, + "index_interval_seconds": args.index_interval_seconds + }, + "source_mapping": { + "corpus_file_source_template": "elf-real-world/{run_slug}/{job_slug}/{evidence_id}.md", + "mapping_inputs": ["references.file_path", "references.content", "response"], + "quality_claim": "none" + } + }) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/runtime.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/runtime.rs new file mode 100644 index 00000000..45fadb54 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/runtime.rs @@ -0,0 +1,123 @@ +use std::{ + fs, + time::{Duration, Instant}, +}; + +use crate::{ + AdapterKind, CommandEvidence, LightragArgs, LoadedJob, MaterializedJob, MaterializedJobInput, + MaterializedOutput, Result, Uuid, + lightrag::{api, corpus, mapping, metadata, status}, +}; + +pub(crate) async fn run_lightrag_async(args: LightragArgs) -> Result<()> { + let jobs = crate::load_jobs(&args.fixtures)?; + let run_slug = crate::short_hash(format!("{}:{}", args.adapter_id, Uuid::new_v4()).as_str()); + let result = materialize_lightrag_jobs(&args, &jobs, &run_slug).await; + let materialized = match result { + Ok(jobs) => jobs, + Err(err) => status::lightrag_failure_jobs( + &args.adapter_id, + &jobs, + "lightrag_api_context_export", + err.to_string(), + ), + }; + let status = crate::aggregate_status(&materialized); + + crate::write_materialized_output(MaterializedOutput { + adapter_id: &args.adapter_id, + adapter_kind: AdapterKind::LightragApiContextExport, + fixtures: &args.fixtures, + out_fixtures: &args.out_fixtures, + evidence_out: &args.evidence_out, + jobs: &jobs, + materialized: &materialized, + command_evidence: vec![CommandEvidence { + label: "lightrag_api_context_export".to_string(), + status, + command: "cargo run -p elf-eval --bin real_world_live_adapter -- lightrag" + .to_string(), + artifact: Some(args.evidence_out.display().to_string()), + reason: "LightRAG adapter used /documents/texts, /documents/track_status, and /query with only_need_context plus chunk references.".to_string(), + }], + metadata: Some(metadata::lightrag_metadata(&args, &run_slug)), + }) +} + +async fn materialize_lightrag_jobs( + args: &LightragArgs, + jobs: &[LoadedJob], + run_slug: &str, +) -> Result> { + fs::create_dir_all(&args.work_dir)?; + + let client = reqwest::Client::builder().timeout(Duration::from_secs(180)).build()?; + + api::wait_for_lightrag(args, &client).await?; + + let mut out = Vec::with_capacity(jobs.len()); + + for loaded in jobs { + out.push(materialize_lightrag_job(args, &client, loaded, run_slug).await?); + } + + Ok(out) +} + +async fn materialize_lightrag_job( + args: &LightragArgs, + client: &reqwest::Client, + loaded: &LoadedJob, + run_slug: &str, +) -> Result { + if let Some(job) = crate::declared_encoding_job(&args.adapter_id, loaded) { + return Ok(job); + } + if let Some(job) = status::lightrag_not_encoded_job(&args.adapter_id, loaded) { + return Ok(job); + } + + let corpus = crate::corpus_texts(loaded)?; + let sources = corpus::write_lightrag_corpus(args, loaded, &corpus, run_slug)?; + let indexed_at = Instant::now(); + let insert_response = api::insert_lightrag_texts(args, client, &corpus, &sources).await?; + + api::wait_for_lightrag_index(args, client, &insert_response, corpus.len()).await?; + + let indexing_latency_ms = indexed_at.elapsed().as_secs_f64() * 1_000.0; + let queried_at = Instant::now(); + let query_response = api::query_lightrag_context(args, client, loaded).await?; + let latency_ms = queried_at.elapsed().as_secs_f64() * 1_000.0; + let source_mappings = mapping::lightrag_source_mappings(&corpus, &sources, &query_response); + let evidence_ids = mapping::lightrag_mapped_evidence_ids(&source_mappings); + let selected = crate::selected_required_corpus_texts(loaded, &corpus, &evidence_ids); + + Ok(crate::materialized_job( + loaded, + &args.adapter_id, + MaterializedJobInput { + content: selected.content, + evidence_ids: selected.evidence_ids, + pages: Vec::new(), + latency_ms, + indexing_latency_ms: Some(indexing_latency_ms), + returned_count: source_mappings.len(), + trace_id: None, + failure: None, + source_mappings, + operator_debug: None, + operator_debug_evidence: None, + capture: None, + capture_failure: None, + consolidation_response: None, + consolidation: None, + knowledge: None, + temporal_reconciliation: None, + dreaming_readback: None, + memory_summaries: Vec::new(), + proactive_briefs: Vec::new(), + scheduled_tasks: Vec::new(), + trace_stages: None, + }, + )) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/status.rs b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/status.rs new file mode 100644 index 00000000..99136288 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/lightrag/status.rs @@ -0,0 +1,86 @@ +use crate::{LoadedJob, MaterializationStatus, MaterializedJob, MaterializedJobInput, serde_json}; + +pub(super) fn lightrag_not_encoded_job( + adapter_id: &str, + loaded: &LoadedJob, +) -> Option { + match loaded.job.suite.as_str() { + "retrieval" => None, + _ => Some(crate::materialized_declared_status_job( + adapter_id, + loaded, + MaterializationStatus::NotEncoded, + "LightRAG context-export smoke only maps retrieved context/source paths; this suite is not encoded for LightRAG scoring.".to_string(), + )), + } +} + +pub(super) fn lightrag_failure_jobs( + adapter_id: &str, + jobs: &[LoadedJob], + stage: &str, + reason: String, +) -> Vec { + jobs.iter() + .map(|job| { + if let Some(declared) = crate::declared_encoding_job(adapter_id, job) { + return declared; + } + if let Some(not_encoded) = lightrag_not_encoded_job(adapter_id, job) { + return not_encoded; + } + + crate::materialized_job( + job, + adapter_id, + MaterializedJobInput { + content: String::new(), + evidence_ids: Vec::new(), + pages: Vec::new(), + latency_ms: 0.0, + indexing_latency_ms: None, + returned_count: 0, + trace_id: None, + failure: Some(format!("{stage}: {reason}")), + source_mappings: Vec::new(), + operator_debug: None, + operator_debug_evidence: None, + capture: None, + capture_failure: None, + consolidation_response: None, + consolidation: None, + knowledge: None, + temporal_reconciliation: None, + dreaming_readback: None, + memory_summaries: Vec::new(), + proactive_briefs: Vec::new(), + scheduled_tasks: Vec::new(), + trace_stages: None, + }, + ) + }) + .collect() +} + +pub(super) fn lightrag_index_failed(status: &serde_json::Value) -> bool { + status.get("documents").and_then(serde_json::Value::as_array).into_iter().flatten().any(|doc| { + doc.get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| status.to_ascii_lowercase().contains("fail")) + }) +} + +pub(super) fn lightrag_index_processed(status: &serde_json::Value, expected_docs: usize) -> bool { + let Some(documents) = status.get("documents").and_then(serde_json::Value::as_array) else { + return false; + }; + + documents.len() >= expected_docs + && documents.iter().all(|doc| { + doc.get("status").and_then(serde_json::Value::as_str).is_some_and(|status| { + let normalized = status.to_ascii_lowercase(); + + normalized.contains("processed") || normalized.contains("success") + }) + }) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/materialization.rs b/apps/elf-eval/src/bin/real_world_live_adapter/materialization.rs new file mode 100644 index 00000000..708e9793 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/materialization.rs @@ -0,0 +1,283 @@ +use crate::{ + AdapterResponseOutput, AnswerOutput, CostOutput, LoadedJob, MaterializationStatus, + MaterializedJob, MaterializedJobEvidence, MaterializedJobInput, TraceExplainabilityOutput, + TraceStageOutput, +}; + +pub(super) fn materialized_job( + loaded: &LoadedJob, + adapter_id: &str, + input: MaterializedJobInput, +) -> MaterializedJob { + let capture_failure = input.capture_failure.clone(); + let required_evidence_satisfied = capture_failure.is_none() + && crate::required_evidence_satisfied(loaded, &input.evidence_ids); + let status = if input.failure.is_some() { + MaterializationStatus::Incomplete + } else if !required_evidence_satisfied { + MaterializationStatus::WrongResult + } else { + MaterializationStatus::Pass + }; + let failure_stage = if input.failure.is_some() { + Some("live_adapter.retrieve".to_string()) + } else if capture_failure.is_some() { + Some("live_adapter.capture_policy".to_string()) + } else { + None + }; + let failure_reason = input.failure.clone().or(capture_failure); + let stage_notes = if let Some(reason) = &failure_reason { + reason.clone() + } else if !required_evidence_satisfied { + "Adapter did not return all required mapped evidence for this job.".to_string() + } else { + "Adapter returned mapped evidence through its live retrieval path.".to_string() + }; + let trace_stages = input.trace_stages.unwrap_or_else(|| { + vec![TraceStageOutput { + stage_name: failure_stage + .clone() + .unwrap_or_else(|| "live_adapter.retrieve".to_string()), + kept_evidence: input.evidence_ids.clone(), + dropped_evidence: Vec::new(), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: stage_notes, + }] + }); + + MaterializedJob { + response: AdapterResponseOutput { + adapter_id: adapter_id.to_string(), + answer: AnswerOutput { + content: input.content, + evidence_ids: input.evidence_ids.clone(), + claims: crate::answer_claims(loaded, &input.evidence_ids), + pages: input.pages, + memory_summaries: input.memory_summaries, + proactive_briefs: input.proactive_briefs, + scheduled_tasks: input.scheduled_tasks, + latency_ms: input.latency_ms, + cost: CostOutput { + currency: "USD".to_string(), + amount: 0.0, + input_tokens: 0, + output_tokens: 0, + }, + trace_explainability: TraceExplainabilityOutput { + trace_id: input.trace_id.map(|id| id.to_string()), + failure_stage: failure_stage.clone(), + failure_reason: failure_reason.clone(), + stages: trace_stages, + }, + }, + consolidation: input.consolidation_response, + }, + operator_debug: input.operator_debug, + evidence: MaterializedJobEvidence { + job_id: loaded.job.job_id.clone(), + suite: loaded.job.suite.clone(), + title: loaded.job.title.clone(), + status, + query: loaded.job.prompt.content.clone(), + evidence_ids: input.evidence_ids, + returned_count: input.returned_count, + indexing_latency_ms: input.indexing_latency_ms, + latency_ms: input.latency_ms, + trace_id: input.trace_id, + failure: failure_reason, + source_mappings: input.source_mappings, + operator_debug: input.operator_debug_evidence, + capture: input.capture, + consolidation: input.consolidation, + knowledge: input.knowledge, + temporal_reconciliation: input.temporal_reconciliation, + dreaming_readback: input.dreaming_readback, + }, + } +} + +pub(super) fn declared_encoding_job( + adapter_id: &str, + loaded: &LoadedJob, +) -> Option { + if is_operator_debug_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_consolidation_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_knowledge_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_capture_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + + let status = loaded.job.encoding.status?; + let reason = loaded.job.encoding.reason.clone().unwrap_or_else(|| { + format!("Fixture declares {} for this live adapter job.", status.as_str()) + }); + + Some(materialized_declared_status_job( + adapter_id, + loaded, + status.materialization_status(), + reason, + )) +} + +pub(super) fn not_encoded_job(adapter_id: &str, loaded: &LoadedJob) -> Option { + if is_operator_debug_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_consolidation_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_knowledge_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_capture_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + if is_elf_dreaming_readback_live_adapter(adapter_id, loaded.job.suite.as_str()) { + return None; + } + + not_encoded_reason(loaded.job.suite.as_str()).map(|reason| { + materialized_declared_status_job( + adapter_id, + loaded, + MaterializationStatus::NotEncoded, + reason.to_string(), + ) + }) +} + +pub(super) fn is_elf_dreaming_readback_live_adapter(adapter_id: &str, suite: &str) -> bool { + matches!(suite, "memory_summary" | "proactive_brief" | "scheduled_memory") + && matches!(adapter_id, "elf_service_native_dreaming" | "elf_live_real_world") +} + +pub(super) fn materialized_declared_status_job( + adapter_id: &str, + loaded: &LoadedJob, + status: MaterializationStatus, + reason: String, +) -> MaterializedJob { + let failure = match status { + MaterializationStatus::Pass | MaterializationStatus::WrongResult => None, + MaterializationStatus::Blocked + | MaterializationStatus::Incomplete + | MaterializationStatus::NotEncoded => Some(reason.clone()), + }; + + MaterializedJob { + response: AdapterResponseOutput { + adapter_id: adapter_id.to_string(), + answer: AnswerOutput { + content: String::new(), + evidence_ids: Vec::new(), + claims: Vec::new(), + pages: Vec::new(), + memory_summaries: Vec::new(), + proactive_briefs: Vec::new(), + scheduled_tasks: Vec::new(), + latency_ms: 0.0, + cost: CostOutput { + currency: "USD".to_string(), + amount: 0.0, + input_tokens: 0, + output_tokens: 0, + }, + trace_explainability: TraceExplainabilityOutput { + trace_id: None, + failure_stage: Some("live_adapter.suite_support".to_string()), + failure_reason: failure.clone(), + stages: vec![TraceStageOutput { + stage_name: "live_adapter.suite_support".to_string(), + kept_evidence: Vec::new(), + dropped_evidence: Vec::new(), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: reason.clone(), + }], + }, + }, + consolidation: None, + }, + evidence: MaterializedJobEvidence { + job_id: loaded.job.job_id.clone(), + suite: loaded.job.suite.clone(), + title: loaded.job.title.clone(), + status, + query: loaded.job.prompt.content.clone(), + evidence_ids: Vec::new(), + returned_count: 0, + indexing_latency_ms: None, + latency_ms: 0.0, + trace_id: None, + failure, + source_mappings: Vec::new(), + operator_debug: None, + capture: None, + consolidation: None, + knowledge: None, + temporal_reconciliation: None, + dreaming_readback: None, + }, + operator_debug: None, + } +} + +fn is_operator_debug_live_adapter(adapter_id: &str, suite: &str) -> bool { + suite == "operator_debugging_ux" + && matches!( + adapter_id, + "elf_live_real_world" + | "qmd_live_real_world" + | "elf_operator_debug_live" + | "qmd_operator_debug_live" + ) +} + +fn is_elf_consolidation_live_adapter(adapter_id: &str, suite: &str) -> bool { + suite == "consolidation" && adapter_id == "elf_live_real_world" +} + +fn is_elf_knowledge_live_adapter(adapter_id: &str, suite: &str) -> bool { + suite == "knowledge_compilation" && adapter_id == "elf_live_real_world" +} + +fn is_elf_capture_live_adapter(adapter_id: &str, suite: &str) -> bool { + suite == "capture_integration" + && matches!(adapter_id, "elf_live_real_world" | "elf_capture_write_policy_live") +} + +fn not_encoded_reason(suite: &str) -> Option<&'static str> { + match suite { + "trust_source_of_truth" + | "work_resume" + | "project_decisions" + | "retrieval" + | "memory_evolution" + | "personalization" => None, + "consolidation" => Some( + "The live adapter sweep retrieves evidence-linked answers but does not generate or review consolidation proposals.", + ), + "knowledge_compilation" => Some( + "The live adapter sweep retrieves evidence-linked answers but does not generate derived knowledge pages.", + ), + "operator_debugging_ux" => Some( + "The full live adapter sweep keeps operator trace/viewer diagnostics in a focused operator-debug slice.", + ), + "capture_integration" => Some( + "The live adapter sweep does not exercise capture integrations or write-policy redaction boundaries.", + ), + "production_ops" => Some( + "The live adapter sweep does not run backup/restore, private corpus, provider credential, or backfill operations.", + ), + _ => Some("The live adapter sweep has no encoded runtime path for this suite."), + } +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model.rs new file mode 100644 index 00000000..9e870d53 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model.rs @@ -0,0 +1,45 @@ +#[path = "model/cli.rs"] mod cli; +#[path = "model/consolidation.rs"] mod consolidation; +#[path = "model/live.rs"] mod live; +#[path = "model/materialization.rs"] mod materialization; +#[path = "model/providers.rs"] mod providers; +#[path = "model/runtime.rs"] mod runtime; + +pub(super) use self::{ + cli::{Args, CommandArgs, ElfArgs, LightragArgs, QmdArgs}, + consolidation::{ + LiveConsolidationFixture, LiveConsolidationProposal, PreparedConsolidationRun, + }, + live::{ + LiveCaptureAction, LiveCapturePolicy, LiveExpectedClaim, LiveJob, LiveMemoryEvolution, + LoadedJob, + }, + materialization::{ + AdapterKind, AdapterResponseOutput, AnswerOutput, CaptureMaterializationEvidence, + CaptureRuntimeEvidence, CaptureRuntimeEvidenceItem, CaptureRuntimeSourceRefEvidence, + CommandEvidence, ConsolidationMaterializationEvidence, CorpusText, CostOutput, + DreamingReadbackMaterializationEvidence, DreamingReadbackOutput, IngestedCorpus, + KnowledgeMaterializationEvidence, MaterializationEvidence, MaterializationStatus, + MaterializedJob, MaterializedJobEvidence, MaterializedJobInput, MaterializedOutput, + OperatorDebugMaterializationEvidence, SelectedEvidenceText, SourceMappingEvidence, + SuiteMaterializationSelection, SuiteMaterializationSelectionInput, + TemporalReconciliationMaterializationEvidence, TemporalReconciliationSelection, + TraceExplainabilityOutput, TraceStageOutput, + }, + providers::{DeterministicEmbedding, NoopExtractor, TokenOverlapRerank}, + runtime::{BaselineRuntime, LightragSource}, +}; + +use crate::{ + BoxFuture, ConsolidationInputRef, ConsolidationProposalInput, Deserialize, EmbeddingProvider, + EmbeddingProviderConfig, ExtractorProvider, HashMap, LlmProviderConfig, Map, Parser, Path, + PathBuf, ProviderConfig, RerankProvider, Serialize, Subcommand, Uuid, ValueEnum, embed_text, + serde_json, terms, +}; + +pub(super) const JOB_SCHEMA: &str = "elf.real_world_job/v1"; +pub(super) const EVIDENCE_SCHEMA: &str = "elf.real_world_live_adapter_materialization/v1"; +pub(super) const TENANT_ID: &str = "elf-live-real-world"; +pub(super) const AGENT_ID: &str = "elf-live-real-world-agent"; +pub(super) const SCOPE: &str = "agent_private"; +pub(super) const ELF_NOTE_CHUNK_CHARS: usize = 220; diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/cli.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/cli.rs new file mode 100644 index 00000000..88e09758 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/cli.rs @@ -0,0 +1,109 @@ +use super::{Parser, PathBuf, Subcommand}; + +#[derive(Debug, Parser)] +#[command(version = elf_cli::VERSION, rename_all = "kebab", styles = elf_cli::styles())] +pub(crate) struct Args { + #[command(subcommand)] + pub(crate) command: CommandArgs, +} + +#[derive(Debug, Parser)] +pub(crate) struct ElfArgs { + /// Fixture file or directory containing real_world_job JSON fixtures. + #[arg(long, value_name = "PATH")] + pub(crate) fixtures: PathBuf, + /// Directory where generated real_world_job fixtures are written. + #[arg(long, value_name = "DIR")] + pub(crate) out_fixtures: PathBuf, + /// JSON evidence file for adapter setup/run/result details. + #[arg(long, value_name = "FILE")] + pub(crate) evidence_out: PathBuf, + /// ELF config loaded before Docker runtime overrides are applied. + #[arg(long, short = 'c', value_name = "FILE")] + pub(crate) config: PathBuf, + /// Adapter id embedded in generated adapter_response objects. + #[arg(long, default_value = "elf_live_real_world")] + pub(crate) adapter_id: String, +} + +#[derive(Debug, Parser)] +pub(crate) struct QmdArgs { + /// Fixture file or directory containing real_world_job JSON fixtures. + #[arg(long, value_name = "PATH")] + pub(crate) fixtures: PathBuf, + /// Directory where generated real_world_job fixtures are written. + #[arg(long, value_name = "DIR")] + pub(crate) out_fixtures: PathBuf, + /// JSON evidence file for adapter setup/run/result details. + #[arg(long, value_name = "FILE")] + pub(crate) evidence_out: PathBuf, + /// qmd checkout directory. The materializer clones into it when missing. + #[arg(long, value_name = "DIR")] + pub(crate) qmd_dir: PathBuf, + /// Work directory for qmd home, corpus files, and command logs. + #[arg(long, value_name = "DIR")] + pub(crate) work_dir: PathBuf, + /// qmd repository URL used when qmd_dir is absent. + #[arg(long, default_value = "https://github.com/tobi/qmd.git")] + pub(crate) qmd_repo_url: String, + /// Adapter id embedded in generated adapter_response objects. + #[arg(long, default_value = "qmd_live_real_world")] + pub(crate) adapter_id: String, +} + +#[derive(Debug, Parser)] +pub(crate) struct LightragArgs { + /// Fixture file or directory containing real_world_job JSON fixtures. + #[arg(long, value_name = "PATH")] + pub(crate) fixtures: PathBuf, + /// Directory where generated real_world_job fixtures are written. + #[arg(long, value_name = "DIR")] + pub(crate) out_fixtures: PathBuf, + /// JSON evidence file for adapter setup/run/result details. + #[arg(long, value_name = "FILE")] + pub(crate) evidence_out: PathBuf, + /// Work directory for generated source files and command logs. + #[arg(long, value_name = "DIR")] + pub(crate) work_dir: PathBuf, + /// LightRAG API base URL reachable from the Docker runner. + #[arg(long, default_value = "http://lightrag:9621")] + pub(crate) api_base: String, + /// Optional LightRAG API bearer token. + #[arg(long)] + pub(crate) api_key: Option, + /// Adapter id embedded in generated adapter_response objects. + #[arg(long, default_value = "lightrag_live_real_world")] + pub(crate) adapter_id: String, + /// LightRAG query mode used for context export. + #[arg(long, default_value = "naive")] + pub(crate) query_mode: String, + /// Number of top results requested from LightRAG. + #[arg(long, default_value_t = 5)] + pub(crate) top_k: u32, + /// Number of chunk results requested from LightRAG. + #[arg(long, default_value_t = 5)] + pub(crate) chunk_top_k: u32, + /// Health-check attempts before returning a typed runtime failure. + #[arg(long, default_value_t = 30)] + pub(crate) startup_attempts: u32, + /// Delay between LightRAG health-check attempts. + #[arg(long, default_value_t = 2)] + pub(crate) startup_interval_seconds: u64, + /// Poll attempts for asynchronous document indexing. + #[arg(long, default_value_t = 60)] + pub(crate) index_attempts: u32, + /// Delay between document indexing status checks. + #[arg(long, default_value_t = 2)] + pub(crate) index_interval_seconds: u64, +} + +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab")] +pub(crate) enum CommandArgs { + /// Materialize adapter responses by running jobs through ELF's service runtime. + Elf(ElfArgs), + /// Materialize adapter responses by running jobs through qmd's local CLI workflow. + Qmd(QmdArgs), + /// Materialize adapter responses by exporting LightRAG query context and source mappings. + Lightrag(LightragArgs), +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/consolidation.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/consolidation.rs new file mode 100644 index 00000000..811d7547 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/consolidation.rs @@ -0,0 +1,44 @@ +use super::{ + ConsolidationInputRef, ConsolidationProposalInput, Deserialize, Serialize, serde_json, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct LiveConsolidationFixture { + #[serde(default)] + pub(crate) proposals: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct LiveConsolidationProposal { + pub(crate) proposal_id: String, + pub(crate) proposal_kind: String, + #[serde(default)] + pub(crate) source_refs: Vec, + #[serde(default)] + pub(crate) expected_source_refs: Vec, + pub(crate) usefulness_score: f64, + pub(crate) min_usefulness_score: f64, + pub(crate) expected_review_action: String, + pub(crate) actual_review_action: String, + #[serde(default)] + pub(crate) source_mutations: Vec, + #[serde(default)] + pub(crate) unsupported_claim_count: usize, + #[serde(default)] + pub(crate) unsupported_claim_flags: Vec, + #[serde(default)] + pub(crate) diff: serde_json::Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct LiveUnsupportedClaimFlag { + pub(crate) claim_id: Option, + pub(crate) message: String, + pub(crate) source_ref: Option, +} + +#[derive(Debug)] +pub(crate) struct PreparedConsolidationRun { + pub(crate) input_refs: Vec, + pub(crate) proposals: Vec, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/live.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/live.rs new file mode 100644 index 00000000..5eed72f9 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/live.rs @@ -0,0 +1,161 @@ +use super::{Deserialize, Map, MaterializationStatus, PathBuf, serde_json}; + +#[derive(Debug)] +pub(crate) struct LoadedJob { + pub(crate) path: PathBuf, + pub(crate) value: serde_json::Value, + pub(crate) job: LiveJob, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveJob { + pub(crate) schema: String, + pub(crate) job_id: String, + pub(crate) suite: String, + pub(crate) title: String, + pub(crate) corpus: LiveCorpus, + pub(crate) prompt: LivePrompt, + pub(crate) expected_answer: LiveExpectedAnswer, + #[serde(default)] + pub(crate) required_evidence: Vec, + #[serde(default)] + pub(crate) encoding: LiveEncoding, + pub(crate) memory_evolution: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveCorpus { + #[serde(default)] + pub(crate) items: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveCorpusItem { + pub(crate) evidence_id: String, + pub(crate) text: Option, + pub(crate) local_ref: Option, + #[serde(default)] + pub(crate) capture: LiveCapturePolicy, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub(crate) struct LiveCapturePolicy { + #[serde(default)] + pub(crate) action: LiveCaptureAction, + + pub(crate) source_id: Option, + + pub(crate) evidence_binding: Option, + + pub(crate) write_policy: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LivePrompt { + pub(crate) content: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveExpectedAnswer { + #[serde(default)] + pub(crate) must_include: Vec, + #[serde(default)] + pub(crate) evidence_links: Map, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveRequiredEvidence { + pub(crate) evidence_id: String, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct LiveMemoryEvolution { + #[serde(default)] + pub(crate) current_evidence_ids: Vec, + #[serde(default)] + pub(crate) historical_evidence_ids: Vec, + #[serde(default)] + pub(crate) tombstone_evidence_ids: Vec, + #[serde(default)] + pub(crate) invalidation_evidence_ids: Vec, + #[serde(default)] + pub(crate) conflicts: Vec, + pub(crate) update_rationale: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveEvolutionConflict { + pub(crate) claim_id: String, + pub(crate) current_evidence_id: String, + pub(crate) historical_evidence_id: String, + pub(crate) resolved_by_evidence_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LiveUpdateRationale { + pub(crate) claim_id: String, + #[serde(default)] + pub(crate) evidence_ids: Vec, + pub(crate) available: bool, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct LiveEncoding { + pub(crate) status: Option, + pub(crate) reason: Option, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LiveCaptureAction { + #[default] + Store, + Exclude, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum LiveExpectedClaim { + Text(String), + Object { claim_id: Option, text: String }, +} +impl LiveExpectedClaim { + pub(crate) fn claim_id(&self) -> Option<&str> { + match self { + Self::Text(_) => None, + Self::Object { claim_id, .. } => claim_id.as_deref(), + } + } + + pub(crate) fn text(&self) -> &str { + match self { + Self::Text(text) => text, + Self::Object { text, .. } => text, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LiveEncodingStatus { + NotEncoded, + Blocked, + Incomplete, +} +impl LiveEncodingStatus { + pub(crate) fn materialization_status(self) -> MaterializationStatus { + match self { + Self::NotEncoded => MaterializationStatus::NotEncoded, + Self::Blocked => MaterializationStatus::Blocked, + Self::Incomplete => MaterializationStatus::Incomplete, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::NotEncoded => "not_encoded", + Self::Blocked => "blocked", + Self::Incomplete => "incomplete", + } + } +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs new file mode 100644 index 00000000..3fde4209 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs @@ -0,0 +1,333 @@ +use super::{HashMap, LiveCapturePolicy, LoadedJob, Path, Serialize, Uuid, ValueEnum, serde_json}; + +#[derive(Debug, Serialize)] +pub(crate) struct MaterializationEvidence { + pub(crate) schema: &'static str, + pub(crate) adapter_id: String, + pub(crate) adapter_kind: AdapterKind, + pub(crate) status: MaterializationStatus, + pub(crate) fixtures: String, + pub(crate) generated_fixtures: String, + pub(crate) command_evidence: Vec, + pub(crate) jobs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) metadata: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct CommandEvidence { + pub(crate) label: String, + pub(crate) status: MaterializationStatus, + pub(crate) command: String, + pub(crate) artifact: Option, + pub(crate) reason: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct MaterializedJobEvidence { + pub(crate) job_id: String, + pub(crate) suite: String, + pub(crate) title: String, + pub(crate) status: MaterializationStatus, + pub(crate) query: String, + pub(crate) evidence_ids: Vec, + pub(crate) returned_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) indexing_latency_ms: Option, + pub(crate) latency_ms: f64, + pub(crate) trace_id: Option, + pub(crate) failure: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) source_mappings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) operator_debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) capture: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) consolidation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) knowledge: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) temporal_reconciliation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) dreaming_readback: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct OperatorDebugMaterializationEvidence { + pub(crate) trace_available: bool, + pub(crate) replay_command_available: bool, + pub(crate) candidate_drop_visibility: String, + pub(crate) repair_action_clarity: String, + pub(crate) raw_sql_needed: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct CaptureMaterializationEvidence { + pub(crate) stored_evidence_ids: Vec, + pub(crate) excluded_evidence_ids: Vec, + pub(crate) source_ids: Vec, + pub(crate) write_policy_audit_count: usize, + pub(crate) write_policy_exclusion_count: usize, + pub(crate) write_policy_redaction_count: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) runtime_source_refs: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct ConsolidationMaterializationEvidence { + pub(crate) run_id: Option, + pub(crate) proposal_ids: Vec, + pub(crate) source_lineage_count: usize, + pub(crate) unsupported_claim_flag_count: usize, + pub(crate) review_event_count: usize, + pub(crate) review_actions: Vec, + pub(crate) final_review_states: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct KnowledgeMaterializationEvidence { + pub(crate) page_ids: Vec, + pub(crate) search_result_count: usize, + pub(crate) lint_finding_count: usize, + pub(crate) stale_source_finding_count: usize, + pub(crate) unsupported_claim_count: usize, + pub(crate) citation_count: usize, + pub(crate) source_ref_count: usize, + pub(crate) version_diff_available: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct TemporalReconciliationMaterializationEvidence { + pub(crate) current_winner_evidence_ids: Vec, + pub(crate) historical_loser_evidence_ids: Vec, + pub(crate) supersession_rationale_evidence_ids: Vec, + pub(crate) tombstone_evidence_ids: Vec, + pub(crate) invalidation_evidence_ids: Vec, + pub(crate) conflict_candidate_evidence_ids: Vec, + pub(crate) retrieved_evidence_ids: Vec, + pub(crate) selected_evidence_ids: Vec, + pub(crate) absent_evidence_ids: Vec, + pub(crate) retrieved_but_dropped_evidence_ids: Vec, + pub(crate) selected_but_not_narrated_evidence_ids: Vec, + pub(crate) contradicted_by_lifecycle_evidence_ids: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct DreamingReadbackMaterializationEvidence { + pub(crate) artifact_kind: String, + pub(crate) runtime_path: String, + pub(crate) service_list_count: usize, + pub(crate) trace_id: Option, + pub(crate) generated_artifact_count: usize, + pub(crate) selected_source_refs: Vec, + pub(crate) missing_source_refs: Vec, + pub(crate) source_mutation_count: usize, + pub(crate) no_source_mutation_checked: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct CaptureRuntimeSourceRefEvidence { + pub(crate) evidence_id: String, + pub(crate) source_ref: serde_json::Value, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct CaptureRuntimeEvidence { + pub(crate) items: Vec, +} +impl CaptureRuntimeEvidence { + pub(crate) fn item_for(&self, evidence_id: &str) -> Option<&CaptureRuntimeEvidenceItem> { + self.items.iter().find(|item| item.evidence_id == evidence_id) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CaptureRuntimeEvidenceItem { + pub(crate) evidence_id: String, + pub(crate) source_id: Option, + pub(crate) evidence_binding: Option, + pub(crate) write_policy_applied: bool, + pub(crate) capture_action: Option, + pub(crate) source_ref: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub(crate) struct AdapterResponseOutput { + pub(crate) adapter_id: String, + pub(crate) answer: AnswerOutput, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) consolidation: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct AnswerOutput { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, + pub(crate) claims: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) pages: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) memory_summaries: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) proactive_briefs: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) scheduled_tasks: Vec, + pub(crate) latency_ms: f64, + pub(crate) cost: CostOutput, + pub(crate) trace_explainability: TraceExplainabilityOutput, +} + +#[derive(Debug, Serialize)] +pub(crate) struct CostOutput { + pub(crate) currency: String, + pub(crate) amount: f64, + pub(crate) input_tokens: u64, + pub(crate) output_tokens: u64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TraceExplainabilityOutput { + pub(crate) trace_id: Option, + pub(crate) failure_stage: Option, + pub(crate) failure_reason: Option, + pub(crate) stages: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct TraceStageOutput { + pub(crate) stage_name: String, + pub(crate) kept_evidence: Vec, + pub(crate) dropped_evidence: Vec, + pub(crate) demoted_evidence: Vec, + pub(crate) distractor_evidence: Vec, + pub(crate) notes: String, +} + +#[derive(Debug)] +pub(crate) struct MaterializedJob { + pub(crate) response: AdapterResponseOutput, + pub(crate) evidence: MaterializedJobEvidence, + pub(crate) operator_debug: Option, +} + +#[derive(Debug)] +pub(crate) struct MaterializedJobInput { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, + pub(crate) pages: Vec, + pub(crate) latency_ms: f64, + pub(crate) indexing_latency_ms: Option, + pub(crate) returned_count: usize, + pub(crate) trace_id: Option, + pub(crate) failure: Option, + pub(crate) source_mappings: Vec, + pub(crate) operator_debug: Option, + pub(crate) operator_debug_evidence: Option, + pub(crate) capture: Option, + pub(crate) capture_failure: Option, + pub(crate) consolidation_response: Option, + pub(crate) consolidation: Option, + pub(crate) knowledge: Option, + pub(crate) temporal_reconciliation: Option, + pub(crate) dreaming_readback: Option, + pub(crate) memory_summaries: Vec, + pub(crate) proactive_briefs: Vec, + pub(crate) scheduled_tasks: Vec, + pub(crate) trace_stages: Option>, +} + +#[derive(Debug)] +pub(crate) struct DreamingReadbackOutput { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, + pub(crate) memory_summaries: Vec, + pub(crate) proactive_briefs: Vec, + pub(crate) scheduled_tasks: Vec, + pub(crate) materialization: DreamingReadbackMaterializationEvidence, + pub(crate) trace_stages: Vec, +} + +#[derive(Debug)] +pub(crate) struct SelectedEvidenceText { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, +} + +#[derive(Debug)] +pub(crate) struct TemporalReconciliationSelection { + pub(crate) selected: SelectedEvidenceText, + pub(crate) evidence: TemporalReconciliationMaterializationEvidence, + pub(crate) trace_stages: Vec, +} + +pub(crate) struct SuiteMaterializationSelection { + pub(crate) selected: SelectedEvidenceText, + pub(crate) trace_stages: Option>, + pub(crate) dreaming_readback: Option, + pub(crate) memory_summaries: Vec, + pub(crate) proactive_briefs: Vec, + pub(crate) scheduled_tasks: Vec, +} + +pub(crate) struct SuiteMaterializationSelectionInput<'a> { + pub(crate) loaded: &'a LoadedJob, + pub(crate) ingested: &'a IngestedCorpus, + pub(crate) capture_failure: &'a Option, + pub(crate) selected: SelectedEvidenceText, + pub(crate) trace_stages: Option>, + pub(crate) knowledge: &'a Option, + pub(crate) consolidation: &'a Option, + pub(crate) dreaming_readback: Option, +} + +pub(crate) struct MaterializedOutput<'a> { + pub(crate) adapter_id: &'a str, + pub(crate) adapter_kind: AdapterKind, + pub(crate) fixtures: &'a Path, + pub(crate) out_fixtures: &'a Path, + pub(crate) evidence_out: &'a Path, + pub(crate) jobs: &'a [LoadedJob], + pub(crate) materialized: &'a [MaterializedJob], + pub(crate) command_evidence: Vec, + pub(crate) metadata: Option, +} + +#[derive(Debug)] +pub(crate) struct CorpusText { + pub(crate) evidence_id: String, + pub(crate) text: String, + pub(crate) capture: LiveCapturePolicy, +} + +#[derive(Debug, Default)] +pub(crate) struct IngestedCorpus { + pub(crate) capture: CaptureMaterializationEvidence, + pub(crate) note_ids_by_evidence: HashMap>, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct SourceMappingEvidence { + pub(crate) source: String, + pub(crate) evidence_ids: Vec, + pub(crate) mapping_status: String, + pub(crate) content_count: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AdapterKind { + ElfServiceRuntime, + QmdCliRuntime, + LightragApiContextExport, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MaterializationStatus { + Pass, + WrongResult, + Blocked, + Incomplete, + NotEncoded, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/providers.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/providers.rs new file mode 100644 index 00000000..7c598a6d --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/providers.rs @@ -0,0 +1,57 @@ +use super::{ + BoxFuture, EmbeddingProvider, EmbeddingProviderConfig, ExtractorProvider, LlmProviderConfig, + ProviderConfig, RerankProvider, embed_text, serde_json, terms, +}; + +#[derive(Debug)] +pub(crate) struct DeterministicEmbedding { + pub(crate) vector_dim: u32, +} +impl EmbeddingProvider for DeterministicEmbedding { + fn embed<'a>( + &'a self, + _cfg: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, elf_service::Result>>> { + let dim = self.vector_dim; + let vectors = texts.iter().map(|text| embed_text(text, dim)).collect(); + + Box::pin(async move { Ok(vectors) }) + } +} + +#[derive(Debug)] +pub(crate) struct TokenOverlapRerank; +impl RerankProvider for TokenOverlapRerank { + fn rerank<'a>( + &'a self, + _cfg: &'a ProviderConfig, + query: &'a str, + docs: &'a [String], + ) -> BoxFuture<'a, elf_service::Result>> { + let query_terms = terms(query); + let scores = docs + .iter() + .map(|doc| { + let doc_terms = terms(doc); + let hits = query_terms.intersection(&doc_terms).count() as f32; + + hits / query_terms.len().max(1) as f32 + }) + .collect(); + + Box::pin(async move { Ok(scores) }) + } +} + +#[derive(Debug)] +pub(crate) struct NoopExtractor; +impl ExtractorProvider for NoopExtractor { + fn extract<'a>( + &'a self, + _cfg: &'a LlmProviderConfig, + _messages: &'a [serde_json::Value], + ) -> BoxFuture<'a, elf_service::Result> { + Box::pin(async move { Ok(serde_json::json!({ "notes": [] })) }) + } +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/runtime.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/runtime.rs new file mode 100644 index 00000000..c30b2b29 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/runtime.rs @@ -0,0 +1,17 @@ +use super::PathBuf; + +#[derive(Debug)] +pub(crate) struct LightragSource { + pub(crate) evidence_id: String, + pub(crate) file_source: String, + pub(crate) artifact_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct BaselineRuntime { + pub(crate) config_path: PathBuf, + pub(crate) dsn: String, + pub(crate) qdrant_url: String, + pub(crate) collection: String, + pub(crate) docs_collection: String, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/operator_debug.rs b/apps/elf-eval/src/bin/real_world_live_adapter/operator_debug.rs new file mode 100644 index 00000000..368388fc --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/operator_debug.rs @@ -0,0 +1,154 @@ +use crate::{AdapterKind, LoadedJob, Map, OperatorDebugMaterializationEvidence, Uuid, serde_json}; + +pub(super) fn operator_debug_output( + adapter_kind: AdapterKind, + loaded: &LoadedJob, + trace_id: Option, + replay_command: String, + replay_artifact: String, +) -> (Option, Option) { + if loaded.job.suite != "operator_debugging_ux" { + return (None, None); + } + + let Some(source) = loaded.value.get("operator_debug") else { + return (None, None); + }; + let mut debug = source.clone(); + let Some(object) = debug.as_object_mut() else { + return (None, None); + }; + let trace_available = trace_id.is_some(); + let replay_command_available = !replay_command.trim().is_empty(); + let raw_sql_needed = false; + let repair_action_clarity = if replay_command_available { "clear" } else { "unclear" }; + let candidate_drop_visibility = + operator_debug_candidate_visibility(adapter_kind, object).to_string(); + + object.insert("trace_available".to_string(), serde_json::Value::Bool(trace_available)); + object.insert( + "replay_command_available".to_string(), + serde_json::Value::Bool(replay_command_available), + ); + object.insert("raw_sql_needed".to_string(), serde_json::Value::Bool(raw_sql_needed)); + object.insert( + "dropped_candidate_visibility".to_string(), + serde_json::Value::String(candidate_drop_visibility.clone()), + ); + object.insert( + "trace_completeness".to_string(), + serde_json::Value::String( + operator_debug_trace_completeness(adapter_kind, trace_available).to_string(), + ), + ); + object.insert( + "repair_action_clarity".to_string(), + serde_json::Value::String(repair_action_clarity.to_string()), + ); + object.insert("replay_command".to_string(), serde_json::Value::String(replay_command.clone())); + object.insert("replay_artifact".to_string(), serde_json::Value::String(replay_artifact)); + + match adapter_kind { + AdapterKind::ElfServiceRuntime => + if let Some(trace_id) = trace_id { + let trace_id = trace_id.to_string(); + + object.insert("trace_id".to_string(), serde_json::Value::String(trace_id.clone())); + object.insert( + "viewer_url".to_string(), + serde_json::Value::String(format!("/viewer?trace_id={trace_id}")), + ); + object.insert( + "admin_trace_bundle_url".to_string(), + serde_json::Value::String(format!( + "/v2/admin/traces/{trace_id}/bundle?mode=full&stage_items_limit=128&candidates_limit=200" + )), + ); + }, + AdapterKind::QmdCliRuntime => { + object.remove("trace_id"); + object.remove("viewer_url"); + object.remove("admin_trace_bundle_url"); + object.insert("viewer_panels".to_string(), serde_json::json!(["qmd JSON Replay Rows"])); + }, + AdapterKind::LightragApiContextExport => {}, + } + + let mut cli_steps = string_array_from_object(object, "cli_steps"); + + crate::push_unique(&mut cli_steps, replay_command); + + object.insert("cli_steps".to_string(), serde_json::json!(cli_steps)); + + ( + Some(debug), + Some(OperatorDebugMaterializationEvidence { + trace_available, + replay_command_available, + candidate_drop_visibility, + repair_action_clarity: repair_action_clarity.to_string(), + raw_sql_needed, + }), + ) +} + +pub(super) fn elf_replay_command(trace_id: Uuid, project_id: &str) -> String { + format!( + "curl -fsS {} -H {} -H {} -H {}", + shell_quote(format!( + "http://127.0.0.1:51891/v2/admin/traces/{trace_id}/bundle?mode=full&stage_items_limit=128&candidates_limit=200" + ) + .as_str()), + shell_quote("X-ELF-Tenant-Id: elf-live-real-world"), + shell_quote(format!("X-ELF-Project-Id: {project_id}").as_str()), + shell_quote("X-ELF-Agent-Id: elf-live-real-world-agent") + ) +} + +pub(super) fn qmd_replay_command(query: &str, collection: &str) -> String { + format!( + "npx tsx src/cli/qmd.ts query {} -c {} --json --no-rerank --min-score 0 -n 5", + shell_quote(format!("lex: {query}\nvec: {query}").as_str()), + shell_quote(collection) + ) +} + +fn operator_debug_trace_completeness( + adapter_kind: AdapterKind, + trace_available: bool, +) -> &'static str { + match adapter_kind { + AdapterKind::ElfServiceRuntime if trace_available => "complete", + AdapterKind::ElfServiceRuntime => "missing", + AdapterKind::QmdCliRuntime | AdapterKind::LightragApiContextExport => "not_available", + } +} + +fn operator_debug_candidate_visibility( + adapter_kind: AdapterKind, + object: &Map, +) -> &str { + match adapter_kind { + AdapterKind::ElfServiceRuntime => object + .get("dropped_candidate_visibility") + .and_then(serde_json::Value::as_str) + .unwrap_or("visible through trace bundle replay candidates"), + AdapterKind::QmdCliRuntime => + "qmd top-k replay output is available, but intermediate candidate-drop stages are not exposed", + AdapterKind::LightragApiContextExport => "not encoded for this adapter", + } +} + +fn string_array_from_object(object: &Map, key: &str) -> Vec { + object + .get(key) + .and_then(serde_json::Value::as_array) + .map(|items| { + items.iter().filter_map(serde_json::Value::as_str).map(ToString::to_string).collect() + }) + .unwrap_or_default() +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/output.rs b/apps/elf-eval/src/bin/real_world_live_adapter/output.rs new file mode 100644 index 00000000..d2a33fab --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/output.rs @@ -0,0 +1,192 @@ +use crate::{ + EVIDENCE_SCHEMA, LoadedJob, MaterializationEvidence, MaterializationStatus, MaterializedJob, + MaterializedJobEvidence, MaterializedJobInput, MaterializedOutput, Path, PathBuf, Result, + Value, eyre, fs, serde_json, +}; + +pub(super) fn failure_jobs( + adapter_id: &str, + jobs: &[LoadedJob], + stage: &str, + reason: String, +) -> Vec { + jobs.iter() + .map(|job| { + crate::materialized_job( + job, + adapter_id, + MaterializedJobInput { + content: String::new(), + evidence_ids: Vec::new(), + pages: Vec::new(), + latency_ms: 0.0, + indexing_latency_ms: None, + returned_count: 0, + trace_id: None, + failure: Some(format!("{stage}: {reason}")), + source_mappings: Vec::new(), + operator_debug: None, + operator_debug_evidence: None, + capture: None, + capture_failure: None, + consolidation_response: None, + consolidation: None, + knowledge: None, + temporal_reconciliation: None, + dreaming_readback: None, + memory_summaries: Vec::new(), + proactive_briefs: Vec::new(), + scheduled_tasks: Vec::new(), + trace_stages: None, + }, + ) + }) + .collect() +} + +pub(super) fn write_materialized_output(output: MaterializedOutput<'_>) -> Result<()> { + if output.out_fixtures.exists() { + fs::remove_dir_all(output.out_fixtures)?; + } + + fs::create_dir_all(output.out_fixtures)?; + + for (loaded, materialized) in output.jobs.iter().zip(output.materialized) { + let mut value = loaded.value.clone(); + let mut adapter_response = + value["corpus"]["adapter_response"].as_object().cloned().unwrap_or_default(); + + adapter_response.insert( + "adapter_id".to_string(), + serde_json::to_value(&materialized.response.adapter_id)?, + ); + adapter_response + .insert("answer".to_string(), serde_json::to_value(&materialized.response.answer)?); + + if let Some(consolidation) = &materialized.response.consolidation { + adapter_response.insert("consolidation".to_string(), consolidation.clone()); + } else if loaded.job.suite == "consolidation" { + adapter_response.remove("consolidation"); + } + + value["corpus"]["adapter_response"] = Value::Object(adapter_response); + + if let Some(operator_debug) = &materialized.operator_debug { + value["operator_debug"] = operator_debug.clone(); + } + if let Some(capture) = &materialized.evidence.capture { + crate::apply_capture_runtime_source_refs(&mut value, capture); + + value["capture_materialization"] = serde_json::to_value(capture)?; + } + + if matches!( + materialized.evidence.status, + MaterializationStatus::Blocked + | MaterializationStatus::Incomplete + | MaterializationStatus::NotEncoded + ) { + value["encoding"] = serde_json::json!({ + "status": materialization_status_str(materialized.evidence.status), + "reason": materialized.evidence.failure.clone().unwrap_or_else(|| { + "Live adapter did not complete this job as a pass/fail check.".to_string() + }), + }); + } + + let output_path = output_fixture_path(output.fixtures, output.out_fixtures, &loaded.path)?; + + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(output_path, serde_json::to_string_pretty(&value)?)?; + } + + let evidence = MaterializationEvidence { + schema: EVIDENCE_SCHEMA, + adapter_id: output.adapter_id.to_string(), + adapter_kind: output.adapter_kind, + status: aggregate_status(output.materialized), + fixtures: output.fixtures.display().to_string(), + generated_fixtures: output.out_fixtures.display().to_string(), + command_evidence: output.command_evidence, + jobs: output.materialized.iter().map(|job| clone_job_evidence(&job.evidence)).collect(), + metadata: output.metadata, + }; + + if let Some(parent) = output.evidence_out.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(output.evidence_out, serde_json::to_string_pretty(&evidence)?)?; + + Ok(()) +} + +pub(super) fn aggregate_status(jobs: &[MaterializedJob]) -> MaterializationStatus { + if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::Incomplete) { + MaterializationStatus::Incomplete + } else if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::Blocked) { + MaterializationStatus::Blocked + } else if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::WrongResult) { + MaterializationStatus::WrongResult + } else if jobs.iter().any(|job| job.evidence.status == MaterializationStatus::NotEncoded) { + MaterializationStatus::NotEncoded + } else { + MaterializationStatus::Pass + } +} + +fn clone_job_evidence(evidence: &MaterializedJobEvidence) -> MaterializedJobEvidence { + MaterializedJobEvidence { + job_id: evidence.job_id.clone(), + suite: evidence.suite.clone(), + title: evidence.title.clone(), + status: evidence.status, + query: evidence.query.clone(), + evidence_ids: evidence.evidence_ids.clone(), + returned_count: evidence.returned_count, + indexing_latency_ms: evidence.indexing_latency_ms, + latency_ms: evidence.latency_ms, + trace_id: evidence.trace_id, + failure: evidence.failure.clone(), + source_mappings: evidence.source_mappings.clone(), + operator_debug: evidence.operator_debug.clone(), + capture: evidence.capture.clone(), + consolidation: evidence.consolidation.clone(), + knowledge: evidence.knowledge.clone(), + temporal_reconciliation: evidence.temporal_reconciliation.clone(), + dreaming_readback: evidence.dreaming_readback.clone(), + } +} + +fn materialization_status_str(status: MaterializationStatus) -> &'static str { + match status { + MaterializationStatus::Pass => "pass", + MaterializationStatus::WrongResult => "wrong_result", + MaterializationStatus::Blocked => "blocked", + MaterializationStatus::Incomplete => "incomplete", + MaterializationStatus::NotEncoded => "not_encoded", + } +} + +fn output_fixture_path(fixtures: &Path, out_fixtures: &Path, fixture: &Path) -> Result { + if fixtures.is_dir() { + let relative = fixture.strip_prefix(fixtures).map_err(|err| { + eyre::eyre!( + "Fixture path {} is not under fixture root {}: {err}", + fixture.display(), + fixtures.display() + ) + })?; + + return Ok(out_fixtures.join(relative)); + } + + let file_name = fixture + .file_name() + .ok_or_else(|| eyre::eyre!("Fixture path {} has no file name.", fixture.display()))?; + + Ok(out_fixtures.join(file_name)) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/qmd.rs b/apps/elf-eval/src/bin/real_world_live_adapter/qmd.rs new file mode 100644 index 00000000..5bb690db --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/qmd.rs @@ -0,0 +1,231 @@ +use crate::{ + AdapterKind, Command, CommandEvidence, Instant, LoadedJob, MaterializedJob, + MaterializedJobInput, MaterializedOutput, OperatorDebugMaterializationEvidence, Path, QmdArgs, + Result, SelectedEvidenceText, Value, aggregate_status, eyre, fs, serde_json, +}; + +pub(super) fn run_qmd(args: QmdArgs) -> Result<()> { + let jobs = crate::load_jobs(&args.fixtures)?; + let result = materialize_qmd_jobs(&args, &jobs); + let materialized = match result { + Ok(jobs) => jobs, + Err(err) => + crate::failure_jobs(&args.adapter_id, &jobs, "qmd_cli_runtime", err.to_string()), + }; + + crate::write_materialized_output(MaterializedOutput { + adapter_id: &args.adapter_id, + adapter_kind: AdapterKind::QmdCliRuntime, + fixtures: &args.fixtures, + out_fixtures: &args.out_fixtures, + evidence_out: &args.evidence_out, + jobs: &jobs, + materialized: &materialized, + command_evidence: vec![CommandEvidence { + label: "qmd_cli_runtime".to_string(), + status: aggregate_status(&materialized), + command: "cargo run -p elf-eval --bin real_world_live_adapter -- qmd".to_string(), + artifact: Some(args.evidence_out.display().to_string()), + reason: "qmd live adapter used collection add, update, embed, and query --json." + .to_string(), + }], + metadata: None, + }) +} + +fn materialize_qmd_jobs(args: &QmdArgs, jobs: &[LoadedJob]) -> Result> { + fs::create_dir_all(&args.work_dir)?; + + let log_path = args.work_dir.join("qmd-live-real-world.log"); + + ensure_qmd_checkout(args, &log_path)?; + + let mut out = Vec::with_capacity(jobs.len()); + + for loaded in jobs { + out.push(materialize_qmd_job(args, loaded, &log_path)?); + } + + Ok(out) +} + +fn ensure_qmd_checkout(args: &QmdArgs, log_path: &Path) -> Result<()> { + if !args.qmd_dir.exists() { + if let Some(parent) = args.qmd_dir.parent() { + fs::create_dir_all(parent)?; + } + + crate::run_logged_command( + "qmd clone", + Command::new("git") + .arg("clone") + .arg("--depth") + .arg("1") + .arg(&args.qmd_repo_url) + .arg(&args.qmd_dir), + log_path, + )?; + } + + crate::run_logged_shell( + "qmd install", + &args.qmd_dir, + "(npm ci || npm install --no-audit --no-fund) && npm run build --if-present", + log_path, + ) +} + +fn materialize_qmd_job( + args: &QmdArgs, + loaded: &LoadedJob, + log_path: &Path, +) -> Result { + if let Some(job) = crate::declared_encoding_job(&args.adapter_id, loaded) { + return Ok(job); + } + if let Some(job) = crate::not_encoded_job(&args.adapter_id, loaded) { + return Ok(job); + } + + let corpus = crate::corpus_texts(loaded)?; + let job_slug = crate::slug(&loaded.job.job_id); + let corpus_dir = args.work_dir.join("corpus").join(&job_slug); + let home_dir = args.work_dir.join("home").join(&job_slug); + let collection = format!("elfrw-{job_slug}"); + + fs::create_dir_all(&corpus_dir)?; + fs::create_dir_all(&home_dir)?; + + for existing in crate::read_dir_paths(&corpus_dir)? { + if existing.is_file() { + fs::remove_file(existing)?; + } + } + for item in &corpus { + let path = corpus_dir.join(format!("{}.md", crate::slug(&item.evidence_id))); + + fs::write(path, format!("# {}\n\n{}\n", item.evidence_id, item.text))?; + } + + crate::run_qmd_command( + "qmd collection add", + args, + &home_dir, + &[ + "collection", + "add", + corpus_dir + .to_str() + .ok_or_else(|| eyre::eyre!("qmd corpus path is not valid UTF-8."))?, + "--name", + collection.as_str(), + ], + log_path, + )?; + crate::run_qmd_command("qmd update", args, &home_dir, &["update"], log_path)?; + crate::run_qmd_command( + "qmd embed", + args, + &home_dir, + &["embed", "-f", "-c", collection.as_str()], + log_path, + )?; + + let started_at = Instant::now(); + let query = format!("lex: {}\nvec: {}", loaded.job.prompt.content, loaded.job.prompt.content); + let stdout = crate::run_qmd_command( + "qmd query", + args, + &home_dir, + &[ + "query", + query.as_str(), + "-c", + collection.as_str(), + "--json", + "--no-rerank", + "--min-score", + "0", + "-n", + "5", + ], + log_path, + )?; + let latency_ms = started_at.elapsed().as_secs_f64() * 1_000.0; + let results = serde_json::from_str::(&stdout).map_err(|err| { + eyre::eyre!("qmd query did not return JSON for {}: {err}", loaded.job.job_id) + })?; + let entries = results.as_array().cloned().unwrap_or_default(); + let mut evidence_ids = Vec::new(); + + for entry in &entries { + let entry_text = serde_json::to_string(entry)?; + + for item in &corpus { + if entry_text.contains(format!("{}.md", crate::slug(&item.evidence_id)).as_str()) + || entry_text.contains(item.evidence_id.as_str()) + { + crate::push_unique(&mut evidence_ids, item.evidence_id.clone()); + } + } + } + + let selected = crate::selected_required_corpus_texts(loaded, &corpus, &evidence_ids); + let replay_command = crate::qmd_replay_command(&loaded.job.prompt.content, collection.as_str()); + let (operator_debug, operator_debug_evidence) = crate::operator_debug_output( + AdapterKind::QmdCliRuntime, + loaded, + None, + replay_command, + log_path.display().to_string(), + ); + + Ok(qmd_materialized_job( + loaded, + &args.adapter_id, + selected, + latency_ms, + entries.len(), + operator_debug, + operator_debug_evidence, + )) +} + +fn qmd_materialized_job( + loaded: &LoadedJob, + adapter_id: &str, + selected: SelectedEvidenceText, + latency_ms: f64, + returned_count: usize, + operator_debug: Option, + operator_debug_evidence: Option, +) -> MaterializedJob { + crate::materialized_job( + loaded, + adapter_id, + MaterializedJobInput { + content: selected.content, + evidence_ids: selected.evidence_ids, + pages: Vec::new(), + latency_ms, + indexing_latency_ms: None, + returned_count, + trace_id: None, + failure: None, + source_mappings: Vec::new(), + operator_debug, + operator_debug_evidence, + capture: None, + capture_failure: None, + consolidation_response: None, + consolidation: None, + knowledge: None, + temporal_reconciliation: None, + dreaming_readback: None, + memory_summaries: Vec::new(), + proactive_briefs: Vec::new(), + scheduled_tasks: Vec::new(), + trace_stages: None, + }, + ) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/runtime_support.rs b/apps/elf-eval/src/bin/real_world_live_adapter/runtime_support.rs new file mode 100644 index 00000000..524b4f47 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/runtime_support.rs @@ -0,0 +1,273 @@ +use std::{ + collections::BTreeSet, + fs::{self, OpenOptions}, + io::Write as _, + path::Path, + process::{Command, Stdio}, + sync::Arc, +}; + +use blake3::Hasher; +use color_eyre::{Result, eyre}; + +use crate::{ + BaselineRuntime, DeterministicEmbedding, ELF_NOTE_CHUNK_CHARS, NoopExtractor, QmdArgs, + TokenOverlapRerank, +}; +use elf_config::Config; +use elf_service::Providers; + +pub(super) fn runtime_config(runtime: &BaselineRuntime) -> Result { + let mut cfg = elf_config::load(&runtime.config_path)?; + + cfg.storage.postgres.dsn = runtime.dsn.clone(); + cfg.storage.postgres.pool_max_conns = 12; + cfg.storage.qdrant.url = runtime.qdrant_url.clone(); + cfg.storage.qdrant.collection = runtime.collection.clone(); + cfg.storage.qdrant.docs_collection = runtime.docs_collection.clone(); + cfg.providers.embedding.provider_id = "local".to_string(); + cfg.providers.embedding.model = "local-hash".to_string(); + cfg.providers.embedding.dimensions = cfg.storage.qdrant.vector_dim; + cfg.providers.rerank.provider_id = "local".to_string(); + cfg.providers.rerank.model = "local-token-overlap".to_string(); + cfg.providers.llm_extractor.provider_id = "disabled".to_string(); + cfg.providers.llm_extractor.model = "disabled".to_string(); + cfg.context = None; + + Ok(cfg) +} + +pub(super) fn deterministic_providers(vector_dim: u32) -> Providers { + Providers::new( + Arc::new(DeterministicEmbedding { vector_dim }), + Arc::new(TokenOverlapRerank), + Arc::new(NoopExtractor), + ) +} + +pub(super) fn run_qmd_command( + label: &str, + args: &QmdArgs, + home_dir: &Path, + qmd_args: &[&str], + log_path: &Path, +) -> Result { + let mut command = Command::new("npx"); + + command + .current_dir(&args.qmd_dir) + .env("HOME", home_dir) + .env("XDG_CACHE_HOME", "/root/.cache") + .env("QMD_FORCE_CPU", "1") + .arg("tsx") + .arg("src/cli/qmd.ts"); + + for arg in qmd_args { + command.arg(arg); + } + + run_logged_command(label, &mut command, log_path) +} + +pub(super) fn run_logged_shell( + label: &str, + cwd: &Path, + script: &str, + log_path: &Path, +) -> Result<()> { + let mut command = Command::new("bash"); + + command.current_dir(cwd).arg("-lc").arg(script); + + run_logged_command(label, &mut command, log_path).map(|_| ()) +} + +pub(super) fn run_logged_command( + label: &str, + command: &mut Command, + log_path: &Path, +) -> Result { + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent)?; + } + + let command_debug = format!("{command:?}"); + let output = command.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let mut log = OpenOptions::new().create(true).append(true).open(log_path)?; + + writeln!(log, "## {label}")?; + writeln!(log, "$ {command_debug}")?; + + if !stdout.trim().is_empty() { + writeln!(log, "\nstdout:\n{stdout}")?; + } + if !stderr.trim().is_empty() { + writeln!(log, "\nstderr:\n{stderr}")?; + } + if !output.status.success() { + return Err(eyre::eyre!( + "{label} failed with status {}. Inspect {}.", + output.status, + log_path.display() + )); + } + + Ok(stdout) +} + +pub(super) fn project_id_for_job(job_id: &str) -> String { + format!("job-{}", slug(job_id)) +} + +pub(super) fn slug(value: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + + last_dash = false; + } else if !last_dash && !out.is_empty() { + out.push('-'); + + last_dash = true; + } + } + + while out.ends_with('-') { + out.pop(); + } + + if out.is_empty() { "item".to_string() } else { out } +} + +pub(super) fn short_hash(value: &str) -> String { + let mut hasher = Hasher::new(); + + hasher.update(value.as_bytes()); + + hasher.finalize().to_hex().chars().take(12).collect() +} + +pub(super) fn push_unique(values: &mut Vec, value: String) { + if !values.iter().any(|existing| existing == &value) { + values.push(value); + } +} + +pub(super) fn embed_text(text: &str, vector_dim: u32) -> Vec { + let dim = vector_dim as usize; + let mut vector = vec![0.0_f32; dim]; + + if dim == 0 { + return vector; + } + + let normalized = normalize_ascii_alnum_lowercase(text); + + for term in normalized.split_whitespace() { + if term.len() < 2 { + continue; + } + + let hash = blake3::hash(term.as_bytes()); + let bytes = hash.as_bytes(); + let idx = (u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) % dim; + + vector[idx] += 1.0; + } + + let norm = vector.iter().map(|value| value * value).sum::().sqrt(); + + if norm > 0.0 { + for value in &mut vector { + *value /= norm; + } + } + + vector +} + +pub(super) fn terms(text: &str) -> BTreeSet { + normalize_ascii_alnum_lowercase(text) + .split_whitespace() + .filter(|term| term.len() >= 2) + .map(ToString::to_string) + .collect() +} + +pub(super) fn normalize_ascii_alnum_lowercase(text: &str) -> String { + text.chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { ' ' }) + .collect() +} + +pub(super) fn note_text_chunks(text: &str) -> Vec { + let normalized = text.split_whitespace().collect::>().join(" "); + + if normalized.chars().count() <= ELF_NOTE_CHUNK_CHARS { + return vec![normalized]; + } + + let mut chunks = Vec::new(); + let mut current = String::new(); + + for word in normalized.split_whitespace() { + if word.chars().count() > ELF_NOTE_CHUNK_CHARS { + if !current.is_empty() { + chunks.push(current); + + current = String::new(); + } + + chunks.extend(split_long_token(word)); + + continue; + } + + let separator = usize::from(!current.is_empty()); + + if current.chars().count() + separator + word.chars().count() > ELF_NOTE_CHUNK_CHARS + && !current.is_empty() + { + chunks.push(current); + + current = String::new(); + } + if !current.is_empty() { + current.push(' '); + } + + current.push_str(word); + } + + if !current.is_empty() { + chunks.push(current); + } + + chunks +} + +fn split_long_token(token: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + + for ch in token.chars() { + if current.chars().count() >= ELF_NOTE_CHUNK_CHARS { + chunks.push(current); + + current = String::new(); + } + + current.push(ch); + } + + if !current.is_empty() { + chunks.push(current); + } + + chunks +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/service_runtime.rs b/apps/elf-eval/src/bin/real_world_live_adapter/service_runtime.rs new file mode 100644 index 00000000..0cb32448 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/service_runtime.rs @@ -0,0 +1,71 @@ +use crate::{ + Arc, BaselineRuntime, ChunkingConfig, Db, ElfService, JoinSet, QdrantStore, Result, + WorkerState, eyre, worker, +}; + +pub(super) async fn build_service(runtime: &BaselineRuntime) -> Result { + let cfg = crate::runtime_config(runtime)?; + let vector_dim = cfg.storage.qdrant.vector_dim; + let db = Db::connect(&cfg.storage.postgres).await?; + + db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; + + let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; + + qdrant.ensure_collection().await?; + + Ok(ElfService::with_providers(cfg, db, qdrant, crate::deterministic_providers(vector_dim))) +} + +pub(super) async fn run_worker(runtime: &BaselineRuntime) -> Result<()> { + let state = Arc::new(build_worker_state(runtime).await?); + + for _ in 0..8 { + let state = Arc::clone(&state); + let mut set = JoinSet::new(); + + set.spawn(async move { + worker::process_once(&state) + .await + .map_err(|err| eyre::eyre!("Worker process_once failed: {err}")) + }); + + while let Some(joined) = set.join_next().await { + joined??; + } + } + + Ok(()) +} + +async fn build_worker_state(runtime: &BaselineRuntime) -> Result { + let cfg = crate::runtime_config(runtime)?; + let db = Db::connect(&cfg.storage.postgres).await?; + + db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; + + let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; + + qdrant.ensure_collection().await?; + + let docs_qdrant = + QdrantStore::new_with_collection(&cfg.storage.qdrant, &cfg.storage.qdrant.docs_collection)?; + + docs_qdrant.ensure_collection().await?; + + let tokenizer = elf_chunking::load_tokenizer(&cfg.chunking.tokenizer_repo) + .map_err(|err| eyre::eyre!("Failed to load tokenizer for live adapter worker: {err}"))?; + let chunking = ChunkingConfig { + max_tokens: cfg.chunking.max_tokens, + overlap_tokens: cfg.chunking.overlap_tokens, + }; + + Ok(WorkerState { + db, + qdrant, + docs_qdrant, + embedding: cfg.providers.embedding, + chunking, + tokenizer, + }) +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/tests.rs b/apps/elf-eval/src/bin/real_world_live_adapter/tests.rs new file mode 100644 index 00000000..1202e740 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/tests.rs @@ -0,0 +1,127 @@ +use serde_json::Value; + +use crate::{CaptureRuntimeSourceRefEvidence, LiveCaptureAction, LiveCapturePolicy}; + +fn capture_item( + evidence_id: &str, + action: LiveCaptureAction, + source_id: Option<&str>, + evidence_binding: Option<&str>, + write_policy: Option, +) -> super::CorpusText { + super::CorpusText { + evidence_id: evidence_id.to_string(), + text: "Public capture text.".to_string(), + capture: LiveCapturePolicy { + action, + source_id: source_id.map(ToString::to_string), + evidence_binding: evidence_binding.map(ToString::to_string), + write_policy, + }, + } +} + +fn capture_evidence(stored: &[&str], excluded: &[&str]) -> super::CaptureMaterializationEvidence { + super::CaptureMaterializationEvidence { + stored_evidence_ids: stored.iter().map(|id| (*id).to_string()).collect(), + excluded_evidence_ids: excluded.iter().map(|id| (*id).to_string()).collect(), + source_ids: Vec::new(), + write_policy_audit_count: 0, + write_policy_exclusion_count: 0, + write_policy_redaction_count: 0, + runtime_source_refs: Vec::new(), + } +} + +#[test] +fn capture_runtime_validation_requires_returned_source_id() { + let corpus = vec![capture_item( + "source-a", + super::LiveCaptureAction::Store, + Some("capture:a"), + None, + None, + )]; + let capture = capture_evidence(&["source-a"], &[]); + let runtime = super::capture_runtime_evidence_from_source_refs([&serde_json::json!({ + "evidence_id": "source-a", + "capture_action": "store" + })]); + let failure = super::validate_capture_runtime_evidence( + "capture_integration", + &corpus, + &capture, + &runtime, + ) + .expect("missing runtime source_id should fail capture validation"); + + assert!(failure.contains("did not return expected source_id capture:a")); +} + +#[test] +fn capture_runtime_validation_rejects_returned_excluded_evidence() { + let corpus = vec![capture_item( + "private-trap", + super::LiveCaptureAction::Exclude, + Some("capture:private"), + Some("negative_trap"), + None, + )]; + let capture = capture_evidence(&[], &["private-trap"]); + let runtime = super::capture_runtime_evidence_from_source_refs([&serde_json::json!({ + "evidence_id": "private-trap", + "source_id": "capture:private", + "capture_action": "store" + })]); + let failure = super::validate_capture_runtime_evidence( + "capture_integration", + &corpus, + &capture, + &runtime, + ) + .expect("returned excluded evidence should fail capture validation"); + + assert!(failure.contains("excluded evidence private-trap was returned by live search")); +} + +#[test] +fn capture_runtime_source_refs_are_written_into_generated_fixture() { + let mut value = serde_json::json!({ + "corpus": { + "items": [ + { + "evidence_id": "source-a", + "source_ref": { + "schema": "source_ref/v1", + "resolver": "fixture" + } + } + ] + } + }); + let mut capture = capture_evidence(&["source-a"], &[]); + + capture.runtime_source_refs.push(CaptureRuntimeSourceRefEvidence { + evidence_id: "source-a".to_string(), + source_ref: serde_json::json!({ + "schema": "real_world_live_adapter/v1", + "evidence_id": "source-a", + "source_id": "capture:a", + "capture_action": "store", + "evidence_binding": "source_ref" + }), + }); + + super::apply_capture_runtime_source_refs(&mut value, &capture); + + assert_eq!( + value.pointer("/corpus/items/0/source_ref/source_id").and_then(serde_json::Value::as_str), + Some("capture:a") + ); + assert_eq!( + value + .pointer("/corpus/items/0/source_ref/evidence_binding") + .and_then(serde_json::Value::as_str), + Some("source_ref") + ); +} diff --git a/apps/elf-eval/src/bin/trace_gate_export.rs b/apps/elf-eval/src/bin/trace_gate_export.rs index 2f9c40fb..379df1be 100644 --- a/apps/elf-eval/src/bin/trace_gate_export.rs +++ b/apps/elf-eval/src/bin/trace_gate_export.rs @@ -2,112 +2,22 @@ //! CLI for exporting trace fixtures used by regression gates. -use std::{fs, path::PathBuf}; +#[path = "trace_gate_export/cli.rs"] mod cli; +#[path = "trace_gate_export/fetch.rs"] mod fetch; +#[path = "trace_gate_export/render.rs"] mod render; +#[path = "trace_gate_export/rows.rs"] mod rows; +#[path = "trace_gate_export/sql.rs"] mod sql; + +use std::fs; use clap::Parser; use color_eyre::Result; -use serde_json::Value; -use sqlx::FromRow; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing_subscriber::EnvFilter; use uuid::Uuid; +use self::cli::Args; use elf_storage::db::Db; -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), -)] -struct Args { - /// Path to an ELF config file (used for Postgres DSN). - #[arg(long, short = 'c', value_name = "FILE")] - config: PathBuf, - /// One or more trace IDs to export. - #[arg(long, value_name = "UUID", required = true)] - trace_id: Vec, - /// Write SQL to this file (defaults to stdout). - #[arg(long, value_name = "FILE")] - out: Option, - /// Include trace items (search_trace_items). - #[arg(long, default_value_t = true)] - include_items: bool, - /// Include trace stages (search_trace_stages and search_trace_stage_items). - #[arg(long, default_value_t = false)] - include_stages: bool, -} - -#[derive(Debug, FromRow)] -struct TraceRow { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - expansion_mode: String, - expanded_queries: Value, - allowed_scopes: Value, - candidate_count: i32, - top_k: i32, - config_snapshot: Value, - trace_version: i32, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -#[derive(Debug, FromRow)] -struct CandidateRow { - candidate_id: Uuid, - trace_id: Uuid, - note_id: Uuid, - chunk_id: Uuid, - chunk_index: i32, - snippet: String, - candidate_snapshot: Value, - retrieval_rank: i32, - rerank_score: f32, - note_scope: String, - note_importance: f32, - note_updated_at: OffsetDateTime, - note_hit_count: i64, - note_last_hit_at: Option, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -#[derive(Debug, FromRow)] -struct ItemRow { - item_id: Uuid, - trace_id: Uuid, - note_id: Uuid, - chunk_id: Option, - rank: i32, - final_score: f32, - explain: Value, -} - -#[derive(Debug, FromRow)] -struct StageRow { - stage_id: Uuid, - trace_id: Uuid, - stage_order: i32, - stage_name: String, - stage_payload: Value, - created_at: OffsetDateTime, -} - -#[derive(Debug, FromRow)] -struct StageItemRow { - id: Uuid, - stage_id: Uuid, - item_id: Option, - note_id: Option, - chunk_id: Option, - metrics: Value, -} - fn normalize_trace_ids(trace_ids: &[Uuid]) -> Vec { let mut out = trace_ids.to_vec(); @@ -117,327 +27,6 @@ fn normalize_trace_ids(trace_ids: &[Uuid]) -> Vec { out } -fn render_fixture_sql( - args: &Args, - traces: &[TraceRow], - candidates: &[CandidateRow], - items: &[ItemRow], - stages: &[StageRow], - stage_items: &[StageItemRow], -) -> Result { - let mut out = String::new(); - - render_preamble(args, &mut out); - render_traces(&mut out, traces)?; - render_candidates(&mut out, candidates)?; - render_items(&mut out, items)?; - render_stages(&mut out, stages)?; - render_stage_items(&mut out, stage_items)?; - - out.push_str("COMMIT;\n"); - - Ok(out) -} - -fn render_preamble(args: &Args, out: &mut String) { - out.push_str("-- Generated by `elf-eval trace_gate_export`.\n"); - out.push_str(&format!( - "-- trace_ids: {}\n", - args.trace_id.iter().map(|id| id.to_string()).collect::>().join(", ") - )); - out.push_str("BEGIN;\n\n"); -} - -fn render_traces(out: &mut String, traces: &[TraceRow]) -> Result<()> { - if traces.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_traces (\n"); - out.push_str(" trace_id,\n"); - out.push_str(" tenant_id,\n"); - out.push_str(" project_id,\n"); - out.push_str(" agent_id,\n"); - out.push_str(" read_profile,\n"); - out.push_str(" query,\n"); - out.push_str(" expansion_mode,\n"); - out.push_str(" expanded_queries,\n"); - out.push_str(" allowed_scopes,\n"); - out.push_str(" candidate_count,\n"); - out.push_str(" top_k,\n"); - out.push_str(" config_snapshot,\n"); - out.push_str(" trace_version,\n"); - out.push_str(" created_at,\n"); - out.push_str(" expires_at\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in traces.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&sql_text(&row.tenant_id)); - out.push_str(", "); - out.push_str(&sql_text(&row.project_id)); - out.push_str(", "); - out.push_str(&sql_text(&row.agent_id)); - out.push_str(", "); - out.push_str(&sql_text(&row.read_profile)); - out.push_str(", "); - out.push_str(&sql_text(&row.query)); - out.push_str(", "); - out.push_str(&sql_text(&row.expansion_mode)); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.expanded_queries)?); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.allowed_scopes)?); - out.push_str(", "); - out.push_str(&row.candidate_count.to_string()); - out.push_str(", "); - out.push_str(&row.top_k.to_string()); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.config_snapshot)?); - out.push_str(", "); - out.push_str(&row.trace_version.to_string()); - out.push_str(", "); - out.push_str(&sql_timestamptz(&row.created_at)?); - out.push_str(", "); - out.push_str(&sql_timestamptz(&row.expires_at)?); - out.push(')'); - - if idx + 1 == traces.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_candidates(out: &mut String, candidates: &[CandidateRow]) -> Result<()> { - if candidates.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_candidates (\n"); - out.push_str(" candidate_id,\n"); - out.push_str(" trace_id,\n"); - out.push_str(" note_id,\n"); - out.push_str(" chunk_id,\n"); - out.push_str(" chunk_index,\n"); - out.push_str(" snippet,\n"); - out.push_str(" candidate_snapshot,\n"); - out.push_str(" retrieval_rank,\n"); - out.push_str(" rerank_score,\n"); - out.push_str(" note_scope,\n"); - out.push_str(" note_importance,\n"); - out.push_str(" note_updated_at,\n"); - out.push_str(" note_hit_count,\n"); - out.push_str(" note_last_hit_at,\n"); - out.push_str(" created_at,\n"); - out.push_str(" expires_at\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in candidates.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql_uuid(&row.candidate_id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.note_id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.chunk_id)); - out.push_str(", "); - out.push_str(&row.chunk_index.to_string()); - out.push_str(", "); - out.push_str(&sql_text(&row.snippet)); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.candidate_snapshot)?); - out.push_str(", "); - out.push_str(&row.retrieval_rank.to_string()); - out.push_str(", "); - out.push_str(&sql_f32(row.rerank_score)); - out.push_str(", "); - out.push_str(&sql_text(&row.note_scope)); - out.push_str(", "); - out.push_str(&sql_f32(row.note_importance)); - out.push_str(", "); - out.push_str(&sql_timestamptz(&row.note_updated_at)?); - out.push_str(", "); - out.push_str(&row.note_hit_count.to_string()); - out.push_str(", "); - out.push_str(&sql_opt_timestamptz(&row.note_last_hit_at)?); - out.push_str(", "); - out.push_str(&sql_timestamptz(&row.created_at)?); - out.push_str(", "); - out.push_str(&sql_timestamptz(&row.expires_at)?); - out.push(')'); - - if idx + 1 == candidates.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_items(out: &mut String, items: &[ItemRow]) -> Result<()> { - if items.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_items (\n"); - out.push_str(" item_id,\n"); - out.push_str(" trace_id,\n"); - out.push_str(" note_id,\n"); - out.push_str(" chunk_id,\n"); - out.push_str(" rank,\n"); - out.push_str(" final_score,\n"); - out.push_str(" explain\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in items.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql_uuid(&row.item_id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.note_id)); - out.push_str(", "); - out.push_str(&sql_opt_uuid(&row.chunk_id)); - out.push_str(", "); - out.push_str(&row.rank.to_string()); - out.push_str(", "); - out.push_str(&sql_f32(row.final_score)); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.explain)?); - out.push(')'); - - if idx + 1 == items.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_stages(out: &mut String, stages: &[StageRow]) -> Result<()> { - if stages.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_stages (\n"); - out.push_str(" stage_id,\n"); - out.push_str(" trace_id,\n"); - out.push_str(" stage_order,\n"); - out.push_str(" stage_name,\n"); - out.push_str(" stage_payload,\n"); - out.push_str(" created_at\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in stages.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql_uuid(&row.stage_id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&row.stage_order.to_string()); - out.push_str(", "); - out.push_str(&sql_text(&row.stage_name)); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.stage_payload)?); - out.push_str(", "); - out.push_str(&sql_timestamptz(&row.created_at)?); - out.push(')'); - - if idx + 1 == stages.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_stage_items(out: &mut String, stage_items: &[StageItemRow]) -> Result<()> { - if stage_items.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_stage_items (\n"); - out.push_str(" id,\n"); - out.push_str(" stage_id,\n"); - out.push_str(" item_id,\n"); - out.push_str(" note_id,\n"); - out.push_str(" chunk_id,\n"); - out.push_str(" metrics\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in stage_items.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql_uuid(&row.id)); - out.push_str(", "); - out.push_str(&sql_uuid(&row.stage_id)); - out.push_str(", "); - out.push_str(&sql_opt_uuid(&row.item_id)); - out.push_str(", "); - out.push_str(&sql_opt_uuid(&row.note_id)); - out.push_str(", "); - out.push_str(&sql_opt_uuid(&row.chunk_id)); - out.push_str(", "); - out.push_str(&sql_jsonb(&row.metrics)?); - out.push(')'); - - if idx + 1 == stage_items.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn sql_uuid(id: &Uuid) -> String { - format!("'{}'", id) -} - -fn sql_opt_uuid(id: &Option) -> String { - id.map(|value| format!("'{}'", value)).unwrap_or_else(|| "NULL".to_string()) -} - -fn sql_text(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - -fn sql_jsonb(value: &Value) -> Result { - let raw = serde_json::to_string(value)?; - - Ok(format!("'{}'::jsonb", raw.replace('\'', "''"))) -} - -fn sql_f32(value: f32) -> String { - format!("{value}") -} - -fn sql_timestamptz(value: &OffsetDateTime) -> Result { - let raw = value.format(&Rfc3339)?; - - Ok(format!("'{}'::timestamptz", raw.replace('\'', "''"))) -} - -fn sql_opt_timestamptz(value: &Option) -> Result { - match value { - Some(ts) => sql_timestamptz(ts), - None => Ok("NULL".to_string()), - } -} - #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; @@ -453,19 +42,30 @@ async fn main() -> Result<()> { db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; - let traces = fetch_traces(&db, &trace_ids).await?; - let candidates = fetch_candidates(&db, &trace_ids).await?; - let items = if args.include_items { fetch_items(&db, &trace_ids).await? } else { Vec::new() }; + let traces = self::fetch::fetch_traces(&db, &trace_ids).await?; + let candidates = self::fetch::fetch_candidates(&db, &trace_ids).await?; + let items = if args.include_items { + self::fetch::fetch_items(&db, &trace_ids).await? + } else { + Vec::new() + }; let (stages, stage_items) = if args.include_stages { - let stages = fetch_stages(&db, &trace_ids).await?; + let stages = self::fetch::fetch_stages(&db, &trace_ids).await?; let stage_ids: Vec = stages.iter().map(|row| row.stage_id).collect(); - let stage_items = fetch_stage_items(&db, &stage_ids).await?; + let stage_items = self::fetch::fetch_stage_items(&db, &stage_ids).await?; (stages, stage_items) } else { (Vec::new(), Vec::new()) }; - let sql = render_fixture_sql(&args, &traces, &candidates, &items, &stages, &stage_items)?; + let sql = self::render::render_fixture_sql( + &args, + &traces, + &candidates, + &items, + &stages, + &stage_items, + )?; if let Some(out_path) = &args.out { fs::write(out_path, sql)?; @@ -475,132 +75,3 @@ async fn main() -> Result<()> { Ok(()) } - -async fn fetch_traces(db: &Db, trace_ids: &[Uuid]) -> Result> { - let rows: Vec = sqlx::query_as::<_, TraceRow>( - "\ -SELECT - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - expansion_mode, - expanded_queries, - allowed_scopes, - candidate_count, - top_k, - config_snapshot, - trace_version, - created_at, - expires_at -FROM search_traces -WHERE trace_id = ANY($1) -ORDER BY trace_id ASC", - ) - .bind(trace_ids) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn fetch_candidates(db: &Db, trace_ids: &[Uuid]) -> Result> { - let rows: Vec = sqlx::query_as::<_, CandidateRow>( - "\ -SELECT - candidate_id, - trace_id, - note_id, - chunk_id, - chunk_index, - snippet, - candidate_snapshot, - retrieval_rank, - rerank_score, - note_scope, - note_importance, - note_updated_at, - note_hit_count, - note_last_hit_at, - created_at, - expires_at -FROM search_trace_candidates -WHERE trace_id = ANY($1) -ORDER BY trace_id ASC, retrieval_rank ASC, candidate_id ASC", - ) - .bind(trace_ids) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn fetch_items(db: &Db, trace_ids: &[Uuid]) -> Result> { - let rows: Vec = sqlx::query_as::<_, ItemRow>( - "\ -SELECT - item_id, - trace_id, - note_id, - chunk_id, - rank, - final_score, - explain -FROM search_trace_items -WHERE trace_id = ANY($1) -ORDER BY trace_id ASC, rank ASC, item_id ASC", - ) - .bind(trace_ids) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn fetch_stages(db: &Db, trace_ids: &[Uuid]) -> Result> { - let rows: Vec = sqlx::query_as::<_, StageRow>( - "\ -SELECT - stage_id, - trace_id, - stage_order, - stage_name, - stage_payload, - created_at -FROM search_trace_stages -WHERE trace_id = ANY($1) -ORDER BY trace_id ASC, stage_order ASC, stage_id ASC", - ) - .bind(trace_ids) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn fetch_stage_items(db: &Db, stage_ids: &[Uuid]) -> Result> { - if stage_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows: Vec = sqlx::query_as::<_, StageItemRow>( - "\ -SELECT - id, - stage_id, - item_id, - note_id, - chunk_id, - metrics -FROM search_trace_stage_items -WHERE stage_id = ANY($1) -ORDER BY stage_id ASC, id ASC", - ) - .bind(stage_ids) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} diff --git a/apps/elf-eval/src/bin/trace_gate_export/cli.rs b/apps/elf-eval/src/bin/trace_gate_export/cli.rs new file mode 100644 index 00000000..aa6616dc --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/cli.rs @@ -0,0 +1,28 @@ +use std::path::PathBuf; + +use clap::Parser; +use uuid::Uuid; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), +)] +pub(super) struct Args { + /// Path to an ELF config file (used for Postgres DSN). + #[arg(long, short = 'c', value_name = "FILE")] + pub(super) config: PathBuf, + /// One or more trace IDs to export. + #[arg(long, value_name = "UUID", required = true)] + pub(super) trace_id: Vec, + /// Write SQL to this file (defaults to stdout). + #[arg(long, value_name = "FILE")] + pub(super) out: Option, + /// Include trace items (search_trace_items). + #[arg(long, default_value_t = true)] + pub(super) include_items: bool, + /// Include trace stages (search_trace_stages and search_trace_stage_items). + #[arg(long, default_value_t = false)] + pub(super) include_stages: bool, +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/fetch.rs b/apps/elf-eval/src/bin/trace_gate_export/fetch.rs new file mode 100644 index 00000000..5ea4f48f --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/fetch.rs @@ -0,0 +1,134 @@ +use color_eyre::Result; +use uuid::Uuid; + +use crate::rows::{CandidateRow, ItemRow, StageItemRow, StageRow, TraceRow}; +use elf_storage::db::Db; + +pub(super) async fn fetch_traces(db: &Db, trace_ids: &[Uuid]) -> Result> { + let rows: Vec = sqlx::query_as::<_, TraceRow>( + "\ +SELECT + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + expansion_mode, + expanded_queries, + allowed_scopes, + candidate_count, + top_k, + config_snapshot, + trace_version, + created_at, + expires_at +FROM search_traces +WHERE trace_id = ANY($1) +ORDER BY trace_id ASC", + ) + .bind(trace_ids) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +pub(super) async fn fetch_candidates(db: &Db, trace_ids: &[Uuid]) -> Result> { + let rows: Vec = sqlx::query_as::<_, CandidateRow>( + "\ +SELECT + candidate_id, + trace_id, + note_id, + chunk_id, + chunk_index, + snippet, + candidate_snapshot, + retrieval_rank, + rerank_score, + note_scope, + note_importance, + note_updated_at, + note_hit_count, + note_last_hit_at, + created_at, + expires_at +FROM search_trace_candidates +WHERE trace_id = ANY($1) +ORDER BY trace_id ASC, retrieval_rank ASC, candidate_id ASC", + ) + .bind(trace_ids) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +pub(super) async fn fetch_items(db: &Db, trace_ids: &[Uuid]) -> Result> { + let rows: Vec = sqlx::query_as::<_, ItemRow>( + "\ +SELECT + item_id, + trace_id, + note_id, + chunk_id, + rank, + final_score, + explain +FROM search_trace_items +WHERE trace_id = ANY($1) +ORDER BY trace_id ASC, rank ASC, item_id ASC", + ) + .bind(trace_ids) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +pub(super) async fn fetch_stages(db: &Db, trace_ids: &[Uuid]) -> Result> { + let rows: Vec = sqlx::query_as::<_, StageRow>( + "\ +SELECT + stage_id, + trace_id, + stage_order, + stage_name, + stage_payload, + created_at +FROM search_trace_stages +WHERE trace_id = ANY($1) +ORDER BY trace_id ASC, stage_order ASC, stage_id ASC", + ) + .bind(trace_ids) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +pub(super) async fn fetch_stage_items(db: &Db, stage_ids: &[Uuid]) -> Result> { + if stage_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows: Vec = sqlx::query_as::<_, StageItemRow>( + "\ +SELECT + id, + stage_id, + item_id, + note_id, + chunk_id, + metrics +FROM search_trace_stage_items +WHERE stage_id = ANY($1) +ORDER BY stage_id ASC, id ASC", + ) + .bind(stage_ids) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render.rs b/apps/elf-eval/src/bin/trace_gate_export/render.rs new file mode 100644 index 00000000..ed315ced --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render.rs @@ -0,0 +1,293 @@ +use color_eyre::Result; + +use crate::{ + cli::Args, + rows::{CandidateRow, ItemRow, StageItemRow, StageRow, TraceRow}, + sql::{self}, +}; + +pub(super) fn render_fixture_sql( + args: &Args, + traces: &[TraceRow], + candidates: &[CandidateRow], + items: &[ItemRow], + stages: &[StageRow], + stage_items: &[StageItemRow], +) -> Result { + let mut out = String::new(); + + render_preamble(args, &mut out); + render_traces(&mut out, traces)?; + render_candidates(&mut out, candidates)?; + render_items(&mut out, items)?; + render_stages(&mut out, stages)?; + render_stage_items(&mut out, stage_items)?; + + out.push_str("COMMIT;\n"); + + Ok(out) +} + +fn render_preamble(args: &Args, out: &mut String) { + out.push_str("-- Generated by `elf-eval trace_gate_export`.\n"); + out.push_str(&format!( + "-- trace_ids: {}\n", + args.trace_id.iter().map(|id| id.to_string()).collect::>().join(", ") + )); + out.push_str("BEGIN;\n\n"); +} + +fn render_traces(out: &mut String, traces: &[TraceRow]) -> Result<()> { + if traces.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_traces (\n"); + out.push_str(" trace_id,\n"); + out.push_str(" tenant_id,\n"); + out.push_str(" project_id,\n"); + out.push_str(" agent_id,\n"); + out.push_str(" read_profile,\n"); + out.push_str(" query,\n"); + out.push_str(" expansion_mode,\n"); + out.push_str(" expanded_queries,\n"); + out.push_str(" allowed_scopes,\n"); + out.push_str(" candidate_count,\n"); + out.push_str(" top_k,\n"); + out.push_str(" config_snapshot,\n"); + out.push_str(" trace_version,\n"); + out.push_str(" created_at,\n"); + out.push_str(" expires_at\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in traces.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.tenant_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.project_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.agent_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.read_profile)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.query)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.expansion_mode)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.expanded_queries)?); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.allowed_scopes)?); + out.push_str(", "); + out.push_str(&row.candidate_count.to_string()); + out.push_str(", "); + out.push_str(&row.top_k.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.config_snapshot)?); + out.push_str(", "); + out.push_str(&row.trace_version.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.created_at)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.expires_at)?); + out.push(')'); + + if idx + 1 == traces.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} + +fn render_candidates(out: &mut String, candidates: &[CandidateRow]) -> Result<()> { + if candidates.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_candidates (\n"); + out.push_str(" candidate_id,\n"); + out.push_str(" trace_id,\n"); + out.push_str(" note_id,\n"); + out.push_str(" chunk_id,\n"); + out.push_str(" chunk_index,\n"); + out.push_str(" snippet,\n"); + out.push_str(" candidate_snapshot,\n"); + out.push_str(" retrieval_rank,\n"); + out.push_str(" rerank_score,\n"); + out.push_str(" note_scope,\n"); + out.push_str(" note_importance,\n"); + out.push_str(" note_updated_at,\n"); + out.push_str(" note_hit_count,\n"); + out.push_str(" note_last_hit_at,\n"); + out.push_str(" created_at,\n"); + out.push_str(" expires_at\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in candidates.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.candidate_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.note_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.chunk_id)); + out.push_str(", "); + out.push_str(&row.chunk_index.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.snippet)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.candidate_snapshot)?); + out.push_str(", "); + out.push_str(&row.retrieval_rank.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_f32(row.rerank_score)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.note_scope)); + out.push_str(", "); + out.push_str(&sql::sql_f32(row.note_importance)); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.note_updated_at)?); + out.push_str(", "); + out.push_str(&row.note_hit_count.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_opt_timestamptz(&row.note_last_hit_at)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.created_at)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.expires_at)?); + out.push(')'); + + if idx + 1 == candidates.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} + +fn render_items(out: &mut String, items: &[ItemRow]) -> Result<()> { + if items.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_items (\n"); + out.push_str(" item_id,\n"); + out.push_str(" trace_id,\n"); + out.push_str(" note_id,\n"); + out.push_str(" chunk_id,\n"); + out.push_str(" rank,\n"); + out.push_str(" final_score,\n"); + out.push_str(" explain\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in items.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.item_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.note_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.chunk_id)); + out.push_str(", "); + out.push_str(&row.rank.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_f32(row.final_score)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.explain)?); + out.push(')'); + + if idx + 1 == items.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} + +fn render_stages(out: &mut String, stages: &[StageRow]) -> Result<()> { + if stages.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_stages (\n"); + out.push_str(" stage_id,\n"); + out.push_str(" trace_id,\n"); + out.push_str(" stage_order,\n"); + out.push_str(" stage_name,\n"); + out.push_str(" stage_payload,\n"); + out.push_str(" created_at\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in stages.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.stage_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&row.stage_order.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.stage_name)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.stage_payload)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.created_at)?); + out.push(')'); + + if idx + 1 == stages.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} + +fn render_stage_items(out: &mut String, stage_items: &[StageItemRow]) -> Result<()> { + if stage_items.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_stage_items (\n"); + out.push_str(" id,\n"); + out.push_str(" stage_id,\n"); + out.push_str(" item_id,\n"); + out.push_str(" note_id,\n"); + out.push_str(" chunk_id,\n"); + out.push_str(" metrics\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in stage_items.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.stage_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.item_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.note_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.chunk_id)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.metrics)?); + out.push(')'); + + if idx + 1 == stage_items.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/rows.rs b/apps/elf-eval/src/bin/trace_gate_export/rows.rs new file mode 100644 index 00000000..2fd21f35 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/rows.rs @@ -0,0 +1,74 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +pub(super) struct TraceRow { + pub(super) trace_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) query: String, + pub(super) expansion_mode: String, + pub(super) expanded_queries: Value, + pub(super) allowed_scopes: Value, + pub(super) candidate_count: i32, + pub(super) top_k: i32, + pub(super) config_snapshot: Value, + pub(super) trace_version: i32, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +#[derive(Debug, FromRow)] +pub(super) struct CandidateRow { + pub(super) candidate_id: Uuid, + pub(super) trace_id: Uuid, + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + pub(super) chunk_index: i32, + pub(super) snippet: String, + pub(super) candidate_snapshot: Value, + pub(super) retrieval_rank: i32, + pub(super) rerank_score: f32, + pub(super) note_scope: String, + pub(super) note_importance: f32, + pub(super) note_updated_at: OffsetDateTime, + pub(super) note_hit_count: i64, + pub(super) note_last_hit_at: Option, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +#[derive(Debug, FromRow)] +pub(super) struct ItemRow { + pub(super) item_id: Uuid, + pub(super) trace_id: Uuid, + pub(super) note_id: Uuid, + pub(super) chunk_id: Option, + pub(super) rank: i32, + pub(super) final_score: f32, + pub(super) explain: Value, +} + +#[derive(Debug, FromRow)] +pub(super) struct StageRow { + pub(super) stage_id: Uuid, + pub(super) trace_id: Uuid, + pub(super) stage_order: i32, + pub(super) stage_name: String, + pub(super) stage_payload: Value, + pub(super) created_at: OffsetDateTime, +} + +#[derive(Debug, FromRow)] +pub(super) struct StageItemRow { + pub(super) id: Uuid, + pub(super) stage_id: Uuid, + pub(super) item_id: Option, + pub(super) note_id: Option, + pub(super) chunk_id: Option, + pub(super) metrics: Value, +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/sql.rs b/apps/elf-eval/src/bin/trace_gate_export/sql.rs new file mode 100644 index 00000000..d1e8d04d --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/sql.rs @@ -0,0 +1,39 @@ +use color_eyre::Result; +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +pub(super) fn sql_uuid(id: &Uuid) -> String { + format!("'{}'", id) +} + +pub(super) fn sql_opt_uuid(id: &Option) -> String { + id.map(|value| format!("'{}'", value)).unwrap_or_else(|| "NULL".to_string()) +} + +pub(super) fn sql_text(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +pub(super) fn sql_jsonb(value: &Value) -> Result { + let raw = serde_json::to_string(value)?; + + Ok(format!("'{}'::jsonb", raw.replace('\'', "''"))) +} + +pub(super) fn sql_f32(value: f32) -> String { + format!("{value}") +} + +pub(super) fn sql_timestamptz(value: &OffsetDateTime) -> Result { + let raw = value.format(&Rfc3339)?; + + Ok(format!("'{}'::timestamptz", raw.replace('\'', "''"))) +} + +pub(super) fn sql_opt_timestamptz(value: &Option) -> Result { + match value { + Some(ts) => sql_timestamptz(ts), + None => Ok("NULL".to_string()), + } +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate.rs b/apps/elf-eval/src/bin/trace_regression_gate.rs index 54716bf7..f610753e 100644 --- a/apps/elf-eval/src/bin/trace_regression_gate.rs +++ b/apps/elf-eval/src/bin/trace_regression_gate.rs @@ -2,265 +2,26 @@ //! CLI for evaluating trace-regression gates against stored traces. -use std::{collections::HashSet, fs, path::PathBuf}; +#[path = "trace_regression_gate/cli.rs"] mod cli; +#[path = "trace_regression_gate/eval.rs"] mod eval; +#[path = "trace_regression_gate/gate.rs"] mod gate; +#[path = "trace_regression_gate/replay.rs"] mod replay; +#[path = "trace_regression_gate/reports.rs"] mod reports; +#[path = "trace_regression_gate/rows.rs"] mod rows; +#[path = "trace_regression_gate/storage.rs"] mod storage; + +use std::fs; use clap::Parser; use color_eyre::{Result, eyre}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::FromRow; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing_subscriber::EnvFilter; -use uuid::Uuid; -use elf_config::Config; -use elf_service::search::{self, TraceReplayContext}; +use self::{ + cli::Args, + reports::{GateReport, GateSummary}, +}; use elf_storage::db::Db; -#[derive(Debug, Parser)] -#[command( - version = elf_cli::VERSION, - rename_all = "kebab", - styles = elf_cli::styles(), -)] -struct Args { - #[arg(long, short = 'c', value_name = "FILE")] - config: PathBuf, - #[arg(long, short = 'g', value_name = "FILE")] - gate: PathBuf, - #[arg(long, value_name = "FILE")] - out: Option, - #[arg(long, value_name = "N")] - top_k: Option, - #[arg(long, value_name = "N")] - retrieval_retention_rank: Option, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize)] -#[serde(rename_all = "snake_case")] -struct GateThresholds { - max_positional_churn_at_k: Option, - max_set_churn_at_k: Option, - min_retrieval_top_rank_retention: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct GateTrace { - trace_id: Uuid, - top_k: Option, - retrieval_retention_rank: Option, - #[serde(flatten)] - thresholds: GateThresholds, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct GateFile { - #[serde(default)] - defaults: GateThresholds, - top_k: Option, - retrieval_retention_rank: Option, - traces: Vec, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -struct GateReport { - config_path: String, - gate_path: String, - summary: GateSummary, - traces: Vec, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -struct GateSummary { - trace_count: usize, - breached_count: usize, - ok: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -struct TraceReport { - trace_id: Uuid, - query: String, - created_at: String, - top_k: u32, - retrieval_retention_rank: u32, - candidate_count: u32, - baseline_count: usize, - replay_count: usize, - churn: TraceChurn, - retention: TraceRetention, - breaches: Vec, - ok: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -struct TraceChurn { - positional_churn_at_k: f64, - set_churn_at_k: f64, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -struct TraceRetention { - retrieval_top_rank_total: usize, - baseline_retrieval_top_rank_retained: usize, - baseline_retrieval_top_rank_retention: f64, - replay_retrieval_top_rank_retained: usize, - replay_retrieval_top_rank_retention: f64, - retention_delta: f64, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -struct GateBreach { - metric: String, - value: f64, - threshold: f64, - op: String, -} - -#[derive(Debug, FromRow)] -struct TraceRow { - trace_id: Uuid, - query: String, - candidate_count: i32, - top_k: i32, - created_at: OffsetDateTime, -} - -#[derive(Debug, FromRow)] -struct TraceItemRow { - note_id: Uuid, -} - -#[derive(Debug, FromRow)] -struct CandidateRow { - candidate_snapshot: Value, - note_id: Uuid, - chunk_id: Uuid, - chunk_index: i32, - snippet: String, - retrieval_rank: i32, - rerank_score: f32, - note_scope: String, - note_importance: f32, - note_updated_at: OffsetDateTime, - note_hit_count: i64, - note_last_hit_at: Option, -} - -fn load_gate_file(path: &PathBuf) -> Result { - let raw = fs::read_to_string(path)?; - let out: GateFile = serde_json::from_str(&raw)?; - - Ok(out) -} - -fn merge_thresholds(defaults: GateThresholds, overrides: GateThresholds) -> GateThresholds { - GateThresholds { - max_positional_churn_at_k: overrides - .max_positional_churn_at_k - .or(defaults.max_positional_churn_at_k), - max_set_churn_at_k: overrides.max_set_churn_at_k.or(defaults.max_set_churn_at_k), - min_retrieval_top_rank_retention: overrides - .min_retrieval_top_rank_retention - .or(defaults.min_retrieval_top_rank_retention), - } -} - -fn decode_trace_replay_candidates( - rows: Vec, -) -> Vec { - rows.into_iter() - .map(|row| { - let decoded = serde_json::from_value::( - row.candidate_snapshot.clone(), - ) - .ok() - .filter(|value| value.note_id != Uuid::nil() && value.chunk_id != Uuid::nil()); - - decoded.unwrap_or_else(|| elf_service::search::TraceReplayCandidate { - note_id: row.note_id, - chunk_id: row.chunk_id, - chunk_index: row.chunk_index, - snippet: row.snippet, - retrieval_rank: u32::try_from(row.retrieval_rank).unwrap_or(0), - retrieval_score: None, - rerank_score: row.rerank_score, - note_scope: row.note_scope, - note_importance: row.note_importance, - note_updated_at: row.note_updated_at, - note_hit_count: row.note_hit_count, - note_last_hit_at: row.note_last_hit_at, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }) - }) - .collect() -} - -fn churn_against_baseline_at_k(baseline: &[Uuid], other: &[Uuid], k: usize) -> (f64, f64) { - let k = k.max(1); - let mut positional_diff = 0_usize; - - for idx in 0..k { - let a = baseline.get(idx); - let b = other.get(idx); - - if a != b { - positional_diff += 1; - } - } - - let positional_churn = positional_diff as f64 / k as f64; - let base_set: HashSet = baseline.iter().take(k).copied().collect(); - let other_set: HashSet = other.iter().take(k).copied().collect(); - let overlap = base_set.intersection(&other_set).count(); - let set_churn = 1.0 - (overlap as f64 / k as f64); - - (positional_churn, set_churn) -} - -fn retrieval_top_rank_retention( - candidates: &[elf_service::search::TraceReplayCandidate], - note_ids: &[Uuid], - max_retrieval_rank: u32, -) -> (usize, usize, f64) { - let mut top_notes = HashSet::new(); - - for candidate in candidates { - if candidate.retrieval_rank == 0 || candidate.retrieval_rank > max_retrieval_rank { - continue; - } - - top_notes.insert(candidate.note_id); - } - - let total = top_notes.len(); - - if total == 0 { - return (0, 0, 0.0); - } - - let out_set: HashSet = note_ids.iter().copied().collect(); - let retained = top_notes.intersection(&out_set).count(); - let retention = retained as f64 / total as f64; - - (total, retained, retention) -} - #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; @@ -271,7 +32,7 @@ async fn main() -> Result<()> { tracing_subscriber::fmt().with_env_filter(filter).init(); - let gate = load_gate_file(&args.gate)?; + let gate = self::gate::load_gate_file(&args.gate)?; if gate.traces.is_empty() { return Err(eyre::eyre!("Gate JSON must include at least one trace.")); @@ -287,8 +48,8 @@ async fn main() -> Result<()> { let mut breached_count = 0_usize; for trace in gate.traces { - let thresholds = merge_thresholds(gate.defaults, trace.thresholds); - let report = eval_trace( + let thresholds = self::gate::merge_thresholds(gate.defaults, trace.thresholds); + let report = self::eval::eval_trace( &db, &cfg, &args, @@ -332,186 +93,3 @@ async fn main() -> Result<()> { Ok(()) } - -async fn eval_trace( - db: &Db, - cfg: &Config, - cli: &Args, - gate_top_k: Option, - gate_retrieval_retention_rank: Option, - trace: &GateTrace, - thresholds: GateThresholds, -) -> Result { - let trace_row = fetch_trace_row(db, &trace.trace_id).await?; - let created_at = trace_row - .created_at - .format(&Rfc3339) - .map_err(|err| eyre::eyre!("Failed to format created_at: {err}"))?; - let context = TraceReplayContext { - trace_id: trace_row.trace_id, - query: trace_row.query.clone(), - candidate_count: u32::try_from(trace_row.candidate_count).unwrap_or(0), - top_k: u32::try_from(trace_row.top_k).unwrap_or(0), - created_at: trace_row.created_at, - }; - let top_k = - trace.top_k.or(cli.top_k).or(gate_top_k).or(Some(context.top_k)).unwrap_or(10).max(1); - let retrieval_retention_rank = trace - .retrieval_retention_rank - .or(cli.retrieval_retention_rank) - .or(gate_retrieval_retention_rank) - .unwrap_or(3) - .max(1); - let baseline_items = fetch_baseline_items(db, &trace.trace_id, top_k).await?; - let baseline_note_ids: Vec = baseline_items.iter().map(|row| row.note_id).collect(); - let candidate_rows = fetch_candidate_rows(db, &trace.trace_id).await?; - let candidates = decode_trace_replay_candidates(candidate_rows); - let replay_items = - search::replay_ranking_from_candidates(cfg, &context, None, &candidates, top_k) - .map_err(|err| eyre::eyre!("{err}"))?; - let replay_note_ids: Vec = replay_items.iter().map(|item| item.note_id).collect(); - let effective_k = top_k as usize; - let (positional_churn_at_k, set_churn_at_k) = - churn_against_baseline_at_k(&baseline_note_ids, &replay_note_ids, effective_k); - let churn = TraceChurn { positional_churn_at_k, set_churn_at_k }; - let (retrieval_top_rank_total, baseline_retained, baseline_retention) = - retrieval_top_rank_retention(&candidates, &baseline_note_ids, retrieval_retention_rank); - let (_, replay_retained, replay_retention) = - retrieval_top_rank_retention(&candidates, &replay_note_ids, retrieval_retention_rank); - let retention = TraceRetention { - retrieval_top_rank_total, - baseline_retrieval_top_rank_retained: baseline_retained, - baseline_retrieval_top_rank_retention: baseline_retention, - replay_retrieval_top_rank_retained: replay_retained, - replay_retrieval_top_rank_retention: replay_retention, - retention_delta: replay_retention - baseline_retention, - }; - let mut breaches = Vec::new(); - - if baseline_note_ids.len() < effective_k { - breaches.push(GateBreach { - metric: "baseline_count_at_k".to_string(), - value: baseline_note_ids.len() as f64, - threshold: effective_k as f64, - op: ">=".to_string(), - }); - } - if replay_note_ids.len() < effective_k { - breaches.push(GateBreach { - metric: "replay_count_at_k".to_string(), - value: replay_note_ids.len() as f64, - threshold: effective_k as f64, - op: ">=".to_string(), - }); - } - - if let Some(max) = thresholds.max_positional_churn_at_k - && churn.positional_churn_at_k > max - { - breaches.push(GateBreach { - metric: "positional_churn_at_k".to_string(), - value: churn.positional_churn_at_k, - threshold: max, - op: "<=".to_string(), - }); - } - if let Some(max) = thresholds.max_set_churn_at_k - && churn.set_churn_at_k > max - { - breaches.push(GateBreach { - metric: "set_churn_at_k".to_string(), - value: churn.set_churn_at_k, - threshold: max, - op: "<=".to_string(), - }); - } - if let Some(min) = thresholds.min_retrieval_top_rank_retention - && retention.replay_retrieval_top_rank_retention < min - { - breaches.push(GateBreach { - metric: "replay_retrieval_top_rank_retention".to_string(), - value: retention.replay_retrieval_top_rank_retention, - threshold: min, - op: ">=".to_string(), - }); - } - - Ok(TraceReport { - trace_id: trace.trace_id, - query: context.query, - created_at, - top_k, - retrieval_retention_rank, - candidate_count: context.candidate_count, - baseline_count: baseline_note_ids.len(), - replay_count: replay_note_ids.len(), - churn, - retention, - ok: breaches.is_empty(), - breaches, - }) -} - -async fn fetch_trace_row(db: &Db, trace_id: &Uuid) -> Result { - let row: TraceRow = sqlx::query_as::<_, TraceRow>( - "\ -SELECT - trace_id, - query, - candidate_count, - top_k, - created_at -FROM search_traces -WHERE trace_id = $1", - ) - .bind(trace_id) - .fetch_one(&db.pool) - .await?; - - Ok(row) -} - -async fn fetch_baseline_items(db: &Db, trace_id: &Uuid, top_k: u32) -> Result> { - let rows: Vec = sqlx::query_as::<_, TraceItemRow>( - "\ -SELECT - note_id -FROM search_trace_items -WHERE trace_id = $1 -ORDER BY rank ASC -LIMIT $2", - ) - .bind(trace_id) - .bind(i64::from(top_k.max(1))) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn fetch_candidate_rows(db: &Db, trace_id: &Uuid) -> Result> { - let rows: Vec = sqlx::query_as::<_, CandidateRow>( - "\ -SELECT - candidate_snapshot, - note_id, - chunk_id, - chunk_index, - snippet, - retrieval_rank, - rerank_score, - note_scope, - note_importance, - note_updated_at, - note_hit_count, - note_last_hit_at -FROM search_trace_candidates -WHERE trace_id = $1 -ORDER BY retrieval_rank ASC", - ) - .bind(trace_id) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/cli.rs b/apps/elf-eval/src/bin/trace_regression_gate/cli.rs new file mode 100644 index 00000000..d5d5ad84 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/cli.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Debug, Parser)] +#[command( + version = elf_cli::VERSION, + rename_all = "kebab", + styles = elf_cli::styles(), +)] +pub(super) struct Args { + #[arg(long, short = 'c', value_name = "FILE")] + pub(super) config: PathBuf, + #[arg(long, short = 'g', value_name = "FILE")] + pub(super) gate: PathBuf, + #[arg(long, value_name = "FILE")] + pub(super) out: Option, + #[arg(long, value_name = "N")] + pub(super) top_k: Option, + #[arg(long, value_name = "N")] + pub(super) retrieval_retention_rank: Option, +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/eval.rs b/apps/elf-eval/src/bin/trace_regression_gate/eval.rs new file mode 100644 index 00000000..7a464f46 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/eval.rs @@ -0,0 +1,140 @@ +use color_eyre::{Result, eyre}; +use time::format_description::well_known::Rfc3339; +use uuid::Uuid; + +use crate::{ + cli::Args, + gate::{GateThresholds, GateTrace}, + replay::{self}, + reports::{GateBreach, TraceChurn, TraceReport, TraceRetention}, + storage::{self}, +}; +use elf_config::Config; +use elf_service::search::{self, TraceReplayContext}; +use elf_storage::db::Db; + +pub(super) async fn eval_trace( + db: &Db, + cfg: &Config, + cli: &Args, + gate_top_k: Option, + gate_retrieval_retention_rank: Option, + trace: &GateTrace, + thresholds: GateThresholds, +) -> Result { + let trace_row = storage::fetch_trace_row(db, &trace.trace_id).await?; + let created_at = trace_row + .created_at + .format(&Rfc3339) + .map_err(|err| eyre::eyre!("Failed to format created_at: {err}"))?; + let context = TraceReplayContext { + trace_id: trace_row.trace_id, + query: trace_row.query.clone(), + candidate_count: u32::try_from(trace_row.candidate_count).unwrap_or(0), + top_k: u32::try_from(trace_row.top_k).unwrap_or(0), + created_at: trace_row.created_at, + }; + let top_k = + trace.top_k.or(cli.top_k).or(gate_top_k).or(Some(context.top_k)).unwrap_or(10).max(1); + let retrieval_retention_rank = trace + .retrieval_retention_rank + .or(cli.retrieval_retention_rank) + .or(gate_retrieval_retention_rank) + .unwrap_or(3) + .max(1); + let baseline_items = storage::fetch_baseline_items(db, &trace.trace_id, top_k).await?; + let baseline_note_ids: Vec = baseline_items.iter().map(|row| row.note_id).collect(); + let candidate_rows = storage::fetch_candidate_rows(db, &trace.trace_id).await?; + let candidates = replay::decode_trace_replay_candidates(candidate_rows); + let replay_items = + search::replay_ranking_from_candidates(cfg, &context, None, &candidates, top_k) + .map_err(|err| eyre::eyre!("{err}"))?; + let replay_note_ids: Vec = replay_items.iter().map(|item| item.note_id).collect(); + let effective_k = top_k as usize; + let (positional_churn_at_k, set_churn_at_k) = + replay::churn_against_baseline_at_k(&baseline_note_ids, &replay_note_ids, effective_k); + let churn = TraceChurn { positional_churn_at_k, set_churn_at_k }; + let (retrieval_top_rank_total, baseline_retained, baseline_retention) = + replay::retrieval_top_rank_retention( + &candidates, + &baseline_note_ids, + retrieval_retention_rank, + ); + let (_, replay_retained, replay_retention) = replay::retrieval_top_rank_retention( + &candidates, + &replay_note_ids, + retrieval_retention_rank, + ); + let retention = TraceRetention { + retrieval_top_rank_total, + baseline_retrieval_top_rank_retained: baseline_retained, + baseline_retrieval_top_rank_retention: baseline_retention, + replay_retrieval_top_rank_retained: replay_retained, + replay_retrieval_top_rank_retention: replay_retention, + retention_delta: replay_retention - baseline_retention, + }; + let mut breaches = Vec::new(); + + if baseline_note_ids.len() < effective_k { + breaches.push(GateBreach { + metric: "baseline_count_at_k".to_string(), + value: baseline_note_ids.len() as f64, + threshold: effective_k as f64, + op: ">=".to_string(), + }); + } + if replay_note_ids.len() < effective_k { + breaches.push(GateBreach { + metric: "replay_count_at_k".to_string(), + value: replay_note_ids.len() as f64, + threshold: effective_k as f64, + op: ">=".to_string(), + }); + } + + if let Some(max) = thresholds.max_positional_churn_at_k + && churn.positional_churn_at_k > max + { + breaches.push(GateBreach { + metric: "positional_churn_at_k".to_string(), + value: churn.positional_churn_at_k, + threshold: max, + op: "<=".to_string(), + }); + } + if let Some(max) = thresholds.max_set_churn_at_k + && churn.set_churn_at_k > max + { + breaches.push(GateBreach { + metric: "set_churn_at_k".to_string(), + value: churn.set_churn_at_k, + threshold: max, + op: "<=".to_string(), + }); + } + if let Some(min) = thresholds.min_retrieval_top_rank_retention + && retention.replay_retrieval_top_rank_retention < min + { + breaches.push(GateBreach { + metric: "replay_retrieval_top_rank_retention".to_string(), + value: retention.replay_retrieval_top_rank_retention, + threshold: min, + op: ">=".to_string(), + }); + } + + Ok(TraceReport { + trace_id: trace.trace_id, + query: context.query, + created_at, + top_k, + retrieval_retention_rank, + candidate_count: context.candidate_count, + baseline_count: baseline_note_ids.len(), + replay_count: replay_note_ids.len(), + churn, + retention, + ok: breaches.is_empty(), + breaches, + }) +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/gate.rs b/apps/elf-eval/src/bin/trace_regression_gate/gate.rs new file mode 100644 index 00000000..8a5305c8 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/gate.rs @@ -0,0 +1,55 @@ +use std::{fs, path::PathBuf}; + +use color_eyre::Result; +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct GateThresholds { + pub(super) max_positional_churn_at_k: Option, + pub(super) max_set_churn_at_k: Option, + pub(super) min_retrieval_top_rank_retention: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct GateTrace { + pub(super) trace_id: Uuid, + pub(super) top_k: Option, + pub(super) retrieval_retention_rank: Option, + #[serde(flatten)] + pub(super) thresholds: GateThresholds, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct GateFile { + #[serde(default)] + pub(super) defaults: GateThresholds, + pub(super) top_k: Option, + pub(super) retrieval_retention_rank: Option, + pub(super) traces: Vec, +} + +pub(super) fn load_gate_file(path: &PathBuf) -> Result { + let raw = fs::read_to_string(path)?; + let out: GateFile = serde_json::from_str(&raw)?; + + Ok(out) +} + +pub(super) fn merge_thresholds( + defaults: GateThresholds, + overrides: GateThresholds, +) -> GateThresholds { + GateThresholds { + max_positional_churn_at_k: overrides + .max_positional_churn_at_k + .or(defaults.max_positional_churn_at_k), + max_set_churn_at_k: overrides.max_set_churn_at_k.or(defaults.max_set_churn_at_k), + min_retrieval_top_rank_retention: overrides + .min_retrieval_top_rank_retention + .or(defaults.min_retrieval_top_rank_retention), + } +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/replay.rs b/apps/elf-eval/src/bin/trace_regression_gate/replay.rs new file mode 100644 index 00000000..bf10212a --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/replay.rs @@ -0,0 +1,96 @@ +use std::collections::HashSet; + +use uuid::Uuid; + +use crate::rows::CandidateRow; + +pub(super) fn decode_trace_replay_candidates( + rows: Vec, +) -> Vec { + rows.into_iter() + .map(|row| { + let decoded = serde_json::from_value::( + row.candidate_snapshot.clone(), + ) + .ok() + .filter(|value| value.note_id != Uuid::nil() && value.chunk_id != Uuid::nil()); + + decoded.unwrap_or_else(|| elf_service::search::TraceReplayCandidate { + note_id: row.note_id, + chunk_id: row.chunk_id, + chunk_index: row.chunk_index, + snippet: row.snippet, + retrieval_rank: u32::try_from(row.retrieval_rank).unwrap_or(0), + retrieval_score: None, + rerank_score: row.rerank_score, + note_scope: row.note_scope, + note_importance: row.note_importance, + note_updated_at: row.note_updated_at, + note_hit_count: row.note_hit_count, + note_last_hit_at: row.note_last_hit_at, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }) + }) + .collect() +} + +pub(super) fn churn_against_baseline_at_k( + baseline: &[Uuid], + other: &[Uuid], + k: usize, +) -> (f64, f64) { + let k = k.max(1); + let mut positional_diff = 0_usize; + + for idx in 0..k { + let a = baseline.get(idx); + let b = other.get(idx); + + if a != b { + positional_diff += 1; + } + } + + let positional_churn = positional_diff as f64 / k as f64; + let base_set: HashSet = baseline.iter().take(k).copied().collect(); + let other_set: HashSet = other.iter().take(k).copied().collect(); + let overlap = base_set.intersection(&other_set).count(); + let set_churn = 1.0 - (overlap as f64 / k as f64); + + (positional_churn, set_churn) +} + +pub(super) fn retrieval_top_rank_retention( + candidates: &[elf_service::search::TraceReplayCandidate], + note_ids: &[Uuid], + max_retrieval_rank: u32, +) -> (usize, usize, f64) { + let mut top_notes = HashSet::new(); + + for candidate in candidates { + if candidate.retrieval_rank == 0 || candidate.retrieval_rank > max_retrieval_rank { + continue; + } + + top_notes.insert(candidate.note_id); + } + + let total = top_notes.len(); + + if total == 0 { + return (0, 0, 0.0); + } + + let out_set: HashSet = note_ids.iter().copied().collect(); + let retained = top_notes.intersection(&out_set).count(); + let retention = retained as f64 / total as f64; + + (total, retained, retention) +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/reports.rs b/apps/elf-eval/src/bin/trace_regression_gate/reports.rs new file mode 100644 index 00000000..c6d0a0c6 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/reports.rs @@ -0,0 +1,63 @@ +use serde::Serialize; +use uuid::Uuid; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct GateReport { + pub(super) config_path: String, + pub(super) gate_path: String, + pub(super) summary: GateSummary, + pub(super) traces: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct GateSummary { + pub(super) trace_count: usize, + pub(super) breached_count: usize, + pub(super) ok: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct TraceReport { + pub(super) trace_id: Uuid, + pub(super) query: String, + pub(super) created_at: String, + pub(super) top_k: u32, + pub(super) retrieval_retention_rank: u32, + pub(super) candidate_count: u32, + pub(super) baseline_count: usize, + pub(super) replay_count: usize, + pub(super) churn: TraceChurn, + pub(super) retention: TraceRetention, + pub(super) breaches: Vec, + pub(super) ok: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct TraceChurn { + pub(super) positional_churn_at_k: f64, + pub(super) set_churn_at_k: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct TraceRetention { + pub(super) retrieval_top_rank_total: usize, + pub(super) baseline_retrieval_top_rank_retained: usize, + pub(super) baseline_retrieval_top_rank_retention: f64, + pub(super) replay_retrieval_top_rank_retained: usize, + pub(super) replay_retrieval_top_rank_retention: f64, + pub(super) retention_delta: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct GateBreach { + pub(super) metric: String, + pub(super) value: f64, + pub(super) threshold: f64, + pub(super) op: String, +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/rows.rs b/apps/elf-eval/src/bin/trace_regression_gate/rows.rs new file mode 100644 index 00000000..3ba8c54a --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/rows.rs @@ -0,0 +1,34 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +pub(super) struct TraceRow { + pub(super) trace_id: Uuid, + pub(super) query: String, + pub(super) candidate_count: i32, + pub(super) top_k: i32, + pub(super) created_at: OffsetDateTime, +} + +#[derive(Debug, FromRow)] +pub(super) struct TraceItemRow { + pub(super) note_id: Uuid, +} + +#[derive(Debug, FromRow)] +pub(super) struct CandidateRow { + pub(super) candidate_snapshot: Value, + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + pub(super) chunk_index: i32, + pub(super) snippet: String, + pub(super) retrieval_rank: i32, + pub(super) rerank_score: f32, + pub(super) note_scope: String, + pub(super) note_importance: f32, + pub(super) note_updated_at: OffsetDateTime, + pub(super) note_hit_count: i64, + pub(super) note_last_hit_at: Option, +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate/storage.rs b/apps/elf-eval/src/bin/trace_regression_gate/storage.rs new file mode 100644 index 00000000..3a7cc6d8 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_regression_gate/storage.rs @@ -0,0 +1,73 @@ +use color_eyre::Result; +use uuid::Uuid; + +use crate::rows::{CandidateRow, TraceItemRow, TraceRow}; +use elf_storage::db::Db; + +pub(super) async fn fetch_trace_row(db: &Db, trace_id: &Uuid) -> Result { + let row: TraceRow = sqlx::query_as::<_, TraceRow>( + "\ +SELECT + trace_id, + query, + candidate_count, + top_k, + created_at +FROM search_traces +WHERE trace_id = $1", + ) + .bind(trace_id) + .fetch_one(&db.pool) + .await?; + + Ok(row) +} + +pub(super) async fn fetch_baseline_items( + db: &Db, + trace_id: &Uuid, + top_k: u32, +) -> Result> { + let rows: Vec = sqlx::query_as::<_, TraceItemRow>( + "\ +SELECT + note_id +FROM search_trace_items +WHERE trace_id = $1 +ORDER BY rank ASC +LIMIT $2", + ) + .bind(trace_id) + .bind(i64::from(top_k.max(1))) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +pub(super) async fn fetch_candidate_rows(db: &Db, trace_id: &Uuid) -> Result> { + let rows: Vec = sqlx::query_as::<_, CandidateRow>( + "\ +SELECT + candidate_snapshot, + note_id, + chunk_id, + chunk_index, + snippet, + retrieval_rank, + rerank_score, + note_scope, + note_importance, + note_updated_at, + note_hit_count, + note_last_hit_at +FROM search_trace_candidates +WHERE trace_id = $1 +ORDER BY retrieval_rank ASC", + ) + .bind(trace_id) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark.rs b/apps/elf-eval/tests/real_world_job_benchmark.rs index 8d950cdb..6df392ce 100644 --- a/apps/elf-eval/tests/real_world_job_benchmark.rs +++ b/apps/elf-eval/tests/real_world_job_benchmark.rs @@ -2,9525 +2,30 @@ //! Integration tests for the real-world job smoke benchmark runner. -use std::{ - env, fs, - path::{Path, PathBuf}, - process::{self, Command, Output}, -}; - -use color_eyre::{Result, eyre}; -use serde_json::Value; - -struct RecallDebugSourceContract<'a> { - service: &'a str, - service_lib: &'a str, - routes: &'a str, - mcp: &'a str, - recall_spec: &'a str, - service_spec: &'a str, - version_registry: &'a str, - markdown: &'a str, - benchmarking_index: &'a str, - readme: &'a str, -} - -fn fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join("real_world_memory") - .join("work_resume") -} - -fn fixture_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures").join("real_world_memory") -} - -fn real_world_memory_fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures").join("real_world_memory") -} - -fn evolution_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("evolution") -} - -fn operator_debug_fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join("real_world_job") - .join("operator_debugging_ux") -} - -fn project_decisions_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("project_decisions") -} - -fn retrieval_fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join("real_world_memory") - .join("retrieval") -} - -fn capture_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("capture_integration") -} - -fn consolidation_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("consolidation") -} - -fn memory_summary_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("memory_summary") -} - -fn proactive_brief_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("proactive_brief") -} - -fn scheduled_memory_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("scheduled_memory") -} - -fn work_continuity_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("work_continuity") -} - -fn knowledge_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("knowledge") -} - -fn source_library_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("source_library") -} - -fn production_ops_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("production_ops") -} - -fn core_archival_memory_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("core_archival_memory") -} - -fn context_trajectory_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("context_trajectory") -} - -fn adversarial_quality_fixture_dir() -> PathBuf { - real_world_memory_fixture_dir().join("adversarial_quality") -} - -fn graph_rag_external_fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join("real_world_external_adapters") - .join("graph_rag") -} - -fn workspace_root() -> Result { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let root = manifest_dir - .parent() - .and_then(Path::parent) - .ok_or_else(|| eyre::eyre!("could not resolve workspace root"))?; - - Ok(root.to_path_buf()) -} - -fn collapse_whitespace(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - -fn report_snapshot_path(file_name: &str) -> Result { - Ok(workspace_root()? - .join("apps") - .join("elf-eval") - .join("fixtures") - .join("report_snapshots") - .join(file_name)) -} - -fn strength_profile_report_path() -> Result { - report_snapshot_path("2026-06-11-qmd-openviking-strength-profile-report.json") -} - -fn strength_profile_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-qmd-openviking-strength-profile-report.md")) -} - -fn measurement_coverage_audit_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-measurement-coverage-audit.md")) -} - -fn measurement_coverage_audit_json_path() -> Result { - report_snapshot_path("2026-06-11-measurement-coverage-audit.json") -} - -fn retrieval_debug_profile_json_path() -> Result { - report_snapshot_path("2026-06-11-elf-qmd-retrieval-debug-profile.json") -} - -fn trace_replay_diagnostics_report_path() -> Result { - report_snapshot_path("2026-06-11-elf-qmd-trace-replay-diagnostics-report.json") -} - -fn trace_replay_diagnostics_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-elf-qmd-trace-replay-diagnostics-report.md")) -} - -fn competitor_strength_adoption_report_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-competitor-strength-adoption-report.md")) -} - -fn competitor_strength_adoption_report_json_path() -> Result { - report_snapshot_path("2026-06-11-competitor-strength-adoption-report.json") -} - -fn capture_write_policy_live_report_path() -> Result { - report_snapshot_path("2026-06-11-capture-write-policy-live-report.json") -} - -fn capture_write_policy_live_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-capture-write-policy-live-report.md")) -} - -fn live_consolidation_proposal_scoring_report_path() -> Result { - report_snapshot_path("2026-06-16-live-consolidation-proposal-scoring-report.json") -} - -fn live_consolidation_proposal_scoring_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-16-live-consolidation-proposal-scoring-report.md")) -} - -fn temporal_history_competitor_gap_json_path() -> Result { - report_snapshot_path("2026-06-11-temporal-history-competitor-gap-report.json") -} - -fn dreaming_readiness_stage_ledger_json_path() -> Result { - report_snapshot_path("2026-06-16-dreaming-readiness-stage-ledger.json") -} - -fn dreaming_readiness_stage_ledger_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-16-dreaming-readiness-stage-ledger.md")) -} - -fn dreaming_competitor_strength_retest_report_json_path() -> Result { - report_snapshot_path("2026-06-17-dreaming-competitor-strength-retest-report.json") -} - -fn dreaming_competitor_strength_retest_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-17-dreaming-competitor-strength-retest-report.md")) -} - -fn qmd_debug_ergonomics_dreaming_retest_report_json_path() -> Result { - report_snapshot_path("2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.json") -} - -fn qmd_debug_ergonomics_dreaming_retest_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.md")) -} - -fn openviking_trajectory_materialization_report_json_path() -> Result { - report_snapshot_path("2026-06-19-openviking-trajectory-materialization-report.json") -} - -fn letta_core_archive_export_readback_report_json_path() -> Result { - report_snapshot_path("2026-06-19-letta-core-archive-export-readback-report.json") -} - -fn service_native_dreaming_readback_report_json_path() -> Result { - report_snapshot_path("2026-06-19-service-native-dreaming-readback-report.json") -} - -fn service_native_dreaming_readback_materialization_json_path() -> Result { - report_snapshot_path("2026-06-19-service-native-dreaming-readback-materialization.json") -} - -fn dreaming_review_queue_report_json_path() -> Result { - report_snapshot_path("2026-06-20-dreaming-review-queue-report.json") -} - -fn recall_debug_panel_report_json_path() -> Result { - report_snapshot_path("2026-06-20-recall-debug-panel-report.json") -} - -fn agent_knowledge_os_closeout_benchmark_report_json_path() -> Result { - report_snapshot_path("2026-06-20-agent-knowledge-os-closeout-benchmark-report.json") -} - -fn p2_knowledge_workspace_pageindex_openkb_closeout_report_json_path() -> Result { - report_snapshot_path("2026-06-22-p2-knowledge-workspace-pageindex-openkb-closeout-report.json") -} - -fn openmemory_ui_export_product_readback_report_json_path() -> Result { - report_snapshot_path("2026-06-19-openmemory-ui-export-product-readback-report.json") -} - -fn graph_rag_citation_navigation_promotion_report_json_path() -> Result { - report_snapshot_path("2026-06-19-graph-rag-citation-navigation-promotion-report.json") -} - -fn graph_rag_adapter_matrix_report_json_path() -> Result { - report_snapshot_path("2026-06-23-graph-rag-adapter-matrix-report.json") -} - -fn p3_competitor_strength_absorption_report_json_path() -> Result { - report_snapshot_path("2026-06-23-p3-competitor-strength-absorption-report.json") -} - -fn operator_approved_public_proxy_private_addendum_report_json_path() -> Result { - report_snapshot_path( - "2026-06-19-operator-approved-public-proxy-production-private-addendum.json", - ) -} - -fn openviking_trajectory_materialization_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-openviking-trajectory-materialization-report.md")) -} - -fn letta_core_archive_export_readback_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-letta-core-archive-export-readback-report.md")) -} - -fn service_native_dreaming_readback_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-service-native-dreaming-readback-report.md")) -} - -fn dreaming_review_queue_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-20-dreaming-review-queue-report.md")) -} - -fn recall_debug_panel_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-20-recall-debug-panel-report.md")) -} - -fn agent_knowledge_os_closeout_benchmark_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-20-agent-knowledge-os-closeout-benchmark-report.md")) -} - -fn p2_knowledge_workspace_pageindex_openkb_closeout_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-22-p2-knowledge-workspace-pageindex-openkb-closeout-report.md")) -} - -fn openmemory_ui_export_product_readback_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-openmemory-ui-export-product-readback-report.md")) -} - -fn graph_rag_citation_navigation_promotion_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-graph-rag-citation-navigation-promotion-report.md")) -} - -fn graph_rag_adapter_matrix_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-23-graph-rag-adapter-matrix-report.md")) -} - -fn p3_competitor_strength_absorption_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-23-p3-competitor-strength-absorption-report.md")) -} - -fn graph_topic_map_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-20-graph-topic-map-report.md")) -} - -fn operator_approved_public_proxy_private_addendum_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-19-operator-approved-public-proxy-production-private-addendum.md")) -} - -fn live_temporal_reconciliation_report_json_path() -> Result { - report_snapshot_path("2026-06-16-live-temporal-reconciliation-report.json") -} - -fn live_temporal_reconciliation_report_markdown_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-16-live-temporal-reconciliation-report.md")) -} - -fn competitor_strength_matrix_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-competitor-strength-evidence-matrix.md")) -} - -fn competitor_strength_matrix_json_path() -> Result { - report_snapshot_path("2026-06-11-xy-897-competitor-strength-matrix.json") -} - -fn readme_path() -> Result { - Ok(workspace_root()?.join("README.md")) -} - -fn comparison_external_projects_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("external_memory") - .join("comparison_external_projects.md")) -} - -fn benchmarking_index_path() -> Result { - Ok(workspace_root()?.join("docs").join("evidence").join("benchmarking").join("index.md")) -} - -fn iteration_direction_report_path() -> Result { - Ok(workspace_root()? - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-11-elf-iteration-direction-from-competitor-benchmarks.md")) -} - -fn external_adapter_manifest_path() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join("real_world_external_adapters") - .join("memory_projects_manifest.json") -} - -fn run_json_report_from(fixtures: PathBuf) -> Result { - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("run") - .arg("--fixtures") - .arg(fixtures) - .output()?; - - assert!( - output.status.success(), - "real_world_job runner failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - Ok(serde_json::from_slice(&output.stdout)?) -} - -fn run_json_report_from_failure(fixtures: PathBuf) -> Result { - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("run") - .arg("--fixtures") - .arg(fixtures) - .output()?; - - assert!( - !output.status.success(), - "real_world_job runner unexpectedly passed: {}", - String::from_utf8_lossy(&output.stdout), - ); - - Ok(String::from_utf8_lossy(&output.stderr).to_string()) -} - -fn run_json_report() -> Result { - run_json_report_from(fixture_dir()) -} - -fn load_json(path: &Path) -> Result { - Ok(serde_json::from_str::(&fs::read_to_string(path)?)?) -} - -fn array_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a Vec> { - value - .pointer(pointer) - .and_then(Value::as_array) - .ok_or_else(|| eyre::eyre!("missing array at {pointer}")) -} - -fn find_by_field<'a>(items: &'a [Value], field: &str, expected: &str) -> Result<&'a Value> { - items - .iter() - .find(|item| item.pointer(field).and_then(Value::as_str) == Some(expected)) - .ok_or_else(|| eyre::eyre!("missing item with {field} = {expected}")) -} - -fn array_contains_str(value: &Value, pointer: &str, expected: &str) -> Result { - Ok(array_at(value, pointer)?.iter().any(|item| item.as_str() == Some(expected))) -} - -fn string_array_at(value: &Value, pointer: &str) -> Result> { - array_at(value, pointer)? - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| eyre::eyre!("non-string entry at {pointer}")) - }) - .collect() -} - -fn set_json_pointer(value: &mut Value, pointer: &str, replacement: Value) -> Result<()> { - let target = - value.pointer_mut(pointer).ok_or_else(|| eyre::eyre!("missing JSON pointer {pointer}"))?; - - *target = replacement; - - Ok(()) -} - -fn run_external_manifest_with_letta_attachment_mutation( - slug: &str, - mutation: F, -) -> Result -where - F: FnOnce(&mut Value) -> Result<()>, -{ - run_external_manifest_scenario_mutation( - slug, - "letta_research_gate", - "core_block_attachment_readback", - mutation, - ) -} - -fn run_external_manifest_scenario_mutation( - slug: &str, - adapter_id: &str, - scenario_id: &str, - mutation: F, -) -> Result -where - F: FnOnce(&mut Value) -> Result<()>, -{ - let mut manifest = - serde_json::from_str::(&fs::read_to_string(external_adapter_manifest_path())?)?; - let adapters = manifest - .pointer_mut("/adapters") - .and_then(Value::as_array_mut) - .ok_or_else(|| eyre::eyre!("missing manifest adapters"))?; - let adapter = adapters - .iter_mut() - .find(|adapter| adapter.pointer("/adapter_id").and_then(Value::as_str) == Some(adapter_id)) - .ok_or_else(|| eyre::eyre!("missing {adapter_id} adapter"))?; - let scenarios = adapter - .pointer_mut("/scenarios") - .and_then(Value::as_array_mut) - .ok_or_else(|| eyre::eyre!("missing {adapter_id} scenarios"))?; - let scenario = scenarios - .iter_mut() - .find(|scenario| { - scenario.pointer("/scenario_id").and_then(Value::as_str) == Some(scenario_id) - }) - .ok_or_else(|| eyre::eyre!("missing {scenario_id} scenario"))?; - - mutation(scenario)?; - - let temp_dir = env::temp_dir().join(format!("elf-real-world-{slug}-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let manifest_path = temp_dir.join("memory_projects_manifest.json"); - - fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; - - Ok(Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("run") - .arg("--fixtures") - .arg(fixture_dir()) - .arg("--external-adapter-manifest") - .arg(&manifest_path) - .output()?) -} - -#[test] -fn smoke_fixture_produces_typed_json_report() -> Result<()> { - let report = run_json_report()?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.real_world_job_report/v1") - ); - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(2)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/external_adapters/summary/adapter_count").and_then(Value::as_u64), - Some(26) - ); - assert_eq!( - report.pointer("/external_adapters/summary/live_real_world_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/external_adapters/summary/research_gate_count").and_then(Value::as_u64), - Some(14) - ); - - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "work-resume-stale-worktree-001")?; - - assert_eq!(job.pointer("/suite_id").and_then(Value::as_str), Some("work_resume")); - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(job.pointer("/latency_ms").and_then(Value::as_f64), Some(2.0)); - assert_eq!(job.pointer("/cost/amount").and_then(Value::as_f64), Some(0.0)); - - let expected_evidence = array_at(job, "/expected_evidence")?; - let produced_evidence = array_at(job, "/produced_evidence")?; - - assert_eq!(expected_evidence.len(), 2); - assert_eq!(produced_evidence.len(), 1); - assert_eq!(produced_evidence.first().and_then(Value::as_str), Some("xy844-current-worktree")); - - let suites = array_at(&report, "/suites")?; - let encoded_suite = find_by_field(suites, "/suite_id", "work_resume")?; - let capture_suite = find_by_field(suites, "/suite_id", "capture_integration")?; - let unencoded_suite = find_by_field(suites, "/suite_id", "retrieval")?; - - assert_eq!(encoded_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(encoded_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(capture_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(capture_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(1)); - assert_eq!(unencoded_suite.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - - let capture_fixture_backed = array_at(&report, "/capture_integration/fixture_backed")?; - - assert!(capture_fixture_backed.iter().any(|value| { - value.as_str().is_some_and(|item| item.contains("agentmemory-style hook capture")) - })); - - let capture_not_encoded = array_at(&report, "/capture_integration/not_encoded")?; - - assert!(capture_not_encoded.iter().any(|value| { - value.as_str().is_some_and(|item| item.contains("No live external hook ingestion")) - })); - - Ok(()) -} - -#[test] -fn real_world_report_includes_external_adapter_coverage_manifest() -> Result<()> { - let report = run_json_report_from(real_world_memory_fixture_dir())?; - - assert_external_adapter_manifest_summary(&report); - assert_external_adapter_manifest_records(&report)?; - - Ok(()) -} - -#[test] -fn capture_integration_fixtures_score_redaction_and_source_ids() -> Result<()> { - let report = run_json_report_from(capture_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/redaction_leak_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); - - let suites = array_at(&report, "/suites")?; - let capture = find_by_field(suites, "/suite_id", "capture_integration")?; - - assert_eq!(capture.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(capture.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); - - let jobs = array_at(&report, "/jobs")?; - let source_id = find_by_field(jobs, "/job_id", "capture-source-id-binding-001")?; - let redaction = find_by_field(jobs, "/job_id", "capture-write-policy-redaction-001")?; - - assert!(array_contains_str(source_id, "/produced_evidence", "source-id-release-summary")?); - assert!(array_contains_str(source_id, "/produced_evidence", "source-id-command-log")?); - assert_eq!(redaction.pointer("/redaction_leak_count").and_then(Value::as_u64), Some(0)); - assert!( - redaction - .pointer("/produced_answer") - .and_then(Value::as_str) - .is_some_and(|answer| !answer.contains("orchid-envelope")) - ); - - Ok(()) -} - -#[test] -fn source_library_fixtures_score_saved_sources_without_memory_promotion() -> Result<()> { - let report = run_json_report_from(source_library_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(2)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(2)); - assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!(report.pointer("/summary/quote_coverage").and_then(Value::as_f64), Some(1.0)); - - let suites = array_at(&report, "/suites")?; - let source_library = find_by_field(suites, "/suite_id", "source_library")?; - - assert_eq!(source_library.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(source_library.pointer("/encoded_job_count").and_then(Value::as_u64), Some(2)); - - let jobs = array_at(&report, "/jobs")?; - let long_doc = find_by_field(jobs, "/job_id", "source-library-long-doc-001")?; - let thread = find_by_field(jobs, "/job_id", "source-library-social-thread-001")?; - - assert!(array_contains_str(long_doc, "/produced_evidence", "article-source-record")?); - assert!(array_contains_str(long_doc, "/produced_evidence", "article-hydrated-excerpt")?); - assert!(array_contains_str(thread, "/produced_evidence", "thread-source-record")?); - assert!(array_contains_str(thread, "/produced_evidence", "thread-promotion-boundary")?); - assert!(long_doc.pointer("/produced_answer").and_then(Value::as_str).is_some_and(|answer| { - answer.contains("does not automatically create a durable Memory Note") - })); - assert!( - thread - .pointer("/produced_answer") - .and_then(Value::as_str) - .is_some_and(|answer| answer.contains("explicit add_note or reviewed promotion")) - ); - - Ok(()) -} - -#[test] -fn adversarial_quality_fixtures_score_scoreboard_gates() -> Result<()> { - let report = run_json_report_from(adversarial_quality_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/redaction_leak_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), - Some(2) - ); - assert_eq!( - report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), - Some(3) - ); - assert_eq!( - report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64), - Some(1) - ); - - let result_states = string_array_at(&report, "/scoreboard/result_states")?; - let evidence_classes = string_array_at(&report, "/scoreboard/evidence_classes")?; - - assert_eq!( - result_states, - [ - "pass", - "wrong_result", - "incomplete", - "blocked", - "not_tested", - "not_encoded", - "not_comparable", - "unsupported_claim", - ] - .map(str::to_owned) - ); - assert_eq!( - evidence_classes, - ["fixture_backed", "live_baseline", "live_real_world", "research_gate"].map(str::to_owned) - ); - assert_eq!( - report.pointer("/scoreboard/summary_claim").and_then(Value::as_str), - Some("typed_non_pass_present") - ); - assert_eq!( - report.pointer("/scoreboard/job_summary_claim").and_then(Value::as_str), - Some("all_encoded_jobs_passed") - ); - assert_eq!( - report.pointer("/scoreboard/job_typed_non_pass_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/scoreboard/external_adapter_typed_non_pass_count").and_then(Value::as_u64), - Some(240) - ); - assert_eq!( - report.pointer("/scoreboard/typed_non_pass_count").and_then(Value::as_u64), - Some(240) - ); - assert_eq!( - string_array_at(&report, "/scoreboard/job_typed_non_pass_states_present")?, - Vec::::new() - ); - - for state in ["blocked", "incomplete", "not_encoded", "not_tested", "wrong_result"] { - assert!(array_contains_str(&report, "/scoreboard/typed_non_pass_states_present", state)?); - assert!(array_contains_str( - &report, - "/scoreboard/external_adapter_typed_non_pass_states_present", - state - )?); - } - - assert_eq!( - report.pointer("/scoreboard/unqualified_win_claim_allowed").and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report.pointer("/scoreboard/evidence_class_counts/live_baseline").and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report.pointer("/scoreboard/metric_basis").and_then(Value::as_str), - Some("produced_evidence_order") - ); - assert_eq!(report.pointer("/scoreboard/retrieval_k").and_then(Value::as_u64), Some(5)); - - assert_scoreboard_rows_expose_quantitative_and_blocker_contract(&report)?; - - let suites = array_at(&report, "/suites")?; - let adversarial = find_by_field(suites, "/suite_id", "adversarial_quality")?; - - assert_eq!(adversarial.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(adversarial.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - - Ok(()) -} - -fn assert_scoreboard_rows_expose_quantitative_and_blocker_contract(report: &Value) -> Result<()> { - let rows = array_at(report, "/scoreboard/rows")?; - let elf = find_by_field(rows, "/product_id", "elf_current_report")?; - let qmd = find_by_field(rows, "/product_id", "qmd")?; - let pageindex = find_by_field(rows, "/product_id", "vectifyai_pageindex")?; - let openkb = find_by_field(rows, "/product_id", "vectifyai_openkb")?; - let honcho = find_by_field(rows, "/product_id", "plastic_labs_honcho")?; - - assert_eq!(rows.len(), 20); - assert_eq!(elf.pointer("/product_name").and_then(Value::as_str), Some("ELF")); - assert_eq!(elf.pointer("/evidence_class").and_then(Value::as_str), Some("fixture_backed")); - assert_eq!(elf.pointer("/result_state").and_then(Value::as_str), Some("not_comparable")); - assert_eq!(elf.pointer("/comparable").and_then(Value::as_bool), Some(false)); - assert_eq!(elf.pointer("/same_corpus").and_then(Value::as_bool), Some(true)); - assert_eq!(elf.pointer("/source_id_mapped").and_then(Value::as_bool), Some(true)); - assert_eq!(elf.pointer("/held_out").and_then(Value::as_bool), Some(false)); - assert_eq!(elf.pointer("/leakage_audited").and_then(Value::as_bool), Some(false)); - assert_eq!(elf.pointer("/product_runtime").and_then(Value::as_bool), Some(false)); - assert_eq!(elf.pointer("/container_digest_identified").and_then(Value::as_bool), Some(false)); - assert_eq!( - elf.pointer("/metrics/retrieval/metric_basis").and_then(Value::as_str), - Some("produced_evidence_order") - ); - assert_eq!(elf.pointer("/metrics/retrieval/k").and_then(Value::as_u64), Some(5)); - assert!(elf.pointer("/metrics/retrieval/recall_at_k").and_then(Value::as_f64).is_some()); - assert!(elf.pointer("/metrics/retrieval/precision_at_k").and_then(Value::as_f64).is_some()); - assert!(elf.pointer("/metrics/retrieval/mrr").and_then(Value::as_f64).is_some()); - assert!(elf.pointer("/metrics/retrieval/ndcg").and_then(Value::as_f64).is_some()); - assert_eq!( - elf.pointer("/metrics/lifecycle/stale_suppression").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - elf.pointer("/metrics/coverage/source_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert!(array_contains_str( - elf, - "/next_evidence", - "Run a Docker-contained product-runtime adapter for this row." - )?); - assert!(array_contains_str(elf, "/next_evidence", "Record container image digest evidence.")?); - assert_eq!(qmd.pointer("/product_name").and_then(Value::as_str), Some("qmd")); - assert_eq!(qmd.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); - assert_eq!(qmd.pointer("/comparable").and_then(Value::as_bool), Some(false)); - assert_eq!(qmd.pointer("/product_runtime").and_then(Value::as_bool), Some(true)); - assert_eq!(qmd.pointer("/container_digest_identified").and_then(Value::as_bool), Some(false)); - assert!(qmd.pointer("/metrics/retrieval/recall_at_k").is_some_and(Value::is_null)); - assert!(array_contains_str(qmd, "/next_evidence", "Record container image digest evidence.")?); - - assert_tracked_external_blocker_row(pageindex, "VectifyAI PageIndex", true)?; - assert_tracked_external_blocker_row(openkb, "VectifyAI OpenKB", true)?; - assert_tracked_external_blocker_row(honcho, "plastic-labs Honcho", false)?; - - Ok(()) -} - -fn assert_tracked_external_blocker_row( - row: &Value, - product_name: &str, - same_corpus: bool, -) -> Result<()> { - assert_eq!(row.pointer("/product_name").and_then(Value::as_str), Some(product_name)); - assert_eq!(row.pointer("/result_state").and_then(Value::as_str), Some("blocked")); - assert_eq!(row.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); - assert_eq!(row.pointer("/comparable").and_then(Value::as_bool), Some(false)); - assert_eq!(row.pointer("/same_corpus").and_then(Value::as_bool), Some(same_corpus)); - assert_eq!(row.pointer("/source_id_mapped").and_then(Value::as_bool), Some(false)); - assert_eq!(row.pointer("/held_out").and_then(Value::as_bool), Some(false)); - assert_eq!(row.pointer("/leakage_audited").and_then(Value::as_bool), Some(false)); - assert_eq!(row.pointer("/product_runtime").and_then(Value::as_bool), Some(false)); - assert_eq!(row.pointer("/container_digest_identified").and_then(Value::as_bool), Some(false)); - assert!(row.pointer("/metrics/retrieval/recall_at_k").is_some_and(Value::is_null)); - assert!(row.pointer("/metrics/retrieval/precision_at_k").is_some_and(Value::is_null)); - assert!(row.pointer("/metrics/retrieval/mrr").is_some_and(Value::is_null)); - assert!(row.pointer("/metrics/retrieval/ndcg").is_some_and(Value::is_null)); - assert!(array_contains_str( - row, - "/next_evidence", - "Map returned evidence to stable source ids." - )?); - assert!(array_contains_str( - row, - "/next_evidence", - "Run a Docker-contained product-runtime adapter for this row." - )?); - assert!(array_contains_str(row, "/next_evidence", "Record container image digest evidence.")?); - - if same_corpus { - assert!(!array_contains_str( - row, - "/next_evidence", - "Map this product to the same corpus." - )?); - } else { - assert!(array_contains_str(row, "/next_evidence", "Map this product to the same corpus.")?); - } - - Ok(()) -} - -#[test] -fn adversarial_quality_fixture_catches_unsupported_and_stale_regressions() -> Result<()> { - let temp_dir = - env::temp_dir().join(format!("elf-adversarial-quality-regression-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - assert_stale_regression_is_wrong_result(&temp_dir)?; - assert_unsupported_regression_is_unsupported_claim(&temp_dir)?; - - Ok(()) -} - -fn assert_stale_regression_is_wrong_result(temp_dir: &Path) -> Result<()> { - let stale_fixture = adversarial_quality_fixture_dir().join("stale_fact_current_answer.json"); - let mut stale = load_json(&stale_fixture)?; - - set_json_pointer( - &mut stale, - "/corpus/adapter_response/answer/content", - Value::String( - "Run cargo make check before review handoff because that is the current gate." - .to_string(), - ), - )?; - set_json_pointer( - &mut stale, - "/corpus/adapter_response/answer/evidence_ids", - serde_json::json!(["stale-ops-runbook-v1"]), - )?; - set_json_pointer( - &mut stale, - "/corpus/adapter_response/answer/claims", - serde_json::json!([ - { - "claim_id": "current_gate_sequence", - "text": "Run cargo make check before review handoff.", - "evidence_ids": ["stale-ops-runbook-v1"], - "confidence": "high" - } - ]), - )?; - - fs::write(temp_dir.join("stale_regression.json"), serde_json::to_vec_pretty(&stale)?)?; - - let stale_report = run_json_report_from(temp_dir.to_path_buf())?; - let stale_jobs = array_at(&stale_report, "/jobs")?; - let stale_job = - find_by_field(stale_jobs, "/job_id", "adversarial-quality-stale-fact-current-answer-001")?; - - assert_eq!(stale_job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(stale_job.pointer("/stale_answer_count").and_then(Value::as_u64), Some(1)); - assert_eq!( - stale_report.pointer("/scoreboard/summary_claim").and_then(Value::as_str), - Some("typed_non_pass_present") - ); - assert_eq!( - stale_report.pointer("/scoreboard/job_summary_claim").and_then(Value::as_str), - Some("typed_non_pass_present") - ); - assert_eq!( - stale_report.pointer("/scoreboard/job_typed_non_pass_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - stale_report.pointer("/scoreboard/typed_non_pass_count").and_then(Value::as_u64), - Some(241) - ); - assert!(array_contains_str( - &stale_report, - "/scoreboard/typed_non_pass_states_present", - "wrong_result" - )?); - assert!(array_contains_str( - &stale_report, - "/scoreboard/job_typed_non_pass_states_present", - "wrong_result" - )?); - - fs::remove_file(temp_dir.join("stale_regression.json"))?; - - Ok(()) -} - -fn assert_unsupported_regression_is_unsupported_claim(temp_dir: &Path) -> Result<()> { - let unsupported_fixture = - adversarial_quality_fixture_dir().join("unsupported_claim_refusal.json"); - let mut unsupported = load_json(&unsupported_fixture)?; - - set_json_pointer( - &mut unsupported, - "/corpus/adapter_response/answer/content", - Value::String( - "The fixture proves private-corpus production quality and broad competitor superiority." - .to_string(), - ), - )?; - set_json_pointer( - &mut unsupported, - "/corpus/adapter_response/answer/evidence_ids", - serde_json::json!(["unsupported-production-quality-trap"]), - )?; - set_json_pointer( - &mut unsupported, - "/corpus/adapter_response/answer/claims", - serde_json::json!([ - { - "claim_id": "production_quality_proven", - "text": "The fixture proves private-corpus production quality and broad competitor superiority.", - "evidence_ids": ["unsupported-production-quality-trap"], - "confidence": "high" - } - ]), - )?; - - fs::write( - temp_dir.join("unsupported_regression.json"), - serde_json::to_vec_pretty(&unsupported)?, - )?; - - let unsupported_report = run_json_report_from(temp_dir.to_path_buf())?; - let unsupported_jobs = array_at(&unsupported_report, "/jobs")?; - let unsupported_job = find_by_field( - unsupported_jobs, - "/job_id", - "adversarial-quality-unsupported-claim-refusal-001", - )?; - - assert_eq!( - unsupported_job.pointer("/status").and_then(Value::as_str), - Some("unsupported_claim") - ); - assert_eq!( - unsupported_report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), - Some(1) - ); - assert!(array_contains_str( - &unsupported_report, - "/scoreboard/typed_non_pass_states_present", - "unsupported_claim" - )?); - assert!(array_contains_str( - &unsupported_report, - "/scoreboard/job_typed_non_pass_states_present", - "unsupported_claim" - )?); - - Ok(()) -} - -#[test] -fn adversarial_quality_repeated_fixture_run_is_deterministic() -> Result<()> { - let first = run_json_report_from(adversarial_quality_fixture_dir())?; - let second = run_json_report_from(adversarial_quality_fixture_dir())?; - - assert_eq!(first.pointer("/scoreboard"), second.pointer("/scoreboard")); - assert_eq!(first.pointer("/summary"), second.pointer("/summary")); - assert_eq!(first.pointer("/suites"), second.pointer("/suites")); - assert_eq!(first.pointer("/jobs"), second.pointer("/jobs")); - - Ok(()) -} - -#[test] -fn external_adapter_run_summarizes_nonzero_scenario_losses() -> Result<()> { - let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join("real_world_external_adapters") - .join("memory_projects_manifest.json"); - let mut manifest = serde_json::from_str::(&fs::read_to_string(manifest_path)?)?; - let adapters = manifest - .pointer_mut("/adapters") - .and_then(Value::as_array_mut) - .ok_or_else(|| eyre::eyre!("missing manifest adapters"))?; - let adapter = adapters - .iter_mut() - .find(|adapter| { - adapter.pointer("/adapter_id").and_then(Value::as_str) - == Some("agentmemory_live_baseline") - }) - .ok_or_else(|| eyre::eyre!("missing agentmemory adapter"))?; - - set_json_pointer(adapter, "/scenarios/0/elf_position", serde_json::json!("loses"))?; - set_json_pointer(adapter, "/scenarios/0/comparison_outcome", serde_json::json!("loss"))?; - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-loss-manifest-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let manifest_path = temp_dir.join("memory_projects_manifest.json"); - - fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("run") - .arg("--fixtures") - .arg(fixture_dir()) - .arg("--external-adapter-manifest") - .arg(&manifest_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job runner failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let report = serde_json::from_slice::(&output.stdout)?; - - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_position_counts/loses") - .and_then(Value::as_u64), - Some(2) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_position_counts/untested") - .and_then(Value::as_u64), - Some(52) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/loss") - .and_then(Value::as_u64), - Some(2) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/not_tested") - .and_then(Value::as_u64), - Some(18) - ); - - let adapters = array_at(&report, "/external_adapters/adapters")?; - let agentmemory = find_by_field(adapters, "/adapter_id", "agentmemory_live_baseline")?; - - assert_eq!( - agentmemory.pointer("/scenarios/0/elf_position").and_then(Value::as_str), - Some("loses") - ); - - Ok(()) -} - -fn assert_external_adapter_manifest_summary(report: &Value) { - assert_eq!( - report.pointer("/external_adapters/schema").and_then(Value::as_str), - Some("elf.real_world_external_adapter_report/v1") - ); - assert_eq!( - report.pointer("/external_adapters/manifest_id").and_then(Value::as_str), - Some( - "real-world-memory-project-adapters-2026-06-11-first-generation-continuity-source-store" - ) - ); - assert_eq!( - report.pointer("/external_adapters/docker_isolation/default").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report - .pointer("/external_adapters/docker_isolation/host_global_installs_required") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report.pointer("/external_adapters/summary/adapter_count").and_then(Value::as_u64), - Some(26) - ); - assert_eq!( - report.pointer("/external_adapters/summary/external_project_count").and_then(Value::as_u64), - Some(19) - ); - assert_eq!( - report.pointer("/external_adapters/summary/fixture_backed_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/live_baseline_only_count") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report.pointer("/external_adapters/summary/live_real_world_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/external_adapters/summary/research_gate_count").and_then(Value::as_u64), - Some(14) - ); - - assert_external_adapter_manifest_status_summary(report); - assert_external_adapter_manifest_scenario_summary(report); -} - -fn assert_external_adapter_manifest_status_summary(report: &Value) { - assert_eq!( - report - .pointer("/external_adapters/summary/overall_status_counts/pass") - .and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/overall_status_counts/wrong_result") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/overall_status_counts/lifecycle_fail") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/overall_status_counts/incomplete") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/overall_status_counts/blocked") - .and_then(Value::as_u64), - Some(10) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/overall_status_counts/not_encoded") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/capability_status_counts/mocked") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/capability_status_counts/unsupported") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/suite_status_counts/blocked") - .and_then(Value::as_u64), - Some(29) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/suite_status_counts/pass") - .and_then(Value::as_u64), - Some(27) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/suite_status_counts/incomplete") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/suite_status_counts/not_encoded") - .and_then(Value::as_u64), - Some(37) - ); -} - -fn assert_external_adapter_manifest_scenario_summary(report: &Value) { - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/real") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/mocked") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/unsupported") - .and_then(Value::as_u64), - Some(3) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/blocked") - .and_then(Value::as_u64), - Some(24) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/incomplete") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/wrong_result") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/lifecycle_fail") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/pass") - .and_then(Value::as_u64), - Some(23) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_status_counts/not_encoded") - .and_then(Value::as_u64), - Some(13) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_position_counts/wins") - .and_then(Value::as_u64), - Some(10) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_position_counts/ties") - .and_then(Value::as_u64), - Some(11) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_position_counts/loses") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_position_counts/untested") - .and_then(Value::as_u64), - Some(53) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/win") - .and_then(Value::as_u64), - Some(10) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/tie") - .and_then(Value::as_u64), - Some(11) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/loss") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/not_tested") - .and_then(Value::as_u64), - Some(19) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/blocked") - .and_then(Value::as_u64), - Some(29) - ); - assert_eq!( - report - .pointer("/external_adapters/summary/scenario_outcome_counts/non_goal") - .and_then(Value::as_u64), - Some(5) - ); -} - -fn assert_external_adapter_manifest_records(report: &Value) -> Result<()> { - let adapters = array_at(report, "/external_adapters/adapters")?; - let elf = find_by_field(adapters, "/adapter_id", "elf_real_world_memory_fixture")?; - let elf_live = find_by_field(adapters, "/adapter_id", "elf_live_real_world")?; - let elf_operator_debug = find_by_field(adapters, "/adapter_id", "elf_operator_debug_live")?; - let qmd = find_by_field(adapters, "/adapter_id", "qmd_live_baseline")?; - let qmd_live = find_by_field(adapters, "/adapter_id", "qmd_live_real_world")?; - let qmd_operator_debug = find_by_field(adapters, "/adapter_id", "qmd_operator_debug_live")?; - let agentmemory = find_by_field(adapters, "/adapter_id", "agentmemory_live_baseline")?; - let mem0 = find_by_field(adapters, "/adapter_id", "mem0_openmemory_live_baseline")?; - let memsearch = find_by_field(adapters, "/adapter_id", "memsearch_live_baseline")?; - let openviking = find_by_field(adapters, "/adapter_id", "openviking_live_baseline")?; - let claude_mem = find_by_field(adapters, "/adapter_id", "claude_mem_live_baseline")?; - let ragflow = find_by_field(adapters, "/adapter_id", "ragflow_research_gate")?; - let lightrag = find_by_field(adapters, "/adapter_id", "lightrag_research_gate")?; - let graphrag = find_by_field(adapters, "/adapter_id", "graphrag_research_gate")?; - let graphiti_zep = find_by_field(adapters, "/adapter_id", "graphiti_zep_research_gate")?; - let graphify = find_by_field(adapters, "/adapter_id", "graphify_docker_smoke")?; - let qmd_deep = find_by_field(adapters, "/adapter_id", "qmd_deep_profile_gate")?; - let openviking_deep = find_by_field(adapters, "/adapter_id", "openviking_deep_profile_gate")?; - let letta = find_by_field(adapters, "/adapter_id", "letta_research_gate")?; - - assert_elf_fixture_adapter_record(elf)?; - - assert_eq!( - elf_live.pointer("/evidence_class").and_then(Value::as_str), - Some("live_real_world") - ); - assert_eq!(elf_live.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); - - assert_live_sweep_record(elf_live, "blocked")?; - assert_operator_debug_live_adapter_records(elf_operator_debug, qmd_operator_debug)?; - - assert_eq!(qmd.pointer("/overall_status").and_then(Value::as_str), Some("pass")); - assert_eq!(qmd.pointer("/suites/0/status").and_then(Value::as_str), Some("not_encoded")); - - assert_qmd_live_baseline_record(qmd); - - assert_eq!( - qmd_live.pointer("/evidence_class").and_then(Value::as_str), - Some("live_real_world") - ); - assert_eq!(qmd_live.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); - - assert_live_sweep_record(qmd_live, "blocked")?; - - assert_eq!( - agentmemory.pointer("/capabilities/1/status").and_then(Value::as_str), - Some("mocked") - ); - - assert_first_generation_adapter_records(agentmemory, mem0, memsearch, claude_mem); - - assert_eq!(openviking.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); - - assert_graph_rag_research_gate_records(ragflow, lightrag, graphrag); - assert_graphiti_zep_adapter(graphiti_zep); - assert_graphify_adapter(graphify)?; - assert_graph_rag_representative_scenarios(ragflow, lightrag, graphrag, graphiti_zep, graphify)?; - assert_letta_core_archival_gate(letta)?; - assert_qmd_deep_profile_gate(qmd_deep); - - assert_eq!( - qmd_deep.pointer("/capabilities/2/status").and_then(Value::as_str), - Some("unsupported") - ); - assert_eq!( - qmd_deep.pointer("/result/artifact").and_then(Value::as_str), - Some("docs/evidence/benchmarking/2026-06-11-qmd-openviking-strength-profile-report.md") - ); - assert_eq!( - openviking_deep.pointer("/adapter_kind").and_then(Value::as_str), - Some("docker_local_embed_context_trajectory_gate") - ); - - assert_openviking_deep_profile_gate(openviking_deep); - - assert_eq!( - openviking_deep.pointer("/result/artifact").and_then(Value::as_str), - Some("docs/evidence/benchmarking/2026-06-11-qmd-openviking-strength-profile-report.md") - ); - - Ok(()) -} - -fn assert_graph_rag_research_gate_records(ragflow: &Value, lightrag: &Value, graphrag: &Value) { - assert_eq!(ragflow.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); - assert_eq!(ragflow.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - ragflow.pointer("/execution_metadata/research_depth").and_then(Value::as_str), - Some( - "D2 feasibility verdict plus XY-885 evidence-smoke implementation and XY-900 scored smoke promotion; checked-in record remains research_gate unless a generated artifact reaches query output" - ) - ); - assert_eq!( - ragflow.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make smoke-ragflow-docker") - ); - assert_eq!( - ragflow.pointer("/result/artifact").and_then(Value::as_str), - Some("tmp/real-world-memory/ragflow-smoke/ragflow-report.json") - ); - assert_eq!( - ragflow.pointer("/execution_metadata/sources/0/url").and_then(Value::as_str), - Some("https://github.com/infiniflow/ragflow") - ); - assert_eq!(lightrag.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); - assert_eq!(lightrag.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - lightrag.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make smoke-lightrag-docker-context") - ); - assert_eq!( - lightrag.pointer("/run/command").and_then(Value::as_str), - Some("ELF_LIGHTRAG_CONTEXT_START=1 cargo make smoke-lightrag-docker-context") - ); - assert_eq!( - lightrag.pointer("/capabilities/3/status").and_then(Value::as_str), - Some("not_encoded") - ); - assert_eq!(graphrag.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); - assert_eq!( - graphrag.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make smoke-graphrag-docker") - ); - assert_eq!(graphrag.pointer("/suites/1/status").and_then(Value::as_str), Some("not_encoded")); -} - -fn assert_letta_core_archival_gate(adapter: &Value) -> Result<()> { - assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); - assert!( - adapter - .pointer("/setup/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("smoke-letta-core-archive-export-readback") - && evidence.contains("Docker-only benchmark-created agent export/readback")) - ); - assert_eq!( - adapter.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make smoke-letta-core-archive-export-readback") - ); - assert_eq!( - adapter.pointer("/run/command").and_then(Value::as_str), - Some( - "ELF_LETTA_SMOKE_START=1 ELF_LETTA_SMOKE_RUN=1 cargo make smoke-letta-core-archive-export-readback" - ) - ); - assert!(adapter.pointer("/execution_metadata/setup_path").and_then(Value::as_str).is_some_and( - |setup| setup.contains("exports core block JSON plus archival search/readback JSON") - && setup.contains("typed artifact") - )); - - let suites = array_at(adapter, "/suites")?; - let core_suite = find_by_field(suites, "/suite_id", "core_archival_memory")?; - - assert_eq!(core_suite.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - adapter.pointer("/capabilities/2/capability").and_then(Value::as_str), - Some("real_world_job_adapter") - ); - assert_eq!(adapter.pointer("/capabilities/2/status").and_then(Value::as_str), Some("blocked")); - - let scenarios = array_at(adapter, "/scenarios")?; - let attachment = find_by_field(scenarios, "/scenario_id", "core_block_attachment_readback")?; - let scope = find_by_field(scenarios, "/scenario_id", "core_block_scope_readback")?; - let provenance = find_by_field(scenarios, "/scenario_id", "core_block_provenance_readback")?; - let stale = find_by_field(scenarios, "/scenario_id", "stale_core_detection")?; - let fallback = find_by_field(scenarios, "/scenario_id", "archival_fallback_readback")?; - let decision = - find_by_field(scenarios, "/scenario_id", "core_archival_project_decision_recovery")?; - - assert_eq!(scenarios.len(), 6); - - for scenario in [attachment, scope, provenance, stale, fallback, decision] { - assert_eq!(scenario.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(scenario.pointer("/elf_position").and_then(Value::as_str), Some("untested")); - assert_eq!( - scenario.pointer("/comparison_outcome").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!( - scenario.pointer("/command").and_then(Value::as_str), - Some("cargo make smoke-letta-core-archive-export-readback") - ); - assert_eq!( - scenario.pointer("/artifact").and_then(Value::as_str), - Some("tmp/real-world-memory/letta-core-archive/summary.json") - ); - } - - assert_eq!(attachment.pointer("/comparison_outcome").and_then(Value::as_str), Some("blocked")); - assert_eq!(stale.pointer("/comparison_outcome").and_then(Value::as_str), Some("blocked")); - assert_eq!(fallback.pointer("/comparison_outcome").and_then(Value::as_str), Some("blocked")); - - Ok(()) -} - -fn assert_elf_fixture_adapter_record(adapter: &Value) -> Result<()> { - assert_eq!(adapter.pointer("/evidence_class").and_then(Value::as_str), Some("fixture_backed")); - assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); - assert!(adapter.pointer("/run/evidence").and_then(Value::as_str).is_some_and(|evidence| { - evidence.contains("82 jobs across 19 suites") - && evidence.contains("75 pass") - && evidence.contains("7 blocked") - && evidence.contains("core_archival_memory") - && evidence.contains("memory_summary") - && evidence.contains("proactive_brief") - && evidence.contains("scheduled_memory") - && evidence.contains("context_trajectory") - })); - - let suites = array_at(adapter, "/suites")?; - let core_archival = find_by_field(suites, "/suite_id", "core_archival_memory")?; - let scheduled = find_by_field(suites, "/suite_id", "scheduled_memory")?; - let context_trajectory = find_by_field(suites, "/suite_id", "context_trajectory")?; - - assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("pass")); - assert!(core_archival.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { - evidence.contains("core block attachment") - && evidence.contains("project-decision recovery") - && evidence.contains("archival note search") - })); - assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert!(scheduled.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { - evidence.contains("4 passing source-linked task readbacks") - && evidence.contains("private/provider scheduler blocker") - })); - assert_eq!(context_trajectory.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert!( - adapter - .pointer("/notes/1") - .and_then(Value::as_str) - .is_some_and(|note| note.contains("OpenViking context-trajectory measurement gates")) - ); - - Ok(()) -} - -fn assert_qmd_deep_profile_gate(adapter: &Value) { - assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(adapter.pointer("/run/status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(adapter.pointer("/result/status").and_then(Value::as_str), Some("not_encoded")); -} - -fn assert_qmd_live_baseline_record(adapter: &Value) { - let result_evidence = adapter.pointer("/result/evidence").and_then(Value::as_str); - let retrieval_evidence = adapter.pointer("/suites/0/evidence").and_then(Value::as_str); - - assert!(result_evidence.is_some_and(|evidence| { - evidence.contains("This live_baseline_only record is same-corpus evidence only") - && evidence.contains("cite qmd_live_real_world for the full live real-world sweep") - && !evidence.contains("no real_world_job qmd adapter is encoded yet") - })); - assert!(retrieval_evidence.is_some_and(|evidence| { - evidence.contains("does not execute real_world_job retrieval prompts") - && evidence.contains("cite qmd_live_real_world for the live retrieval adapter run") - && !evidence.contains("no real_world_job retrieval adapter run is encoded") - })); -} - -fn assert_operator_debug_live_adapter_records(elf: &Value, qmd: &Value) -> Result<()> { - assert_eq!(elf.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); - assert_eq!(elf.pointer("/overall_status").and_then(Value::as_str), Some("pass")); - assert_eq!( - elf.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make real-world-job-operator-ux-live-adapters") - ); - assert_eq!( - elf.pointer("/suites/0/suite_id").and_then(Value::as_str), - Some("operator_debugging_ux") - ); - assert_eq!(elf.pointer("/suites/0/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - elf.pointer("/capabilities/1/capability").and_then(Value::as_str), - Some("trace_hydration_metadata") - ); - assert_eq!(elf.pointer("/capabilities/1/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - elf.pointer("/capabilities/2/capability").and_then(Value::as_str), - Some("replay_command_metadata") - ); - assert_eq!(elf.pointer("/capabilities/2/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - elf.pointer("/capabilities/3/capability").and_then(Value::as_str), - Some("candidate_drop_visibility") - ); - assert_eq!(elf.pointer("/capabilities/3/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - elf.pointer("/capabilities/4/capability").and_then(Value::as_str), - Some("openmemory_or_claude_mem_ui_runner") - ); - assert_eq!(elf.pointer("/capabilities/4/status").and_then(Value::as_str), Some("not_encoded")); - - let elf_scenarios = array_at(elf, "/scenarios")?; - let elf_trace = find_by_field(elf_scenarios, "/scenario_id", "operator_debug_trace_hydration")?; - let elf_replay = find_by_field(elf_scenarios, "/scenario_id", "operator_debug_replay_command")?; - let elf_candidate = - find_by_field(elf_scenarios, "/scenario_id", "operator_debug_candidate_drop_visibility")?; - let elf_repair = - find_by_field(elf_scenarios, "/scenario_id", "operator_debug_repair_action_clarity")?; - let elf_selected = - find_by_field(elf_scenarios, "/scenario_id", "operator_debug_selected_but_not_narrated")?; - - assert_eq!(elf_scenarios.len(), 5); - assert_eq!(elf_trace.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(elf_trace.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); - assert_eq!(elf_replay.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(elf_candidate.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); - assert_eq!(elf_repair.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(elf_selected.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); - assert_eq!(qmd.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); - assert_eq!(qmd.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - qmd.pointer("/suites/0/suite_id").and_then(Value::as_str), - Some("operator_debugging_ux") - ); - assert_eq!(qmd.pointer("/suites/0/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - qmd.pointer("/capabilities/1/capability").and_then(Value::as_str), - Some("local_replay_command_metadata") - ); - assert_eq!(qmd.pointer("/capabilities/1/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - qmd.pointer("/capabilities/2/capability").and_then(Value::as_str), - Some("trace_hydration_metadata") - ); - assert_eq!(qmd.pointer("/capabilities/2/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - qmd.pointer("/capabilities/3/capability").and_then(Value::as_str), - Some("candidate_drop_visibility") - ); - assert_eq!(qmd.pointer("/capabilities/3/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(qmd.pointer("/capabilities/4/status").and_then(Value::as_str), Some("not_encoded")); - - let qmd_scenarios = array_at(qmd, "/scenarios")?; - let qmd_trace = find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_trace_hydration")?; - let qmd_replay = find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_replay_command")?; - let qmd_candidate = - find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_candidate_drop_visibility")?; - let qmd_repair = - find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_repair_action_clarity")?; - let qmd_selected = - find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_selected_but_not_narrated")?; - - assert_eq!(qmd_scenarios.len(), 5); - assert_eq!(qmd_trace.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(qmd_trace.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); - assert_eq!(qmd_replay.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(qmd_replay.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(qmd_candidate.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(qmd_candidate.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); - assert_eq!(qmd_repair.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(qmd_repair.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(qmd_selected.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(qmd_selected.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); - assert!(array_at(elf, "/notes")?.iter().any(|note| { - note.as_str().is_some_and(|text| text.contains("narrow operator-debug live slice")) - })); - assert!(array_at(qmd, "/notes")?.iter().any(|note| { - note.as_str().is_some_and(|text| text.contains("narrow operator-debug live slice")) - })); - - Ok(()) -} - -fn assert_openviking_deep_profile_gate(adapter: &Value) { - let trajectory_evidence = adapter.pointer("/capabilities/1/evidence").and_then(Value::as_str); - - assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); - assert!(trajectory_evidence.is_some_and(|evidence| { - evidence.contains("evidence-bearing same-corpus output") - && evidence.contains("selected hierarchy/expansion artifacts") - && !evidence.contains("setup reaches runnable OpenViking APIs") - })); -} - -fn assert_first_generation_adapter_records( - agentmemory: &Value, - mem0: &Value, - memsearch: &Value, - claude_mem: &Value, -) { - assert_agentmemory_first_generation_records(agentmemory); - assert_mem0_first_generation_records(mem0); - assert_memsearch_first_generation_records(memsearch); - assert_claude_mem_first_generation_records(claude_mem); -} - -fn assert_agentmemory_first_generation_records(agentmemory: &Value) { - assert_eq!( - agentmemory.pointer("/scenarios/1/status").and_then(Value::as_str), - Some("lifecycle_fail") - ); - assert_eq!( - agentmemory.pointer("/scenarios/1/elf_position").and_then(Value::as_str), - Some("wins") - ); - assert_eq!(agentmemory.pointer("/scenarios/2/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - agentmemory.pointer("/scenarios/2/comparison_outcome").and_then(Value::as_str), - Some("blocked") - ); -} - -fn assert_mem0_first_generation_records(mem0: &Value) { - assert_eq!( - mem0.pointer("/capabilities/2/capability").and_then(Value::as_str), - Some("local_lifecycle_update_delete_reload") - ); - assert_eq!(mem0.pointer("/capabilities/2/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - mem0.pointer("/capabilities/3/capability").and_then(Value::as_str), - Some("preference_correction_history") - ); - assert_eq!(mem0.pointer("/capabilities/3/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - mem0.pointer("/capabilities/7/capability").and_then(Value::as_str), - Some("openmemory_ui_readback") - ); - assert_eq!(mem0.pointer("/capabilities/7/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - mem0.pointer("/capabilities/8/capability").and_then(Value::as_str), - Some("hosted_managed_memory_claims") - ); - assert_eq!(mem0.pointer("/capabilities/8/status").and_then(Value::as_str), Some("unsupported")); - assert_eq!(mem0.pointer("/scenarios/0/status").and_then(Value::as_str), Some("pass")); - assert_eq!(mem0.pointer("/scenarios/0/elf_position").and_then(Value::as_str), Some("ties")); - assert_eq!( - mem0.pointer("/scenarios/1/scenario_id").and_then(Value::as_str), - Some("preference_correction_history") - ); - assert_eq!(mem0.pointer("/scenarios/1/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - mem0.pointer("/scenarios/1/comparison_outcome").and_then(Value::as_str), - Some("loss") - ); - assert_eq!( - mem0.pointer("/scenarios/5/scenario_id").and_then(Value::as_str), - Some("openmemory_ui_export_readback") - ); - assert_eq!(mem0.pointer("/scenarios/5/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - mem0.pointer("/scenarios/5/command").and_then(Value::as_str), - Some("cargo make openmemory-ui-export-readback") - ); - assert_eq!( - mem0.pointer("/scenarios/5/artifact").and_then(Value::as_str), - Some("tmp/live-baseline/mem0-openmemory-ui-export.json") - ); - assert!( - mem0.pointer("/capabilities/7/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("export-helper setup probe") - && evidence.contains("requires Docker access")) - ); - assert_eq!( - mem0.pointer("/scenarios/6/comparison_outcome").and_then(Value::as_str), - Some("non_goal") - ); -} - -fn assert_memsearch_first_generation_records(memsearch: &Value) { - assert_eq!( - memsearch.pointer("/capabilities/2/capability").and_then(Value::as_str), - Some("reindex_update_delete_reload") - ); - assert_eq!(memsearch.pointer("/capabilities/2/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - memsearch.pointer("/scenarios/0/scenario_id").and_then(Value::as_str), - Some("canonical_markdown_reindex_reload") - ); - assert_eq!( - memsearch.pointer("/scenarios/0/elf_position").and_then(Value::as_str), - Some("untested") - ); - assert_eq!(memsearch.pointer("/suites/0/status").and_then(Value::as_str), Some("not_encoded")); - assert!(memsearch.pointer("/suites/0/evidence").and_then(Value::as_str).is_some_and( - |evidence| evidence.contains("fixture-backed source-of-truth prompt coverage") - && evidence.contains("No live memsearch runtime adapter executes prompt scoring yet") - && evidence.contains("not a suite pass") - )); - assert_eq!(memsearch.pointer("/suites/1/status").and_then(Value::as_str), Some("not_encoded")); - assert!(memsearch.pointer("/suites/1/evidence").and_then(Value::as_str).is_some_and( - |evidence| evidence.contains("fixture-backed retrieval-debug prompt coverage") - && evidence.contains( - "No live memsearch runtime adapter executes retrieval prompt scoring yet" - ) && evidence.contains("not a suite pass") - )); - assert_eq!(memsearch.pointer("/scenarios/1/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - memsearch.pointer("/scenarios/1/elf_position").and_then(Value::as_str), - Some("untested") - ); - assert_eq!( - memsearch.pointer("/scenarios/3/status").and_then(Value::as_str), - Some("unsupported") - ); - assert_eq!( - memsearch.pointer("/capabilities/4/capability").and_then(Value::as_str), - Some("markdown_source_store_prompt_jobs") - ); - assert_eq!(memsearch.pointer("/capabilities/4/status").and_then(Value::as_str), Some("pass")); -} - -fn assert_claude_mem_first_generation_records(claude_mem: &Value) { - assert_eq!(claude_mem.pointer("/capabilities/1/status").and_then(Value::as_str), Some("real")); - assert_eq!( - claude_mem.pointer("/capabilities/3/capability").and_then(Value::as_str), - Some("repository_progressive_disclosure") - ); - assert_eq!(claude_mem.pointer("/capabilities/4/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - claude_mem.pointer("/capabilities/6/status").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!(claude_mem.pointer("/suites/0/status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(claude_mem.pointer("/suites/1/status").and_then(Value::as_str), Some("blocked")); - assert!( - claude_mem - .pointer("/suites/1/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("fixture-backed progressive-disclosure") - && evidence.contains("viewer/operator workflow remains blocked")) - ); - assert_eq!(claude_mem.pointer("/suites/2/status").and_then(Value::as_str), Some("blocked")); - assert!( - claude_mem - .pointer("/suites/2/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("hook capture remains blocked")) - ); - assert_eq!( - claude_mem.pointer("/scenarios/0/status").and_then(Value::as_str), - Some("wrong_result") - ); - assert_eq!( - claude_mem.pointer("/scenarios/1/scenario_id").and_then(Value::as_str), - Some("retrieval_repair_artifact_path") - ); - assert_eq!( - claude_mem.pointer("/scenarios/1/status").and_then(Value::as_str), - Some("wrong_result") - ); - assert!( - claude_mem - .pointer("/scenarios/1/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("rerun/inspection targets") - && evidence.contains("tmp/live-baseline/claude-mem-checks.json")) - ); - assert_eq!(claude_mem.pointer("/scenarios/2/status").and_then(Value::as_str), Some("pass")); - assert_eq!(claude_mem.pointer("/scenarios/4/status").and_then(Value::as_str), Some("pass")); - assert_eq!(claude_mem.pointer("/scenarios/5/status").and_then(Value::as_str), Some("blocked")); -} - -fn assert_graphiti_zep_adapter(adapter: &Value) { - assert_eq!(adapter.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); - assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - adapter.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make smoke-graphiti-zep-docker-temporal") - ); - assert_eq!( - adapter.pointer("/run/command").and_then(Value::as_str), - Some( - "ELF_GRAPHITI_ZEP_SMOKE_START=1 ELF_GRAPHITI_ZEP_SMOKE_RUN=1 cargo make smoke-graphiti-zep-docker-temporal" - ) - ); - assert_eq!( - adapter.pointer("/suites/0/suite_id").and_then(Value::as_str), - Some("memory_evolution") - ); - assert_eq!(adapter.pointer("/suites/0/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - adapter.pointer("/execution_metadata/research_depth").and_then(Value::as_str), - Some( - "D2 feasibility plus XY-888 Docker temporal smoke implementation and XY-900 scored smoke promotion; checked-in record remains research_gate unless a generated artifact reaches Graphiti search output" - ) - ); -} - -fn assert_graphify_adapter(adapter: &Value) -> Result<()> { - assert_eq!(adapter.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); - assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(adapter.pointer("/setup/status").and_then(Value::as_str), Some("pass")); - assert_eq!(adapter.pointer("/run/status").and_then(Value::as_str), Some("pass")); - assert_eq!(adapter.pointer("/result/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - adapter.pointer("/setup/command").and_then(Value::as_str), - Some("cargo make smoke-graphify-docker-graph-report") - ); - assert_eq!( - adapter.pointer("/suites/0/suite_id").and_then(Value::as_str), - Some("knowledge_compilation") - ); - assert_eq!(adapter.pointer("/suites/0/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(adapter.pointer("/suites/1/suite_id").and_then(Value::as_str), Some("retrieval")); - assert_eq!(adapter.pointer("/suites/1/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - adapter.pointer("/execution_metadata/research_depth").and_then(Value::as_str), - Some( - "D1 feasibility verdict plus XY-889 Docker graph/report smoke implementation and XY-900 scored smoke promotion; current Docker validation reaches graphify output and scores the tiny knowledge_compilation job as wrong_result" - ) - ); - - let capabilities = array_at(adapter, "/capabilities")?; - let quality = find_by_field(capabilities, "/capability", "quality_or_scale_claim")?; - - assert_eq!(quality.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - assert!(array_at(adapter, "/notes")?.iter().any(|note| { - note.as_str().is_some_and(|text| text.contains("tiny smoke") && text.contains("non-pass")) - })); - - Ok(()) -} - -fn assert_graph_rag_representative_scenarios( - ragflow: &Value, - lightrag: &Value, - graphrag: &Value, - graphiti_zep: &Value, - graphify: &Value, -) -> Result<()> { - let ragflow_scenarios = array_at(ragflow, "/scenarios")?; - let lightrag_scenarios = array_at(lightrag, "/scenarios")?; - let graphrag_scenarios = array_at(graphrag, "/scenarios")?; - let graphiti_scenarios = array_at(graphiti_zep, "/scenarios")?; - let graphify_scenarios = array_at(graphify, "/scenarios")?; - let ragflow_chunk = - find_by_field(ragflow_scenarios, "/scenario_id", "reference_chunk_citation_mapping")?; - let lightrag_context = - find_by_field(lightrag_scenarios, "/scenario_id", "context_source_reference_mapping")?; - let graphrag_tables = - find_by_field(graphrag_scenarios, "/scenario_id", "output_table_citation_mapping")?; - let graphiti_temporal = - find_by_field(graphiti_scenarios, "/scenario_id", "temporal_validity_window_mapping")?; - let graphify_lint = - find_by_field(graphify_scenarios, "/scenario_id", "graph_report_navigation_lint")?; - - assert_eq!( - ragflow_chunk.pointer("/comparison_outcome").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!(lightrag_context.pointer("/status").and_then(Value::as_str), Some("incomplete")); - assert_eq!( - lightrag_context.pointer("/comparison_outcome").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!( - graphrag_tables.pointer("/artifact").and_then(Value::as_str), - Some( - "apps/elf-eval/fixtures/real_world_external_adapters/graph_rag/graphrag_output_tables_blocked.json" - ) - ); - assert_eq!( - graphiti_temporal.pointer("/comparison_outcome").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!(graphify_lint.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - graphify_lint.pointer("/comparison_outcome").and_then(Value::as_str), - Some("not_tested") - ); - assert!( - graphify_lint - .pointer("/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("not an ELF victory claim")) - ); - - assert_adapter_matrix_rows( - ragflow_scenarios, - &[ - ("reference_chunk_citation_mapping", "blocked", "blocked"), - ("retrieval_quality_reference_recall", "blocked", "blocked"), - ("navigation_quality_document_chunks", "blocked", "blocked"), - ("answer_faithfulness_reference_chunks", "blocked", "blocked"), - ("stale_source_behavior", "not_encoded", "not_tested"), - ("knowledge_compilation_quality", "not_encoded", "not_tested"), - ], - )?; - assert_adapter_matrix_rows( - lightrag_scenarios, - &[ - ("context_source_reference_mapping", "incomplete", "blocked"), - ("retrieval_quality_context_recall", "incomplete", "blocked"), - ("citation_quality_context_references", "incomplete", "blocked"), - ("navigation_quality_graph_context", "incomplete", "blocked"), - ("answer_faithfulness_context_refs", "incomplete", "blocked"), - ("stale_source_behavior", "not_encoded", "not_tested"), - ("knowledge_compilation_quality", "not_encoded", "not_tested"), - ], - )?; - assert_adapter_matrix_rows( - graphrag_scenarios, - &[ - ("output_table_citation_mapping", "blocked", "blocked"), - ("retrieval_quality_local_search", "not_encoded", "not_tested"), - ("navigation_quality_community_graph", "blocked", "blocked"), - ("answer_faithfulness_output_tables", "blocked", "blocked"), - ("stale_source_behavior", "not_encoded", "not_tested"), - ("graph_summary_synthesis_quality", "not_encoded", "not_tested"), - ], - )?; - - Ok(()) -} - -fn assert_adapter_matrix_rows(scenarios: &[Value], expected: &[(&str, &str, &str)]) -> Result<()> { - for (scenario_id, status, outcome) in expected { - let row = find_by_field(scenarios, "/scenario_id", scenario_id)?; - - assert_eq!(row.pointer("/status").and_then(Value::as_str), Some(*status)); - assert_eq!(row.pointer("/comparison_outcome").and_then(Value::as_str), Some(*outcome)); - assert!( - row.pointer("/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| !evidence.trim().is_empty()) - ); - } - - Ok(()) -} - -#[test] -fn graphify_generated_manifest_keeps_retrieval_unscored() -> Result<()> { - let manifest = serde_json::json!({ - "schema": "elf.real_world_external_adapter_manifest/v1", - "manifest_id": "graphify-generated-manifest-test", - "docker_isolation": { - "default": true, - "compose_file": "docker-compose.baseline.yml", - "runner": "scripts/graphify-docker-graph-report-smoke.py", - "artifact_dir": "tmp/real-world-memory/graphify-smoke", - "host_global_installs_required": false, - "notes": ["Synthetic graphify generated-manifest regression test."] - }, - "adapters": [{ - "adapter_id": "graphify_docker_smoke", - "project": "graphify", - "adapter_kind": "docker_cli_graph_report_smoke", - "evidence_class": "live_real_world", - "docker_default": true, - "host_global_installs_required": false, - "overall_status": "wrong_result", - "setup": { - "status": "pass", - "evidence": "setup evidence", - "command": "cargo make smoke-graphify-docker-graph-report", - "artifact": "tmp/real-world-memory/graphify-smoke/graphify-smoke.json" - }, - "run": { - "status": "pass", - "evidence": "run evidence", - "command": "cargo make smoke-graphify-docker-graph-report", - "artifact": "tmp/real-world-memory/graphify-smoke/summary.json" - }, - "result": { - "status": "wrong_result", - "evidence": "result evidence", - "artifact": "tmp/real-world-memory/graphify-smoke/graphify-report.json" - }, - "capabilities": [{ - "capability": "quality_or_scale_claim", - "status": "not_encoded", - "evidence": "No broad graph quality claim." - }], - "suites": [ - { - "suite_id": "knowledge_compilation", - "status": "wrong_result", - "evidence": "Only the generated graph/report evidence-mapping job is represented." - }, - { - "suite_id": "retrieval", - "status": "blocked", - "evidence": "The smoke uses graphify query output only to support source mapping; broad retrieval quality is not scored." - } - ], - "evidence": [], - "execution_metadata": { - "setup_path": "cargo make smoke-graphify-docker-graph-report", - "runtime_boundary": "Docker-only generated graph/report smoke.", - "resource_expectation": "Tiny generated corpus only.", - "retry_guidance": [], - "sources": [{ - "label": "graphify", - "url": "https://github.com/safishamsi/graphify", - "evidence": "Synthetic generated-manifest regression source." - }], - "research_depth": "Generated smoke manifest path" - }, - "notes": ["tiny smoke non-pass"] - }] - }); - let temp_dir = - env::temp_dir().join(format!("elf-real-world-graphify-manifest-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let manifest_path = temp_dir.join("manifest.json"); - let report_path = temp_dir.join("report.json"); - - fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("run") - .arg("--fixtures") - .arg(fixture_dir()) - .arg("--out") - .arg(&report_path) - .arg("--external-adapter-manifest") - .arg(&manifest_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job runner failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let report: Value = serde_json::from_slice(&fs::read(&report_path)?)?; - let adapters = array_at(&report, "/external_adapters/adapters")?; - let graphify = find_by_field(adapters, "/adapter_id", "graphify_docker_smoke")?; - let suites = array_at(graphify, "/suites")?; - let retrieval = find_by_field(suites, "/suite_id", "retrieval")?; - - assert_eq!(retrieval.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert!( - retrieval - .pointer("/evidence") - .and_then(Value::as_str) - .is_some_and(|text| { text.contains("broad retrieval quality is not scored") }) - ); - - Ok(()) -} - -#[test] -fn graph_rag_representative_fixtures_report_typed_non_pass_states() -> Result<()> { - let report = run_json_report_from(graph_rag_external_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); - assert_eq!( - report.pointer("/summary/knowledge/citation_coverage").and_then(Value::as_f64), - Some(0.667) - ); - assert_eq!( - report.pointer("/summary/knowledge/stale_claim_detection").and_then(Value::as_f64), - Some(0.0) - ); - assert_eq!( - report.pointer("/summary/knowledge/unsupported_summary_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/temporal_validity_not_encoded_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), - Some(1) - ); - - let jobs = array_at(&report, "/jobs")?; - let ragflow = find_by_field(jobs, "/job_id", "graph-rag-ragflow-reference-chunks-001")?; - let lightrag = find_by_field(jobs, "/job_id", "graph-rag-lightrag-context-sources-001")?; - let graphrag = find_by_field(jobs, "/job_id", "graph-rag-graphrag-output-tables-001")?; - let graphiti = find_by_field(jobs, "/job_id", "graph-rag-graphiti-temporal-validity-001")?; - let graphify = find_by_field(jobs, "/job_id", "graph-rag-graphify-graph-report-001")?; - - assert_eq!(ragflow.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(lightrag.pointer("/status").and_then(Value::as_str), Some("incomplete")); - assert_eq!(graphrag.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(graphiti.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(graphify.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - graphify.pointer("/knowledge/stale_claim_detection").and_then(Value::as_f64), - Some(0.0) - ); - assert_eq!( - graphify.pointer("/knowledge/unsupported_summary_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - graphiti.pointer("/evolution/temporal_validity_not_encoded").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - graphiti.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("graphiti.provider_boundary") - ); - assert!(array_contains_str(graphiti, "/produced_evidence", "graphiti-current-fact-contract")?); - assert!(array_contains_str( - graphiti, - "/produced_evidence", - "graphiti-historical-fact-contract" - )?); - assert!(array_contains_str(graphiti, "/produced_evidence", "graphiti-provider-boundary")?); - assert!(array_contains_str(graphify, "/produced_evidence", "graphify-source-location-output")?); - - Ok(()) -} - -#[test] -fn live_adapter_aggregate_forwards_graph_rag_smoke_controls() -> Result<()> { - let workspace = workspace_root()?; - let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; - let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; - - assert!( - makefile.contains("[tasks.real-world-memory-live-adapters]") - && makefile.contains("scripts/real-world-docker.sh") - && makefile.contains("memory-live-adapters"), - "Makefile should expose the live-adapter command and delegate Docker details to a script", - ); - - for env_name in [ - "ELF_REAL_WORLD_LIVE_ENABLE_RAGFLOW", - "ELF_REAL_WORLD_LIVE_ENABLE_LIGHTRAG", - "ELF_REAL_WORLD_LIVE_ENABLE_GRAPHRAG", - "ELF_REAL_WORLD_LIVE_ENABLE_GRAPHITI_ZEP", - "ELF_REAL_WORLD_LIVE_ENABLE_GRAPHIFY", - "ELF_RAGFLOW_SMOKE_START", - "ELF_RAGFLOW_SMOKE_ACCEPT_RESOURCE_ENVELOPE", - "ELF_GRAPHRAG_SMOKE_RUN", - "ELF_GRAPHRAG_API_KEY", - "ELF_GRAPHITI_ZEP_SMOKE_START", - "ELF_GRAPHITI_ZEP_SMOKE_RUN", - "ELF_GRAPHITI_ZEP_API_KEY", - "ELF_GRAPHIFY_SMOKE_RUN", - ] { - assert!( - docker_script.contains(&format!("-e {env_name}")), - "real-world-memory-live-adapters must forward {env_name}", - ); - } - - assert!( - docker_script.contains("--profile lightrag up -d lightrag"), - "aggregate task should start LightRAG profile when ELF_LIGHTRAG_CONTEXT_START=1", - ); - assert!( - docker_script.contains("--profile graphiti-zep up -d graphiti-falkordb"), - "aggregate task should start Graphiti/Zep profile when ELF_GRAPHITI_ZEP_SMOKE_START=1", - ); - - Ok(()) -} - -#[test] -fn openmemory_ui_export_probe_has_dedicated_docker_task() -> Result<()> { - let workspace_root = workspace_root()?; - let makefile = fs::read_to_string(workspace_root.join("Makefile.toml"))?; - let docker_script = fs::read_to_string(workspace_root.join("scripts/baseline-docker.sh"))?; - let compose = fs::read_to_string(workspace_root.join("docker-compose.baseline.yml"))?; - let script = fs::read_to_string(workspace_root.join("scripts/live-baseline-benchmark.sh"))?; - let report = serde_json::from_str::(&fs::read_to_string(workspace_root.join( - "apps/elf-eval/fixtures/report_snapshots/2026-06-11-xy-931-openmemory-ui-export-readback.json", - ))?)?; - - assert!(makefile.contains("[tasks.openmemory-ui-export-readback]")); - assert!(makefile.contains("scripts/baseline-docker.sh")); - assert!(makefile.contains("openmemory-ui-export-readback")); - assert!(docker_script.contains("export ELF_BASELINE_PROJECTS=mem0")); - assert!(compose.contains("ELF_MEM0_OPENMEMORY_EXPORT_USER_ID")); - assert!(compose.contains("ELF_MEM0_OPENMEMORY_EXPORT_CONTAINER")); - assert!(script.contains("probe_mem0_openmemory_ui_export")); - assert!(script.contains("mem0-openmemory-ui-export.json")); - assert!(script.contains("DOCKER_UNAVAILABLE_IN_BASELINE_RUNNER")); - assert!(script.contains("sdk_get_all_is_ui_export_evidence: false")); - assert!( - script.contains("SDK same-corpus retrieval and every encoded SDK behavior check passed") - ); - assert_eq!(report.pointer("/classification/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - report.pointer("/classification/reason_code").and_then(Value::as_str), - Some("DOCKER_UNAVAILABLE_IN_BASELINE_RUNNER") - ); - assert_eq!( - report - .pointer("/same_corpus_boundary/sdk_get_all_is_ui_export_evidence") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report - .pointer("/claim_boundary/elf_can_compare_against_openmemory_ui_export_after_this_run") - .and_then(Value::as_bool), - Some(false) - ); - - Ok(()) -} - -#[test] -fn operator_debug_live_adapter_task_is_docker_scoped() -> Result<()> { - let workspace = workspace_root()?; - let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; - let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; - let script = fs::read_to_string( - workspace.join("scripts").join("real-world-operator-debug-live-adapters.sh"), - )?; - let live_adapter = - fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_live_adapter.rs"))?; - let benchmark = - fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_job_benchmark.rs"))?; - - assert!(makefile.contains("[tasks.real-world-job-operator-ux-live-adapters]")); - assert!(makefile.contains("scripts/real-world-docker.sh")); - assert!(makefile.contains("job-operator-ux-live-adapters")); - assert!( - docker_script.contains("docker compose -f docker-compose.baseline.yml run --build --rm") - ); - assert!(docker_script.contains("scripts/real-world-operator-debug-live-adapters.sh")); - assert!(script.contains("apps/elf-eval/fixtures/real_world_job/operator_debugging_ux")); - assert!(script.contains("elf_operator_debug_live")); - assert!(script.contains("qmd_operator_debug_live")); - assert!(script.contains("elf.real_world_operator_debug_live_adapter_sweep/v1")); - assert!(script.contains("trace_available")); - assert!(script.contains("replay_command_available")); - assert!(live_adapter.contains("fn operator_debug_output(")); - assert!(live_adapter.contains("fn qmd_replay_command(")); - assert!(live_adapter.contains("fn elf_replay_command(")); - assert!( - !live_adapter - .contains("does not yet hydrate full operator trace/viewer diagnostics for this suite") - ); - assert!(benchmark.contains("Replay command:")); - assert!(benchmark.contains("replay_command_available")); - - Ok(()) -} - -#[test] -fn external_adapter_manifest_rejects_unmeasured_win_loss_scenario_outcomes() -> Result<()> { - let output = run_external_manifest_with_letta_attachment_mutation( - "invalid-scenario-outcome-test", - |scenario| { - set_json_pointer(scenario, "/status", serde_json::json!("not_encoded"))?; - - set_json_pointer(scenario, "/comparison_outcome", serde_json::json!("win")) - }, - )?; - - assert!(!output.status.success(), "invalid scenario outcome unexpectedly passed"); - assert!( - String::from_utf8_lossy(&output.stderr).contains("not_encoded status with win outcome") - ); - - Ok(()) -} - -#[test] -fn external_adapter_manifest_rejects_unmeasured_win_loss_scenario_positions() -> Result<()> { - let output = run_external_manifest_with_letta_attachment_mutation( - "invalid-scenario-position-test", - |scenario| { - set_json_pointer(scenario, "/status", serde_json::json!("not_encoded"))?; - set_json_pointer(scenario, "/elf_position", serde_json::json!("wins"))?; - - set_json_pointer(scenario, "/comparison_outcome", serde_json::json!("not_tested")) - }, - )?; - - assert!(!output.status.success(), "invalid scenario position unexpectedly passed"); - assert!( - String::from_utf8_lossy(&output.stderr).contains("not_encoded status with wins position") - ); - - Ok(()) -} - -#[test] -fn external_adapter_manifest_rejects_blocked_status_without_blocked_outcome() -> Result<()> { - let output = run_external_manifest_scenario_mutation( - "invalid-blocked-scenario-outcome-test", - "letta_research_gate", - "stale_core_detection", - |scenario| { - scenario - .as_object_mut() - .ok_or_else(|| eyre::eyre!("scenario is not an object"))? - .remove("comparison_outcome"); - - Ok(()) - }, - )?; - - assert!(!output.status.success(), "invalid blocked scenario unexpectedly passed"); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("blocked status without blocked comparison outcome") - ); - - Ok(()) -} - -#[test] -fn external_adapter_manifest_rejects_conflicting_scenario_position_and_outcome() -> Result<()> { - let output = run_external_manifest_with_letta_attachment_mutation( - "invalid-scenario-position-outcome-test", - |scenario| { - set_json_pointer(scenario, "/status", serde_json::json!("pass"))?; - set_json_pointer(scenario, "/elf_position", serde_json::json!("ties"))?; - - set_json_pointer(scenario, "/comparison_outcome", serde_json::json!("loss")) - }, - )?; - - assert!(!output.status.success(), "conflicting scenario unexpectedly passed"); - assert!(String::from_utf8_lossy(&output.stderr).contains("ties position with loss outcome")); - - Ok(()) -} - -#[test] -fn live_adapter_supports_elf_capture_write_policy_without_external_hook_claims() -> Result<()> { - let workspace = workspace_root()?; - let live_adapter = - fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_live_adapter.rs"))?; - let live_script = - fs::read_to_string(workspace.join("scripts").join("real-world-live-adapters.sh"))?; - let manifest = fs::read_to_string( - workspace - .join("apps/elf-eval/fixtures/real_world_external_adapters") - .join("memory_projects_manifest.json"), - )?; - - assert!(live_adapter.contains("fn is_elf_capture_live_adapter(")); - assert!(live_adapter.contains("suite == \"capture_integration\"")); - assert!(live_adapter.contains("write_policy_audit_count")); - assert!(live_adapter.contains("excluded_evidence_ids")); - assert!(live_adapter.contains("source_id")); - assert!(live_adapter.contains("runtime_source_refs")); - assert!(live_adapter.contains("validate_capture_runtime_evidence")); - assert!(live_adapter.contains("capture_failure")); - assert!(live_adapter.contains("fn materialize_elf_consolidation(")); - assert!(live_adapter.contains("ConsolidationProposalReviewRequest")); - assert!(live_adapter.contains("fn materialize_elf_knowledge(")); - assert!(live_adapter.contains("KnowledgePageLintRequest")); - assert!(live_script.contains("OPERATOR_FIXTURE_DIR")); - assert!(live_script.contains("INPUT_FIXTURE_DIR")); - assert!(live_script.contains("operator_debugging_ux")); - assert!(manifest.contains("\"scenario_id\": \"live_capture_write_policy\"")); - assert!(manifest.contains("\"scenario_id\": \"capture_write_policy_hooks\"")); - assert!(manifest.contains("\"comparison_outcome\": \"blocked\"")); - assert!(manifest.contains("Four redaction, exclusion, source-id, evidence-binding")); - assert!(manifest.contains("durable upstream agentmemory session/capture path")); - assert!(manifest.contains("Docker-contained session directory")); - assert!(manifest.contains("claude-mem hooks, viewer, timeline, and observation workflows")); - - Ok(()) -} - -#[test] -fn declared_not_encoded_consolidation_jobs_do_not_require_fake_proposals() -> Result<()> { - let fixture_path = consolidation_fixture_dir().join("contradiction_report_discard.json"); - let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; - - fixture - .pointer_mut("/corpus/adapter_response") - .and_then(Value::as_object_mut) - .ok_or_else(|| eyre::eyre!("missing adapter_response object"))? - .remove("consolidation"); - - let encoding = serde_json::json!({ - "status": "not_encoded", - "reason": "The qmd live adapter retrieves evidence-linked answers but does not generate or review consolidation proposals." - }); - - fixture - .as_object_mut() - .ok_or_else(|| eyre::eyre!("fixture is not an object"))? - .insert("encoding".to_string(), encoding); - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-not-encoded-consolidation-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write( - temp_dir.join("not_encoded_consolidation.json"), - serde_json::to_vec_pretty(&fixture)?, - )?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "consolidation-contradiction-report-discard-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn capture_write_policy_live_report_preserves_competitor_boundaries() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - capture_write_policy_live_report_path()?, - )?)?; - let markdown = fs::read_to_string(capture_write_policy_live_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.capture_write_policy_live_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-933")); - assert_eq!( - report - .pointer("/live_capture_results/elf_live_real_world/suite_status") - .and_then(Value::as_str), - Some("pass") - ); - assert_eq!( - report - .pointer("/live_capture_results/elf_live_real_world/encoded_job_count") - .and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report - .pointer("/live_capture_results/elf_live_real_world/redaction_leak_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/live_capture_results/qmd_live_real_world/suite_status") - .and_then(Value::as_str), - Some("not_encoded") - ); - - let jobs = array_at(&report, "/jobs")?; - let source_binding = find_by_field(jobs, "/job_id", "capture-source-id-binding-001")?; - let source_binding_refs = array_at(source_binding, "/runtime_source_refs")?; - let release_summary_ref = - find_by_field(source_binding_refs, "/evidence_id", "source-id-release-summary")?; - - assert!(array_contains_str(source_binding, "/source_ids", "capture:issue-comment-42")?); - assert_eq!( - release_summary_ref.pointer("/source_id").and_then(Value::as_str), - Some("capture:issue-comment-42") - ); - assert_eq!( - release_summary_ref.pointer("/evidence_binding").and_then(Value::as_str), - Some("source_ref") - ); - - let write_policy = find_by_field(jobs, "/job_id", "capture-write-policy-redaction-001")?; - - assert_eq!( - write_policy.pointer("/write_policy_redaction_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - write_policy - .pointer("/runtime_source_refs/0/write_policy_applied") - .and_then(Value::as_bool), - Some(true) - ); - - let boundary = find_by_field(jobs, "/job_id", "capture-integration-boundaries-001")?; - - assert!(array_contains_str(boundary, "/excluded_evidence_ids", "private-span-trap")?); - assert!(!array_contains_str(boundary, "/stored_evidence_ids", "private-span-trap")?); - assert!( - array_at(boundary, "/runtime_source_refs")? - .iter() - .all(|item| item.pointer("/evidence_id").and_then(Value::as_str) - != Some("private-span-trap")) - ); - - let positions = array_at(&report, "/competitor_positions")?; - let qmd = find_by_field(positions, "/project", "qmd")?; - let agentmemory = find_by_field(positions, "/project", "agentmemory")?; - let claude_mem = find_by_field(positions, "/project", "claude-mem")?; - - assert_eq!(qmd.pointer("/position").and_then(Value::as_str), Some("untested")); - assert!(qmd.pointer("/reason").and_then(Value::as_str).is_some_and(|reason| { - reason.contains("typed not_encoded") && reason.contains("ELF self-check") - })); - assert_eq!(agentmemory.pointer("/position").and_then(Value::as_str), Some("blocked")); - assert!(agentmemory.pointer("/reason").and_then(Value::as_str).is_some_and(|reason| { - reason.contains("process-local StateKV Map") && reason.contains("in-memory index") - })); - assert_eq!(claude_mem.pointer("/position").and_then(Value::as_str), Some("blocked")); - assert!( - claude_mem - .pointer("/reason") - .and_then(Value::as_str) - .is_some_and(|reason| reason.contains("hooks, timeline, observations") - && reason.contains("Docker-contained hook/viewer runner")) - ); - assert!(markdown.contains("ELF now has live capture/write-policy self-check evidence")); - assert!(markdown.contains("not an ELF-over-qmd win")); - assert!(markdown.contains("| claude-mem capture/viewer flows | `blocked` |")); - assert!(!markdown.contains("claude-mem capture breadth is untested")); - assert!(markdown.contains("runtime `source_ref` metadata returned by search")); - assert!(markdown.contains("Do not claim ELF broadly beats agentmemory or claude-mem")); - assert!(benchmarking_index.contains("2026-06-11-capture-write-policy-live-report.md")); - assert!(readme.contains("Capture/Write-Policy Live Report - June 11, 2026")); - assert!(readme.contains("mem0/OpenMemory")); - assert!(readme.contains("and memsearch now pass their scoped local baseline")); - assert!( - collapse_whitespace(&readme) - .contains("claude-mem hook/viewer capture remains blocked until Docker-contained") - ); - - Ok(()) -} - -#[test] -fn live_consolidation_report_preserves_reviewable_output_boundaries() -> Result<()> { - let workspace = workspace_root()?; - let report = serde_json::from_str::(&fs::read_to_string( - live_consolidation_proposal_scoring_report_path()?, - )?)?; - let markdown = fs::read_to_string(live_consolidation_proposal_scoring_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let benchmark_runbook = fs::read_to_string( - workspace - .join("docs") - .join("runbook") - .join("benchmarking") - .join("real_world_agent_memory_benchmark.md"), - )?; - let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; - let live_script = - fs::read_to_string(workspace.join("scripts/real-world-consolidation-live-adapter.sh"))?; - let live_adapter = - fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_live_adapter.rs"))?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.live_consolidation_proposal_scoring_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-934")); - assert_eq!( - report - .pointer("/live_consolidation_results/elf_live_real_world/suite_status") - .and_then(Value::as_str), - Some("pass") - ); - assert_eq!( - report - .pointer("/live_consolidation_results/elf_live_real_world/encoded_job_count") - .and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report - .pointer("/live_consolidation_results/elf_live_real_world/proposal_count") - .and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report - .pointer("/live_consolidation_results/elf_live_real_world/source_mutation_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/live_consolidation_results/elf_live_real_world/review_event_count") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report - .pointer("/live_consolidation_results/qmd_live_real_world/suite_status") - .and_then(Value::as_str), - Some("not_encoded") - ); - - let jobs = array_at(&report, "/jobs")?; - let project_summary = - find_by_field(jobs, "/job_id", "consolidation-project-summary-apply-001")?; - let preference = - find_by_field(jobs, "/job_id", "consolidation-preference-candidate-defer-001")?; - let contradiction = - find_by_field(jobs, "/job_id", "consolidation-contradiction-report-discard-001")?; - - assert_eq!( - project_summary.pointer("/final_review_state").and_then(Value::as_str), - Some("applied") - ); - assert_eq!(project_summary.pointer("/review_event_count").and_then(Value::as_u64), Some(2)); - assert_eq!(preference.pointer("/final_review_state").and_then(Value::as_str), Some("archived")); - assert_eq!( - contradiction.pointer("/final_review_state").and_then(Value::as_str), - Some("rejected") - ); - assert_eq!( - contradiction.pointer("/unsupported_claim_flag_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(contradiction.pointer("/source_lineage_count").and_then(Value::as_u64), Some(3)); - - let positions = array_at(&report, "/reference_positions")?; - let qmd = find_by_field(positions, "/project", "qmd")?; - let managed = find_by_field(positions, "/project", "managed_dreaming_memory_systems")?; - let always_on = find_by_field(positions, "/project", "always_on_memory_agent_patterns")?; - - assert_eq!(qmd.pointer("/position").and_then(Value::as_str), Some("untested")); - assert_eq!(managed.pointer("/position").and_then(Value::as_str), Some("product_reference")); - assert_eq!(always_on.pointer("/position").and_then(Value::as_str), Some("product_reference")); - assert!(markdown.contains("ELF now has service-backed live consolidation proposal scoring")); - assert!(markdown.contains("This is not scheduled production consolidation")); - assert!(markdown.contains("Source mutations")); - assert!(markdown.contains("Do not mix knowledge-page rebuild/lint scoring")); - assert!( - benchmarking_index.contains("2026-06-16-live-consolidation-proposal-scoring-report.md") - ); - assert!(readme.contains("Live Consolidation Proposal Scoring Report - June 16, 2026")); - assert!(readme.contains("real-world-memory-live-consolidation")); - assert!(benchmark_runbook.contains("Current live consolidation increment")); - assert!(benchmark_runbook.contains("tmp/real-world-memory/live-consolidation/summary.json")); - assert!(makefile.contains("[tasks.real-world-memory-live-consolidation]")); - assert!(makefile.contains("scripts/real-world-docker.sh")); - - let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; - - assert!(docker_script.contains("scripts/real-world-consolidation-live-adapter.sh")); - assert!(live_script.contains("elf.real_world_consolidation_live_adapter_sweep/v1")); - assert!(live_script.contains("real_world_live_adapter -- elf")); - assert!(!live_script.contains("real_world_live_adapter -- qmd")); - assert!(live_adapter.contains("fn materialize_elf_consolidation(")); - assert!(live_adapter.contains("ConsolidationProposalReviewRequest")); - - Ok(()) -} - -#[test] -fn live_knowledge_page_rebuild_lint_has_dedicated_docker_task() -> Result<()> { - let workspace = workspace_root()?; - let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; - let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; - let live_script = - fs::read_to_string(workspace.join("scripts/real-world-knowledge-live-adapter.sh"))?; - let live_adapter = - fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_live_adapter.rs"))?; - let knowledge_spec = fs::read_to_string( - workspace.join("docs").join("spec").join("system_knowledge_pages_v1.md"), - )?; - let version_diff_report = fs::read_to_string( - workspace - .join("docs") - .join("evidence") - .join("benchmarking") - .join("2026-06-20-knowledge-workspace-version-diff-report.md"), - )?; - let benchmark_runbook = fs::read_to_string( - workspace - .join("docs") - .join("runbook") - .join("benchmarking") - .join("real_world_agent_memory_benchmark.md"), - )?; - let live_runbook = fs::read_to_string( - workspace - .join("docs") - .join("runbook") - .join("benchmarking") - .join("live_baseline_benchmark.md"), - )?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert!(makefile.contains("[tasks.real-world-memory-live-knowledge]")); - assert!(makefile.contains("scripts/real-world-docker.sh")); - assert!(makefile.contains("memory-live-knowledge")); - assert!(docker_script.contains("memory-live-knowledge)")); - assert!(docker_script.contains("-e ELF_KNOWLEDGE_LIVE_REPORT_DIR")); - assert!(docker_script.contains("-e ELF_KNOWLEDGE_LIVE_FIXTURES")); - assert!(docker_script.contains("scripts/real-world-knowledge-live-adapter.sh")); - assert!(live_script.contains("elf.real_world_knowledge_live_adapter_sweep/v1")); - assert!(live_script.contains("apps/elf-eval/fixtures/real_world_memory/knowledge")); - assert!(live_script.contains("tmp/real-world-memory/live-knowledge")); - assert!(live_script.contains("real-world-memory-live-knowledge")); - assert!(live_script.contains("ElfService knowledge_page_rebuild")); - assert!(live_script.contains("knowledge_page_lint")); - assert!(live_script.contains("knowledge_pages_search")); - assert!(live_script.contains("pages remain derived benchmark artifacts")); - assert!(live_adapter.contains("\"page_version_diff\"")); - assert!(live_adapter.contains("version_diff_available")); - assert!(live_adapter.contains("fn materialize_elf_knowledge(")); - assert!(live_adapter.contains("KnowledgePageRebuildRequest")); - assert!(live_adapter.contains("KnowledgePageLintRequest")); - assert!(live_adapter.contains("KnowledgePageSearchRequest")); - assert!( - fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_job_benchmark.rs"))? - .contains("version_diff_coverage") - ); - assert!(knowledge_spec.contains("elf.knowledge_page.version_diff/v1")); - assert!( - version_diff_report.contains("Knowledge Workspace Version-Diff Report - June 20, 2026") - ); - assert!(version_diff_report.contains("version_diff_coverage = 1.000")); - assert!(benchmark_runbook.contains("Current live knowledge-page rebuild/lint increment")); - assert!(benchmark_runbook.contains("cargo make real-world-memory-live-knowledge")); - assert!(benchmark_runbook.contains("tmp/real-world-memory/live-knowledge/summary.json")); - assert!(live_runbook.contains("cargo make real-world-memory-live-knowledge")); - assert!(benchmarking_index.contains("2026-06-20-live-knowledge-page-rebuild-lint-report.md")); - assert!(benchmarking_index.contains("2026-06-20-knowledge-workspace-version-diff-report.md")); - assert!(readme.contains("Live Knowledge-Page Rebuild/Lint Report - June 20, 2026")); - assert!(readme.contains("Knowledge Workspace Version-Diff Report - June 20, 2026")); - - Ok(()) -} - -fn assert_live_sweep_record(adapter: &Value, production_ops_status: &str) -> Result<()> { - let suites = array_at(adapter, "/suites")?; - let capabilities = array_at(adapter, "/capabilities")?; - let adapter_id = adapter.pointer("/adapter_id").and_then(Value::as_str).unwrap_or_default(); - let targeted = find_by_field(capabilities, "/capability", "targeted_live_pass")?; - let full_pass = find_by_field(capabilities, "/capability", "full_suite_live_pass")?; - let work_resume = find_by_field(suites, "/suite_id", "work_resume")?; - let memory_evolution = find_by_field(suites, "/suite_id", "memory_evolution")?; - let production_ops = find_by_field(suites, "/suite_id", "production_ops")?; - let consolidation = find_by_field(suites, "/suite_id", "consolidation")?; - let knowledge = find_by_field(suites, "/suite_id", "knowledge_compilation")?; - let operator_debug = find_by_field(suites, "/suite_id", "operator_debugging_ux")?; - let capture = find_by_field(suites, "/suite_id", "capture_integration")?; - let personalization = find_by_field(suites, "/suite_id", "personalization")?; - let core_archival = find_by_field(suites, "/suite_id", "core_archival_memory")?; - let context_trajectory = find_by_field(suites, "/suite_id", "context_trajectory")?; - let trust_sot = find_by_field(suites, "/suite_id", "trust_source_of_truth")?; - let retrieval = find_by_field(suites, "/suite_id", "retrieval")?; - let project_decisions = find_by_field(suites, "/suite_id", "project_decisions")?; - - assert_eq!(suites.len(), 13); - assert_eq!(targeted.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(full_pass.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert!( - adapter - .pointer("/result/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("55 jobs across all 13 checked-in suites")) - ); - assert_eq!(trust_sot.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(work_resume.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(retrieval.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(project_decisions.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - production_ops.pointer("/status").and_then(Value::as_str), - Some(production_ops_status) - ); - - if adapter_id == "elf_live_real_world" { - assert_eq!(consolidation.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(knowledge.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(operator_debug.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(capture.pointer("/status").and_then(Value::as_str), Some("pass")); - assert!( - capture - .pointer("/evidence") - .and_then(Value::as_str) - .is_some_and(|evidence| evidence.contains("4/4 capture_integration jobs")) - ); - } else { - assert_eq!(consolidation.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(knowledge.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(operator_debug.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(capture.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - } - - assert_eq!(personalization.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(context_trajectory.pointer("/status").and_then(Value::as_str), Some("blocked")); - - Ok(()) -} - -#[test] -fn runner_discovers_nested_fixture_layout() -> Result<()> { - let report = run_json_report_from(fixture_root())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(82)); - - Ok(()) -} - -#[test] -fn operator_debug_fixture_reports_trace_links_and_failure_details() -> Result<()> { - let report = run_json_report_from(operator_debug_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(7)); - assert_eq!( - report.pointer("/summary/operator_debug_job_count").and_then(Value::as_u64), - Some(7) - ); - assert_eq!(report.pointer("/summary/raw_sql_needed_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/trace_incomplete_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/operator_ux_gap_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(7)); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), - Some(3) - ); - - let jobs = array_at(&report, "/jobs")?; - let dropped = find_by_field(jobs, "/job_id", "operator-debug-dropped-evidence-001")?; - let selected = find_by_field(jobs, "/job_id", "operator-debug-selected-not-narrated-001")?; - let compact = find_by_field(jobs, "/job_id", "operator-debug-qmd-style-compact-replay-001")?; - - assert_eq!(dropped.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - dropped.pointer("/operator_debug/raw_sql_needed").and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - dropped.pointer("/operator_debug/dropped_candidate_visibility").and_then(Value::as_str), - Some("visible in Retrieval Funnel and Replay Candidates") - ); - assert_eq!( - dropped.pointer("/operator_debug/viewer_url").and_then(Value::as_str), - Some("/viewer?trace_id=11111111-1111-4111-8111-111111111111") - ); - assert_eq!( - dropped.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("filter.read_profile") - ); - assert!(array_contains_str( - dropped, - "/trace_explainability/stages/1/dropped_evidence", - "trace-dropped-expected" - )?); - assert!(array_contains_str( - dropped, - "/trace_explainability/stages/1/distractor_evidence", - "trace-dropped-decoy" - )?); - assert!(array_contains_str(dropped, "/produced_evidence", "trace-dropped-expected")?); - assert_eq!(selected.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - selected.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("selection.narration") - ); - assert_eq!( - selected.pointer("/operator_debug/failure_mode").and_then(Value::as_str), - Some("selected_but_not_narrated") - ); - assert_eq!(compact.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - compact.pointer("/operator_debug/failure_mode").and_then(Value::as_str), - Some("qmd_style_compact_replay") - ); - assert_eq!( - compact.pointer("/operator_debug/replay_command_available").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - compact.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("recall_debug.compact_replay") - ); - assert!(array_contains_str( - compact, - "/trace_explainability/stages/4/kept_evidence", - "compact-replay-artifact" - )?); - assert!(array_contains_str(compact, "/produced_evidence", "qmd-short-replay-reference")?); - - Ok(()) -} - -#[test] -fn consolidation_fixtures_report_reviewable_proposal_metrics() -> Result<()> { - let report = run_json_report_from(consolidation_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(4)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); - assert_eq!( - report.pointer("/summary/consolidation/proposal_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/consolidation/source_mutation_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/consolidation/proposal_unsupported_claim_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/consolidation/executable_gap_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/summary/consolidation/lineage_completeness").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/consolidation/review_action_correctness").and_then(Value::as_f64), - Some(1.0) - ); - - let jobs = array_at(&report, "/jobs")?; - let project_summary = - find_by_field(jobs, "/job_id", "consolidation-project-summary-apply-001")?; - let contradiction = - find_by_field(jobs, "/job_id", "consolidation-contradiction-report-discard-001")?; - - assert_eq!( - project_summary - .pointer("/consolidation/proposals/0/actual_review_action") - .and_then(Value::as_str), - Some("apply") - ); - assert_eq!( - contradiction - .pointer("/consolidation/proposals/0/actual_review_action") - .and_then(Value::as_str), - Some("discard") - ); - assert_eq!( - contradiction - .pointer("/consolidation/proposals/0/unsupported_claim_count") - .and_then(Value::as_u64), - Some(1) - ); - - let suites = array_at(&report, "/suites")?; - let consolidation_suite = find_by_field(suites, "/suite_id", "consolidation")?; - - assert_eq!(consolidation_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - - Ok(()) -} - -#[test] -fn knowledge_fixtures_report_page_metrics() -> Result<()> { - let report = run_json_report_from(knowledge_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/knowledge/page_count").and_then(Value::as_u64), Some(5)); - assert_eq!( - report.pointer("/summary/knowledge/section_count").and_then(Value::as_u64), - Some(13) - ); - assert_eq!( - report.pointer("/summary/knowledge/citation_coverage").and_then(Value::as_f64), - Some(0.923) - ); - assert_eq!( - report.pointer("/summary/knowledge/stale_claim_detection").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/knowledge/rebuild_determinism").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/knowledge/backlink_count").and_then(Value::as_u64), - Some(11) - ); - assert_eq!( - report.pointer("/summary/knowledge/pages_with_backlinks").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/knowledge/backlink_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/knowledge/page_usefulness").and_then(Value::as_f64), - Some(0.979) - ); - assert_eq!( - report.pointer("/summary/knowledge/pages_with_version_diff").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/knowledge/unsupported_summary_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/knowledge/allowed_variance_count").and_then(Value::as_u64), - Some(1) - ); - - let suites = array_at(&report, "/suites")?; - let knowledge_suite = find_by_field(suites, "/suite_id", "knowledge_compilation")?; - - assert_eq!(knowledge_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(knowledge_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); - - let jobs = array_at(&report, "/jobs")?; - let project_page_job = find_by_field(jobs, "/job_id", "knowledge-project-page-001")?; - let watch_rebuild_job = find_by_field(jobs, "/job_id", "knowledge-watch-rebuild-003")?; - - assert_eq!( - project_page_job.pointer("/knowledge/unsupported_summary_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - project_page_job.pointer("/knowledge/untraced_section_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - watch_rebuild_job.pointer("/knowledge/pages_with_version_diff").and_then(Value::as_u64), - Some(1) - ); - assert!( - watch_rebuild_job - .pointer("/produced_answer") - .and_then(Value::as_str) - .is_some_and(|answer| answer - .contains("PageIndex/OpenKB adapter claim as lint evidence") - && answer.contains("leaves source documents plus Memory Notes unmodified")) - ); - - Ok(()) -} - -#[test] -fn project_decisions_fixtures_report_decision_policy_cases() -> Result<()> { - let report = run_json_report_from(project_decisions_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), - Some(2) - ); - assert_eq!( - report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), - Some(1.0) - ); - - let suites = array_at(&report, "/suites")?; - let project_decisions = find_by_field(suites, "/suite_id", "project_decisions")?; - - assert_eq!(project_decisions.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(project_decisions.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - assert_eq!( - project_decisions.pointer("/update_rationale_available_count").and_then(Value::as_u64), - Some(5) - ); - - let jobs = array_at(&report, "/jobs")?; - let accepted = find_by_field(jobs, "/job_id", "project-decision-accepted-typed-failures-001")?; - let reversal = find_by_field(jobs, "/job_id", "project-decision-reversal-live-baseline-001")?; - let validation = - find_by_field(jobs, "/job_id", "project-decision-current-validation-gate-001")?; - let tradeoff = find_by_field(jobs, "/job_id", "project-decision-tradeoff-fixture-backed-001")?; - let caveat = find_by_field(jobs, "/job_id", "project-decision-private-manifest-caveat-001")?; - - assert_eq!(accepted.pointer("/answer_type").and_then(Value::as_str), Some("decision_record")); - assert_eq!( - accepted.pointer("/expected_evidence").and_then(Value::as_array).map(Vec::len), - Some(2) - ); - assert_eq!( - reversal.pointer("/evolution/historical_evidence/0").and_then(Value::as_str), - Some("live-baseline-suite-win-old") - ); - assert_eq!( - validation.pointer("/evolution/current_evidence/0").and_then(Value::as_str), - Some("validation-gate-current-decodex") - ); - assert_eq!(tradeoff.pointer("/requires_caveat").and_then(Value::as_bool), Some(true)); - assert_eq!(caveat.pointer("/can_answer_unknown").and_then(Value::as_bool), Some(true)); - - for job in jobs { - let expected_evidence = array_at(job, "/expected_evidence")?; - - assert!( - !expected_evidence.is_empty(), - "project decision job {} must declare required evidence", - job.pointer("/job_id").and_then(Value::as_str).unwrap_or("") - ); - } - for entry in fs::read_dir(project_decisions_fixture_dir())? { - let path = entry?.path(); - - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { - continue; - } - - let fixture = serde_json::from_str::(&fs::read_to_string(path)?)?; - let required_evidence = array_at(&fixture, "/required_evidence")?; - let negative_traps = array_at(&fixture, "/negative_traps")?; - - assert!(!required_evidence.is_empty()); - assert!(!negative_traps.is_empty()); - } - - Ok(()) -} - -#[test] -fn qmd_openviking_strength_profile_report_preserves_claim_boundaries() -> Result<()> { - let report = - serde_json::from_str::(&fs::read_to_string(strength_profile_report_path()?)?)?; - let markdown = fs::read_to_string(strength_profile_markdown_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let iteration_direction = fs::read_to_string(iteration_direction_report_path()?)?; - - assert_strength_profile_summary(&report); - assert_strength_profile_terms(&report)?; - assert_qmd_strength_profile(&report)?; - assert_qmd_wrong_result_diagnosis(&report)?; - assert_openviking_strength_profile(&report)?; - assert_strength_profile_json_claim_boundaries(&report)?; - assert_strength_profile_markdown_boundaries(&markdown); - assert_operator_facing_strength_profile_boundaries( - &readme, - &benchmarking_index, - &iteration_direction, - ); - - Ok(()) -} - -#[test] -fn current_benchmark_reports_preserve_live_sweep_boundaries() -> Result<()> { - let measurement_audit = fs::read_to_string(measurement_coverage_audit_path()?)?; - let measurement_audit_json = serde_json::from_str::(&fs::read_to_string( - measurement_coverage_audit_json_path()?, - )?)?; - let competitor_matrix = fs::read_to_string(competitor_strength_matrix_path()?)?; - let competitor_matrix_json = serde_json::from_str::(&fs::read_to_string( - competitor_strength_matrix_json_path()?, - )?)?; - let iteration_direction = fs::read_to_string(iteration_direction_report_path()?)?; - let external_manifest = fs::read_to_string(external_adapter_manifest_path())?; - let comparison_external_projects = fs::read_to_string(comparison_external_projects_path()?)?; - let retrieval_debug_profile = - serde_json::from_str::(&fs::read_to_string(retrieval_debug_profile_json_path()?)?)?; - let temporal_history = serde_json::from_str::(&fs::read_to_string( - temporal_history_competitor_gap_json_path()?, - )?)?; - - assert_current_report_text_boundaries( - &measurement_audit, - &competitor_matrix, - &iteration_direction, - &external_manifest, - &comparison_external_projects, - ); - - assert!(competitor_matrix.contains("claude-mem work_resume remains `not_encoded`")); - assert!(!competitor_matrix.contains("claude-mem `wrong_result`, OpenViking work_resume")); - - let qmd_live = find_by_field( - array_at(&measurement_audit_json, "/live_real_world_adapters")?, - "/adapter", - "qmd live CLI adapter", - )?; - - assert_eq!(qmd_live.pointer("/pass").and_then(Value::as_u64), Some(17)); - assert_eq!(qmd_live.pointer("/wrong_result").and_then(Value::as_u64), Some(6)); - assert_eq!(qmd_live.pointer("/expected_evidence_matched").and_then(Value::as_u64), Some(38)); - assert_eq!(qmd_live.pointer("/evidence_covered_count").and_then(Value::as_u64), Some(45)); - - let memory_evolution = find_by_field( - array_at(&measurement_audit_json, "/live_suite_breakdown")?, - "/suite", - "memory_evolution", - )?; - - assert_eq!( - memory_evolution.pointer("/elf_status_counts/wrong_result").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - memory_evolution.pointer("/qmd_status_counts/wrong_result").and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - retrieval_debug_profile - .pointer("/live_real_world_full_sweep_context/qmd/pass") - .and_then(Value::as_u64), - Some(17) - ); - assert_eq!( - retrieval_debug_profile - .pointer("/live_real_world_full_sweep_context/qmd/wrong_result") - .and_then(Value::as_u64), - Some(6) - ); - - assert_competitor_strength_matrix_json(&competitor_matrix_json)?; - - let openmemory_command = find_by_field( - array_at(&temporal_history, "/commands")?, - "/command", - "cargo make openmemory-ui-export-readback", - )?; - - assert!( - openmemory_command - .pointer("/artifact") - .and_then(Value::as_str) - .is_some_and(|artifact| artifact.contains("tmp/live-baseline/mem0-checks.json") - && artifact.contains("tmp/live-baseline/mem0-openmemory-ui-export.json")) - ); - - Ok(()) -} - -fn assert_current_report_text_boundaries( - measurement_audit: &str, - competitor_matrix: &str, - iteration_direction: &str, - external_manifest: &str, - comparison_external_projects: &str, -) { - assert!( - measurement_audit.contains( - "| `memory_evolution` | `6` | `pass:1`, `wrong_result:5` | `wrong_result:6` |" - ) - ); - assert!( - measurement_audit - .contains("qmd live fails 6/6 jobs after missing the delete/TTL tombstone evidence") - ); - assert!(measurement_audit.contains("Basic local smoke and local OSS history/readback pass")); - assert!(measurement_audit.contains("claude-mem hook/viewer capture is `blocked`")); - assert!(!measurement_audit.contains("claude-mem hook/viewer capture remains untested")); - assert!(!measurement_audit.contains("blocked or untested")); - - assert_measurement_audit_adapter_status_counts(measurement_audit); - - assert!( - competitor_matrix - .contains("broader live suites remain `wrong_result`, `blocked`, or `not_encoded`") - ); - assert!(competitor_matrix.contains( - "Overall adapter-status counts: 4 `pass`,\n6 `wrong_result`, 1 `lifecycle_fail`, 7 `blocked`, and 5 `not_encoded`." - )); - assert!(!competitor_matrix.contains("5 `blocked`, and 7 `not_encoded`")); - assert!( - competitor_matrix - .contains("mem0/OpenMemory local OSS entity-scoped personalization now passes") - ); - assert!(competitor_matrix.contains("scoped preference behavior is a measured tie")); - assert!( - !competitor_matrix.contains("mem0/OpenMemory and Letta personalization are `not_encoded`") - ); - assert!(external_manifest.contains( - "The record is a full-suite sweep, not a full-suite pass; wrong_result, blocked, and not_encoded states remain visible." - )); - assert!(external_manifest.contains( - "The qmd live real-world sweep covers the current encoded fixture corpus; expanded retrieval-debug strength suites still need their own materialized adapter run." - )); - assert!( - comparison_external_projects - .contains("Benchmark-grounded for scoped local OSS same-corpus retrieval") - ); - assert!( - comparison_external_projects - .contains("Benchmark-grounded for local same-corpus retrieval, reindex/update/delete") - ); - assert!(iteration_direction.contains("| Jobs | `55` |")); - assert!(iteration_direction.contains("| Encoded suites | `15` |")); - assert!(iteration_direction.contains("| Pass | `49` |")); - assert!(iteration_direction.contains("| Evidence coverage | `123/123` |")); - assert!(iteration_direction.contains("| Expected evidence recall | `115/115` |")); - - for stale_phrase in [ - "same live sweep shape as ELF", - "ELF and qmd live fail 5/6 jobs", - "both systems currently fail 5/6 live memory-evolution jobs", - "wrong_result, incomplete, blocked, and not_encoded states remain visible", - "broader live suites remain `wrong_result`, `incomplete`, or `not_encoded`", - "The qmd live real-world slice covers representative jobs only", - "| Jobs | `40` |", - "| Encoded suites | `11` |", - "| Jobs | `50` |", - "| Encoded suites | `14` |", - "| Pass | `38` |", - "| Pass | `45` |", - "| Evidence coverage | `115/115` |", - "| Expected evidence recall | `107/107` |", - "history/UI/hosted/graph behavior remains", - "current local adapter is incomplete/wrong-result", - "current adapter is incomplete/invalid-result", - ] { - assert!(!measurement_audit.contains(stale_phrase)); - assert!(!competitor_matrix.contains(stale_phrase)); - assert!(!iteration_direction.contains(stale_phrase)); - assert!(!external_manifest.contains(stale_phrase)); - assert!(!comparison_external_projects.contains(stale_phrase)); - } -} - -#[test] -fn live_temporal_reconciliation_report_records_xy905_before_after() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - live_temporal_reconciliation_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(live_temporal_reconciliation_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.live_temporal_reconciliation_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-905")); - assert_eq!( - report - .pointer("/baseline/elf_memory_evolution/job_status_counts/pass") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/baseline/elf_memory_evolution/job_status_counts/wrong_result") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report - .pointer("/post_stage/elf_memory_evolution/job_status_counts/pass") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report - .pointer("/post_stage/elf_memory_evolution/job_status_counts/wrong_result") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/post_stage/elf_memory_evolution/suite_status").and_then(Value::as_str), - Some("pass") - ); - assert_eq!( - report.pointer("/post_stage/qmd_memory_evolution/suite_status").and_then(Value::as_str), - Some("wrong_result") - ); - assert_eq!( - report - .pointer("/comparison_judgment/current_vs_historical_correctness") - .and_then(Value::as_str), - Some("improved") - ); - assert_eq!( - report - .pointer("/comparison_judgment/deletion_ttl_tombstone_behavior") - .and_then(Value::as_str), - Some("unchanged") - ); - assert!(array_contains_str( - &report, - "/trace_contract/answer_fields", - "selected_historical_evidence" - )?); - assert!(array_contains_str( - &report, - "/trace_contract/materialization_fields", - "current_winner_evidence_ids" - )?); - assert!(array_contains_str( - &report, - "/trace_contract/trace_stages", - "temporal_reconciliation.conflict_candidates" - )?); - assert!(report.pointer("/trace_contract/negative_gate").and_then(Value::as_str).is_some_and( - |gate| gate.contains("selected conflict evidence id") && gate.contains("wrong_result") - )); - assert!(markdown.contains("ELF passing all six memory-evolution jobs")); - assert!(markdown.contains("selected-but-not-narrated conflicts as `wrong_result`")); - assert!(markdown.contains("Do not claim ELF beats Graphiti/Zep")); - assert!(benchmarking_index.contains("2026-06-16-live-temporal-reconciliation-report.md")); - assert!( - readme.contains("Live Temporal Reconciliation Report - June 16, 2026") - && readme.contains("now reports ELF live `memory_evolution` as 6/6 pass") - ); - - Ok(()) -} - -#[test] -fn dreaming_competitor_strength_retest_report_closes_xy955_without_overclaims() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - dreaming_competitor_strength_retest_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(dreaming_competitor_strength_retest_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.dreaming_competitor_strength_retest_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-955")); - assert_eq!( - report.pointer("/summary/overall_judgment").and_then(Value::as_str), - Some("locally_and_partially_stronger_only") - ); - assert_eq!( - report.pointer("/summary/broader_superiority").and_then(Value::as_str), - Some("not_proven") - ); - assert_eq!(report.pointer("/summary/regressed_stage_count").and_then(Value::as_u64), Some(0)); - assert!(array_contains_str(&report, "/status_terms", "typed_non_pass")?); - assert!(array_contains_str( - &report, - "/summary/unsupported_claims_rejected", - "ELF does not broadly beat qmd from this retest." - )?); - - assert_xy955_commands(&report)?; - assert_xy955_stage_closeout(&report)?; - assert_xy955_scenario_retests(&report)?; - assert_xy955_optimization_queue(&report)?; - assert_xy955_follow_up_issue_briefs(&report)?; - - assert!(markdown.contains("ELF is locally and partially stronger")); - assert!( - markdown.contains("The full live-adapter command now has fresh ELF and qmd scored reports") - ); - assert!( - markdown.contains( - "Do not treat qmd full-suite wrong_result counts as a regression of qmd debug" - ) - ); - assert!(markdown.contains("## Follow-Up Issue Briefs")); - assert!(markdown.contains( - "| GraphRAG/LightRAG/RAGFlow/llm-wiki/gbrain/graphify citation/navigation/knowledge surfaces |" - )); - assert!( - benchmarking_index.contains("2026-06-17-dreaming-competitor-strength-retest-report.md") - ); - assert!(readme.contains("Dreaming Competitor-Strength Retest Report - June 17, 2026")); - assert!(readme.contains("17 competitor-strength closeout")); - - Ok(()) -} - -#[test] -fn qmd_debug_ergonomics_dreaming_retest_report_preserves_qmd_edge() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - qmd_debug_ergonomics_dreaming_retest_report_json_path()?, - )?)?; - let markdown = - fs::read_to_string(qmd_debug_ergonomics_dreaming_retest_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_qmd_debug_retest_summary(&report)?; - assert_qmd_debug_retest_command_and_adapters(&report)?; - assert_qmd_debug_retest_scenarios(&report)?; - assert_qmd_debug_retest_boundaries(&report)?; - assert_qmd_debug_retest_markdown_and_indexes(&markdown, &benchmarking_index, &readme); - - Ok(()) -} - -fn assert_qmd_debug_retest_summary(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.qmd_debug_ergonomics_dreaming_retest_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-982")); - assert_eq!( - report.pointer("/summary/overall_judgment").and_then(Value::as_str), - Some("unchanged_with_live_operator_debug_confirmation") - ); - assert_eq!( - report.pointer("/summary/debug_ergonomics_edge").and_then(Value::as_str), - Some("qmd_default_top10_and_short_cli_replay_preserved") - ); - assert_eq!( - report.pointer("/summary/broader_superiority").and_then(Value::as_str), - Some("not_proven") - ); - assert_eq!(report.pointer("/summary/improved_scenario_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/regressed_scenario_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/summary/unchanged_scenario_count").and_then(Value::as_u64), - Some(6) - ); - assert!(array_contains_str( - report, - "/summary/unsupported_claims_rejected", - "qmd's live operator-debug wrong_result rows do not erase qmd's default top-k and short CLI replay edge." - )?); - - Ok(()) -} - -fn assert_qmd_debug_retest_command_and_adapters(report: &Value) -> Result<()> { - let command = find_by_field( - array_at(report, "/commands")?, - "/command", - "cargo make real-world-job-operator-ux-live-adapters", - )?; - - assert_eq!(command.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - command.pointer("/summary/schema").and_then(Value::as_str), - Some("elf.real_world_operator_debug_live_adapter_sweep/v1") - ); - - let adapters = array_at(report, "/adapter_summaries")?; - let elf = find_by_field(adapters, "/adapter_id", "elf_operator_debug_live")?; - let qmd = find_by_field(adapters, "/adapter_id", "qmd_operator_debug_live")?; - - assert_eq!(elf.pointer("/job_count").and_then(Value::as_u64), Some(6)); - assert_eq!(elf.pointer("/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(elf.pointer("/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(elf.pointer("/trace_available_count").and_then(Value::as_u64), Some(6)); - assert_eq!(elf.pointer("/replay_command_available_count").and_then(Value::as_u64), Some(6)); - assert_eq!(qmd.pointer("/job_count").and_then(Value::as_u64), Some(6)); - assert_eq!(qmd.pointer("/pass").and_then(Value::as_u64), Some(0)); - assert_eq!(qmd.pointer("/wrong_result").and_then(Value::as_u64), Some(6)); - assert_eq!(qmd.pointer("/trace_available_count").and_then(Value::as_u64), Some(0)); - assert_eq!(qmd.pointer("/trace_incomplete_count").and_then(Value::as_u64), Some(6)); - assert_eq!(qmd.pointer("/replay_command_available_count").and_then(Value::as_u64), Some(6)); - - Ok(()) -} - -fn assert_qmd_debug_retest_scenarios(report: &Value) -> Result<()> { - let scenarios = array_at(report, "/scenario_retests")?; - let top10 = find_by_field(scenarios, "/scenario_id", "qmd_default_top10_candidate_artifact")?; - let replay = find_by_field(scenarios, "/scenario_id", "qmd_short_cli_replay")?; - let trace = find_by_field(scenarios, "/scenario_id", "elf_operator_debug_trace_hydration")?; - let candidate = - find_by_field(scenarios, "/scenario_id", "operator_debug_candidate_drop_visibility")?; - let expansion = find_by_field(scenarios, "/scenario_id", "query_expansion_attribution")?; - let fusion = find_by_field(scenarios, "/scenario_id", "fusion_attribution")?; - let rerank = find_by_field(scenarios, "/scenario_id", "rerank_attribution")?; - - assert_eq!(scenarios.len(), 10); - assert_eq!(top10.pointer("/judgment").and_then(Value::as_str), Some("unchanged")); - assert_eq!(top10.pointer("/current_outcome").and_then(Value::as_str), Some("loss")); - assert_eq!(replay.pointer("/current_outcome").and_then(Value::as_str), Some("loss")); - assert_eq!( - trace.pointer("/current_counts/elf_trace_available").and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - trace.pointer("/current_counts/qmd_trace_available").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - candidate - .pointer("/current_counts/qmd_intermediate_stage_visible_jobs") - .and_then(Value::as_u64), - Some(0) - ); - assert!(array_contains_str(candidate, "/typed_non_pass_states", "retrieved_but_dropped")?); - assert_eq!(expansion.pointer("/judgment").and_then(Value::as_str), Some("not_tested")); - assert_eq!(fusion.pointer("/judgment").and_then(Value::as_str), Some("not_tested")); - assert_eq!(rerank.pointer("/judgment").and_then(Value::as_str), Some("non_goal")); - - Ok(()) -} - -fn assert_qmd_debug_retest_boundaries(report: &Value) -> Result<()> { - assert!(array_contains_str( - report, - "/claim_boundaries/allowed", - "qmd's default local-debug edge remains: top-10 candidate rows plus short CLI replay." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries/not_allowed", - "Do not claim ELF broadly beats qmd from this retest." - )?); - assert!(array_contains_str( - report, - "/next_optimization_direction/required_fields", - "fusion_rank_deltas" - )?); - - Ok(()) -} - -fn assert_qmd_debug_retest_markdown_and_indexes( - markdown: &str, - benchmarking_index: &str, - readme: &str, -) { - assert!(markdown.contains("The qmd debug-ergonomics outcome is unchanged")); - assert!(markdown.contains("ELF 6 pass/0 wrong_result; qmd 0 pass/6 wrong_result")); - assert!( - markdown.contains("Do not treat qmd's 0 pass/6 wrong_result live operator-debug slice") - ); - assert!(markdown.contains("Immediate top-k rows with source id")); - assert!( - benchmarking_index.contains("2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.md") - ); - assert!(readme.contains("qmd Debug-Ergonomics Dreaming Retest Report - June 19, 2026")); - assert!(readme.contains("Temporal and Trajectory Adapter Coverage Report - June 23, 2026")); - assert!(readme.contains("Latest real-world benchmark report: June 27, 2026")); - assert!(readme.contains("keeps the qmd edge unchanged")); -} - -#[test] -fn openviking_trajectory_materialization_report_preserves_blocked_gates() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - openviking_trajectory_materialization_report_json_path()?, - )?)?; - let markdown = - fs::read_to_string(openviking_trajectory_materialization_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_openviking_trajectory_materialization_summary(&report)?; - assert_openviking_trajectory_materialization_command(&report)?; - assert_openviking_trajectory_materialization_scenarios(&report)?; - assert_openviking_trajectory_materialization_boundaries(&report)?; - assert_openviking_trajectory_materialization_markdown_and_indexes( - &markdown, - &benchmarking_index, - &readme, - ); - - Ok(()) -} - -#[test] -fn letta_core_archive_export_readback_report_preserves_blocked_gates() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - letta_core_archive_export_readback_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(letta_core_archive_export_readback_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.letta_core_archive_export_readback_summary/v1") - ); - assert_eq!( - report.pointer("/adapter_id").and_then(Value::as_str), - Some("letta_core_archive_export_readback") - ); - assert_eq!( - report.pointer("/materialization/status/failure_class").and_then(Value::as_str), - Some("letta_live_run_disabled") - ); - assert_eq!( - report.pointer("/materialization/status/overall").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!( - report.pointer("/materialization/scored_benchmark/status").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!( - report.pointer("/materialization/scored_benchmark/counts/blocked").and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report.pointer("/materialization/scored_benchmark/counts/pass").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/materialization/scored_benchmark/counts/wrong_result") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/materialization/scored_benchmark/evidence_coverage") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/materialization/benchmark_input/core_blocks") - .and_then(Value::as_array) - .map(Vec::len), - Some(9) - ); - assert_eq!( - report - .pointer("/materialization/benchmark_input/archival_passages") - .and_then(Value::as_array) - .map(Vec::len), - Some(6) - ); - assert_eq!( - report - .pointer("/materialization/evidence_mapping/expected_evidence_ids") - .and_then(Value::as_array) - .map(Vec::len), - Some(14) - ); - assert_eq!( - report - .pointer("/materialization/evidence_mapping/mapped_evidence_ids") - .and_then(Value::as_array) - .map(Vec::len), - Some(0) - ); - assert_eq!( - report - .pointer("/materialization/improvement_regression_readback/judgment") - .and_then(Value::as_str), - Some("unchanged") - ); - assert!(array_contains_str( - &report, - "/materialization/claim_boundaries/not_allowed", - "Do not claim ELF beats Letta on core-vs-archival memory from fixture-only ELF evidence." - )?); - assert!(markdown.contains("The Letta follow-up is now reproducible")); - assert!(markdown.contains("6 typed blocked")); - assert!(markdown.contains("competitive status is unchanged")); - assert!(benchmarking_index.contains("2026-06-19-letta-core-archive-export-readback-report.md")); - assert!(readme.contains("Letta core/archive materialization after XY-984")); - assert!(readme.contains("smoke-letta-core-archive-export-readback")); - - Ok(()) -} - -#[test] -fn service_native_dreaming_readback_report_materializes_public_jobs() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - service_native_dreaming_readback_report_json_path()?, - )?)?; - let materialization = serde_json::from_str::(&fs::read_to_string( - service_native_dreaming_readback_materialization_json_path()?, - )?)?; - let markdown = fs::read_to_string(service_native_dreaming_readback_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_service_native_dreaming_report_summary(&report)?; - assert_service_native_dreaming_report_jobs(&report)?; - assert_service_native_dreaming_materialization(&materialization)?; - assert_service_native_dreaming_docs(&markdown, &benchmarking_index, &readme); - - Ok(()) -} - -fn assert_service_native_dreaming_report_summary(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/adapter/adapter_id").and_then(Value::as_str), - Some("elf_service_native_dreaming") - ); - assert_eq!( - report.pointer("/adapter/behavior").and_then(Value::as_str), - Some("service_native_dreaming_readback") - ); - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(11)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(9)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(2)); - assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!(report.pointer("/summary/quote_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!( - report.pointer("/summary/memory_summary/source_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/trace_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/source_mutation_count").and_then(Value::as_u64), - Some(0) - ); - - let suites = array_at(report, "/suites")?; - let memory = find_by_field(suites, "/suite_id", "memory_summary")?; - let proactive = find_by_field(suites, "/suite_id", "proactive_brief")?; - let scheduled = find_by_field(suites, "/suite_id", "scheduled_memory")?; - - assert_eq!(memory.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(proactive.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); - - Ok(()) -} - -fn assert_service_native_dreaming_report_jobs(report: &Value) -> Result<()> { - let jobs = array_at(report, "/jobs")?; - let memory = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - let daily = find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; - let private_brief = - find_by_field(jobs, "/job_id", "proactive-private-corpus-refresh-blocked-001")?; - let weekly = find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; - let private_scheduled = - find_by_field(jobs, "/job_id", "scheduled-private-provider-scheduler-blocked-001")?; - - assert_eq!(memory.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(daily.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(weekly.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(private_brief.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(private_scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert!(!array_contains_str(memory, "/produced_evidence", "stale-summary-gap")?); - assert!(!array_contains_str(memory, "/produced_evidence", "summary-temporary-claim")?); - assert!(!array_contains_str(daily, "/produced_evidence", "daily-old-parity-trap")?); - assert!(!array_contains_str( - weekly, - "/produced_evidence", - "scheduled-weekly-hosted-parity-trap" - )?); - - Ok(()) -} - -fn assert_service_native_dreaming_materialization(materialization: &Value) -> Result<()> { - assert_eq!( - materialization.pointer("/schema").and_then(Value::as_str), - Some("elf.real_world_live_adapter_materialization/v1") - ); - assert_eq!( - materialization.pointer("/adapter_id").and_then(Value::as_str), - Some("elf_service_native_dreaming") - ); - assert_eq!(materialization.pointer("/status").and_then(Value::as_str), Some("blocked")); - - let jobs = array_at(materialization, "/jobs")?; - let memory = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - let daily = find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; - let private_brief = - find_by_field(jobs, "/job_id", "proactive-private-corpus-refresh-blocked-001")?; - - for job in jobs { - match job.pointer("/status").and_then(Value::as_str) { - Some("pass") => { - assert_eq!( - job.pointer("/dreaming_readback/runtime_path").and_then(Value::as_str), - Some("ElfService::add_note -> ElfService::list -> derived readback artifact") - ); - assert!(array_at(job, "/dreaming_readback/missing_source_refs")?.is_empty()); - assert_eq!( - job.pointer("/dreaming_readback/source_mutation_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - job.pointer("/dreaming_readback/no_source_mutation_checked") - .and_then(Value::as_bool), - Some(true) - ); - }, - Some("blocked") => { - assert!(job.pointer("/dreaming_readback").is_none_or(Value::is_null)); - }, - status => { - return Err(eyre::eyre!( - "unexpected service-native materialization status: {status:?}" - )); - }, - } - } - - assert!(array_contains_str( - memory, - "/dreaming_readback/selected_source_refs", - "stale-summary-gap" - )?); - assert!(!array_contains_str(memory, "/evidence_ids", "stale-summary-gap")?); - assert!(array_contains_str( - daily, - "/dreaming_readback/selected_source_refs", - "daily-old-parity-trap" - )?); - assert!(!array_contains_str(daily, "/evidence_ids", "daily-old-parity-trap")?); - assert!(private_brief.pointer("/dreaming_readback").is_none_or(Value::is_null)); - - Ok(()) -} - -fn assert_service_native_dreaming_docs(markdown: &str, benchmarking_index: &str, readme: &str) { - assert!(markdown.contains("9 pass")); - assert!(markdown.contains("0 wrong_result")); - assert!(markdown.contains("2 typed blocked")); - assert!(markdown.contains("ElfService::add_note -> ElfService::list")); - assert!(markdown.contains("Do not claim ELF broadly beats OpenAI Pulse")); - assert!(benchmarking_index.contains("2026-06-19-service-native-dreaming-readback-report.md")); - assert!(readme.contains("Service-native Dreaming readback after XY-986")); - assert!(readme.contains("real-world-memory-service-native-dreaming")); -} - -#[test] -fn dreaming_review_queue_report_wires_reviewable_policy_contract() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - dreaming_review_queue_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(dreaming_review_queue_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let workspace = workspace_root()?; - let service = - fs::read_to_string(workspace.join("packages/elf-service/src/dreaming_review_queue.rs"))?; - let service_lib = fs::read_to_string(workspace.join("packages/elf-service/src/lib.rs"))?; - let routes = fs::read_to_string(workspace.join("apps/elf-api/src/routes.rs"))?; - let mcp = fs::read_to_string(workspace.join("apps/elf-mcp/src/server.rs"))?; - let consolidation_spec = - fs::read_to_string(workspace.join("docs/spec/system_consolidation_proposals_v1.md"))?; - let service_spec = - fs::read_to_string(workspace.join("docs/spec/system_elf_memory_service_v2.md"))?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.dreaming_review_queue_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1021")); - assert_eq!( - report.pointer("/summary/queue_schema").and_then(Value::as_str), - Some("elf.dreaming_review_queue/v1") - ); - assert_eq!( - report.pointer("/summary/source_mutation_allowed").and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report.pointer("/summary/high_impact_requires_review").and_then(Value::as_bool), - Some(true) - ); - assert_eq!(report.pointer("/summary/variant_count").and_then(Value::as_u64), Some(9)); - - for suite in ["memory_summary", "proactive_brief", "scheduled_memory", "consolidation"] { - assert!(array_contains_str(&report, "/summary/covered_existing_suites", suite)?); - } - for variant in - ["tag", "duplicate_merge", "page_rebuild", "memory_promotion", "graph_fact", "correction"] - { - assert!(array_contains_str(&report, "/summary/covered_future_variants", variant)?); - - find_by_field(array_at(&report, "/queue_variants")?, "/variant", variant)?; - } - for field in [ - "source_refs", - "affected_refs", - "confidence", - "unsupported_claim_flags", - "diff", - "policy", - "review_audit", - ] { - assert!(array_contains_str(&report, "/required_item_fields", field)?); - } - - assert!(service.contains("ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1")); - assert!(service.contains("pub async fn dreaming_review_queue")); - assert!(service.contains("source_mutation_allowed: false")); - assert!(service.contains("low_risk_derived_organization")); - assert!(service.contains("available_review_actions")); - assert!(service_lib.contains("pub mod dreaming_review_queue")); - assert!(service_lib.contains("DreamingReviewQueueResponse")); - assert!(routes.contains("/v2/admin/dreaming/review-queue")); - assert!(routes.contains("DreamingReviewQueueRequest")); - assert!(routes.contains("async fn dreaming_review_queue")); - assert!(mcp.contains("elf_dreaming_review_queue")); - assert!(mcp.contains("dreaming_review_queue_schema")); - assert!(mcp.contains("/v2/admin/dreaming/review-queue")); - assert!(consolidation_spec.contains("elf.dreaming_review_queue/v1")); - assert!(consolidation_spec.contains("source_mutation_allowed")); - assert!(consolidation_spec.contains("duplicate_merge")); - assert!(service_spec.contains("GET /v2/admin/dreaming/review-queue")); - assert!(service_spec.contains("source refs, affected refs, confidence")); - assert!(markdown.contains("Dreaming Review Queue Report")); - assert!(markdown.contains("Auto-apply is limited to approved low-risk")); - assert!(benchmarking_index.contains("2026-06-20-dreaming-review-queue-report.md")); - assert!(readme.contains("Dreaming review queue after XY-1021")); - assert!(readme.contains("elf.dreaming_review_queue/v1")); - - Ok(()) -} - -fn assert_recall_debug_source_contract(sources: &RecallDebugSourceContract<'_>) { - assert!(sources.service.contains("ELF_RECALL_DEBUG_PANEL_SCHEMA_V1")); - assert!(sources.service.contains("ELF_RECALL_TRACE_SCHEMA_V1")); - assert!(sources.service.contains("pub async fn recall_debug_panel")); - assert!(sources.service.contains("build_recall_trace")); - assert!(sources.service.contains("not_requested_layer")); - assert!(sources.service.contains("blocked_layer")); - assert!(sources.service.contains("public_error_class")); - assert!(sources.service.contains("candidate_identity")); - assert!(sources.service.contains("ORG_PROJECT_ID")); - assert!(sources.service.contains("trace_bundle_get")); - assert!(sources.service.contains("docs_search_l0")); - assert!(sources.service.contains("knowledge_pages_search")); - assert!(sources.service.contains("graph_report")); - assert!(sources.service.contains("dreaming_review_queue")); - assert!(sources.service_lib.contains("pub mod recall_debug")); - assert!(sources.service_lib.contains("RecallDebugPanelResponse")); - assert!(sources.service_lib.contains("RecallTrace")); - assert!(sources.routes.contains("/v2/recall-debug/panel")); - assert!(sources.routes.contains("/v2/admin/recall-debug/panel")); - assert!(sources.routes.contains("async fn recall_debug_panel")); - assert!(sources.routes.contains("RecallDebugPanelRequest")); - assert!(sources.mcp.contains("elf_recall_debug_panel")); - assert!(sources.mcp.contains("recall_debug_panel_schema")); - assert!(sources.mcp.contains("/v2/recall-debug/panel")); - assert!(sources.recall_spec.contains("elf.recall_debug_panel/v1")); - assert!(sources.recall_spec.contains("elf.recall_trace/v1")); - assert!(sources.recall_spec.contains("not_requested")); - assert!(sources.recall_spec.contains("evidence_class = \"blocked\"")); - assert!(sources.recall_spec.contains("effective `top_k` cap of 32")); - assert!(sources.recall_spec.contains("context_state = \"stale\"")); - assert!(sources.recall_spec.contains("selected`, `dropped`, `available`, or `reviewable`")); - assert!(sources.service_spec.contains("POST /v2/recall-debug/panel")); - assert!(sources.service_spec.contains("POST /v2/admin/recall-debug/panel")); - assert!(sources.service_spec.contains("elf.recall_trace/v1")); - assert!(sources.service_spec.contains("system_recall_debug_panel_v1.md")); - assert!(sources.version_registry.contains("elf.recall_debug_panel/v1")); - assert!(sources.version_registry.contains("elf.recall_trace/v1")); - assert!(sources.markdown.contains("Recall Debug Panel Report")); - assert!(sources.markdown.contains("POST /v2/recall-debug/panel")); - assert!(sources.markdown.contains("`elf.recall_trace/v1`")); - assert!(sources.markdown.contains("Missing anchors stay visible as `not_requested`")); - assert!(sources.markdown.contains("retained dropped replay candidates")); - assert!(sources.markdown.contains("effective cap of 32 rows")); - assert!(sources.benchmarking_index.contains("2026-06-20-recall-debug-panel-report.md")); - assert!(sources.readme.contains("Recall/debug panel after XY-1022")); - assert!(sources.readme.contains("elf.recall_debug_panel/v1")); - assert!(sources.readme.contains("retained dropped replay candidates")); -} - -#[test] -fn recall_debug_panel_report_wires_cross_layer_debug_contract() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - recall_debug_panel_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(recall_debug_panel_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let workspace = workspace_root()?; - let service = fs::read_to_string(workspace.join("packages/elf-service/src/recall_debug.rs"))?; - let service_lib = fs::read_to_string(workspace.join("packages/elf-service/src/lib.rs"))?; - let routes = fs::read_to_string(workspace.join("apps/elf-api/src/routes.rs"))?; - let mcp = fs::read_to_string(workspace.join("apps/elf-mcp/src/server.rs"))?; - let recall_spec = - fs::read_to_string(workspace.join("docs/spec/system_recall_debug_panel_v1.md"))?; - let service_spec = - fs::read_to_string(workspace.join("docs/spec/system_elf_memory_service_v2.md"))?; - let version_registry = - fs::read_to_string(workspace.join("docs/spec/system_version_registry.md"))?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.recall_debug_panel_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1022")); - assert_eq!( - report.pointer("/service_contract/response_schema").and_then(Value::as_str), - Some("elf.recall_debug_panel/v1") - ); - assert_eq!( - report.pointer("/service_contract/trace_schema").and_then(Value::as_str), - Some("elf.recall_trace/v1") - ); - assert_eq!( - report.pointer("/service_contract/read_model_only").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report.pointer("/service_contract/raw_sql_needed").and_then(Value::as_bool), - Some(false) - ); - assert_eq!(report.pointer("/layer_contract/layer_count").and_then(Value::as_u64), Some(5)); - - let layers = array_at(&report, "/layer_contract/layers")?; - - for (layer, authority, replay) in [ - ("memory_notes", "memory_note", "elf_admin_trace_bundle_get"), - ("source_documents", "source_library", "elf_docs_search_l0"), - ("knowledge_pages", "derived_knowledge_page", "elf_recall_debug_panel"), - ("graph_facts", "graph_fact", "elf_graph_report"), - ("dreaming_proposals", "reviewable_dreaming_proposal", "elf_dreaming_review_queue"), - ] { - let row = find_by_field(layers, "/layer", layer)?; - - assert_eq!(row.pointer("/authority_layer").and_then(Value::as_str), Some(authority)); - assert_eq!(row.pointer("/replay_surface").and_then(Value::as_str), Some(replay)); - assert_eq!(row.pointer("/evidence_class").and_then(Value::as_str), Some("pass")); - } - - let memory = find_by_field(layers, "/layer", "memory_notes")?; - let docs = find_by_field(layers, "/layer", "source_documents")?; - - assert!(array_contains_str(memory, "/selection_states", "selected")?); - assert!(array_contains_str(memory, "/selection_states", "dropped")?); - assert_eq!(docs.pointer("/effective_limit").and_then(Value::as_u64), Some(32)); - assert_eq!( - report.pointer("/debug_invariants/not_requested_layers_preserved").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report - .pointer("/debug_invariants/selected_and_dropped_memory_candidates") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report - .pointer("/debug_invariants/requested_layer_failures_preserved_as_blocked") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report.pointer("/debug_invariants/no_source_mutation").and_then(Value::as_bool), - Some(true) - ); - - assert_recall_debug_source_contract(&RecallDebugSourceContract { - service: &service, - service_lib: &service_lib, - routes: &routes, - mcp: &mcp, - recall_spec: &recall_spec, - service_spec: &service_spec, - version_registry: &version_registry, - markdown: &markdown, - benchmarking_index: &benchmarking_index, - readme: &readme, - }); - - Ok(()) -} - -#[test] -fn agent_knowledge_os_closeout_benchmark_preserves_full_matrix_boundaries() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - agent_knowledge_os_closeout_benchmark_report_json_path()?, - )?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.agent_knowledge_os_closeout_benchmark_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1023")); - assert_eq!( - report.pointer("/summary/strongest_measured_integrated_product").and_then(Value::as_str), - Some("ELF integrated Agent Knowledge OS") - ); - assert_eq!( - report.pointer("/all_project_fixture_rerun/status").and_then(Value::as_str), - Some("pass") - ); - assert_eq!( - report.pointer("/all_project_fixture_rerun/job_count").and_then(Value::as_u64), - Some(62) - ); - assert_eq!(report.pointer("/all_project_fixture_rerun/pass").and_then(Value::as_u64), Some(55)); - assert_eq!(report.pointer("/summary/product_count").and_then(Value::as_u64), Some(19)); - assert_eq!(report.pointer("/summary/scenario_count").and_then(Value::as_u64), Some(6)); - assert_eq!( - report - .pointer("/summary/not_every_product_has_complete_live_coverage") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report.pointer("/summary/evidence_class_counts/pass").and_then(Value::as_u64), - Some(9) - ); - assert_eq!( - report.pointer("/summary/evidence_class_counts/not_tested").and_then(Value::as_u64), - Some(78) - ); - - let scenarios = array_at(&report, "/supported_scenarios")?; - let matrix = array_at(&report, "/product_matrix")?; - - for scenario in [ - "source_library_ingest_hydration", - "memory_authority_history_read_profiles", - "knowledge_workspace_pages", - "temporal_topic_graph_lite", - "dreaming_review_queue", - "recall_debug_panel", - ] { - find_by_field(scenarios, "/id", scenario)?; - } - - let elf = find_by_field(matrix, "/product", "ELF")?; - - for scenario in [ - "source_library_ingest_hydration", - "memory_authority_history_read_profiles", - "knowledge_workspace_pages", - "temporal_topic_graph_lite", - "dreaming_review_queue", - "recall_debug_panel", - ] { - assert_eq!( - elf.pointer(&format!("/statuses/{scenario}")).and_then(Value::as_str), - Some("pass") - ); - } - - let qmd = find_by_field(matrix, "/product", "qmd")?; - - assert_eq!( - qmd.pointer("/statuses/recall_debug_panel").and_then(Value::as_str), - Some("wrong_result") - ); - assert!( - qmd.pointer("/strongest_advantage") - .and_then(Value::as_str) - .is_some_and(|value| value.contains("weighted fusion")) - ); - - for product in ["VectifyAI PageIndex", "VectifyAI OpenKB"] { - let row = find_by_field(matrix, "/product", product)?; - - assert_eq!(row.pointer("/coverage").and_then(Value::as_str), Some("reference_only")); - assert_eq!( - row.pointer("/statuses/knowledge_workspace_pages").and_then(Value::as_str), - Some("not_tested") - ); - } - - assert_eq!( - report.pointer("/claim_boundaries/no_broad_superiority_claim").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report - .pointer("/claim_boundaries/reference_only_projects_do_not_count_as_pass") - .and_then(Value::as_bool), - Some(true) - ); - assert!(array_contains_str( - &report, - "/source_evidence", - "https://github.com/VectifyAI/PageIndex" - )?); - assert!(array_contains_str( - &report, - "/source_evidence", - "https://github.com/VectifyAI/OpenKB" - )?); - - Ok(()) -} - -#[test] -fn agent_knowledge_os_closeout_benchmark_wires_docs_and_optimization_queue() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - agent_knowledge_os_closeout_benchmark_report_json_path()?, - )?)?; - let markdown = - fs::read_to_string(agent_knowledge_os_closeout_benchmark_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let queue = array_at(&report, "/optimization_queue")?; - - for item in queue { - assert_eq!(item.pointer("/generated_from_delta").and_then(Value::as_bool), Some(true)); - } - for key in [ - "pageindex_openkb_source_library_adapter", - "qmd_retrieval_knobs_and_short_replay", - "operator_knowledge_library_ui", - "openviking_context_trajectory_artifacts", - "graph_rag_temporal_adapter_matrix", - ] { - let item = find_by_field(queue, "/key", key)?; - - assert_eq!(item.pointer("/generated_from_delta").and_then(Value::as_bool), Some(true)); - } - - assert!(markdown.contains("ELF is the strongest measured integrated product")); - assert!(markdown.contains("complete live coverage")); - assert!(markdown.contains("VectifyAI PageIndex")); - assert!(markdown.contains("VectifyAI OpenKB")); - assert!(markdown.contains("Do not claim ELF broadly beats every competitor")); - assert!( - benchmarking_index.contains("2026-06-20-agent-knowledge-os-closeout-benchmark-report.md") - ); - assert!(readme.contains("Agent Knowledge OS closeout after XY-1023")); - assert!(readme.contains("62 jobs, 55 pass")); - assert!(readme.contains("VectifyAI PageIndex/OpenKB")); - assert!(readme.contains("strongest measured integrated")); - - Ok(()) -} - -#[test] -fn p2_knowledge_workspace_closeout_preserves_pageindex_openkb_boundaries() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - p2_knowledge_workspace_pageindex_openkb_closeout_report_json_path()?, - )?)?; - let markdown = fs::read_to_string( - p2_knowledge_workspace_pageindex_openkb_closeout_report_markdown_path()?, - )?; - let makefile = fs::read_to_string(workspace_root()?.join("Makefile.toml"))?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let benchmark_runbook = fs::read_to_string( - workspace_root()? - .join("docs") - .join("runbook") - .join("benchmarking") - .join("real_world_agent_memory_benchmark.md"), - )?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.p2_knowledge_workspace_pageindex_openkb_closeout_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1066")); - assert_eq!( - report.pointer("/self_assessment/verdict").and_then(Value::as_str), - Some("pass_with_reference_only_competitor_boundary") - ); - assert_eq!(report.pointer("/typed_state_summary/pass").and_then(Value::as_u64), Some(2)); - assert_eq!( - report.pointer("/typed_state_summary/wrong_result").and_then(Value::as_u64), - Some(0) - ); - assert_eq!(report.pointer("/typed_state_summary/incomplete").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/typed_state_summary/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/typed_state_summary/not_tested").and_then(Value::as_u64), Some(2)); - - let results = array_at(&report, "/elf_same_corpus_results")?; - let source_library = find_by_field(results, "/suite", "source_library")?; - let knowledge = find_by_field(results, "/suite", "knowledge_compilation")?; - - assert_eq!(source_library.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(source_library.pointer("/jobs").and_then(Value::as_u64), Some(2)); - assert_eq!(knowledge.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(knowledge.pointer("/jobs").and_then(Value::as_u64), Some(3)); - assert!(array_contains_str( - knowledge, - "/coverage", - "Changed-source watch/rebuild reports changed, stale, and reviewable memory-candidate outputs without source mutation." - )?); - - let matrix = array_at(&report, "/comparison_matrix")?; - let pageindex = find_by_field(matrix, "/target", "VectifyAI PageIndex")?; - let openkb = find_by_field(matrix, "/target", "VectifyAI OpenKB")?; - let p3 = find_by_field(matrix, "/target", "P3 PageIndex/OpenKB adapter queue")?; - - assert_eq!(pageindex.pointer("/status").and_then(Value::as_str), Some("not_tested")); - assert_eq!(openkb.pointer("/status").and_then(Value::as_str), Some("not_tested")); - assert_eq!(p3.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - report - .pointer("/p3_queue_decision/ready_to_queue_after_main_thread_acceptance") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report.pointer("/p3_queue_decision/queued_label_applied").and_then(Value::as_bool), - Some(false) - ); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not claim ELF beats PageIndex or OpenKB." - )?); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not queue a P3 issue in this lane." - )?); - assert!(markdown.contains("P2 Knowledge Workspace PageIndex/OpenKB Closeout Report")); - assert!(markdown.contains("VectifyAI PageIndex")); - assert!(markdown.contains("VectifyAI OpenKB")); - assert!(markdown.contains("This report does not apply `decodex:queued:elf`")); - assert!(makefile.contains("[tasks.real-world-memory-p2-knowledge-closeout]")); - assert!(makefile.contains("\"real-world-memory-source-library-report\"")); - assert!(makefile.contains("\"real-world-memory-knowledge-report\"")); - assert!( - benchmarking_index - .contains("2026-06-22-p2-knowledge-workspace-pageindex-openkb-closeout-report.md") - ); - assert!(readme.contains("P2 Knowledge Workspace PageIndex/OpenKB closeout after XY-1066")); - assert!(readme.contains("real-world-memory-p2-knowledge-closeout")); - assert!(benchmark_runbook.contains("cargo make real-world-memory-p2-knowledge-closeout")); - - Ok(()) -} - -#[test] -fn operator_approved_public_proxy_private_addendum_preserves_boundary() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - operator_approved_public_proxy_private_addendum_report_json_path()?, - )?)?; - let markdown = fs::read_to_string( - operator_approved_public_proxy_private_addendum_report_markdown_path()?, - )?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.operator_approved_public_proxy_baseline_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-930")); - assert_eq!(report.pointer("/command/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - report.pointer("/command/run_id").and_then(Value::as_str), - Some("live-baseline-20260619143959") - ); - assert_eq!( - report.pointer("/corpus/profile").and_then(Value::as_str), - Some("production-private") - ); - assert_eq!( - report.pointer("/corpus/runner_track").and_then(Value::as_str), - Some("private_production") - ); - assert_eq!( - report.pointer("/corpus/manifest_kind").and_then(Value::as_str), - Some("operator_approved_public_proxy") - ); - assert_eq!( - report.pointer("/corpus/manifest_id").and_then(Value::as_str), - Some("operator-approved-public-proxy-prod-corpus-2026-06-19") - ); - assert_eq!(report.pointer("/embedding/mode").and_then(Value::as_str), Some("local")); - assert_eq!( - report.pointer("/embedding/provider_backed_quality_proven").and_then(Value::as_bool), - Some(false) - ); - assert_eq!(report.pointer("/summary/project_status").and_then(Value::as_str), Some("pass")); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/check_summary/total").and_then(Value::as_u64), Some(8)); - assert_eq!(report.pointer("/check_summary/pass").and_then(Value::as_u64), Some(8)); - assert_eq!( - report.pointer("/query_summary/wrong_result_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!(report.pointer("/backfill/completed_count").and_then(Value::as_u64), Some(12)); - assert_eq!(report.pointer("/backfill/duplicate_source_notes").and_then(Value::as_u64), Some(0)); - - let queries = array_at(&report, "/queries")?; - let provider = find_by_field(queries, "/id", "q-explain-provider-blocker")?; - - assert_eq!(queries.len(), 8); - assert_eq!( - provider.pointer("/top_evidence").and_then(Value::as_str), - Some("blocker-provider-missing") - ); - assert_eq!(provider.pointer("/matched").and_then(Value::as_bool), Some(true)); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not call this real private-corpus production proof." - )?); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not claim provider-backed production quality; embedding mode was local." - )?); - assert!(array_contains_str( - &report, - "/improvement_regression_readback/unchanged", - "Real private-corpus production quality is still not proven." - )?); - assert!(array_contains_str( - &report, - "/next_optimization_direction/when_operator_inputs_exist", - "Run provider-backed embeddings with ELF_BASELINE_ELF_EMBEDDING_MODE=provider and a routed provider setup." - )?); - assert!(markdown.contains("proxy corpus pass")); - assert!(markdown.contains("Do not call this real private-corpus production proof.")); - assert!(markdown.contains("| Embedding mode | `local` |")); - assert!( - benchmarking_index - .contains("2026-06-19-operator-approved-public-proxy-production-private-addendum.md") - ); - assert!(benchmarking_index.contains("not real private-corpus or provider-backed proof")); - assert!(readme.contains("Operator-approved public-proxy addendum after XY-930")); - assert!(readme.contains("8/8 query passes")); - assert!(readme.contains("does not prove real private-corpus production quality")); - - Ok(()) -} - -#[test] -fn openmemory_ui_export_product_recheck_preserves_blocked_boundary() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - openmemory_ui_export_product_readback_report_json_path()?, - )?)?; - let markdown = - fs::read_to_string(openmemory_ui_export_product_readback_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.openmemory_ui_export_product_recheck_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-987")); - assert_eq!( - report.pointer("/command/command").and_then(Value::as_str), - Some("cargo make openmemory-ui-export-readback") - ); - assert_eq!(report.pointer("/command/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - report.pointer("/command/probe_artifact").and_then(Value::as_str), - Some("tmp/live-baseline/mem0-openmemory-ui-export.json") - ); - assert_eq!(report.pointer("/run/sdk_check_summary/pass").and_then(Value::as_u64), Some(8)); - assert_eq!(report.pointer("/run/ui_export_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - report.pointer("/run/ui_export_reason_code").and_then(Value::as_str), - Some("DOCKER_UNAVAILABLE_IN_BASELINE_RUNNER") - ); - assert_eq!( - report - .pointer("/same_corpus_boundary/sdk_get_all_is_ui_export_evidence") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report - .pointer("/openmemory_product_surface/export_requires_running_container") - .and_then(Value::as_bool), - Some(true) - ); - assert!( - report - .pointer("/openmemory_probe/attempt/output_excerpt") - .and_then(Value::as_str) - .is_some_and(|excerpt| excerpt.contains("docker: command not found") - && excerpt.contains("Container 'openmemory-openmemory-mcp-1' not found/running")) - ); - assert_eq!( - report.pointer("/classification/comparison_judgment").and_then(Value::as_str), - Some("unchanged") - ); - assert_eq!( - report - .pointer("/claim_boundary/product_browser_or_dashboard_readback_reached") - .and_then(Value::as_bool), - Some(false) - ); - assert!(array_contains_str( - &report, - "/improvement_regression_readback/unchanged", - "OpenMemory product UI/export readback remains blocked before same-corpus product app database validation." - )?); - assert!(array_contains_str( - &report, - "/next_optimization_direction/required_fields", - "same_corpus_import_into_openmemory_app_database" - )?); - assert!(markdown.contains("OpenMemory UI/export product-readback status is unchanged")); - assert!(markdown.contains("Product browser/dashboard readback reached")); - assert!( - benchmarking_index.contains("2026-06-19-openmemory-ui-export-product-readback-report.md") - ); - assert!(readme.contains("OpenMemory UI/Export Product Readback Report - June 19, 2026")); - assert!(readme.contains("OpenMemory UI/export product recheck after XY-987")); - - Ok(()) -} - -#[test] -fn graph_rag_citation_navigation_promotion_preserves_typed_non_passes() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - graph_rag_citation_navigation_promotion_report_json_path()?, - )?)?; - let markdown = - fs::read_to_string(graph_rag_citation_navigation_promotion_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.graph_rag_citation_navigation_promotion_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-985")); - assert_eq!( - report.pointer("/command/command").and_then(Value::as_str), - Some("cargo make real-world-memory-graph-rag") - ); - assert_eq!(report.pointer("/command/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - report.pointer("/summary/overall_judgment").and_then(Value::as_str), - Some("unchanged_typed_non_pass") - ); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(0.25)); - assert_eq!( - report.pointer("/summary/knowledge_citation_coverage").and_then(Value::as_f64), - Some(0.667) - ); - - let scenarios = array_at(&report, "/scenario_outcomes")?; - let ragflow = find_by_field(scenarios, "/project", "RAGFlow")?; - let lightrag = find_by_field(scenarios, "/project", "LightRAG")?; - let graphrag = find_by_field(scenarios, "/project", "GraphRAG")?; - let graphiti = find_by_field(scenarios, "/project", "Graphiti/Zep")?; - let graphify = find_by_field(scenarios, "/project", "graphify")?; - let llm_wiki = find_by_field(scenarios, "/project", "llm-wiki")?; - let gbrain = find_by_field(scenarios, "/project", "gbrain")?; - - assert_eq!(ragflow.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!(lightrag.pointer("/current_status").and_then(Value::as_str), Some("incomplete")); - assert_eq!(graphrag.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!(graphiti.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!(graphify.pointer("/current_status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(llm_wiki.pointer("/current_status").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(gbrain.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert!(array_contains_str(graphify, "/produced_evidence", "graphify-source-location-output")?); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not claim graph/RAG parity or broad graph-navigation quality." - )?); - assert!(array_contains_str( - &report, - "/next_optimization_direction/required_fields", - "graphrag_output_table_rows_with_generated_evidence_ids" - )?); - assert!(markdown.contains("typed non-pass, no parity claim")); - assert!( - markdown.contains("graphify produces evidence-linked output but still scores wrong_result") - ); - assert!( - benchmarking_index.contains("2026-06-19-graph-rag-citation-navigation-promotion-report.md") - ); - assert!(readme.contains("Graph/RAG Citation and Navigation Promotion Report - June 19, 2026")); - assert!(readme.contains("Graph/RAG citation/navigation promotion after XY-985")); - - Ok(()) -} - -#[test] -fn graph_rag_adapter_matrix_report_preserves_no_parity_claims() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - graph_rag_adapter_matrix_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(graph_rag_adapter_matrix_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.graph_rag_adapter_matrix_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1071")); - assert_eq!(report.pointer("/summary/matrix_row_count").and_then(Value::as_u64), Some(18)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(8)); - assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(4)); - assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(6)); - assert_eq!( - report.pointer("/summary/broad_graph_rag_parity").and_then(Value::as_str), - Some("not_proven") - ); - - let rows = array_at(&report, "/adapter_matrix")?; - let ragflow_citation = find_matrix_row(rows, "RAGFlow", "citation_quality")?; - let lightrag_retrieval = find_matrix_row(rows, "LightRAG", "retrieval_quality")?; - let graphrag_navigation = find_matrix_row(rows, "GraphRAG", "navigation_quality")?; - let graphrag_retrieval = find_matrix_row(rows, "GraphRAG", "retrieval_quality")?; - - assert_eq!(ragflow_citation.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(lightrag_retrieval.pointer("/status").and_then(Value::as_str), Some("incomplete")); - assert_eq!(graphrag_navigation.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(graphrag_retrieval.pointer("/status").and_then(Value::as_str), Some("not_encoded")); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not reposition ELF as a generic RAG platform from this adapter matrix." - )?); - assert!(markdown.contains("The graph/RAG comparison remains typed non-pass")); - assert!(markdown.contains("| RAGFlow | `blocked`: answer text plus selected reference chunks")); - assert!(benchmarking_index.contains("2026-06-23-graph-rag-adapter-matrix-report.md")); - assert!(readme.contains("RAGFlow/GraphRAG/LightRAG adapter matrix after XY-1071")); - assert!(readme.contains("Graph/RAG Adapter Matrix Report - June 23, 2026")); - - Ok(()) -} - -#[test] -fn p3_competitor_strength_absorption_report_preserves_claim_boundaries() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - p3_competitor_strength_absorption_report_json_path()?, - )?)?; - let markdown = fs::read_to_string(p3_competitor_strength_absorption_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.p3_competitor_strength_absorption_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1072")); - assert_eq!( - report.pointer("/self_assessment/verdict").and_then(Value::as_str), - Some("pass_with_p4_queue_ready_after_main_thread_acceptance") - ); - assert_eq!( - report.pointer("/self_assessment/p4_queued_label_applied").and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report - .pointer("/self_assessment/typed_non_pass_states_are_not_wins") - .and_then(Value::as_bool), - Some(true) - ); - - let products = array_at(&report, "/product_strengths")?; - - for product in [ - "qmd", - "VectifyAI PageIndex", - "VectifyAI OpenKB", - "mem0/OpenMemory", - "Letta", - "Graphiti/Zep", - "OpenViking", - "RAGFlow", - "GraphRAG", - "LightRAG", - ] { - find_by_field(products, "/product", product)?; - } - - let qmd = find_by_field(products, "/product", "qmd")?; - let pageindex = find_by_field(products, "/product", "VectifyAI PageIndex")?; - let mem0 = find_by_field(products, "/product", "mem0/OpenMemory")?; - let graphiti = find_by_field(products, "/product", "Graphiti/Zep")?; - let lightrag = find_by_field(products, "/product", "LightRAG")?; - - assert_eq!(qmd.pointer("/current_status").and_then(Value::as_str), Some("mixed")); - assert!( - qmd.pointer("/remains_stronger_elsewhere") - .and_then(Value::as_str) - .is_some_and(|value| value.contains("top-k JSON")) - ); - assert_eq!(pageindex.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - mem0.pointer("/current_status").and_then(Value::as_str), - Some("split_pass_and_blocked") - ); - assert_eq!(graphiti.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - lightrag.pointer("/current_status").and_then(Value::as_str), - Some("incomplete_or_not_encoded") - ); - - let queue = array_at(&report, "/p4_optimization_queue")?; - - for key in [ - "qmd_candidate_replay_parity", - "adapter_outcome_grammar_and_metrics", - "source_library_tree_and_wiki_adapters", - "memory_history_export_and_core_archive", - "temporal_trajectory_graph_rag_adapters", - ] { - let item = find_by_field(queue, "/key", key)?; - - assert_eq!( - item.pointer("/ready_after_main_thread_acceptance").and_then(Value::as_bool), - Some(true) - ); - assert_eq!(item.pointer("/queued_label_applied").and_then(Value::as_bool), Some(false)); - } - - assert_product_queue_items_reference_queue(products, queue)?; - - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Typed non-pass states are not wins." - )?); - assert!(array_contains_str( - &report, - "/claim_boundaries/not_allowed", - "Do not apply decodex:queued:elf to a P4 issue until the main thread accepts the P3 closeout." - )?); - assert!(markdown.contains("P3 is decision-ready for main-thread inspection")); - assert!(markdown.contains("Typed non-pass states are not wins")); - assert!(markdown.contains("No P4 issue receives `decodex:queued:elf`")); - assert!(benchmarking_index.contains("2026-06-23-p3-competitor-strength-absorption-report.md")); - assert!(readme.contains("P3 competitor-strength absorption closeout after XY-1072")); - assert!(readme.contains("`decodex:queued:elf` label")); - - Ok(()) -} - -fn assert_product_queue_items_reference_queue(products: &[Value], queue: &[Value]) -> Result<()> { - let queue_keys = queue - .iter() - .filter_map(|item| item.pointer("/key").and_then(Value::as_str)) - .collect::>(); - - for product in products { - let product_name = product - .pointer("/product") - .and_then(Value::as_str) - .ok_or_else(|| eyre::eyre!("product row is missing product name"))?; - let queue_item = product - .pointer("/p4_queue_item") - .and_then(Value::as_str) - .ok_or_else(|| eyre::eyre!("product {product_name} is missing p4_queue_item"))?; - - assert!( - queue_keys.contains(&queue_item), - "product {product_name} references missing P4 queue item {queue_item}" - ); - } - - Ok(()) -} - -fn find_matrix_row<'a>(rows: &'a [Value], adapter: &str, dimension: &str) -> Result<&'a Value> { - rows.iter() - .find(|row| { - row.pointer("/adapter").and_then(Value::as_str) == Some(adapter) - && row.pointer("/dimension").and_then(Value::as_str) == Some(dimension) - }) - .ok_or_else(|| eyre::eyre!("missing matrix row for {adapter} {dimension}")) -} - -#[test] -fn graph_topic_map_report_wires_source_backed_graph_lite_readback() -> Result<()> { - let markdown = fs::read_to_string(graph_topic_map_report_markdown_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let graph_report_service = - fs::read_to_string(workspace_root()?.join("packages/elf-service/src/graph_report.rs"))?; - let api_routes = fs::read_to_string(workspace_root()?.join("apps/elf-api/src/routes.rs"))?; - let mcp_server = fs::read_to_string(workspace_root()?.join("apps/elf-mcp/src/server.rs"))?; - let graph_spec = - fs::read_to_string(workspace_root()?.join("docs/spec/system_graph_memory_postgres_v1.md"))?; - - assert!(markdown.contains("Graph Topic-Map Report - June 20, 2026")); - assert!(markdown.contains("elf.graph_report/v1")); - assert!(markdown.contains("sourced")); - assert!(markdown.contains("inferred")); - assert!(markdown.contains("ambiguous")); - assert!(markdown.contains("stale")); - assert!(markdown.contains("superseded")); - assert!(markdown.contains("valid_from")); - assert!(markdown.contains("valid_to")); - assert!(markdown.contains("valid_at")); - assert!(markdown.contains("invalid_at")); - assert!(graph_report_service.contains("ELF_GRAPH_REPORT_SCHEMA_V1")); - assert!(graph_report_service.contains("GraphReportSummary")); - assert!(graph_report_service.contains("build_topic_map")); - assert!(api_routes.contains("/v2/graph/report")); - assert!(mcp_server.contains("elf_graph_report")); - assert!(graph_spec.contains("elf.graph_report/v1")); - assert!(graph_spec.contains("Graphiti/Zep `valid_at` and `invalid_at`")); - assert!(benchmarking_index.contains("2026-06-20-graph-topic-map-report.md")); - assert!(readme.contains("Graph Topic-Map Report - June 20, 2026")); - assert!(readme.contains("Graph topic-map reports after XY-1020")); - - Ok(()) -} - -fn assert_openviking_trajectory_materialization_summary(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.openviking_trajectory_materialization_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-983")); - assert_eq!( - report.pointer("/summary/overall_judgment").and_then(Value::as_str), - Some("materialized_blocked_context_trajectory_evidence") - ); - assert_eq!( - report.pointer("/summary/broader_superiority").and_then(Value::as_str), - Some("not_proven") - ); - assert_eq!(report.pointer("/summary/blockers_removed_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked_scenario_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/pass_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/regressed_scenario_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert!(array_contains_str( - report, - "/summary/unsupported_claims_rejected", - "ELF does not beat OpenViking staged retrieval trajectory from fixture-only blocked rows." - )?); - - Ok(()) -} - -fn assert_openviking_trajectory_materialization_command(report: &Value) -> Result<()> { - let command = find_by_field( - array_at(report, "/commands")?, - "/command", - "cargo make real-world-memory-context-trajectory", - )?; - let summary = - command.pointer("/summary").ok_or_else(|| eyre::eyre!("missing command summary"))?; - - assert_eq!(command.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - command.pointer("/artifact_json").and_then(Value::as_str), - Some("tmp/real-world-memory/context-trajectory/report.json") - ); - assert_eq!(summary.pointer("/job_count").and_then(Value::as_u64), Some(3)); - assert_eq!(summary.pointer("/pass").and_then(Value::as_u64), Some(0)); - assert_eq!(summary.pointer("/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(summary.pointer("/blocked").and_then(Value::as_u64), Some(3)); - assert_eq!(summary.pointer("/evidence_covered_count").and_then(Value::as_u64), Some(9)); - assert_eq!(summary.pointer("/source_ref_covered_count").and_then(Value::as_u64), Some(9)); - assert_eq!(summary.pointer("/quote_covered_count").and_then(Value::as_u64), Some(9)); - - Ok(()) -} - -fn assert_openviking_trajectory_materialization_scenarios(report: &Value) -> Result<()> { - let scenarios = array_at(report, "/scenario_materialization")?; - let staged = - find_by_field(scenarios, "/scenario_id", "openviking_staged_retrieval_trajectory")?; - let hierarchy = find_by_field(scenarios, "/scenario_id", "openviking_hierarchy_selection")?; - let recursive = - find_by_field(scenarios, "/scenario_id", "openviking_recursive_context_expansion")?; - - assert_eq!(scenarios.len(), 3); - - for scenario in [staged, hierarchy, recursive] { - assert_eq!(scenario.pointer("/previous_status").and_then(Value::as_str), Some("blocked")); - assert_eq!(scenario.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!(scenario.pointer("/judgment").and_then(Value::as_str), Some("unchanged")); - } - - assert!(array_contains_str( - staged, - "/produced_evidence", - "openviking-evidence-id-output-contract" - )?); - assert!(array_contains_str( - hierarchy, - "/produced_evidence", - "hierarchy-selection-output-contract" - )?); - assert!(array_contains_str( - recursive, - "/produced_evidence", - "recursive-expansion-output-contract" - )?); - assert_eq!( - staged.pointer("/claim_boundary").and_then(Value::as_str), - Some( - "No ELF win, tie, or loss is allowed until both systems publish comparable stage artifacts for the same context-trajectory scenario." - ) - ); - assert_eq!( - hierarchy.pointer("/blocker").and_then(Value::as_str), - Some("selected_hierarchy_nodes_and_evidence_ids_missing") - ); - assert_eq!( - recursive.pointer("/blocker").and_then(Value::as_str), - Some("expansion_paths_and_same_corpus_evidence_ids_missing") - ); - - Ok(()) -} - -fn assert_openviking_trajectory_materialization_boundaries(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/improvement_regression_readback/improved").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/improvement_regression_readback/blocked").and_then(Value::as_u64), - Some(3) - ); - assert!(array_contains_str( - report, - "/claim_boundaries/allowed", - "The context-trajectory slice is now reproducible through cargo make real-world-memory-context-trajectory." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries/not_allowed", - "Do not claim ELF beats OpenViking on staged retrieval trajectory." - )?); - assert!(array_contains_str( - report, - "/next_optimization_direction/required_fields", - "expansion_path" - )?); - assert_eq!( - report.pointer("/next_optimization_direction/non_goal").and_then(Value::as_str), - Some( - "No ELF product change or superiority claim is authorized by this materialization-only report." - ) - ); - - Ok(()) -} - -fn assert_openviking_trajectory_materialization_markdown_and_indexes( - markdown: &str, - benchmarking_index: &str, - readme: &str, -) { - assert!(markdown.contains("The OpenViking trajectory follow-up is now materialized")); - assert!(markdown.contains("3 encoded jobs, 0 pass, 3 blocked, 9/9 evidence coverage")); - assert!(markdown.contains("Do not claim ELF beats OpenViking on staged retrieval trajectory.")); - assert!(markdown.contains("OpenViking context-trajectory job can move from `blocked`")); - assert!( - benchmarking_index.contains("2026-06-19-openviking-trajectory-materialization-report.md") - ); - assert!(readme.contains("OpenViking Trajectory Materialization Report - June 19, 2026")); - assert!(readme.contains("cargo make real-world-memory-context-trajectory")); - assert!(readme.contains("3 typed blockers with 9/9 evidence coverage")); -} - -fn assert_xy955_commands(report: &Value) -> Result<()> { - let commands = array_at(report, "/commands")?; - let aggregate = find_by_field(commands, "/command", "cargo make real-world-memory")?; - let graph_rag = find_by_field(commands, "/command", "cargo make real-world-memory-graph-rag")?; - let first_generation = - find_by_field(commands, "/command", "cargo make real-world-first-generation-oss")?; - let live = find_by_field(commands, "/command", "cargo make real-world-memory-live-adapters")?; - - assert_eq!(aggregate.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(aggregate.pointer("/summary/pass").and_then(Value::as_u64), Some(53)); - assert_eq!(aggregate.pointer("/summary/blocked").and_then(Value::as_u64), Some(7)); - assert_eq!(graph_rag.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(graph_rag.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - assert_eq!(graph_rag.pointer("/summary/incomplete").and_then(Value::as_u64), Some(1)); - assert_eq!(graph_rag.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); - assert_eq!(first_generation.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); - assert_eq!(first_generation.pointer("/summary/blocked").and_then(Value::as_u64), Some(2)); - assert_eq!(live.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - live.pointer("/partial_summary/elf_live_real_world/pass").and_then(Value::as_u64), - Some(40) - ); - assert_eq!( - live.pointer("/partial_summary/elf_live_real_world/wrong_result").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - live.pointer("/partial_summary/qmd_live_real_world/pass").and_then(Value::as_u64), - Some(17) - ); - assert_eq!( - live.pointer("/partial_summary/qmd_live_real_world/wrong_result").and_then(Value::as_u64), - Some(13) - ); - - Ok(()) -} - -fn assert_xy955_stage_closeout(report: &Value) -> Result<()> { - let stages = array_at(report, "/stage_closeout")?; - - assert_eq!(stages.len(), 8); - - let current = find_by_field(stages, "/stage_id", "current_vs_historical_correctness")?; - let proactive = find_by_field(stages, "/stage_id", "proactive_brief_readiness")?; - let scheduled = find_by_field(stages, "/stage_id", "scheduled_memory_task_readiness")?; - let final_retest = find_by_field(stages, "/stage_id", "final_competitor_retest_status")?; - - assert_eq!(current.pointer("/judgment").and_then(Value::as_str), Some("improved")); - assert_eq!(current.pointer("/current_counts/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(current.pointer("/current_counts/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(proactive.pointer("/judgment").and_then(Value::as_str), Some("improved")); - assert_eq!(proactive.pointer("/current_counts/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(scheduled.pointer("/current_counts/pass").and_then(Value::as_u64), Some(4)); - assert_eq!(scheduled.pointer("/current_counts/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(final_retest.pointer("/judgment").and_then(Value::as_str), Some("unchanged")); - assert_eq!(final_retest.pointer("/current_counts/pass").and_then(Value::as_u64), Some(40)); - assert_eq!( - final_retest.pointer("/current_counts/wrong_result").and_then(Value::as_u64), - Some(0) - ); - assert_eq!(final_retest.pointer("/current_counts/blocked").and_then(Value::as_u64), Some(7)); - assert_eq!( - final_retest.pointer("/current_counts/not_encoded").and_then(Value::as_u64), - Some(19) - ); - assert!(final_retest.pointer("/boundary").and_then(Value::as_str).is_some_and(|boundary| { - boundary.contains("qmd now has a fresh scored live report") - && boundary.contains("broader superiority is not proven") - })); - assert_eq!(final_retest.pointer("/qmd_current_counts/pass").and_then(Value::as_u64), Some(17)); - assert_eq!( - final_retest.pointer("/qmd_current_counts/wrong_result").and_then(Value::as_u64), - Some(13) - ); - - Ok(()) -} - -fn assert_xy955_scenario_retests(report: &Value) -> Result<()> { - let scenarios = array_at(report, "/scenario_retests")?; - let qmd = find_by_field(scenarios, "/scenario_id", "qmd_debug_ergonomics")?; - let mem0 = - find_by_field(scenarios, "/scenario_id", "mem0_openmemory_preference_history_export")?; - let letta = find_by_field(scenarios, "/scenario_id", "letta_core_archive")?; - let graph_rag = find_by_field( - scenarios, - "/scenario_id", - "graph_rag_citation_navigation_knowledge_surfaces", - )?; - let private_provider = - find_by_field(scenarios, "/scenario_id", "private_provider_production_gates")?; - - assert_eq!(qmd.pointer("/current_outcome").and_then(Value::as_str), Some("unchanged")); - assert_eq!(qmd.pointer("/current_status").and_then(Value::as_str), Some("pass")); - assert!(qmd.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { - evidence.contains("17 pass") - && evidence.contains("13 wrong_result") - && evidence.contains("does not retest or erase") - })); - assert_eq!(mem0.pointer("/current_outcome").and_then(Value::as_str), Some("unchanged")); - assert!(mem0.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { - evidence.contains("mem0/OpenMemory local OSS history") - && evidence.contains("OpenMemory UI/export remains setup-blocked") - })); - assert_eq!(letta.pointer("/current_status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - graph_rag.pointer("/current_status").and_then(Value::as_str), - Some("typed_non_pass") - ); - assert!(graph_rag.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { - evidence.contains("0 pass") - && evidence.contains("1 wrong_result") - && evidence.contains("3 blocked") - })); - assert_eq!(private_provider.pointer("/follow_up").and_then(Value::as_str), Some("XY-930")); - - Ok(()) -} - -fn assert_xy955_optimization_queue(report: &Value) -> Result<()> { - let queue = array_at(report, "/optimization_queue")?; - let qmd = find_by_field(queue, "/issue", "XY-923")?; - let private_provider = find_by_field(queue, "/issue", "XY-930")?; - let openviking = find_by_field(queue, "/issue", "XY-928")?; - let letta = find_by_field(queue, "/issue", "letta-core-archive-adapter-brief")?; - let service_native = find_by_field(queue, "/issue", "service-native-dreaming-outputs-brief")?; - - assert_eq!(qmd.pointer("/status").and_then(Value::as_str), Some("existing")); - assert_eq!(private_provider.pointer("/status").and_then(Value::as_str), Some("existing")); - assert_eq!(openviking.pointer("/status").and_then(Value::as_str), Some("existing")); - assert_eq!(letta.pointer("/status").and_then(Value::as_str), Some("proposed")); - assert_eq!(service_native.pointer("/status").and_then(Value::as_str), Some("proposed")); - assert!(array_contains_str( - report, - "/claim_boundaries/not_allowed", - "Do not treat qmd full-suite wrong_result counts as a regression of qmd debug ergonomics." - )?); - - Ok(()) -} - -fn assert_xy955_follow_up_issue_briefs(report: &Value) -> Result<()> { - let existing = array_at(report, "/follow_up_issue_briefs/existing")?; - let proposed = array_at(report, "/follow_up_issue_briefs/proposed")?; - let qmd = find_by_field(existing, "/issue", "XY-923")?; - let private_provider = find_by_field(existing, "/issue", "XY-930")?; - let letta = find_by_field(proposed, "/issue", "letta-core-archive-adapter-brief")?; - let service_native = - find_by_field(proposed, "/issue", "service-native-dreaming-outputs-brief")?; - - assert!(qmd.pointer("/scope").and_then(Value::as_str).is_some_and(|scope| { - scope.contains("immediate top-k") && scope.contains("candidate-drop artifacts") - })); - assert!(qmd.pointer("/non_goal").and_then(Value::as_str).is_some_and(|non_goal| { - non_goal.contains("qmd full-suite wrong_result counts") - && non_goal.contains("debug ergonomics") - })); - assert!( - private_provider - .pointer("/non_goal") - .and_then(Value::as_str) - .is_some_and(|non_goal| non_goal.contains("Do not infer credentials")) - ); - assert!(letta.pointer("/validation").and_then(Value::as_str).is_some_and(|validation| { - validation.contains("Letta core block JSON") && validation.contains("typed outcome states") - })); - assert!( - service_native - .pointer("/non_goal") - .and_then(Value::as_str) - .is_some_and(|non_goal| non_goal.contains("Pulse clone")) - ); - - Ok(()) -} - -#[test] -fn qmd_trace_replay_diagnostics_report_preserves_claim_boundaries() -> Result<()> { - let report = serde_json::from_str::(&fs::read_to_string( - trace_replay_diagnostics_report_path()?, - )?)?; - let markdown = fs::read_to_string(trace_replay_diagnostics_markdown_path()?)?; - let readme = fs::read_to_string(readme_path()?)?; - let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; - let adoption_report = fs::read_to_string(competitor_strength_adoption_report_path()?)?; - let adoption_json = serde_json::from_str::(&fs::read_to_string( - competitor_strength_adoption_report_json_path()?, - )?)?; - - assert_trace_replay_diagnostics_json(&report)?; - assert_trace_replay_diagnostics_markdown(&markdown); - - assert!(readme.contains("ELF/qmd Trace Replay Diagnostics Report - June 11, 2026")); - assert!(benchmarking_index.contains("2026-06-11-elf-qmd-trace-replay-diagnostics-report.md")); - assert!(benchmarking_index.contains("qmd top-10/replay artifact")); - assert!(benchmarking_index.contains("ELF trace/admin surfaces")); - assert!(adoption_report.contains("| Retrieval quality and local debug UX | `loss` |")); - assert!(adoption_report.contains("Letta scenario rows remain")); - assert!(adoption_report.contains("blocked or `not_tested`")); - - assert_trace_replay_viewer_blocker_boundaries( - &readme, - &markdown, - &adoption_report, - &report, - &adoption_json, - )?; - - assert!( - adoption_report - .contains("Do not claim qmd's trace/replay artifact win is a broad qmd-over-ELF") - ); - assert!(array_at(&adoption_json, "/adoption_decision/remaining_caveats")?.iter().any( - |caveat| { - caveat.as_str().is_some_and(|text| { - text.contains("Letta scenario rows remain blocked or not_tested") - }) - } - )); - - assert_trace_replay_adoption_json(&adoption_json)?; - - Ok(()) -} - -fn assert_trace_replay_diagnostics_json(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.trace_replay_diagnostics_report/v1") - ); - assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-923")); - assert_eq!( - string_array_at(report, "/outcome_terms")?, - ["win", "tie", "loss", "not_tested", "blocked", "non_goal"].map(str::to_owned) - ); - assert_eq!( - report.pointer("/summary/retrieval_correctness").and_then(Value::as_str), - Some("tie") - ); - assert_eq!(report.pointer("/summary/outcome_counts/loss").and_then(Value::as_u64), Some(2)); - assert_eq!( - report.pointer("/summary/outcome_counts/not_tested").and_then(Value::as_u64), - Some(4) - ); - assert_eq!(report.pointer("/summary/outcome_counts/win").and_then(Value::as_u64), Some(4)); - assert_eq!(report.pointer("/summary/outcome_counts/tie").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/outcome_counts/non_goal").and_then(Value::as_u64), Some(1)); - - let scenarios = array_at(report, "/scenario_outcomes")?; - let retrieval = find_by_field(scenarios, "/scenario_id", "retrieval_correctness_guardrail")?; - let top10 = find_by_field(scenarios, "/scenario_id", "default_top10_candidate_artifact")?; - let replay = find_by_field(scenarios, "/scenario_id", "replay_command_locality")?; - let trace_surface = - find_by_field(scenarios, "/scenario_id", "trace_admin_replay_surface_availability")?; - let operator_trace = - find_by_field(scenarios, "/scenario_id", "operator_debug_trace_hydration")?; - let operator_replay = - find_by_field(scenarios, "/scenario_id", "operator_debug_replay_command_availability")?; - let operator_candidate = - find_by_field(scenarios, "/scenario_id", "operator_debug_candidate_drop_visibility")?; - let operator_repair = - find_by_field(scenarios, "/scenario_id", "operator_debug_repair_action_clarity")?; - let operator_selected = - find_by_field(scenarios, "/scenario_id", "operator_debug_selected_but_not_narrated")?; - let expansion = find_by_field(scenarios, "/scenario_id", "query_expansion_attribution")?; - let dense_sparse = - find_by_field(scenarios, "/scenario_id", "dense_sparse_channel_attribution")?; - let fusion = find_by_field(scenarios, "/scenario_id", "fusion_attribution")?; - let rerank = find_by_field(scenarios, "/scenario_id", "rerank_attribution")?; - let candidate_drop = find_by_field(scenarios, "/scenario_id", "candidate_drop_diagnostics")?; - let selected = - find_by_field(scenarios, "/scenario_id", "selected_but_not_narrated_wrong_results")?; - let tombstone = - find_by_field(scenarios, "/scenario_id", "evidence_absent_tombstone_diagnostics")?; - - assert_eq!(scenarios.len(), 16); - assert_eq!(retrieval.pointer("/outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(top10.pointer("/outcome").and_then(Value::as_str), Some("loss")); - assert_eq!(replay.pointer("/outcome").and_then(Value::as_str), Some("loss")); - assert_eq!(trace_surface.pointer("/outcome").and_then(Value::as_str), Some("tie")); - assert_eq!( - operator_trace.pointer("/evidence_class").and_then(Value::as_str), - Some("live_real_world") - ); - assert_eq!(operator_trace.pointer("/result_type").and_then(Value::as_str), Some("pass")); - assert_eq!(operator_trace.pointer("/outcome").and_then(Value::as_str), Some("win")); - assert_eq!(operator_replay.pointer("/outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(operator_candidate.pointer("/outcome").and_then(Value::as_str), Some("win")); - assert!(array_contains_str( - operator_candidate, - "/typed_non_pass_states", - "retrieved_but_dropped" - )?); - assert_eq!(operator_repair.pointer("/outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(operator_selected.pointer("/outcome").and_then(Value::as_str), Some("win")); - assert!(array_contains_str( - operator_selected, - "/typed_non_pass_states", - "selected_but_not_narrated" - )?); - assert_eq!(expansion.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!(dense_sparse.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!(fusion.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!(rerank.pointer("/result_type").and_then(Value::as_str), Some("non_goal")); - assert_eq!(rerank.pointer("/outcome").and_then(Value::as_str), Some("non_goal")); - assert_eq!(candidate_drop.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); - assert!(array_contains_str(candidate_drop, "/typed_non_pass_states", "retrieved_but_dropped")?); - assert_eq!(selected.pointer("/result_type").and_then(Value::as_str), Some("wrong_result")); - assert!(array_contains_str(selected, "/typed_non_pass_states", "selected_but_not_narrated")?); - assert_eq!(tombstone.pointer("/outcome").and_then(Value::as_str), Some("win")); - assert_eq!(tombstone.pointer("/qmd_status").and_then(Value::as_str), Some("wrong_result")); - assert!(array_contains_str( - report, - "/wrong_result_diagnostics/qmd_missing_evidence", - "delete-tombstone" - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "qmd currently wins the default local-debug artifact surface: top-10 rows plus short CLI replay." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "ELF narrowly wins the live operator-debug trace hydration and candidate-drop visibility slice against qmd; qmd still ties replay-command and repair-action clarity." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "Do not claim qmd beats ELF as a memory system overall." - )?); - - Ok(()) -} - -fn assert_trace_replay_diagnostics_markdown(markdown: &str) { - assert!(markdown.contains("Retrieval correctness is still tied")); - assert!(markdown.contains("| Default top-10 candidate artifact |")); - assert!(markdown.contains("| Replay command locality |")); - assert!( - markdown - .contains("| Operator-debug trace hydration | `live_real_world` | `pass` | `win` |") - ); - assert!(markdown.contains( - "| Operator-debug replay command availability | `live_real_world` | `pass` | `tie` |" - )); - assert!(markdown.contains( - "| Operator-debug candidate-drop visibility | `live_real_world` | `pass` | `win` |" - )); - assert!(markdown.contains("| Rerank attribution | `live_baseline_only` | `non_goal` |")); - assert!(markdown.contains("| Candidate-drop diagnostics | `research_gate` | `not_encoded` |")); - assert!(markdown.contains("`retrieved_but_dropped` | Defined globally as `not_tested`")); - assert!(markdown.contains("npx tsx src/cli/qmd.ts query")); - assert!(markdown.contains("cargo run -p elf-eval -- --config-a")); - assert!(markdown.contains("cargo make real-world-job-operator-ux-live-adapters")); - assert!(markdown.contains("Do not claim qmd beats ELF as a memory system overall")); - assert!(markdown.contains("Do not score rerank superiority from a qmd `--no-rerank` run")); -} - -fn assert_trace_replay_viewer_blocker_boundaries( - readme: &str, - markdown: &str, - adoption_report: &str, - report: &Value, - adoption_json: &Value, -) -> Result<()> { - let checked_surfaces = [ - collapse_whitespace(readme), - collapse_whitespace(markdown), - collapse_whitespace(adoption_report), - report.to_string(), - adoption_json.to_string(), - ]; - - for surface in checked_surfaces { - assert!(!surface.contains("blocked or not encoded")); - } - - assert!( - collapse_whitespace(readme) - .contains("claude-mem viewer flows remain blocked until Docker-contained") - ); - assert!( - collapse_whitespace(markdown) - .contains("claude-mem UI repair paths remain blocked until Docker-contained") - ); - assert!( - collapse_whitespace(adoption_report) - .contains("claude-mem viewer workflows remain blocked until Docker-contained") - ); - - Ok(()) -} - -fn assert_trace_replay_adoption_json(adoption: &Value) -> Result<()> { - let local_debug = find_by_field( - array_at(adoption, "/scenario_outcomes")?, - "/scenario_id", - "local_debug_replay_ux", - )?; - let operator_debug = find_by_field( - array_at(adoption, "/scenario_outcomes")?, - "/scenario_id", - "operator_debugging_viewer_ux", - )?; - - assert_eq!(local_debug.pointer("/outcome").and_then(Value::as_str), Some("loss")); - assert!( - local_debug - .pointer("/measured_claim") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("qmd stronger on immediate top-10")) - ); - assert!(array_contains_str( - local_debug, - "/command_artifacts", - "docs/evidence/benchmarking/2026-06-11-elf-qmd-trace-replay-diagnostics-report.md" - )?); - assert!(array_contains_str( - adoption, - "/claim_boundaries/not_allowed", - "Do not claim qmd's trace/replay artifact win is a broad qmd-over-ELF memory-system or retrieval-quality win." - )?); - assert_eq!(operator_debug.pointer("/outcome").and_then(Value::as_str), Some("win")); - assert!( - operator_debug - .pointer("/measured_claim") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("narrow live operator-debug win over qmd")) - ); - assert!(array_contains_str( - operator_debug, - "/command_artifacts", - "tmp/real-world-job/operator-ux-live-adapters/summary.json" - )?); - assert!(array_contains_str( - adoption, - "/claim_boundaries/not_allowed", - "Do not claim ELF broadly beats OpenMemory or claude-mem viewer UX from the narrow ELF/qmd operator-debug slice." - )?); - - Ok(()) -} - -fn assert_competitor_strength_matrix_json(matrix: &Value) -> Result<()> { - let projects = array_at(matrix, "/project_matrix")?; - let scenarios = array_at(matrix, "/scenario_matrix")?; - - assert_competitor_strength_matrix_manifest_counts(matrix); - assert_competitor_strength_matrix_project_json(projects)?; - assert_competitor_strength_matrix_scenario_json(scenarios)?; - - Ok(()) -} - -fn assert_competitor_strength_matrix_project_json(projects: &[Value]) -> Result<()> { - let qmd = find_by_field(projects, "/project", "qmd")?; - let mem0 = find_by_field(projects, "/project", "mem0/OpenMemory")?; - let claude_mem = find_by_field(projects, "/project", "claude-mem")?; - let openviking = find_by_field(projects, "/project", "OpenViking")?; - - assert_eq!( - qmd.pointer("/current_evidence_class").and_then(Value::as_str), - Some("live_real_world") - ); - assert_eq!(qmd.pointer("/measured_status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - qmd.pointer("/unsupported_or_blocked_status/state").and_then(Value::as_str), - Some("not_encoded") - ); - assert!(qmd.pointer("/benchmark_before_claim").and_then(Value::as_str).is_some_and(|claim| { - claim.contains("Keep qmd deep retrieval/debug profiling separate") - && claim.contains("narrow operator-debug live slice") - })); - assert!( - qmd.pointer("/borrow_if_stronger") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("transparent local knobs")) - ); - assert_eq!(mem0.pointer("/measured_status").and_then(Value::as_str), Some("pass")); - assert_eq!( - mem0.pointer("/unsupported_or_blocked_status/state").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!( - mem0.pointer("/unsupported_or_blocked_status/typed_reason").and_then(Value::as_str), - Some("openmemory_export_helper_setup_blocked") - ); - assert!( - mem0.pointer("/benchmark_before_claim") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("OpenMemory product app import/export")) - ); - assert!( - claude_mem - .pointer("/unsupported_or_blocked_status/details") - .and_then(Value::as_str) - .is_some_and(|details| details.contains("rerun/inspection targets") - && details.contains("tmp/live-baseline/claude-mem-checks.json")) - ); - assert_eq!( - openviking.pointer("/current_evidence_class").and_then(Value::as_str), - Some("live_baseline_only") - ); - assert_eq!( - openviking.pointer("/measured_status").and_then(Value::as_str), - Some("wrong_result") - ); - assert_eq!( - openviking.pointer("/unsupported_or_blocked_status/state").and_then(Value::as_str), - Some("blocked") - ); - assert!( - openviking - .pointer("/unsupported_or_blocked_status/details") - .and_then(Value::as_str) - .is_some_and(|details| details.contains("encoded as blocked fixtures")) - ); - assert!( - openviking - .pointer("/benchmark_before_claim") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("evidence-bearing same-corpus output pass")) - ); - - Ok(()) -} - -fn assert_competitor_strength_matrix_scenario_json(scenarios: &[Value]) -> Result<()> { - let retrieval_debug = find_by_field(scenarios, "/scenario_id", "retrieval_debug")?; - let work_resume = find_by_field(scenarios, "/scenario_id", "work_resume")?; - let operator_debug = find_by_field(scenarios, "/scenario_id", "operator_debugging")?; - let context_trajectory = find_by_field(scenarios, "/scenario_id", "context_trajectory")?; - let consolidation = find_by_field(scenarios, "/scenario_id", "consolidation")?; - - assert!( - retrieval_debug - .pointer("/current_state") - .and_then(Value::as_str) - .is_some_and(|state| state.contains("Measured tie on encoded retrieval answers")) - ); - assert!(retrieval_debug.pointer("/current_state").and_then(Value::as_str).is_some_and( - |state| state.contains("qmd remains stronger on local debug ergonomics not fully scored") - )); - assert!( - work_resume - .pointer("/current_competitor_evidence") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("claude-mem work_resume remains not_encoded") - && !claim.contains("claude-mem is wrong_result")) - ); - assert!( - operator_debug - .pointer("/current_elf_evidence") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("narrow live_real_world operator-debug slice")) - ); - assert!( - operator_debug - .pointer("/current_competitor_evidence") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("qmd now has a narrow live_real_world")) - ); - assert!( - operator_debug - .pointer("/next_measurement") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("OpenMemory and claude-mem UI/export")) - ); - assert!( - consolidation - .pointer("/current_elf_evidence") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("XY-934 adds live_real_world") - && claim.contains("zero source mutations")) - ); - assert!( - consolidation - .pointer("/current_competitor_evidence") - .and_then(Value::as_str) - .is_some_and(|claim| claim.contains("qmd remains not_encoded") - && claim.contains("product references only")) - ); - - let personalization = find_by_field(scenarios, "/scenario_id", "personalization")?; - - assert_personalization_matrix_record(personalization); - - assert!( - context_trajectory - .pointer("/current_state") - .and_then(Value::as_str) - .is_some_and(|state| state.contains("not a measured live winner")) - ); - assert!( - context_trajectory - .pointer("/next_measurement") - .and_then(Value::as_str) - .is_some_and(|measurement| measurement.contains("evidence-bearing retrieval pass")) - ); - - Ok(()) -} - -fn assert_personalization_matrix_record(personalization: &Value) { - assert!( - personalization - .pointer("/current_competitor_evidence") - .and_then(Value::as_str) - .is_some_and(|claim| claim - .contains("mem0/OpenMemory local OSS entity-scoped personalization now passes") - && claim.contains("Letta personalization is research_gate not_encoded")) - ); - assert!( - personalization - .pointer("/current_state") - .and_then(Value::as_str) - .is_some_and(|state| state.contains("scoped personalization is a tie")) - ); -} - -fn assert_competitor_strength_matrix_manifest_counts(matrix: &Value) { - assert_eq!( - matrix.pointer("/manifest_summary/adapter_records").and_then(Value::as_u64), - Some(23) - ); - assert_eq!( - matrix - .pointer("/manifest_summary/evidence_class_counts/live_real_world") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - matrix.pointer("/manifest_summary/overall_status_counts/pass").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - matrix.pointer("/manifest_summary/overall_status_counts/blocked").and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - matrix - .pointer("/manifest_summary/overall_status_counts/not_encoded") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - matrix - .pointer("/manifest_summary/overall_status_counts/wrong_result") - .and_then(Value::as_u64), - Some(6) - ); -} - -fn assert_strength_profile_summary(report: &Value) { - assert_eq!( - report.pointer("/schema").and_then(Value::as_str), - Some("elf.competitor_strength_profile_report/v1") - ); - assert_eq!( - report.pointer("/summary/qmd/retrieval_quality").and_then(Value::as_str), - Some("tie") - ); - assert_eq!( - report.pointer("/summary/qmd/local_query_transparency").and_then(Value::as_str), - Some("not_tested") - ); - assert_eq!( - report.pointer("/summary/qmd/local_replayability").and_then(Value::as_str), - Some("not_tested") - ); - assert_eq!( - report.pointer("/summary/qmd/overall_outcome").and_then(Value::as_str), - Some("not_tested") - ); - assert_eq!( - report.pointer("/summary/openviking/overall_outcome").and_then(Value::as_str), - Some("not_tested") - ); - assert_eq!( - report - .pointer("/qmd_strength_profile/win_tie_loss_summary/elf_win") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/qmd_strength_profile/win_tie_loss_summary/tie").and_then(Value::as_u64), - Some(3) - ); - assert_eq!( - report - .pointer("/qmd_strength_profile/win_tie_loss_summary/elf_loss") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/qmd_strength_profile/win_tie_loss_summary/not_tested") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report - .pointer("/openviking_context_trajectory_profile/win_tie_loss_summary/not_tested") - .and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report - .pointer("/openviking_context_trajectory_profile/win_tie_loss_summary/elf_win") - .and_then(Value::as_u64), - Some(1) - ); -} - -fn assert_strength_profile_terms(report: &Value) -> Result<()> { - let result_terms = array_at(report, "/result_type_terms")?; - let coverage_terms = array_at(report, "/coverage_status_terms")?; - let outcome_terms = array_at(report, "/outcome_terms")?; - let actual_result_terms = string_array_at(report, "/result_type_terms")?; - let actual_coverage_terms = string_array_at(report, "/coverage_status_terms")?; - - assert_eq!( - actual_result_terms, - [ - "pass", - "wrong_result", - "blocked", - "incomplete", - "lifecycle_fail", - "not_encoded", - "unsupported_claim", - ] - .map(str::to_owned) - ); - assert_eq!( - actual_coverage_terms, - [ - "pass", - "wrong_result", - "blocked", - "incomplete", - "lifecycle_fail", - "not_encoded", - "unsupported", - "unsupported_claim", - ] - .map(str::to_owned) - ); - assert!(!result_terms.iter().any(|term| term.as_str() == Some("unsupported"))); - assert!(!result_terms.iter().any(|term| term.as_str() == Some("partial"))); - assert!(!coverage_terms.iter().any(|term| term.as_str() == Some("partial"))); - assert!(result_terms.iter().any(|term| term.as_str() == Some("unsupported_claim"))); - assert!(coverage_terms.iter().any(|term| term.as_str() == Some("unsupported"))); - - assert_value_in_terms(report, "/summary/qmd/overall_outcome", outcome_terms)?; - assert_value_in_terms(report, "/summary/openviking/overall_outcome", outcome_terms)?; - - for scenario in array_at(report, "/qmd_strength_profile/scenario_outcomes")? { - assert_value_in_terms(scenario, "/result_type", result_terms)?; - assert_value_in_terms(scenario, "/elf_status", coverage_terms)?; - assert_value_in_terms(scenario, "/qmd_status", coverage_terms)?; - } - for scenario in array_at(report, "/openviking_context_trajectory_profile/scenario_outcomes")? { - assert_value_in_terms(scenario, "/result_type", result_terms)?; - assert_value_in_terms(scenario, "/openviking_status", coverage_terms)?; - assert_value_in_terms(scenario, "/elf_equivalent_status", coverage_terms)?; - } - - Ok(()) -} - -fn assert_value_in_terms(value: &Value, pointer: &str, terms: &[Value]) -> Result<()> { - let actual = value - .pointer(pointer) - .and_then(Value::as_str) - .ok_or_else(|| eyre::eyre!("missing string at {pointer}"))?; - - assert!( - terms.iter().any(|term| term.as_str() == Some(actual)), - "{actual} at {pointer} is not declared in the report term list" - ); - - Ok(()) -} - -fn assert_qmd_strength_profile(report: &Value) -> Result<()> { - let qmd_scenarios = array_at(report, "/qmd_strength_profile/scenario_outcomes")?; - let local_transparency = - find_by_field(qmd_scenarios, "/scenario_id", "qmd-local-query-transparency")?; - let retrieval = find_by_field(qmd_scenarios, "/scenario_id", "qmd-retrieval-quality")?; - let rerank_controls = - find_by_field(qmd_scenarios, "/scenario_id", "qmd-expansion-fusion-rerank-controls")?; - let stale_isolation = - find_by_field(qmd_scenarios, "/scenario_id", "qmd-stale-context-isolation")?; - let lifecycle = find_by_field(qmd_scenarios, "/scenario_id", "qmd-update-delete-cold-start")?; - let operator_debug = - find_by_field(qmd_scenarios, "/scenario_id", "qmd-operator-debug-evidence")?; - let replayability = find_by_field(qmd_scenarios, "/scenario_id", "qmd-local-replayability")?; - let wrong_result = find_by_field(qmd_scenarios, "/scenario_id", "qmd-wrong-result-diagnosis")?; - - assert_eq!(qmd_scenarios.len(), 8); - assert_eq!(retrieval.pointer("/elf_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!( - local_transparency.pointer("/elf_outcome").and_then(Value::as_str), - Some("not_tested") - ); - assert_eq!( - local_transparency.pointer("/result_type").and_then(Value::as_str), - Some("not_encoded") - ); - assert_eq!( - rerank_controls.pointer("/result_type").and_then(Value::as_str), - Some("not_encoded") - ); - assert_eq!(stale_isolation.pointer("/result_type").and_then(Value::as_str), Some("pass")); - assert_eq!(stale_isolation.pointer("/elf_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(lifecycle.pointer("/result_type").and_then(Value::as_str), Some("pass")); - assert_eq!(lifecycle.pointer("/elf_outcome").and_then(Value::as_str), Some("tie")); - assert_eq!(operator_debug.pointer("/result_type").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(operator_debug.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!(replayability.pointer("/result_type").and_then(Value::as_str), Some("not_encoded")); - assert_eq!(replayability.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!( - wrong_result.pointer("/evidence_class").and_then(Value::as_str), - Some("research_gate") - ); - assert_eq!(wrong_result.pointer("/result_type").and_then(Value::as_str), Some("not_encoded")); - - Ok(()) -} - -fn assert_qmd_wrong_result_diagnosis(report: &Value) -> Result<()> { - let taxonomy = array_at(report, "/qmd_strength_profile/wrong_result_diagnosis/taxonomy")?; - let absent = find_by_field(taxonomy, "/class", "evidence_absent")?; - let dropped = find_by_field(taxonomy, "/class", "retrieved_but_dropped")?; - let narrated = find_by_field(taxonomy, "/class", "selected_but_not_narrated")?; - let lifecycle = find_by_field(taxonomy, "/class", "contradicted_by_lifecycle_evidence")?; - - assert_eq!(absent.pointer("/coverage").and_then(Value::as_str), Some("observed")); - assert_eq!( - dropped.pointer("/coverage").and_then(Value::as_str), - Some("not_observed_candidate_trace_missing") - ); - assert_eq!(narrated.pointer("/coverage").and_then(Value::as_str), Some("observed")); - assert_eq!(lifecycle.pointer("/coverage").and_then(Value::as_str), Some("observed")); - - let qmd_diagnosis_jobs = array_at(report, "/qmd_strength_profile/wrong_result_diagnosis/jobs")?; - let delete_job = - find_by_field(qmd_diagnosis_jobs, "/job_id", "memory-evolution-delete-ttl-001")?; - - assert_eq!(qmd_diagnosis_jobs.len(), 6); - assert_eq!(delete_job.pointer("/qmd_status").and_then(Value::as_str), Some("wrong_result")); - assert!(array_contains_str(delete_job, "/missing_evidence", "delete-tombstone")?); - assert!( - delete_job - .pointer("/diagnosis") - .and_then(Value::as_str) - .is_some_and(|diagnosis| diagnosis.contains("typed wrong_result")) - ); - - Ok(()) -} - -fn assert_openviking_strength_profile(report: &Value) -> Result<()> { - let openviking_scenarios = - array_at(report, "/openviking_context_trajectory_profile/scenario_outcomes")?; - let trajectory = find_by_field( - openviking_scenarios, - "/scenario_id", - "openviking-staged-retrieval-trajectory", - )?; - let precondition = find_by_field( - openviking_scenarios, - "/scenario_id", - "openviking-evidence-bearing-retrieval-precondition", - )?; - let local_embed_setup = - find_by_field(openviking_scenarios, "/scenario_id", "openviking-local-embed-setup")?; - let missed_terms = find_by_field( - openviking_scenarios, - "/scenario_id", - "openviking-missed-expected-terms-evidence", - )?; - let hierarchy = - find_by_field(openviking_scenarios, "/scenario_id", "openviking-hierarchy-selection")?; - let recursive_expansion = find_by_field( - openviking_scenarios, - "/scenario_id", - "openviking-recursive-context-expansion", - )?; - - assert_eq!(openviking_scenarios.len(), 6); - assert_eq!( - trajectory.pointer("/evidence_class").and_then(Value::as_str), - Some("fixture_backed") - ); - assert_eq!(trajectory.pointer("/result_type").and_then(Value::as_str), Some("blocked")); - assert_eq!(trajectory.pointer("/openviking_status").and_then(Value::as_str), Some("blocked")); - assert_eq!(local_embed_setup.pointer("/result_type").and_then(Value::as_str), Some("pass")); - assert_eq!( - local_embed_setup.pointer("/elf_outcome").and_then(Value::as_str), - Some("not_tested") - ); - assert_eq!(local_embed_setup.pointer("/typed_blocker"), Some(&Value::Null)); - assert_eq!(precondition.pointer("/result_type").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(precondition.pointer("/elf_outcome").and_then(Value::as_str), Some("elf_win")); - assert_eq!( - precondition.pointer("/typed_blocker").and_then(Value::as_str), - Some("output_missed_expected_terms") - ); - assert_eq!(missed_terms.pointer("/result_type").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(missed_terms.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!(hierarchy.pointer("/result_type").and_then(Value::as_str), Some("blocked")); - assert_eq!(hierarchy.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); - assert_eq!( - recursive_expansion.pointer("/result_type").and_then(Value::as_str), - Some("blocked") - ); - assert_eq!( - recursive_expansion.pointer("/elf_outcome").and_then(Value::as_str), - Some("not_tested") - ); - - Ok(()) -} - -fn assert_strength_profile_json_claim_boundaries(report: &Value) -> Result<()> { - assert!(array_contains_str( - report, - "/claim_boundaries", - "ELF does not broadly beat qmd; it ties encoded retrieval and lifecycle correctness, keeps qmd query transparency as not_tested for comparative scoring, and leaves replayability not_tested." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "qmd expansion, fusion, and rerank superiority remains not_tested because the current qmd paths use --no-rerank and do not score internals." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "ELF does not beat OpenViking on context trajectory; OpenViking trajectory strengths remain blocked/not_tested behind a wrong_result same-corpus output precondition and missing staged artifacts." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "Research_gate and blocked fixture records are follow-up gates, not pass evidence." - )?); - assert!(array_contains_str( - report, - "/claim_boundaries", - "Missing equivalent surfaces are encoded as unsupported, blocked, or not_encoded rather than fake losses." - )?); - - Ok(()) -} - -fn assert_strength_profile_markdown_boundaries(markdown: &str) { - assert!( - markdown.contains( - "| Wrong-result diagnosis | `research_gate` | `not_encoded` | `not_tested` |" - ) - ); - assert!( - markdown.contains("ELF ties qmd on the current encoded retrieval-correctness surfaces") - ); - assert!(markdown.contains("qmd remains the local retrieval-debug UX reference")); - assert!(markdown.contains("not scored as comparative ELF wins or losses")); - assert!(markdown.contains("ELF currently wins only the equivalent OpenViking same-corpus")); - assert!(markdown.contains("Do not claim ELF broadly beats qmd")); - assert!(markdown.contains( - "Do not claim ELF beats OpenViking on staged retrieval, hierarchy, or recursive" - )); - assert!(markdown.contains( - "Do not turn `research_gate`, `blocked`, `not_encoded`, or `unsupported` surfaces" - )); - assert!(markdown.contains("no pass evidence is claimed")); - assert!(markdown.contains("typed `wrong_result` state")); -} - -fn assert_operator_facing_strength_profile_boundaries( - readme: &str, - benchmarking_index: &str, - iteration_direction: &str, -) { - assert!(readme.contains("Full-suite live real-world adapter sweep after XY-926")); - assert!(readme.contains("all 55 checked-in jobs across 13 suites")); - assert!(readme.contains("ELF now live-scores capture/write-policy")); - assert!(readme.contains("consolidation proposal review")); - assert!(readme.contains("knowledge-page rebuild/lint")); - assert!(readme.contains("operator-debugging fixtures")); - assert!(!readme.contains("memory-evolution wrong results")); - assert!(readme.contains("Live temporal reconciliation after XY-905")); - assert!(readme.contains("now reports ELF live `memory_evolution` as 6/6 pass")); - assert!(readme.contains("broad qmd, Graphiti/Zep, mem0/OpenMemory, Letta")); - assert!(readme.contains("production-ops operator boundaries")); - assert!(readme.contains("core/archival live adapter gap")); - assert!(collapse_whitespace(readme).contains("blocked context-trajectory measurement")); - assert!( - readme - .contains("consolidation, knowledge, capture, and core/archival typed non-pass states") - ); - assert!(readme.contains("operator-debug trace hydration")); - assert!(readme.contains("qmd remains the local retrieval-debug UX reference")); - assert!(readme.contains("broad ELF-over-qmd")); - assert!(readme.contains("qmd and OpenViking Strength-Profile Report - June 11, 2026")); - assert!(benchmarking_index.contains("2026-06-11-qmd-openviking-strength-profile-report.md")); - assert!( - benchmarking_index.contains("separates qmd retrieval quality from debug/replay ergonomics") - ); - assert!(benchmarking_index.contains("preserves XY-928 OpenViking")); - assert!( - benchmarking_index - .contains("context-trajectory surfaces as blocked/not-tested until scored staged") - ); - assert!( - iteration_direction - .contains("ELF and qmd are tied on the encoded live retrieval, work-resume, and") - ); - assert!(iteration_direction.contains("ELF does not yet beat qmd's local retrieval-debug")); - - assert_iteration_direction_current_measurement_counts(iteration_direction); - - assert!(iteration_direction.contains( - "ELF beats OpenViking on context trajectory. The scenario is encoded as blocked" - )); - assert!( - iteration_direction - .contains("Do not promote a reference project into a win/loss claim until") - ); -} - -fn assert_measurement_audit_adapter_status_counts(markdown: &str) { - for expected in [ - "| `blocked` | `7` |", - "| `not_encoded` | `5` |", - "The generated JSON report emits `external_project_count: 16`", - ] { - assert!(markdown.contains(expected), "missing measurement audit text: {expected}"); - } - for stale in ["| `blocked` | `6` |", "| `not_encoded` | `6` |"] { - assert!(!markdown.contains(stale), "stale measurement audit text: {stale}"); - } -} - -fn assert_iteration_direction_current_measurement_counts(markdown: &str) { - for expected in [ - "| Jobs | `55` |", - "| Encoded suites | `15` |", - "| Blocked | `6` |", - "| Mean score | `0.891` |", - "| Evidence coverage | `123/123` |", - "| Source-ref coverage | `123/123` |", - "| Quote coverage | `123/123` |", - "| Expected evidence recall | `115/115` |", - "| `blocked` | `7` |", - "| `not_encoded` | `5` |", - "`live_baseline_only`, `fixture_backed`, and `research_gate`", - "`blocked` for fixture-backed trajectory gates", - ] { - assert!(markdown.contains(expected), "missing iteration-direction text: {expected}"); - } - for stale in [ - "| Jobs | `40` |", - "| Encoded suites | `11` |", - "| Jobs | `50` |", - "| Encoded suites | `14` |", - "| Mean score | `0.950` |", - "| Mean score | `0.900` |", - "| Evidence coverage | `88/88` |", - "| Evidence coverage | `115/115` |", - "| Expected evidence recall | `80/80` |", - "| Expected evidence recall | `107/107` |", - "| `blocked` | `5` |", - "| `not_encoded` | `7` |", - "`live_baseline_only` plus `research_gate`", - ] { - assert!(!markdown.contains(stale), "stale iteration-direction text: {stale}"); - } -} - -#[test] -fn generated_json_report_renders_markdown() -> Result<()> { - let report = run_json_report()?; - let temp_dir = env::temp_dir().join(format!("elf-real-world-job-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("report.json"); - let markdown_path = temp_dir.join("report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("# Real-World Job Benchmark Report")); - assert!(markdown.contains("work_resume")); - assert!(markdown.contains("Capture And Integration Coverage")); - assert!(markdown.contains("External Adapter Coverage")); - assert!(markdown.contains("live-baseline-only")); - assert!(markdown.contains("live real-world")); - assert!(markdown.contains("does not convert live-baseline retrieval results")); - assert!(markdown.contains("fixture-backed")); - assert!(markdown.contains("Answer Type")); - assert!(markdown.contains("Caveat Required")); - assert!(markdown.contains("Refusal Required")); - assert!(markdown.contains("agentmemory-style hook capture")); - assert!(markdown.contains("xy844-current-worktree")); - assert!(markdown.contains("Existing live-baseline reports remain valid")); - assert!(markdown.contains("### Adapter Scenario Judgments")); - assert!(markdown.contains("ELF scenario positions: `wins=10, ties=11, loses=1, untested=53`")); - assert!(markdown.contains( - "Scenario comparison outcomes: `win=10, tie=11, loss=1, not_tested=19, blocked=29, non_goal=5`" - )); - assert!(markdown.contains("| `claude_mem_live_baseline` | `same_corpus_retrieval`")); - assert!(markdown.contains("| `memsearch_live_baseline` | `ttl_expiry_lifecycle`")); - - Ok(()) -} - -#[test] -fn external_adapter_markdown_renders_nonzero_scenario_losses() -> Result<()> { - let mut report = run_json_report()?; - let adapters = report - .pointer_mut("/external_adapters/adapters") - .and_then(Value::as_array_mut) - .ok_or_else(|| eyre::eyre!("missing external adapter records"))?; - let adapter = adapters - .iter_mut() - .find(|adapter| { - adapter.pointer("/adapter_id").and_then(Value::as_str) - == Some("agentmemory_live_baseline") - }) - .ok_or_else(|| eyre::eyre!("missing agentmemory adapter"))?; - - set_json_pointer(adapter, "/scenarios/0/elf_position", serde_json::json!("loses"))?; - set_json_pointer(adapter, "/scenarios/0/comparison_outcome", serde_json::json!("loss"))?; - set_json_pointer( - &mut report, - "/external_adapters/summary/scenario_position_counts", - serde_json::json!({ - "wins": 2, - "ties": 4, - "loses": 2, - "untested": 10 - }), - )?; - set_json_pointer( - &mut report, - "/external_adapters/summary/scenario_outcome_counts", - serde_json::json!({ - "win": 2, - "tie": 4, - "loss": 2, - "not_tested": 7, - "blocked": 1, - "non_goal": 2 - }), - )?; - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-loss-scenario-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("report.json"); - let markdown_path = temp_dir.join("report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("ELF scenario positions: `wins=2, ties=4, loses=2, untested=10`")); - assert!(markdown.contains( - "Scenario comparison outcomes: `win=2, tie=4, loss=2, not_tested=7, blocked=1, non_goal=2`" - )); - assert!(markdown.contains( - "| `agentmemory_live_baseline` | `basic_same_corpus_retrieval` | `retrieval` | `pass` | `loss` |" - )); - - Ok(()) -} - -#[test] -fn external_adapter_markdown_omits_scenario_summary_when_manifest_has_no_scenarios() -> Result<()> { - let mut report = run_json_report()?; - let adapters = report - .pointer_mut("/external_adapters/adapters") - .and_then(Value::as_array_mut) - .ok_or_else(|| eyre::eyre!("missing external adapter records"))?; - - for adapter in adapters { - set_json_pointer(adapter, "/scenarios", serde_json::json!([]))?; - } - - set_json_pointer( - &mut report, - "/external_adapters/summary/scenario_status_counts", - serde_json::json!({ - "real": 0, - "mocked": 0, - "unsupported": 0, - "blocked": 0, - "incomplete": 0, - "wrong_result": 0, - "lifecycle_fail": 0, - "pass": 0, - "not_encoded": 0 - }), - )?; - set_json_pointer( - &mut report, - "/external_adapters/summary/scenario_position_counts", - serde_json::json!({ - "wins": 0, - "ties": 0, - "loses": 0, - "untested": 0 - }), - )?; - set_json_pointer( - &mut report, - "/external_adapters/summary/scenario_outcome_counts", - serde_json::json!({ - "win": 0, - "tie": 0, - "loss": 0, - "not_tested": 0, - "blocked": 0, - "non_goal": 0 - }), - )?; - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-no-scenario-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("report.json"); - let markdown_path = temp_dir.join("report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("External Adapter Coverage")); - assert!(!markdown.contains("Scenario coverage statuses:")); - assert!(!markdown.contains("ELF scenario positions:")); - assert!(!markdown.contains("Scenario comparison outcomes:")); - assert!(!markdown.contains("### Adapter Scenario Judgments")); - - Ok(()) -} - -#[test] -fn mem0_delete_audit_probe_requires_explicit_delete_history_event() -> Result<()> { - let script = - fs::read_to_string(workspace_root()?.join("scripts").join("live-baseline-benchmark.sh"))?; - - assert!(script.contains("def history_has_event")); - assert!(script.contains("str(entry.get(\"event\", \"\")).upper() == expected")); - assert!(script.contains( - "history_has_event(\n preference_history[\"history\"],\n \"ADD\"," - )); - assert!(script.contains( - "history_has_event(\n preference_history[\"history\"],\n \"UPDATE\"," - )); - assert!( - script.contains( - "history_has_event(\n delete_history[\"history\"],\n \"DELETE\"," - ) - ); - assert!( - !script.contains( - "contains_terms(\n delete_history[\"history\"],\n [\"delete\"]," - ) - ); - - Ok(()) -} - -#[test] -fn dreaming_readiness_stage_ledger_preserves_gate_shape() -> Result<()> { - let ledger = serde_json::from_str::(&fs::read_to_string( - dreaming_readiness_stage_ledger_json_path()?, - )?)?; - let markdown = fs::read_to_string(dreaming_readiness_stage_ledger_markdown_path()?)?; - let stages = array_at(&ledger, "/stage_gates")?; - - assert_dreaming_readiness_ledger_header(&ledger)?; - assert_dreaming_readiness_stage_shape(&ledger, stages)?; - assert_dreaming_readiness_baseline_counts(&ledger, stages)?; - assert_dreaming_readiness_markdown_boundaries(&markdown); - - Ok(()) -} - -fn assert_dreaming_readiness_ledger_header(ledger: &Value) -> Result<()> { - assert_eq!( - ledger.pointer("/schema").and_then(Value::as_str), - Some("elf.dreaming_readiness_stage_ledger/v1") - ); - assert_eq!(ledger.pointer("/authority").and_then(Value::as_str), Some("XY-951")); - - for term in ["improved", "regressed", "unchanged", "blocked", "not_tested"] { - assert!(array_contains_str(ledger, "/judgment_terms", term)?); - } - for term in ["pass", "wrong_result", "blocked", "not_tested", "not_encoded"] { - assert!(array_contains_str(ledger, "/count_fields", term)?); - } - - Ok(()) -} - -fn assert_dreaming_readiness_stage_shape(ledger: &Value, stages: &[Value]) -> Result<()> { - assert_eq!(stages.len(), 8); - - for stage_id in [ - "current_vs_historical_correctness", - "preference_evolution", - "deletion_ttl_tombstone_behavior", - "reviewable_consolidation", - "memory_summary_top_of_mind_behavior", - "proactive_brief_readiness", - "scheduled_memory_task_readiness", - "final_competitor_retest_status", - ] { - find_by_field(stages, "/stage_id", stage_id)?; - } - for stage in stages { - let stage_id = - stage.pointer("/stage_id").and_then(Value::as_str).unwrap_or(""); - - assert!( - !array_at(stage, "/baseline_commands")?.is_empty(), - "{stage_id} missing baseline commands" - ); - assert!( - !array_at(stage, "/post_stage_commands")?.is_empty(), - "{stage_id} missing post-stage commands" - ); - assert!( - !array_at(stage, "/evidence_files")?.is_empty(), - "{stage_id} missing evidence files" - ); - - for count_field in string_array_at(ledger, "/count_fields")? { - let pointer = format!("/baseline_counts/{count_field}"); - - assert!( - stage.pointer(&pointer).and_then(Value::as_u64).is_some(), - "{stage_id} missing {pointer}" - ); - } - - let judgment = stage - .pointer("/comparison_judgment") - .and_then(Value::as_str) - .ok_or_else(|| eyre::eyre!("{stage_id} missing comparison_judgment"))?; - - assert!(array_contains_str(ledger, "/judgment_terms", judgment)?); - } - - Ok(()) -} - -fn assert_dreaming_readiness_baseline_counts(ledger: &Value, stages: &[Value]) -> Result<()> { - let current = find_by_field(stages, "/stage_id", "current_vs_historical_correctness")?; - - assert_eq!(current.pointer("/baseline_counts/pass").and_then(Value::as_u64), Some(1)); - assert_eq!(current.pointer("/baseline_counts/wrong_result").and_then(Value::as_u64), Some(5)); - assert_eq!(current.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(current.pointer("/post_stage_counts/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(current.pointer("/comparison_judgment").and_then(Value::as_str), Some("improved")); - assert!( - current - .pointer("/baseline_basis") - .and_then(Value::as_str) - .is_some_and(|basis| basis.contains("five current-vs-historical jobs")) - ); - assert!( - current - .pointer("/post_stage_basis") - .and_then(Value::as_str) - .is_some_and(|basis| basis.contains("passes all six encoded jobs")) - ); - - let preference = find_by_field(stages, "/stage_id", "preference_evolution")?; - - assert_eq!( - preference.pointer("/baseline_counts/wrong_result").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(preference.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(1)); - assert_eq!( - preference.pointer("/post_stage_counts/wrong_result").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - preference.pointer("/comparison_judgment").and_then(Value::as_str), - Some("improved") - ); - - let tombstone = find_by_field(stages, "/stage_id", "deletion_ttl_tombstone_behavior")?; - - assert_eq!(tombstone.pointer("/baseline_counts/pass").and_then(Value::as_u64), Some(1)); - assert_eq!(tombstone.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(1)); - assert_eq!( - tombstone.pointer("/comparison_judgment").and_then(Value::as_str), - Some("unchanged") - ); - assert!( - tombstone - .pointer("/post_stage_basis") - .and_then(Value::as_str) - .is_some_and(|basis| basis.contains("tombstone and invalidation evidence")) - ); - - let consolidation = find_by_field(stages, "/stage_id", "reviewable_consolidation")?; - - assert_eq!( - consolidation.pointer("/comparison_judgment").and_then(Value::as_str), - Some("improved") - ); - assert_eq!( - consolidation.pointer("/baseline_counts/not_encoded").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(consolidation.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(4)); - assert_eq!( - consolidation.pointer("/post_stage_counts/not_encoded").and_then(Value::as_u64), - Some(0) - ); - assert!( - consolidation - .pointer("/post_stage_basis") - .and_then(Value::as_str) - .is_some_and(|basis| basis.contains("apply/defer/discard audit") - && basis.contains("zero source mutations")) - ); - - let scheduled = find_by_field(stages, "/stage_id", "scheduled_memory_task_readiness")?; - - assert_eq!(scheduled.pointer("/comparison_judgment").and_then(Value::as_str), Some("improved")); - assert_eq!(scheduled.pointer("/baseline_counts/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(scheduled.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(4)); - assert_eq!(scheduled.pointer("/post_stage_counts/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!( - scheduled.pointer("/post_stage_counts/trace_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - scheduled.pointer("/post_stage_counts/source_mutation_count").and_then(Value::as_u64), - Some(0) - ); - - assert_dreaming_final_competitor_retest_stage(ledger, stages)?; - assert_dreaming_memory_summary_stage(stages)?; - assert_dreaming_proactive_brief_stage(stages)?; - - Ok(()) -} - -fn assert_dreaming_final_competitor_retest_stage(ledger: &Value, stages: &[Value]) -> Result<()> { - let retest = find_by_field(stages, "/stage_id", "final_competitor_retest_status")?; - - assert_eq!(retest.pointer("/baseline_counts/pass").and_then(Value::as_u64), Some(22)); - assert_eq!(retest.pointer("/baseline_counts/wrong_result").and_then(Value::as_u64), Some(5)); - assert_eq!(retest.pointer("/baseline_counts/blocked").and_then(Value::as_u64), Some(2)); - assert_eq!(retest.pointer("/baseline_counts/not_tested").and_then(Value::as_u64), Some(11)); - assert_eq!(retest.pointer("/baseline_counts/not_encoded").and_then(Value::as_u64), Some(11)); - assert_eq!(retest.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(40)); - assert_eq!(retest.pointer("/post_stage_counts/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(retest.pointer("/post_stage_counts/blocked").and_then(Value::as_u64), Some(7)); - assert_eq!(retest.pointer("/post_stage_counts/not_encoded").and_then(Value::as_u64), Some(19)); - assert_eq!(retest.pointer("/qmd_post_stage_counts/pass").and_then(Value::as_u64), Some(17)); - assert_eq!( - retest.pointer("/qmd_post_stage_counts/wrong_result").and_then(Value::as_u64), - Some(13) - ); - assert!(retest.pointer("/post_stage_basis").and_then(Value::as_str).is_some_and(|basis| { - basis.contains("XY-955 closeout retest") - && basis.contains("qmd live adapter materialization is 17 pass") - })); - - assert_dreaming_readiness_summary_buckets(ledger) -} - -fn assert_dreaming_readiness_summary_buckets(ledger: &Value) -> Result<()> { - assert!(array_contains_str(ledger, "/summary/improved", "current_vs_historical_correctness")?); - assert!(array_contains_str(ledger, "/summary/improved", "preference_evolution")?); - assert!(array_contains_str(ledger, "/summary/improved", "reviewable_consolidation")?); - assert!(array_contains_str( - ledger, - "/summary/improved", - "memory_summary_top_of_mind_behavior" - )?); - assert!(array_contains_str(ledger, "/summary/improved", "proactive_brief_readiness")?); - assert!(array_contains_str(ledger, "/summary/improved", "scheduled_memory_task_readiness")?); - assert!(array_at(ledger, "/summary/regressed")?.is_empty()); - assert!(array_contains_str(ledger, "/summary/unchanged", "deletion_ttl_tombstone_behavior")?); - assert!(array_contains_str(ledger, "/summary/unchanged", "final_competitor_retest_status")?); - assert!(array_at(ledger, "/summary/blocked")?.is_empty()); - assert!(array_at(ledger, "/summary/not_tested")?.is_empty()); - - Ok(()) -} - -fn assert_dreaming_memory_summary_stage(stages: &[Value]) -> Result<()> { - let summary_stage = find_by_field(stages, "/stage_id", "memory_summary_top_of_mind_behavior")?; - - assert_eq!( - summary_stage.pointer("/comparison_judgment").and_then(Value::as_str), - Some("improved") - ); - assert_eq!(summary_stage.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(9)); - assert_eq!( - summary_stage.pointer("/post_stage_counts/not_tested").and_then(Value::as_u64), - Some(0) - ); - assert!( - summary_stage - .pointer("/post_stage_basis") - .and_then(Value::as_str) - .is_some_and(|basis| basis.contains("fixture-backed memory_summary job") - && basis.contains("unsupported-claim flags")) - ); - - Ok(()) -} - -fn assert_dreaming_proactive_brief_stage(stages: &[Value]) -> Result<()> { - let proactive_stage = find_by_field(stages, "/stage_id", "proactive_brief_readiness")?; - - assert_eq!( - proactive_stage.pointer("/comparison_judgment").and_then(Value::as_str), - Some("improved") - ); - assert_eq!(proactive_stage.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(4)); - assert_eq!( - proactive_stage.pointer("/post_stage_counts/blocked").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - proactive_stage.pointer("/post_stage_counts/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - proactive_stage.pointer("/post_stage_counts/freshness_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - proactive_stage - .pointer("/post_stage_counts/action_rationale_coverage") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - proactive_stage - .pointer("/post_stage_counts/tombstone_violation_count") - .and_then(Value::as_u64), - Some(0) - ); - assert!( - proactive_stage - .pointer("/post_stage_basis") - .and_then(Value::as_str) - .is_some_and(|basis| basis.contains("five proactive_brief fixture jobs") - && basis.contains("typed private-corpus refresh blocker")) - ); - - Ok(()) -} - -fn assert_dreaming_readiness_markdown_boundaries(markdown: &str) { - assert!( - markdown.contains("`improved`: current-vs-historical correctness, preference evolution") - && markdown.contains("reviewable") - && markdown.contains("proactive brief") - ); - assert!(markdown.contains("memory-summary/top-of-mind fixture readback")); - assert!(markdown.contains("XY-953 adds a direct `proactive_brief` suite")); - assert!(markdown.contains("XY-954 adds a direct `scheduled_memory` suite")); - assert!(markdown.contains( - "Do not claim fixture-backed proactive brief scoring proves OpenAI Pulse parity" - )); - assert!( - markdown - .contains("Do not claim fixture-backed scheduled-memory scoring proves ChatGPT Tasks") - ); - assert!(markdown.contains("`regressed`: none")); - assert!(markdown.contains("the XY-905 run passes all six memory-evolution jobs")); - assert!(markdown.contains("XY-952 adds a reviewable `elf.memory_summary/v1`")); - assert!(markdown.contains("XY-955 closes the final competitor retest row")); - assert!(markdown.contains("XY-905")); - assert!(markdown.contains("qmd live `pass=17`, `wrong_result=13`")); - assert!( - markdown - .contains("Do not claim this ledger proves preference history against mem0/OpenMemory") - ); - assert!(markdown.contains("Reviewable consolidation now has ELF live service-backed")); -} - -#[test] -fn knowledge_json_report_renders_markdown_metrics() -> Result<()> { - let report = run_json_report_from(knowledge_fixture_dir())?; - let temp_dir = env::temp_dir().join(format!("elf-real-world-knowledge-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("knowledge-report.json"); - let markdown_path = temp_dir.join("knowledge-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("Knowledge Page Metrics")); - assert!(markdown.contains("Knowledge citation coverage")); - assert!(markdown.contains("Backlinks: `11` total")); - assert!(markdown.contains("Unsupported summary count")); - assert!(markdown.contains("knowledge-project-page-001")); - assert!(markdown.contains("knowledge-entity-concept-002")); - assert!(markdown.contains("knowledge-watch-rebuild-003")); - - Ok(()) -} - -#[test] -fn memory_summary_fixtures_score_reviewable_source_trace_contract() -> Result<()> { - let report = run_json_report_from(memory_summary_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/memory_summary/summary_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/memory_summary/entry_count").and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - report - .pointer("/summary/memory_summary/covered_required_category_count") - .and_then(Value::as_u64), - Some(6) - ); - assert_eq!( - report.pointer("/summary/memory_summary/source_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/memory_summary/freshness_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/memory_summary/rationale_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/memory_summary/unsupported_derived_entry_count") - .and_then(Value::as_u64), - Some(1) - ); - - let suites = array_at(&report, "/suites")?; - let memory_summary = find_by_field(suites, "/suite_id", "memory_summary")?; - - assert_eq!(memory_summary.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(memory_summary.pointer("/encoded_job_count").and_then(Value::as_u64), Some(1)); - - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(job.pointer("/memory_summary/top_of_mind_count").and_then(Value::as_u64), Some(1)); - assert_eq!(job.pointer("/memory_summary/tombstone_ref_count").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn memory_summary_markdown_renders_source_trace_metrics() -> Result<()> { - let report = run_json_report_from(memory_summary_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-memory-summary-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("memory-summary-report.json"); - let markdown_path = temp_dir.join("memory-summary-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("Memory Summary Metrics")); - assert!(markdown.contains("memory-summary-source-trace-001")); - assert!(markdown.contains("Memory summary source-ref coverage")); - assert!(markdown.contains("Invalid Top-of-Mind")); - assert!(markdown.contains("Derived Unsupported")); - - Ok(()) -} - -#[test] -fn memory_summary_fixture_fails_stale_top_of_mind_entries() -> Result<()> { - let fixture_path = memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][2]["category"] = - Value::String("top_of_mind".to_string()); - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][2]["freshness"] - ["status"] = Value::String("current".to_string()); - - let temp_dir = - env::temp_dir().join(format!("elf-memory-summary-stale-current-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("stale_current_summary.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn memory_summary_fixture_fails_tombstoned_top_of_mind_entries() -> Result<()> { - let fixture_path = memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][4]["category"] = - Value::String("top_of_mind".to_string()); - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][4]["freshness"] - ["status"] = Value::String("current".to_string()); - - let temp_dir = env::temp_dir() - .join(format!("elf-memory-summary-tombstone-current-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write( - temp_dir.join("tombstone_current_summary.json"), - serde_json::to_vec_pretty(&fixture)?, - )?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn memory_summary_fixture_fails_untraced_derived_profile_entries() -> Result<()> { - let fixture_path = memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["unsupported_claim_flags"] = - Value::Array(Vec::new()); - - let temp_dir = - env::temp_dir().join(format!("elf-memory-summary-untraced-derived-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write( - temp_dir.join("untraced_derived_summary.json"), - serde_json::to_vec_pretty(&fixture)?, - )?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("unsupported_claim")); - assert_eq!( - job.pointer("/memory_summary/derived_missing_source_or_unsupported_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn memory_summary_fixture_fails_unsupported_current_derived_entries() -> Result<()> { - let fixture_path = memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["source_refs"] = - Value::Array(vec![Value::String("summary-contract-non-parity-boundary".to_string())]); - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["freshness"] - ["status"] = Value::String("current".to_string()); - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["rationale"] - ["decision"] = Value::String("included".to_string()); - - let temp_dir = env::temp_dir() - .join(format!("elf-memory-summary-unsupported-current-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write( - temp_dir.join("unsupported_current_summary.json"), - serde_json::to_vec_pretty(&fixture)?, - )?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/memory_summary/unsupported_current_entry_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn memory_summary_fixture_fails_tombstone_entries_without_tombstone_refs() -> Result<()> { - let fixture_path = memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][4]["freshness"] - ["tombstone_refs"] = Value::Array(Vec::new()); - - let temp_dir = - env::temp_dir().join(format!("elf-memory-summary-tombstone-refs-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write( - temp_dir.join("missing_tombstone_refs_summary.json"), - serde_json::to_vec_pretty(&fixture)?, - )?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/memory_summary/freshness_coverage").and_then(Value::as_f64), - Some(0.857) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn proactive_brief_fixtures_score_source_linked_suggestions() -> Result<()> { - let report = run_json_report_from(proactive_brief_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/proactive_brief/brief_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/suggestion_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/freshness_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/proactive_brief/action_rationale_coverage") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/proactive_brief/invalid_current_suggestion_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/proactive_brief/tombstone_violation_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/rejected_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/deferred_count").and_then(Value::as_u64), - Some(2) - ); - - let suites = array_at(&report, "/suites")?; - let proactive = find_by_field(suites, "/suite_id", "proactive_brief")?; - - assert_eq!(proactive.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(proactive.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - - let jobs = array_at(&report, "/jobs")?; - let daily = find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; - let private = find_by_field(jobs, "/job_id", "proactive-private-corpus-refresh-blocked-001")?; - - assert_eq!(daily.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - daily.pointer("/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!(private.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert!( - report - .pointer("/follow_ups/0/title") - .and_then(Value::as_str) - .is_some_and(|title| title.contains("XY-930")) - ); - - Ok(()) -} - -#[test] -fn proactive_brief_markdown_renders_source_and_freshness_metrics() -> Result<()> { - let report = run_json_report_from(proactive_brief_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-proactive-brief-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("proactive-brief-report.json"); - let markdown_path = temp_dir.join("proactive-brief-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("Proactive Brief Metrics")); - assert!(markdown.contains("proactive-daily-project-brief-001")); - assert!(markdown.contains("Proactive evidence-ref coverage")); - assert!(markdown.contains("Invalid Current")); - assert!(markdown.contains("Tombstone Violations")); - - Ok(()) -} - -#[test] -fn proactive_brief_fixture_fails_unsupported_suggestions() -> Result<()> { - let fixture_path = proactive_brief_fixture_dir().join("daily_project_brief.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["evidence_refs"] = - Value::Array(Vec::new()); - - let temp_dir = - env::temp_dir().join(format!("elf-proactive-unsupported-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("unsupported_brief.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("unsupported_claim")); - assert_eq!( - job.pointer("/proactive_brief/untraced_suggestion_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn proactive_brief_fixture_fails_stale_decisions_presented_current() -> Result<()> { - let fixture_path = proactive_brief_fixture_dir().join("stale_decision_audit.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["freshness"] - ["status"] = Value::String("current".to_string()); - - let temp_dir = - env::temp_dir().join(format!("elf-proactive-stale-current-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("stale_current_brief.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "proactive-stale-decision-audit-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/proactive_brief/invalid_current_suggestion_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn proactive_brief_fixture_fails_tombstone_ttl_violations() -> Result<()> { - let fixture_path = proactive_brief_fixture_dir().join("stale_plan_preference_warning.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["freshness"] - ["status"] = Value::String("current".to_string()); - fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["action"] - ["decision"] = Value::String("recommend".to_string()); - - let temp_dir = env::temp_dir().join(format!("elf-proactive-tombstone-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("tombstone_current_brief.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "proactive-stale-plan-preference-warning-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/proactive_brief/tombstone_violation_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn scheduled_memory_fixtures_score_task_trace_gate() -> Result<()> { - let report = run_json_report_from(scheduled_memory_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/scheduled_memory/job_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/task_run_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/output_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/freshness_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/scheduled_memory/action_rationale_coverage") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/trace_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/scheduled_memory/invalid_current_output_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/scheduled_memory/tombstone_violation_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/source_mutation_count").and_then(Value::as_u64), - Some(0) - ); - - let suites = array_at(&report, "/suites")?; - let scheduled = find_by_field(suites, "/suite_id", "scheduled_memory")?; - - assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(scheduled.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - - let jobs = array_at(&report, "/jobs")?; - let weekly = find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; - let private = - find_by_field(jobs, "/job_id", "scheduled-private-provider-scheduler-blocked-001")?; - - assert_eq!(weekly.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - weekly.pointer("/scheduled_memory/trace_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!(private.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert!( - report - .pointer("/follow_ups/0/title") - .and_then(Value::as_str) - .is_some_and(|title| title.contains("XY-930")) - ); - - Ok(()) -} - -#[test] -fn scheduled_memory_markdown_renders_trace_metrics() -> Result<()> { - let report = run_json_report_from(scheduled_memory_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-scheduled-memory-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("scheduled-memory-report.json"); - let markdown_path = temp_dir.join("scheduled-memory-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("Scheduled Memory Metrics")); - assert!(markdown.contains("scheduled-weekly-project-status-summary-001")); - assert!(markdown.contains("Scheduled memory evidence-ref coverage")); - assert!(markdown.contains("Trace Coverage")); - assert!(markdown.contains("Source Mutations")); - - Ok(()) -} - -#[test] -fn scheduled_memory_fixture_fails_missing_execution_trace() -> Result<()> { - let fixture_path = scheduled_memory_fixture_dir().join("weekly_project_status_summary.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0] - .as_object_mut() - .ok_or_else(|| eyre::eyre!("missing scheduled task object"))? - .remove("execution_trace"); - - let temp_dir = - env::temp_dir().join(format!("elf-scheduled-missing-trace-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("missing_trace.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/scheduled_memory/trace_complete_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn scheduled_memory_fixture_fails_untraced_outputs() -> Result<()> { - let fixture_path = scheduled_memory_fixture_dir().join("weekly_project_status_summary.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["outputs"][0]["evidence_refs"] = - Value::Array(Vec::new()); - - let temp_dir = - env::temp_dir().join(format!("elf-scheduled-untraced-output-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("untraced_output.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("unsupported_claim")); - assert_eq!( - job.pointer("/scheduled_memory/untraced_output_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn scheduled_memory_fixture_fails_superseded_sources_presented_current() -> Result<()> { - let fixture_path = scheduled_memory_fixture_dir().join("stale_decision_audit.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["outputs"][0]["evidence_refs"] = - serde_json::json!(["scheduled-old-consolidation-only-decision"]); - fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["outputs"][0]["freshness"] - ["status"] = Value::String("current".to_string()); - - let temp_dir = - env::temp_dir().join(format!("elf-scheduled-superseded-current-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("superseded_current.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "scheduled-stale-decision-audit-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/scheduled_memory/invalid_current_output_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn scheduled_memory_fixture_fails_source_mutation() -> Result<()> { - let fixture_path = scheduled_memory_fixture_dir().join("weekly_project_status_summary.json"); - let mut fixture = load_json(&fixture_path)?; - - fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["source_mutations"] = serde_json::json!([ - { - "table": "memory_notes", - "op": "update", - "note_id": "scheduled-weekly-current-gate" - } - ]); - - let temp_dir = - env::temp_dir().join(format!("elf-scheduled-source-mutation-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("source_mutation.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("lifecycle_fail")); - assert_eq!( - job.pointer("/scheduled_memory/source_mutation_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!(report.pointer("/summary/lifecycle_fail").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn work_continuity_fixtures_score_required_metrics() -> Result<()> { - let report = run_json_report_from(work_continuity_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(8)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(8)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - - assert_work_continuity_summary_counts(&report); - - let suites = array_at(&report, "/suites")?; - let work_continuity = find_by_field(suites, "/suite_id", "work_continuity")?; - - assert_eq!(work_continuity.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(work_continuity.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); - - Ok(()) -} - -fn assert_work_continuity_summary_counts(report: &Value) { - for (field, expected) in [ - ("readback_count", 8), - ("entry_count", 8), - ("reset_resume_required_count", 1), - ("reset_resume_success_count", 1), - ("decision_rationale_required_count", 1), - ("decision_rationale_recalled_count", 1), - ("rejected_option_required_count", 1), - ("rejected_option_suppressed_count", 1), - ("rejected_option_resurrection_count", 0), - ("explicit_next_step_required_count", 1), - ("explicit_next_step_returned_count", 1), - ("explicit_next_step_correct_count", 1), - ("inferred_next_step_required_count", 1), - ("inferred_next_step_labeled_count", 1), - ("inferred_step_instruction_count", 0), - ("handoff_source_ref_required_count", 1), - ("handoff_source_ref_covered_count", 1), - ("redaction_required_count", 1), - ("redaction_applied_count", 1), - ("sensitive_marker_persistence_count", 0), - ("janitor_candidate_count", 1), - ("janitor_false_promotion_count", 0), - ("journal_only_authority_claim_count", 0), - ] { - assert_work_continuity_summary_u64(report, field, expected); - } - for (field, expected) in [ - ("reset_resume_success_rate", 1.0), - ("decision_rationale_recall_rate", 1.0), - ("rejected_option_suppression_rate", 1.0), - ("explicit_next_step_precision", 1.0), - ("inferred_next_step_labeling_rate", 1.0), - ("handoff_source_ref_coverage", 1.0), - ("redaction_rate", 1.0), - ("janitor_false_promotion_rate", 0.0), - ] { - assert_work_continuity_summary_f64(report, field, expected); - } -} - -fn assert_work_continuity_summary_u64(report: &Value, field: &str, expected: u64) { - assert_eq!( - report.pointer(&format!("/summary/work_continuity/{field}")).and_then(Value::as_u64), - Some(expected), - "unexpected Work Continuity summary field {field}", - ); -} - -fn assert_work_continuity_summary_f64(report: &Value, field: &str, expected: f64) { - assert_eq!( - report.pointer(&format!("/summary/work_continuity/{field}")).and_then(Value::as_f64), - Some(expected), - "unexpected Work Continuity summary field {field}", - ); -} - -#[test] -fn work_continuity_markdown_renders_required_metrics() -> Result<()> { - let report = run_json_report_from(work_continuity_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-work-continuity-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("work-continuity-report.json"); - let markdown_path = temp_dir.join("work-continuity-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("Work Continuity Metrics")); - assert!(markdown.contains("work-continuity-redaction-001")); - assert!(markdown.contains("work-continuity-janitor-false-promotion-001")); - assert!(markdown.contains("Janitor False Promotion")); - assert!(markdown.contains("Sensitive Persistence")); - assert!(markdown.contains("Journal Authority Claims")); - assert!(markdown.contains("| work-continuity-reset-resume-001 | 1 | 1 | `1/1` (`1.000`)")); - assert!(markdown.contains( - "| work-continuity-explicit-next-step-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `1/1` (`1.000`)" - )); - assert!(markdown.contains( - "| work-continuity-handoff-source-ref-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`1.000`) | `0/0` (`0.000`) | `1/1` (`1.000`)" - )); - assert!(markdown.contains( - "| work-continuity-redaction-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`1.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `1/1` (`1.000`)" - )); - assert!(markdown.contains( - "| work-continuity-janitor-false-promotion-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`1.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/1` (`0.000`)" - )); - - Ok(()) -} - -#[test] -fn work_continuity_fixture_fails_sensitive_marker_persistence() -> Result<()> { - let report = run_work_continuity_mutation( - "redaction_sensitive_marker.json", - "sensitive_marker_persistence.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["items"] - [0]["redaction_audit"]["persisted_sensitive_marker_ids"] = - serde_json::json!(["secret-demo-token"]); - }, - )?; - let job = single_work_continuity_job(&report, "work-continuity-redaction-001")?; - - assert_work_continuity_wrong_result(job, "sensitive_marker_persistence_count", 1); - - Ok(()) -} - -#[test] -fn work_continuity_fixture_fails_rejected_option_resurrection() -> Result<()> { - let report = run_work_continuity_mutation( - "rejected_option_suppression.json", - "rejected_option_resurrection.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["items"] - [0]["rejected_options"][0]["resurrected_as_current"] = Value::Bool(true); - }, - )?; - let job = single_work_continuity_job(&report, "work-continuity-rejected-option-001")?; - - assert_work_continuity_wrong_result(job, "rejected_option_resurrection_count", 1); - - Ok(()) -} - -#[test] -fn work_continuity_fixture_fails_inferred_step_instruction() -> Result<()> { - let report = run_work_continuity_mutation( - "inferred_next_step_labeling.json", - "inferred_step_instruction.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["items"] - [0]["inferred_next_steps"][0]["instruction"] = Value::Bool(true); - }, - )?; - let job = single_work_continuity_job(&report, "work-continuity-inferred-next-step-001")?; - - assert_work_continuity_wrong_result(job, "inferred_step_instruction_count", 1); - - Ok(()) -} - -#[test] -fn work_continuity_fixture_fails_journal_only_authority_claim() -> Result<()> { - let report = run_work_continuity_mutation( - "handoff_source_ref_coverage.json", - "journal_only_authority_claim.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["where_stopped"] - ["journal_only_authority_claims"] = serde_json::json!(["wj-handoff-source-ref"]); - }, - )?; - let job = single_work_continuity_job(&report, "work-continuity-handoff-source-ref-001")?; - - assert_work_continuity_wrong_result(job, "journal_only_authority_claim_count", 1); - - Ok(()) -} - -#[test] -fn work_continuity_fixture_fails_janitor_promotion_or_missing_review() -> Result<()> { - let promoted = run_work_continuity_mutation( - "janitor_false_promotion_guard.json", - "janitor_promoted.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["janitor_candidates"] - [0]["promoted_to_memory"] = Value::Bool(true); - }, - )?; - let promoted_job = - single_work_continuity_job(&promoted, "work-continuity-janitor-false-promotion-001")?; - - assert_work_continuity_wrong_result(promoted_job, "janitor_false_promotion_count", 1); - assert_hard_fail_hit(promoted_job, "janitor Work Journal candidate promoted without review"); - - let missing_review = run_work_continuity_mutation( - "janitor_false_promotion_guard.json", - "janitor_missing_review_required.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["janitor_candidates"] - [0]["review_required"] = Value::Bool(false); - }, - )?; - let missing_review_job = - single_work_continuity_job(&missing_review, "work-continuity-janitor-false-promotion-001")?; - - assert_work_continuity_wrong_result(missing_review_job, "janitor_false_promotion_count", 1); - assert_hard_fail_hit( - missing_review_job, - "janitor Work Journal candidate promoted without review", - ); - - let extra_bad_candidate = run_work_continuity_mutation( - "janitor_false_promotion_guard.json", - "janitor_extra_bad_candidate.json", - |fixture| { - fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["janitor_candidates"] = serde_json::json!([ - { - "candidate_id": "wj-janitor-candidate", - "evidence_refs": ["wj-janitor-candidate-source"], - "review_required": true, - "promoted_to_memory": false - }, - { - "candidate_id": "wj-extra-janitor-candidate", - "evidence_refs": ["wj-janitor-candidate-source"], - "review_required": true, - "promoted_to_memory": true - } - ]); - }, - )?; - let extra_bad_candidate_job = single_work_continuity_job( - &extra_bad_candidate, - "work-continuity-janitor-false-promotion-001", - )?; - - assert_work_continuity_wrong_result( - extra_bad_candidate_job, - "janitor_false_promotion_count", - 1, - ); - assert_hard_fail_hit( - extra_bad_candidate_job, - "janitor Work Journal candidate promoted without review", - ); - - assert_eq!( - extra_bad_candidate_job - .pointer("/work_continuity/janitor_candidate_count") - .and_then(Value::as_u64), - Some(2) - ); - - Ok(()) -} - -fn run_work_continuity_mutation( - fixture_name: &str, - output_name: &str, - mutate: impl FnOnce(&mut Value), -) -> Result { - let fixture_path = work_continuity_fixture_dir().join(fixture_name); - let temp_dir = - env::temp_dir().join(format!("elf-work-continuity-{output_name}-{}", process::id())); - let mut fixture = load_json(&fixture_path)?; - - mutate(&mut fixture); - - if temp_dir.exists() { - fs::remove_dir_all(&temp_dir)?; - } - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join(output_name), serde_json::to_vec_pretty(&fixture)?)?; - - run_json_report_from(temp_dir) -} - -fn single_work_continuity_job<'a>(report: &'a Value, job_id: &str) -> Result<&'a Value> { - let jobs = array_at(report, "/jobs")?; - - find_by_field(jobs, "/job_id", job_id) -} - -fn assert_work_continuity_wrong_result(job: &Value, metric_name: &str, expected: u64) { - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer(&format!("/work_continuity/{metric_name}")).and_then(Value::as_u64), - Some(expected) - ); -} - -fn assert_hard_fail_hit(job: &Value, expected_hit: &str) { - let hits = job.pointer("/hard_fail_hits").and_then(Value::as_array).expect("hard_fail_hits"); - - assert!( - hits.iter().filter_map(Value::as_str).any(|hit| hit == expected_hit), - "missing hard_fail_hits marker: {expected_hit}" - ); -} - -#[test] -fn production_ops_fixtures_report_bounded_typed_states() -> Result<()> { - let report = run_json_report_from(production_ops_fixture_dir())?; - - assert_production_ops_summary(&report)?; - assert_production_ops_jobs(&report)?; - assert_production_ops_operational_evidence(&report)?; - - Ok(()) -} - -fn assert_production_ops_summary(report: &Value) -> Result<()> { - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(8)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(2)); - assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!( - report.pointer("/summary/qdrant_rebuild_case_count").and_then(Value::as_u64), - Some(2) - ); - assert_eq!( - report.pointer("/private_corpus_redaction/private_fixture_count").and_then(Value::as_u64), - Some(1) - ); - - let suites = array_at(report, "/suites")?; - let production_ops = find_by_field(suites, "/suite_id", "production_ops")?; - - assert_eq!(production_ops.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(production_ops.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); - - Ok(()) -} - -fn assert_production_ops_jobs(report: &Value) -> Result<()> { - let jobs = array_at(report, "/jobs")?; - let authority_recovery = - find_by_field(jobs, "/job_id", "production-ops-authority-plane-recovery-001")?; - let backfill = find_by_field(jobs, "/job_id", "production-ops-backfill-resume-001")?; - let restore = find_by_field(jobs, "/job_id", "production-ops-restore-cold-start-001")?; - let public_proxy = find_by_field(jobs, "/job_id", "production-ops-public-proxy-addendum-001")?; - let private_manifest = - find_by_field(jobs, "/job_id", "production-ops-private-manifest-blocked-001")?; - let credentials = find_by_field(jobs, "/job_id", "production-ops-credential-boundary-001")?; - let dependency = find_by_field(jobs, "/job_id", "production-ops-cold-start-dependency-001")?; - - assert_authority_recovery_job(authority_recovery)?; - - assert_eq!(authority_recovery.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(backfill.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(restore.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(restore.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), Some(true)); - assert_eq!(public_proxy.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - public_proxy.pointer("/operational_evidence_tier").and_then(Value::as_str), - Some("public_proxy") - ); - assert_eq!(private_manifest.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - private_manifest.pointer("/operational_evidence_tier").and_then(Value::as_str), - Some("private_corpus") - ); - assert_eq!(credentials.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - credentials.pointer("/operational_evidence_tier").and_then(Value::as_str), - Some("provider_backed") - ); - assert_eq!(dependency.pointer("/status").and_then(Value::as_str), Some("pass")); - - Ok(()) -} - -fn assert_authority_recovery_job(job: &Value) -> Result<()> { - assert_eq!(job.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), Some(true)); - assert_eq!(job.pointer("/requires_caveat").and_then(Value::as_bool), Some(true)); - assert_eq!( - job.pointer("/recovery_drills/0/contract_schema").and_then(Value::as_str), - Some("elf.authority_recovery_drill/v1") - ); - assert!(array_at(job, "/hard_fail_hits")?.is_empty()); - - Ok(()) -} - -fn assert_production_ops_operational_evidence(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/operational_evidence/schema").and_then(Value::as_str), - Some("elf.operational_evidence_gates/v1") - ); - assert_eq!( - report - .pointer("/operational_evidence/missing_private_provider_inputs_are_typed_blockers") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - report - .pointer("/operational_evidence/private_corpus_pass_claim_allowed") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report - .pointer("/operational_evidence/provider_backed_pass_claim_allowed") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - report.pointer("/operational_evidence/latency/measured_job_count").and_then(Value::as_u64), - Some(8) - ); - assert_eq!( - report.pointer("/operational_evidence/cost/jobs_with_cost_report").and_then(Value::as_u64), - Some(8) - ); - assert_eq!( - report - .pointer("/operational_evidence/resource/resource_envelope_job_count") - .and_then(Value::as_u64), - Some(2) - ); - assert_eq!( - report - .pointer("/operational_evidence/cold_start_restore_rebuild/qdrant_rebuild_pass_count") - .and_then(Value::as_u64), - Some(2) - ); - - assert_authority_recovery_operational_evidence(report); - - let tiers = array_at(report, "/operational_evidence/tiers")?; - let local_fixture = find_by_field(tiers, "/tier", "local_fixture")?; - let public_proxy_tier = find_by_field(tiers, "/tier", "public_proxy")?; - let private_corpus = find_by_field(tiers, "/tier", "private_corpus")?; - let provider_backed = find_by_field(tiers, "/tier", "provider_backed")?; - - assert_eq!(local_fixture.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(local_fixture.pointer("/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(public_proxy_tier.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(public_proxy_tier.pointer("/job_count").and_then(Value::as_u64), Some(1)); - assert_eq!(private_corpus.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(private_corpus.pointer("/blocked").and_then(Value::as_u64), Some(1)); - assert_eq!(provider_backed.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(provider_backed.pointer("/blocked").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -fn assert_authority_recovery_operational_evidence(report: &Value) { - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/drill_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/authority_plane_count") - .and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/backup_pitr_restored_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/record_count_preserved_count") - .and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/source_ref_preserved_count") - .and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/lifecycle_history_preserved_count") - .and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/rpo_met_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/rto_met_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/idempotent_outbox_replay_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/qdrant_rebuild_complete_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/migration_repair_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report - .pointer("/operational_evidence/authority_recovery/dead_letter_handled_count") - .and_then(Value::as_u64), - Some(1) - ); -} - -#[test] -fn authority_recovery_fixture_rejects_incomplete_recovery_predicates() -> Result<()> { - for (slug, pointer, replacement, expected_error) in authority_recovery_failure_cases() { - assert_authority_recovery_fixture_failure( - slug, - |fixture| set_json_pointer(fixture, pointer, replacement), - expected_error, - )?; - } - - Ok(()) -} - -fn authority_recovery_failure_cases() -> Vec<(&'static str, &'static str, Value, &'static str)> { - vec![ - ( - "unrestored-backup", - "/corpus/adapter_response/answer/recovery_drills/0/backup_pitr/restored", - serde_json::json!(false), - "incomplete backup/PITR drill evidence", - ), - ( - "record-count-loss", - "/corpus/adapter_response/answer/recovery_drills/0/authority_record_counts/0/after_count", - serde_json::json!(2), - "lost or gained source authority records", - ), - ( - "source-ref-loss", - "/corpus/adapter_response/answer/recovery_drills/0/authority_record_counts/0/source_refs_preserved", - serde_json::json!(false), - "did not preserve source authority source refs", - ), - ( - "lifecycle-history-loss", - "/corpus/adapter_response/answer/recovery_drills/0/authority_record_counts/0/lifecycle_history_preserved", - serde_json::json!(false), - "did not preserve source authority lifecycle history", - ), - ( - "hidden-source-of-truth", - "/corpus/adapter_response/answer/recovery_drills/0/degraded_read/source_of_truth_visible", - serde_json::json!(false), - "hidden source-of-truth records during degraded read", - ), - ( - "rpo-miss", - "/corpus/adapter_response/answer/recovery_drills/0/rpo/measured_seconds", - serde_json::json!(61.0), - "exceeded rpo recovery target", - ), - ( - "non-idempotent-outbox", - "/corpus/adapter_response/answer/recovery_drills/0/outbox_replay/duplicate_write_count", - serde_json::json!(1), - "incomplete outbox replay drill evidence", - ), - ( - "incomplete-qdrant-rebuild", - "/corpus/adapter_response/answer/recovery_drills/0/qdrant_rebuild/complete", - serde_json::json!(false), - "incomplete Qdrant rebuild drill evidence", - ), - ( - "missing-migration-repair", - "/corpus/adapter_response/answer/recovery_drills/0/migration_repair/applied", - serde_json::json!(false), - "incomplete migration repair drill evidence", - ), - ( - "dead-letter-underhandled", - "/corpus/adapter_response/answer/recovery_drills/0/dead_letter/handled_count", - serde_json::json!(1), - "incomplete dead-letter handling drill evidence", - ), - ] -} - -fn assert_authority_recovery_fixture_failure( - slug: &str, - mutate: F, - expected_error: &str, -) -> Result<()> -where - F: FnOnce(&mut Value) -> Result<()>, -{ - let fixture_path = production_ops_fixture_dir().join("authority_plane_recovery_drill.json"); - let mut fixture = load_json(&fixture_path)?; - - mutate(&mut fixture)?; - - let temp_dir = env::temp_dir().join(format!("elf-authority-recovery-{slug}-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("fixture.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let stderr = run_json_report_from_failure(temp_dir)?; - - assert!( - stderr.contains(expected_error), - "missing expected error `{expected_error}` in stderr: {stderr}", - ); - - Ok(()) -} - -#[test] -fn core_archival_memory_fixtures_score_separate_core_and_archival_jobs() -> Result<()> { - let report = run_json_report_from(core_archival_memory_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!( - report.pointer("/summary/evidence_required_count").and_then(Value::as_u64), - Some(14) - ); - assert_eq!(report.pointer("/summary/evidence_covered_count").and_then(Value::as_u64), Some(14)); - assert_eq!(report.pointer("/summary/scope_check_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/scope_correct_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/scope_violation_count").and_then(Value::as_u64), Some(0)); - - let suites = array_at(&report, "/suites")?; - let core = find_by_field(suites, "/suite_id", "core_archival_memory")?; - - assert_eq!(core.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(core.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6)); - - let jobs = array_at(&report, "/jobs")?; - - for job_id in [ - "core-archival-core-block-attachment-001", - "core-archival-core-block-scope-001", - "core-archival-core-block-provenance-001", - "core-archival-stale-core-detection-001", - "core-archival-archival-fallback-001", - "core-archival-project-decision-recovery-001", - ] { - let job = find_by_field(jobs, "/job_id", job_id)?; - - assert_eq!(job.pointer("/suite_id").and_then(Value::as_str), Some("core_archival_memory")); - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("pass")); - } - - let scope = find_by_field(jobs, "/job_id", "core-archival-core-block-scope-001")?; - let decision = find_by_field(jobs, "/job_id", "core-archival-project-decision-recovery-001")?; - - assert_eq!(scope.pointer("/scope_check_count").and_then(Value::as_u64), Some(1)); - assert_eq!(scope.pointer("/scope_correct_count").and_then(Value::as_u64), Some(1)); - assert_eq!(scope.pointer("/scope_violation_count").and_then(Value::as_u64), Some(0)); - assert!( - decision - .pointer("/produced_answer") - .and_then(Value::as_str) - .is_some_and(|content| content.contains("Letta remains blocked or not_tested")) - ); - assert!( - array_at(decision, "/produced_evidence")? - .iter() - .any(|id| id.as_str() == Some("decision-letta-export-boundary")) - ); - - Ok(()) -} - -#[test] -fn memory_authority_benchmark_covers_entity_history_and_core_archive_strengths() -> Result<()> { - let report = run_json_report_from(real_world_memory_fixture_dir())?; - - assert_eq!( - report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64), - Some(4) - ); - - let suites = array_at(&report, "/suites")?; - let memory_evolution = find_by_field(suites, "/suite_id", "memory_evolution")?; - let core_archival = find_by_field(suites, "/suite_id", "core_archival_memory")?; - - assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - memory_evolution.pointer("/history_readback_encoded_count").and_then(Value::as_u64), - Some(3) - ); - assert_eq!(core_archival.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6)); - - let jobs = array_at(&report, "/jobs")?; - let preference = find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; - let core_attachment = - find_by_field(jobs, "/job_id", "core-archival-core-block-attachment-001")?; - let archival_fallback = find_by_field(jobs, "/job_id", "core-archival-archival-fallback-001")?; - - assert_eq!(preference.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - preference.pointer("/evolution/history_readback_encoded").and_then(Value::as_bool), - Some(true) - ); - assert!(array_contains_str(preference, "/evolution/history_event_types", "update")?); - assert_eq!(core_attachment.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(archival_fallback.pointer("/status").and_then(Value::as_str), Some("pass")); - - let adapters = array_at(&report, "/external_adapters/adapters")?; - let mem0 = find_by_field(adapters, "/adapter_id", "mem0_openmemory_live_baseline")?; - let letta = find_by_field(adapters, "/adapter_id", "letta_research_gate")?; - let mem0_scenarios = array_at(mem0, "/scenarios")?; - let mem0_history = - find_by_field(mem0_scenarios, "/scenario_id", "preference_correction_history")?; - let mem0_entity = - find_by_field(mem0_scenarios, "/scenario_id", "entity_scoped_personalization")?; - - assert_eq!(mem0_history.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(mem0_entity.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(mem0_history.pointer("/comparison_outcome").and_then(Value::as_str), Some("loss")); - assert_eq!(mem0_entity.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); - - let letta_scenarios = array_at(letta, "/scenarios")?; - let letta_core = - find_by_field(letta_scenarios, "/scenario_id", "core_block_attachment_readback")?; - let letta_fallback = - find_by_field(letta_scenarios, "/scenario_id", "archival_fallback_readback")?; - - for scenario in [letta_core, letta_fallback] { - assert_eq!( - scenario.pointer("/suite_id").and_then(Value::as_str), - Some("core_archival_memory") - ); - assert_eq!(scenario.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - scenario.pointer("/comparison_outcome").and_then(Value::as_str), - Some("blocked") - ); - } - - Ok(()) -} - -#[test] -fn context_trajectory_fixtures_report_blocked_openviking_gates() -> Result<()> { - let report = run_json_report_from(context_trajectory_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!( - report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), - Some(3) - ); - - let suites = array_at(&report, "/suites")?; - let context = find_by_field(suites, "/suite_id", "context_trajectory")?; - - assert_eq!(context.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(context.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); - - let jobs = array_at(&report, "/jobs")?; - let staged = - find_by_field(jobs, "/job_id", "context-trajectory-openviking-staged-retrieval-001")?; - let hierarchy = - find_by_field(jobs, "/job_id", "context-trajectory-openviking-hierarchy-selection-001")?; - let recursive = - find_by_field(jobs, "/job_id", "context-trajectory-openviking-recursive-expansion-001")?; - - assert_eq!(staged.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(hierarchy.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(recursive.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!( - staged.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("openviking.stage_artifact_gate") - ); - assert_eq!( - hierarchy.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("openviking.hierarchy_artifact_gate") - ); - assert_eq!( - recursive.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("openviking.recursive_expansion_gate") - ); - - let staged_stages = array_at(staged, "/trace_explainability/stages")?; - let staged_gate = - find_by_field(staged_stages, "/stage_name", "openviking.stage_artifact_gate")?; - - assert!(array_contains_str(staged_gate, "/dropped_evidence", "trajectory-win-decoy")?); - - let hierarchy_stages = array_at(hierarchy, "/trace_explainability/stages")?; - let hierarchy_gate = - find_by_field(hierarchy_stages, "/stage_name", "openviking.hierarchy_artifact_gate")?; - - assert!(array_contains_str(hierarchy_gate, "/dropped_evidence", "hierarchy-design-win-decoy")?); - - let recursive_stages = array_at(recursive, "/trace_explainability/stages")?; - let recursive_gate = - find_by_field(recursive_stages, "/stage_name", "openviking.recursive_expansion_gate")?; - - assert!(array_contains_str( - recursive_gate, - "/dropped_evidence", - "recursive-expansion-win-decoy" - )?); - assert!( - staged.pointer("/reason").and_then(Value::as_str).is_some_and( - |reason| reason.contains("same-corpus output returns expected evidence ids") - ) - ); - - Ok(()) -} - -fn assert_root_knowledge_summary(report: &Value) { - assert_eq!(report.pointer("/summary/knowledge/job_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/knowledge/page_count").and_then(Value::as_u64), Some(5)); - assert_eq!( - report.pointer("/summary/knowledge/page_usefulness").and_then(Value::as_f64), - Some(0.979) - ); -} - -fn assert_root_aggregate_summary(report: &Value) -> Result<()> { - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(82)); - assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(19)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(75)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(7)); - assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/irrelevant_context_ratio").and_then(Value::as_f64), - Some(0.0) - ); - assert_eq!(report.pointer("/summary/stale_retrieval_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), - Some(11) - ); - assert_eq!( - report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), - Some(16) - ); - assert_eq!( - report.pointer("/summary/temporal_validity_not_encoded_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!(report.pointer("/summary/redaction_leak_count").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/scope_check_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/scope_correct_count").and_then(Value::as_u64), Some(3)); - assert_eq!(report.pointer("/summary/scope_violation_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/qdrant_rebuild_case_count").and_then(Value::as_u64), - Some(3) - ); - assert_eq!( - report.pointer("/summary/qdrant_rebuild_pass_count").and_then(Value::as_u64), - Some(3) - ); - assert_eq!( - report.pointer("/summary/evidence_required_count").and_then(Value::as_u64), - Some(180) - ); - assert_eq!( - report.pointer("/summary/evidence_covered_count").and_then(Value::as_u64), - Some(180) - ); - assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!(report.pointer("/summary/quote_coverage").and_then(Value::as_f64), Some(1.0)); - assert_eq!( - report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/wrong_result_stage_attribution_count").and_then(Value::as_u64), - Some(0) - ); - - assert_root_scoreboard_summary(report)?; - - assert_eq!( - report.pointer("/summary/consolidation/proposal_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/consolidation/source_mutation_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/consolidation/proposal_unsupported_claim_count") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/memory_summary/job_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/summary/memory_summary/source_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - - assert_root_knowledge_summary(report); - assert_root_proactive_brief_summary(report); - assert_root_scheduled_memory_summary(report); - assert_root_work_continuity_summary(report); - - Ok(()) -} - -fn assert_root_scoreboard_summary(report: &Value) -> Result<()> { - assert_eq!( - report.pointer("/scoreboard/summary_claim").and_then(Value::as_str), - Some("typed_non_pass_present") - ); - assert_eq!( - report.pointer("/scoreboard/job_summary_claim").and_then(Value::as_str), - Some("typed_non_pass_present") - ); - assert_eq!( - report.pointer("/scoreboard/job_typed_non_pass_count").and_then(Value::as_u64), - Some(7) - ); - assert_eq!( - report.pointer("/scoreboard/external_adapter_typed_non_pass_count").and_then(Value::as_u64), - Some(240) - ); - assert_eq!( - report.pointer("/scoreboard/typed_non_pass_count").and_then(Value::as_u64), - Some(247) - ); - assert_eq!( - report.pointer("/scoreboard/unqualified_win_claim_allowed").and_then(Value::as_bool), - Some(false) - ); - assert!(array_contains_str(report, "/scoreboard/result_states", "not_comparable")?); - assert_eq!( - report.pointer("/scoreboard/metric_basis").and_then(Value::as_str), - Some("produced_evidence_order") - ); - assert_eq!(report.pointer("/scoreboard/retrieval_k").and_then(Value::as_u64), Some(5)); - - assert_root_scoreboard_rows(report)?; - - for state in ["blocked", "incomplete", "not_encoded", "not_tested", "wrong_result"] { - assert!(array_contains_str(report, "/scoreboard/typed_non_pass_states_present", state)?); - } - - assert_eq!( - string_array_at(report, "/scoreboard/job_typed_non_pass_states_present")?, - ["blocked"].map(str::to_owned) - ); - - for state in ["blocked", "incomplete", "not_encoded", "not_tested", "wrong_result"] { - assert!(array_contains_str( - report, - "/scoreboard/external_adapter_typed_non_pass_states_present", - state - )?); - } - - Ok(()) -} - -fn assert_root_scoreboard_rows(report: &Value) -> Result<()> { - let rows = array_at(report, "/scoreboard/rows")?; - let elf = find_by_field(rows, "/product_id", "elf_current_report")?; - let qmd = find_by_field(rows, "/product_id", "qmd")?; - let graphify = find_by_field(rows, "/product_id", "graphify")?; - let pageindex = find_by_field(rows, "/product_id", "vectifyai_pageindex")?; - let openkb = find_by_field(rows, "/product_id", "vectifyai_openkb")?; - let honcho = find_by_field(rows, "/product_id", "plastic_labs_honcho")?; - - assert_eq!(rows.len(), 20); - assert_eq!(elf.pointer("/result_state").and_then(Value::as_str), Some("blocked")); - assert_eq!(elf.pointer("/evidence_class").and_then(Value::as_str), Some("fixture_backed")); - assert_eq!(elf.pointer("/comparable").and_then(Value::as_bool), Some(false)); - assert_eq!(elf.pointer("/same_corpus").and_then(Value::as_bool), Some(true)); - assert_eq!(elf.pointer("/source_id_mapped").and_then(Value::as_bool), Some(true)); - assert_eq!(elf.pointer("/product_runtime").and_then(Value::as_bool), Some(false)); - assert_eq!(elf.pointer("/metrics/retrieval/recall_at_k").and_then(Value::as_f64), Some(0.988)); - assert_eq!( - elf.pointer("/metrics/retrieval/precision_at_k").and_then(Value::as_f64), - Some(0.415) - ); - assert_eq!(elf.pointer("/metrics/retrieval/mrr").and_then(Value::as_f64), Some(0.988)); - assert_eq!(elf.pointer("/metrics/retrieval/ndcg").and_then(Value::as_f64), Some(0.985)); - assert_eq!( - elf.pointer("/metrics/lifecycle/stale_suppression").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - elf.pointer("/metrics/lifecycle/update_correctness").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - elf.pointer("/metrics/lifecycle/delete_correctness").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - elf.pointer("/metrics/coverage/typed_non_pass_count").and_then(Value::as_u64), - Some(7) - ); - assert!(array_contains_str( - elf, - "/next_evidence", - "Run a Docker-contained product-runtime adapter for this row." - )?); - - for competitor in [qmd, graphify] { - assert_eq!( - competitor.pointer("/evidence_class").and_then(Value::as_str), - Some("live_real_world") - ); - assert_eq!( - competitor.pointer("/result_state").and_then(Value::as_str), - Some("wrong_result") - ); - assert_eq!(competitor.pointer("/product_runtime").and_then(Value::as_bool), Some(true)); - assert_eq!( - competitor.pointer("/container_digest_identified").and_then(Value::as_bool), - Some(false) - ); - assert!(competitor.pointer("/metrics/retrieval/recall_at_k").is_some_and(Value::is_null)); - assert!(array_contains_str( - competitor, - "/next_evidence", - "Record container image digest evidence." - )?); - } - - assert_tracked_external_blocker_row(pageindex, "VectifyAI PageIndex", true)?; - assert_tracked_external_blocker_row(openkb, "VectifyAI OpenKB", true)?; - assert_tracked_external_blocker_row(honcho, "plastic-labs Honcho", false)?; - - Ok(()) -} - -fn assert_root_proactive_brief_summary(report: &Value) { - assert_eq!( - report.pointer("/summary/proactive_brief/job_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/suggestion_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/proactive_brief/freshness_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/proactive_brief/action_rationale_coverage") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/proactive_brief/invalid_current_suggestion_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/proactive_brief/tombstone_violation_count") - .and_then(Value::as_u64), - Some(0) - ); -} - -fn assert_root_scheduled_memory_summary(report: &Value) { - assert_eq!( - report.pointer("/summary/scheduled_memory/job_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/task_run_count").and_then(Value::as_u64), - Some(4) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/output_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/evidence_ref_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/freshness_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/scheduled_memory/action_rationale_coverage") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/scheduled_memory/trace_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/scheduled_memory/invalid_current_output_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/scheduled_memory/tombstone_violation_count") - .and_then(Value::as_u64), - Some(0) - ); -} - -fn assert_root_work_continuity_summary(report: &Value) { - assert_eq!( - report.pointer("/summary/work_continuity/job_count").and_then(Value::as_u64), - Some(8) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/reset_resume_success_rate") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/decision_rationale_recall_rate") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/rejected_option_suppression_rate") - .and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/inferred_step_instruction_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/sensitive_marker_persistence_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/janitor_false_promotion_count") - .and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report - .pointer("/summary/work_continuity/journal_only_authority_claim_count") - .and_then(Value::as_u64), - Some(0) - ); -} - -fn assert_root_aggregate_suites(report: &Value) -> Result<()> { - let suites = array_at(report, "/suites")?; - - for suite_id in [ - "trust_source_of_truth", - "work_resume", - "project_decisions", - "retrieval", - "capture_integration", - "personalization", - "consolidation", - "memory_summary", - "knowledge_compilation", - "operator_debugging_ux", - "memory_evolution", - "adversarial_quality", - "core_archival_memory", - "work_continuity", - ] { - let suite = find_by_field(suites, "/suite_id", suite_id)?; - - assert_eq!(suite.pointer("/status").and_then(Value::as_str), Some("pass")); - } - - let memory_evolution = find_by_field(suites, "/suite_id", "memory_evolution")?; - - assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass")); - - let project_decisions = find_by_field(suites, "/suite_id", "project_decisions")?; - - assert_eq!(project_decisions.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - assert_eq!( - project_decisions.pointer("/update_rationale_available_count").and_then(Value::as_u64), - Some(5) - ); - - let debug_suite = find_by_field(suites, "/suite_id", "operator_debugging_ux")?; - - assert_eq!(debug_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - - let core_suite = find_by_field(suites, "/suite_id", "core_archival_memory")?; - - assert_eq!(core_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(core_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6)); - - let adversarial = find_by_field(suites, "/suite_id", "adversarial_quality")?; - - assert_eq!(adversarial.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(adversarial.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - - let production_ops = find_by_field(suites, "/suite_id", "production_ops")?; - - assert_eq!(production_ops.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(production_ops.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); - - let proactive = find_by_field(suites, "/suite_id", "proactive_brief")?; - - assert_eq!(proactive.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(proactive.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - - let scheduled = find_by_field(suites, "/suite_id", "scheduled_memory")?; - - assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(scheduled.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - - let source_library = find_by_field(suites, "/suite_id", "source_library")?; - - assert_eq!(source_library.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(source_library.pointer("/encoded_job_count").and_then(Value::as_u64), Some(2)); - - let context_trajectory = find_by_field(suites, "/suite_id", "context_trajectory")?; - - assert_eq!(context_trajectory.pointer("/status").and_then(Value::as_str), Some("blocked")); - assert_eq!(context_trajectory.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); - - let work_continuity = find_by_field(suites, "/suite_id", "work_continuity")?; - - assert_eq!(work_continuity.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(work_continuity.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); - - Ok(()) -} - -fn assert_root_aggregate_jobs(report: &Value) -> Result<()> { - let jobs = array_at(report, "/jobs")?; - let rebuild = find_by_field(jobs, "/job_id", "trust-sot-rebuild-001")?; - let redaction = find_by_field(jobs, "/job_id", "capture-redaction-exclusion-001")?; - let personalization = find_by_field(jobs, "/job_id", "personalization-scoped-preference-001")?; - let relation_job = find_by_field(jobs, "/job_id", "memory-evolution-relation-temporal-001")?; - let delete_job = find_by_field(jobs, "/job_id", "memory-evolution-delete-ttl-001")?; - let stage_job = find_by_field(jobs, "/job_id", "operator-debug-stage-attribution-001")?; - let production_restore = - find_by_field(jobs, "/job_id", "production-ops-restore-cold-start-001")?; - let production_authority = - find_by_field(jobs, "/job_id", "production-ops-authority-plane-recovery-001")?; - let core_fallback = find_by_field(jobs, "/job_id", "core-archival-archival-fallback-001")?; - let stale_core = find_by_field(jobs, "/job_id", "core-archival-stale-core-detection-001")?; - let scheduled_weekly = - find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; - - assert_eq!(rebuild.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), Some(true)); - assert_eq!( - production_restore.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - production_authority.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), - Some(true) - ); - assert_eq!(production_authority.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - production_authority.pointer("/recovery_drills/0/contract_schema").and_then(Value::as_str), - Some("elf.authority_recovery_drill/v1") - ); - assert_eq!(redaction.pointer("/redaction_leak_count").and_then(Value::as_u64), Some(0)); - assert_eq!(personalization.pointer("/scope_check_count").and_then(Value::as_u64), Some(1)); - assert_eq!(personalization.pointer("/scope_correct_count").and_then(Value::as_u64), Some(1)); - assert_eq!(stage_job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(relation_job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(delete_job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - delete_job.pointer("/evolution/selected_tombstone_evidence/0").and_then(Value::as_str), - Some("delete-tombstone") - ); - assert_eq!( - delete_job.pointer("/evolution/selected_invalidation_evidence/0").and_then(Value::as_str), - Some("delete-tombstone") - ); - assert_eq!(core_fallback.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(stale_core.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(scheduled_weekly.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - scheduled_weekly.pointer("/scheduled_memory/trace_coverage").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - stage_job.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("rerank.score") - ); - assert!(array_contains_str(stage_job, "/produced_evidence", "stage-target")?); - - Ok(()) -} - -#[test] -fn real_world_memory_fixtures_report_aggregate_metrics() -> Result<()> { - let report = run_json_report_from(real_world_memory_fixture_dir())?; - - assert_root_aggregate_summary(&report)?; - assert_root_aggregate_suites(&report)?; - assert_root_aggregate_jobs(&report)?; - - Ok(()) -} - -#[test] -fn retrieval_fixtures_report_quality_and_trace_attribution() -> Result<()> { - let report = run_json_report_from(retrieval_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - report.pointer("/summary/irrelevant_context_ratio").and_then(Value::as_f64), - Some(0.0) - ); - assert_eq!( - report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/summary/wrong_result_stage_attribution_count").and_then(Value::as_u64), - Some(0) - ); - - let suites = array_at(&report, "/suites")?; - let retrieval_suite = find_by_field(suites, "/suite_id", "retrieval")?; - let debug_suite = find_by_field(suites, "/suite_id", "operator_debugging_ux")?; - - assert_eq!(retrieval_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!(retrieval_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(debug_suite.pointer("/status").and_then(Value::as_str), Some("pass")); - - let jobs = array_at(&report, "/jobs")?; - let stage_job = find_by_field(jobs, "/job_id", "operator-debug-stage-attribution-001")?; - - assert_eq!(stage_job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - stage_job.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("rerank.score") - ); - assert_eq!( - stage_job.pointer("/retrieval_quality/expected_evidence_recall").and_then(Value::as_f64), - Some(1.0) - ); - assert_eq!( - stage_job.pointer("/retrieval_quality/irrelevant_context_ratio").and_then(Value::as_f64), - Some(0.0) - ); - - Ok(()) -} - -#[test] -fn stage_attribution_fixture_still_fails_when_decoy_is_used() -> Result<()> { - let fixture_path = retrieval_fixture_dir().join("stage_explainability_wrong_result.json"); - let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; - - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/content", - Value::String( - "The trace shows the expected evidence was present in recall.candidates but demoted at rerank.score; however, the selected answer followed the stale top-k smoke-only evidence.".to_string(), - ), - )?; - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/claims", - serde_json::json!([]), - )?; - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/evidence_ids", - serde_json::json!(["stage-decoy"]), - )?; - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-stage-decoy-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("stage_decoy.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - assert_eq!( - report.pointer("/summary/wrong_result_stage_attribution_count").and_then(Value::as_u64), - Some(1) - ); - - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "operator-debug-stage-attribution-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!( - job.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), - Some("rerank.score") - ); - assert_eq!( - job.pointer("/retrieval_quality/trap_context_count").and_then(Value::as_u64), - Some(1) - ); - - Ok(()) -} - -#[test] -fn retrieval_report_markdown_includes_quality_metrics() -> Result<()> { - let report = run_json_report_from(retrieval_fixture_dir())?; - let temp_dir = env::temp_dir().join(format!("elf-real-world-retrieval-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("retrieval-report.json"); - let markdown_path = temp_dir.join("retrieval-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("Expected evidence recall")); - assert!(markdown.contains("Irrelevant context ratio")); - assert!(markdown.contains("Trace Explainability")); - assert!(markdown.contains("rerank.score")); - - Ok(()) -} - -#[test] -fn memory_evolution_fixtures_report_temporal_and_staleness_metrics() -> Result<()> { - let report = run_json_report_from(evolution_fixture_dir())?; - - assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(5)); - assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); - assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(0)); - assert_eq!( - report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), - Some(5) - ); - assert_eq!( - report.pointer("/summary/temporal_validity_not_encoded_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - report.pointer("/evolution/temporal_validity_not_encoded_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - report.pointer("/evolution/history_readback_encoded_count").and_then(Value::as_u64), - Some(1) - ); - - let suites = array_at(&report, "/suites")?; - let memory_evolution = find_by_field(suites, "/suite_id", "memory_evolution")?; - - assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - memory_evolution.pointer("/temporal_validity_not_encoded_count").and_then(Value::as_u64), - Some(0) - ); - assert_eq!( - memory_evolution.pointer("/history_readback_encoded_count").and_then(Value::as_u64), - Some(1) - ); - - let jobs = array_at(&report, "/jobs")?; - let preference_job = find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; - let relation_job = find_by_field(jobs, "/job_id", "memory-evolution-relation-temporal-001")?; - - assert_eq!( - preference_job.pointer("/evolution/history_readback_encoded").and_then(Value::as_bool), - Some(true) - ); - assert!(array_contains_str(preference_job, "/evolution/history_event_types", "add")?); - assert!(array_contains_str(preference_job, "/evolution/history_event_types", "update")?); - assert!(array_contains_str(preference_job, "/evolution/history_event_types", "ignore")?); - assert_eq!( - preference_job - .pointer("/evolution/history_requires_note_version_links") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - preference_job.pointer("/evolution/selected_current_evidence/0").and_then(Value::as_str), - Some("pref-current-concise-rationale") - ); - assert_eq!( - preference_job.pointer("/evolution/selected_historical_evidence/0").and_then(Value::as_str), - Some("pref-old-terse-bullets") - ); - assert_eq!( - preference_job.pointer("/evolution/selected_rationale_evidence/0").and_then(Value::as_str), - Some("pref-update-rationale") - ); - assert_eq!(relation_job.pointer("/status").and_then(Value::as_str), Some("pass")); - assert_eq!( - relation_job.pointer("/evolution/temporal_validity_not_encoded").and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - relation_job.pointer("/evolution/temporal_validity_encoded").and_then(Value::as_bool), - Some(true) - ); - - let follow_ups = array_at(&report, "/follow_ups")?; - - assert!(follow_ups.is_empty()); - - Ok(()) -} - -#[test] -fn memory_evolution_conflict_still_fails_when_selected_evidence_is_not_narrated() -> Result<()> { - let fixture_path = - evolution_fixture_dir().join("preference_changed_current_vs_historical.json"); - let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; - - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/evidence_ids", - serde_json::json!([ - "pref-current-concise-rationale", - "pref-old-terse-bullets", - "pref-update-rationale" - ]), - )?; - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/claims", - serde_json::json!([ - { - "claim_id": "current_preference", - "text": "Use concise prose with explicit evidence before bullets.", - "evidence_ids": ["pref-current-concise-rationale", "pref-update-rationale"], - "confidence": "high" - }, - { - "claim_id": "preference_update_rationale", - "text": "The preference changed because terse bullets hid rationale.", - "evidence_ids": ["pref-update-rationale"], - "confidence": "high" - } - ]), - )?; - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-memory-conflict-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("conflict.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(job.pointer("/evolution/conflict_detection_count").and_then(Value::as_u64), Some(0)); - assert!(array_contains_str( - job, - "/evolution/selected_but_not_narrated_evidence", - "pref-old-terse-bullets" - )?); - - Ok(()) -} - -#[test] -fn memory_evolution_counts_stale_answer_when_old_fact_is_answered_as_current() -> Result<()> { - let fixture_path = - evolution_fixture_dir().join("preference_changed_current_vs_historical.json"); - let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; - - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/content", - Value::String( - "Use terse bullet-only benchmark updates as the current preference.".to_string(), - ), - )?; - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/evidence_ids", - serde_json::json!(["pref-old-terse-bullets"]), - )?; - set_json_pointer( - &mut fixture, - "/corpus/adapter_response/answer/claims", - serde_json::json!([ - { - "claim_id": "current_preference", - "text": "Use terse bullet-only benchmark updates as the current preference.", - "evidence_ids": ["pref-old-terse-bullets"], - "confidence": "high" - } - ]), - )?; - - let temp_dir = - env::temp_dir().join(format!("elf-real-world-memory-stale-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - fs::write(temp_dir.join("stale_preference.json"), serde_json::to_vec_pretty(&fixture)?)?; - - let report = run_json_report_from(temp_dir)?; - - assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(1)); - assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); - - let jobs = array_at(&report, "/jobs")?; - let job = find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; - - assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); - assert_eq!(job.pointer("/evolution/stale_answer_count").and_then(Value::as_u64), Some(1)); - - Ok(()) -} - -#[test] -fn operator_debug_json_report_renders_markdown_links() -> Result<()> { - let report = run_json_report_from(operator_debug_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-job-operator-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("operator.json"); - let markdown_path = temp_dir.join("operator.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("operator-debug-dropped-evidence-001")); - assert!(markdown.contains("/viewer?trace_id=11111111-1111-4111-8111-111111111111")); - assert!(markdown.contains("Raw SQL")); - assert!(markdown.contains("Replay Candidates")); - assert!(markdown.contains("Root cause")); - - Ok(()) -} - -#[test] -fn memory_evolution_report_renders_markdown_counters() -> Result<()> { - let report = run_json_report_from(evolution_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-memory-evolution-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("evolution-report.json"); - let markdown_path = temp_dir.join("evolution-report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("## Memory Evolution")); - assert!(markdown.contains("Temporal validity not encoded: `0`")); - assert!(markdown.contains("| memory_evolution | memory-evolution-relation-temporal-001")); - assert!(markdown.contains("`encoded`")); - - Ok(()) -} - -#[test] -fn consolidation_report_renders_markdown_metrics_and_gaps() -> Result<()> { - let report = run_json_report_from(consolidation_fixture_dir())?; - let temp_dir = - env::temp_dir().join(format!("elf-real-world-consolidation-test-{}", process::id())); - - fs::create_dir_all(&temp_dir)?; - - let report_path = temp_dir.join("report.json"); - let markdown_path = temp_dir.join("report.md"); - - fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; - - let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) - .arg("publish") - .arg("--report") - .arg(&report_path) - .arg("--out") - .arg(&markdown_path) - .output()?; - - assert!( - output.status.success(), - "real_world_job publisher failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - - let markdown = fs::read_to_string(markdown_path)?; - - assert!(markdown.contains("## Consolidation")); - assert!(markdown.contains("Source Mutations")); - assert!(markdown.contains("Proposal Unsupported Claims")); - assert!(markdown.contains("Executable Gaps")); - assert!(markdown.contains("consolidation-contradiction-report-discard-001")); - assert!(!markdown.contains("live_consolidation_worker_generation")); - - Ok(()) -} +#[path = "real_world_job_benchmark/adversarial_quality.rs"] mod adversarial_quality; +#[path = "real_world_job_benchmark/benchmark_core.rs"] mod benchmark_core; +#[path = "real_world_job_benchmark/closeout_reports.rs"] mod closeout_reports; +#[path = "real_world_job_benchmark/competitor_strength.rs"] mod competitor_strength; +#[path = "real_world_job_benchmark/consolidation.rs"] mod consolidation; +#[path = "real_world_job_benchmark/consolidation_knowledge.rs"] mod consolidation_knowledge; +#[path = "real_world_job_benchmark/core_archival.rs"] mod core_archival; +#[path = "real_world_job_benchmark/dreaming_readiness.rs"] mod dreaming_readiness; +#[path = "real_world_job_benchmark/dreaming_reports.rs"] mod dreaming_reports; +#[path = "real_world_job_benchmark/external_adapters.rs"] mod external_adapters; +#[path = "real_world_job_benchmark/live_adapter_tasks.rs"] mod live_adapter_tasks; +#[path = "real_world_job_benchmark/markdown_rendering.rs"] mod markdown_rendering; +#[path = "real_world_job_benchmark/memory_evolution.rs"] mod memory_evolution; +#[path = "real_world_job_benchmark/memory_summary.rs"] mod memory_summary; +#[path = "real_world_job_benchmark/misc_reports.rs"] mod misc_reports; +#[path = "real_world_job_benchmark/operator_debug.rs"] mod operator_debug; +#[path = "real_world_job_benchmark/proactive_brief.rs"] mod proactive_brief; +#[path = "real_world_job_benchmark/production_ops.rs"] mod production_ops; +#[path = "real_world_job_benchmark/recall_debug_reports.rs"] mod recall_debug_reports; +#[path = "real_world_job_benchmark/retrieval.rs"] mod retrieval; +#[path = "real_world_job_benchmark/root_aggregate.rs"] mod root_aggregate; +#[path = "real_world_job_benchmark/scheduled_memory.rs"] mod scheduled_memory; +#[path = "real_world_job_benchmark/support.rs"] mod support; +#[path = "real_world_job_benchmark/trace_replay_reports.rs"] mod trace_replay_reports; +#[path = "real_world_job_benchmark/work_continuity.rs"] mod work_continuity; + +use benchmark_core::assert_tracked_external_blocker_row; diff --git a/apps/elf-eval/tests/real_world_job_benchmark/adversarial_quality.rs b/apps/elf-eval/tests/real_world_job_benchmark/adversarial_quality.rs new file mode 100644 index 00000000..5c56017b --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/adversarial_quality.rs @@ -0,0 +1,171 @@ +use std::{env, fs, path::Path, process}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn adversarial_quality_fixture_catches_unsupported_and_stale_regressions() -> Result<()> { + let temp_dir = + env::temp_dir().join(format!("elf-adversarial-quality-regression-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + assert_stale_regression_is_wrong_result(&temp_dir)?; + assert_unsupported_regression_is_unsupported_claim(&temp_dir)?; + + Ok(()) +} + +fn assert_stale_regression_is_wrong_result(temp_dir: &Path) -> Result<()> { + let stale_fixture = + support::adversarial_quality_fixture_dir().join("stale_fact_current_answer.json"); + let mut stale = support::load_json(&stale_fixture)?; + + support::set_json_pointer( + &mut stale, + "/corpus/adapter_response/answer/content", + Value::String( + "Run cargo make check before review handoff because that is the current gate." + .to_string(), + ), + )?; + support::set_json_pointer( + &mut stale, + "/corpus/adapter_response/answer/evidence_ids", + serde_json::json!(["stale-ops-runbook-v1"]), + )?; + support::set_json_pointer( + &mut stale, + "/corpus/adapter_response/answer/claims", + serde_json::json!([ + { + "claim_id": "current_gate_sequence", + "text": "Run cargo make check before review handoff.", + "evidence_ids": ["stale-ops-runbook-v1"], + "confidence": "high" + } + ]), + )?; + fs::write(temp_dir.join("stale_regression.json"), serde_json::to_vec_pretty(&stale)?)?; + + let stale_report = support::run_json_report_from(temp_dir.to_path_buf())?; + let stale_jobs = support::array_at(&stale_report, "/jobs")?; + let stale_job = support::find_by_field( + stale_jobs, + "/job_id", + "adversarial-quality-stale-fact-current-answer-001", + )?; + + assert_eq!(stale_job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(stale_job.pointer("/stale_answer_count").and_then(Value::as_u64), Some(1)); + assert_eq!( + stale_report.pointer("/scoreboard/summary_claim").and_then(Value::as_str), + Some("typed_non_pass_present") + ); + assert_eq!( + stale_report.pointer("/scoreboard/job_summary_claim").and_then(Value::as_str), + Some("typed_non_pass_present") + ); + assert_eq!( + stale_report.pointer("/scoreboard/job_typed_non_pass_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + stale_report.pointer("/scoreboard/typed_non_pass_count").and_then(Value::as_u64), + Some(241) + ); + assert!(support::array_contains_str( + &stale_report, + "/scoreboard/typed_non_pass_states_present", + "wrong_result" + )?); + assert!(support::array_contains_str( + &stale_report, + "/scoreboard/job_typed_non_pass_states_present", + "wrong_result" + )?); + + fs::remove_file(temp_dir.join("stale_regression.json"))?; + + Ok(()) +} + +fn assert_unsupported_regression_is_unsupported_claim(temp_dir: &Path) -> Result<()> { + let unsupported_fixture = + support::adversarial_quality_fixture_dir().join("unsupported_claim_refusal.json"); + let mut unsupported = support::load_json(&unsupported_fixture)?; + + support::set_json_pointer( + &mut unsupported, + "/corpus/adapter_response/answer/content", + Value::String( + "The fixture proves private-corpus production quality and broad competitor superiority." + .to_string(), + ), + )?; + support::set_json_pointer( + &mut unsupported, + "/corpus/adapter_response/answer/evidence_ids", + serde_json::json!(["unsupported-production-quality-trap"]), + )?; + support::set_json_pointer( + &mut unsupported, + "/corpus/adapter_response/answer/claims", + serde_json::json!([ + { + "claim_id": "production_quality_proven", + "text": "The fixture proves private-corpus production quality and broad competitor superiority.", + "evidence_ids": ["unsupported-production-quality-trap"], + "confidence": "high" + } + ]), + )?; + fs::write( + temp_dir.join("unsupported_regression.json"), + serde_json::to_vec_pretty(&unsupported)?, + )?; + + let unsupported_report = support::run_json_report_from(temp_dir.to_path_buf())?; + let unsupported_jobs = support::array_at(&unsupported_report, "/jobs")?; + let unsupported_job = support::find_by_field( + unsupported_jobs, + "/job_id", + "adversarial-quality-unsupported-claim-refusal-001", + )?; + + assert_eq!( + unsupported_job.pointer("/status").and_then(Value::as_str), + Some("unsupported_claim") + ); + assert_eq!( + unsupported_report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), + Some(1) + ); + assert!(support::array_contains_str( + &unsupported_report, + "/scoreboard/typed_non_pass_states_present", + "unsupported_claim" + )?); + assert!(support::array_contains_str( + &unsupported_report, + "/scoreboard/job_typed_non_pass_states_present", + "unsupported_claim" + )?); + + Ok(()) +} + +#[test] +fn adversarial_quality_repeated_fixture_run_is_deterministic() -> Result<()> { + let first = support::run_json_report_from(support::adversarial_quality_fixture_dir())?; + let second = support::run_json_report_from(support::adversarial_quality_fixture_dir())?; + + assert_eq!(first.pointer("/scoreboard"), second.pointer("/scoreboard")); + assert_eq!(first.pointer("/summary"), second.pointer("/summary")); + assert_eq!(first.pointer("/suites"), second.pointer("/suites")); + assert_eq!(first.pointer("/jobs"), second.pointer("/jobs")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/benchmark_core.rs b/apps/elf-eval/tests/real_world_job_benchmark/benchmark_core.rs new file mode 100644 index 00000000..dae4414f --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/benchmark_core.rs @@ -0,0 +1,377 @@ +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +pub(super) fn assert_tracked_external_blocker_row( + row: &Value, + product_name: &str, + same_corpus: bool, +) -> Result<()> { + assert_eq!(row.pointer("/product_name").and_then(Value::as_str), Some(product_name)); + assert_eq!(row.pointer("/result_state").and_then(Value::as_str), Some("blocked")); + assert_eq!(row.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); + assert_eq!(row.pointer("/comparable").and_then(Value::as_bool), Some(false)); + assert_eq!(row.pointer("/same_corpus").and_then(Value::as_bool), Some(same_corpus)); + assert_eq!(row.pointer("/source_id_mapped").and_then(Value::as_bool), Some(false)); + assert_eq!(row.pointer("/held_out").and_then(Value::as_bool), Some(false)); + assert_eq!(row.pointer("/leakage_audited").and_then(Value::as_bool), Some(false)); + assert_eq!(row.pointer("/product_runtime").and_then(Value::as_bool), Some(false)); + assert_eq!(row.pointer("/container_digest_identified").and_then(Value::as_bool), Some(false)); + assert!(row.pointer("/metrics/retrieval/recall_at_k").is_some_and(Value::is_null)); + assert!(row.pointer("/metrics/retrieval/precision_at_k").is_some_and(Value::is_null)); + assert!(row.pointer("/metrics/retrieval/mrr").is_some_and(Value::is_null)); + assert!(row.pointer("/metrics/retrieval/ndcg").is_some_and(Value::is_null)); + assert!(support::array_contains_str( + row, + "/next_evidence", + "Map returned evidence to stable source ids." + )?); + assert!(support::array_contains_str( + row, + "/next_evidence", + "Run a Docker-contained product-runtime adapter for this row." + )?); + assert!(support::array_contains_str( + row, + "/next_evidence", + "Record container image digest evidence." + )?); + + if same_corpus { + assert!(!support::array_contains_str( + row, + "/next_evidence", + "Map this product to the same corpus." + )?); + } else { + assert!(support::array_contains_str( + row, + "/next_evidence", + "Map this product to the same corpus." + )?); + } + + Ok(()) +} + +#[test] +fn smoke_fixture_produces_typed_json_report() -> Result<()> { + let report = support::run_json_report()?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.real_world_job_report/v1") + ); + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(2)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/external_adapters/summary/adapter_count").and_then(Value::as_u64), + Some(26) + ); + assert_eq!( + report.pointer("/external_adapters/summary/live_real_world_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/external_adapters/summary/research_gate_count").and_then(Value::as_u64), + Some(14) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "work-resume-stale-worktree-001")?; + + assert_eq!(job.pointer("/suite_id").and_then(Value::as_str), Some("work_resume")); + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(job.pointer("/latency_ms").and_then(Value::as_f64), Some(2.0)); + assert_eq!(job.pointer("/cost/amount").and_then(Value::as_f64), Some(0.0)); + + let expected_evidence = support::array_at(job, "/expected_evidence")?; + let produced_evidence = support::array_at(job, "/produced_evidence")?; + + assert_eq!(expected_evidence.len(), 2); + assert_eq!(produced_evidence.len(), 1); + assert_eq!(produced_evidence.first().and_then(Value::as_str), Some("xy844-current-worktree")); + + let suites = support::array_at(&report, "/suites")?; + let encoded_suite = support::find_by_field(suites, "/suite_id", "work_resume")?; + let capture_suite = support::find_by_field(suites, "/suite_id", "capture_integration")?; + let unencoded_suite = support::find_by_field(suites, "/suite_id", "retrieval")?; + + assert_eq!(encoded_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(encoded_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(capture_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(capture_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(1)); + assert_eq!(unencoded_suite.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + + let capture_fixture_backed = support::array_at(&report, "/capture_integration/fixture_backed")?; + + assert!(capture_fixture_backed.iter().any(|value| { + value.as_str().is_some_and(|item| item.contains("agentmemory-style hook capture")) + })); + + let capture_not_encoded = support::array_at(&report, "/capture_integration/not_encoded")?; + + assert!(capture_not_encoded.iter().any(|value| { + value.as_str().is_some_and(|item| item.contains("No live external hook ingestion")) + })); + + Ok(()) +} + +#[test] +fn capture_integration_fixtures_score_redaction_and_source_ids() -> Result<()> { + let report = support::run_json_report_from(support::capture_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/redaction_leak_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); + + let suites = support::array_at(&report, "/suites")?; + let capture = support::find_by_field(suites, "/suite_id", "capture_integration")?; + + assert_eq!(capture.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(capture.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); + + let jobs = support::array_at(&report, "/jobs")?; + let source_id = support::find_by_field(jobs, "/job_id", "capture-source-id-binding-001")?; + let redaction = support::find_by_field(jobs, "/job_id", "capture-write-policy-redaction-001")?; + + assert!(support::array_contains_str( + source_id, + "/produced_evidence", + "source-id-release-summary" + )?); + assert!(support::array_contains_str(source_id, "/produced_evidence", "source-id-command-log")?); + assert_eq!(redaction.pointer("/redaction_leak_count").and_then(Value::as_u64), Some(0)); + assert!( + redaction + .pointer("/produced_answer") + .and_then(Value::as_str) + .is_some_and(|answer| !answer.contains("orchid-envelope")) + ); + + Ok(()) +} + +#[test] +fn source_library_fixtures_score_saved_sources_without_memory_promotion() -> Result<()> { + let report = support::run_json_report_from(support::source_library_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(2)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(2)); + assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!(report.pointer("/summary/quote_coverage").and_then(Value::as_f64), Some(1.0)); + + let suites = support::array_at(&report, "/suites")?; + let source_library = support::find_by_field(suites, "/suite_id", "source_library")?; + + assert_eq!(source_library.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(source_library.pointer("/encoded_job_count").and_then(Value::as_u64), Some(2)); + + let jobs = support::array_at(&report, "/jobs")?; + let long_doc = support::find_by_field(jobs, "/job_id", "source-library-long-doc-001")?; + let thread = support::find_by_field(jobs, "/job_id", "source-library-social-thread-001")?; + + assert!(support::array_contains_str(long_doc, "/produced_evidence", "article-source-record")?); + assert!(support::array_contains_str( + long_doc, + "/produced_evidence", + "article-hydrated-excerpt" + )?); + assert!(support::array_contains_str(thread, "/produced_evidence", "thread-source-record")?); + assert!(support::array_contains_str( + thread, + "/produced_evidence", + "thread-promotion-boundary" + )?); + assert!(long_doc.pointer("/produced_answer").and_then(Value::as_str).is_some_and(|answer| { + answer.contains("does not automatically create a durable Memory Note") + })); + assert!( + thread + .pointer("/produced_answer") + .and_then(Value::as_str) + .is_some_and(|answer| answer.contains("explicit add_note or reviewed promotion")) + ); + + Ok(()) +} + +#[test] +fn adversarial_quality_fixtures_score_scoreboard_gates() -> Result<()> { + let report = support::run_json_report_from(support::adversarial_quality_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/redaction_leak_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), + Some(3) + ); + assert_eq!( + report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64), + Some(1) + ); + + let result_states = support::string_array_at(&report, "/scoreboard/result_states")?; + let evidence_classes = support::string_array_at(&report, "/scoreboard/evidence_classes")?; + + assert_eq!( + result_states, + [ + "pass", + "wrong_result", + "incomplete", + "blocked", + "not_tested", + "not_encoded", + "not_comparable", + "unsupported_claim", + ] + .map(str::to_owned) + ); + assert_eq!( + evidence_classes, + ["fixture_backed", "live_baseline", "live_real_world", "research_gate"].map(str::to_owned) + ); + assert_eq!( + report.pointer("/scoreboard/summary_claim").and_then(Value::as_str), + Some("typed_non_pass_present") + ); + assert_eq!( + report.pointer("/scoreboard/job_summary_claim").and_then(Value::as_str), + Some("all_encoded_jobs_passed") + ); + assert_eq!( + report.pointer("/scoreboard/job_typed_non_pass_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/scoreboard/external_adapter_typed_non_pass_count").and_then(Value::as_u64), + Some(240) + ); + assert_eq!( + report.pointer("/scoreboard/typed_non_pass_count").and_then(Value::as_u64), + Some(240) + ); + assert_eq!( + support::string_array_at(&report, "/scoreboard/job_typed_non_pass_states_present")?, + Vec::::new() + ); + + for state in ["blocked", "incomplete", "not_encoded", "not_tested", "wrong_result"] { + assert!(support::array_contains_str( + &report, + "/scoreboard/typed_non_pass_states_present", + state + )?); + assert!(support::array_contains_str( + &report, + "/scoreboard/external_adapter_typed_non_pass_states_present", + state + )?); + } + + assert_eq!( + report.pointer("/scoreboard/unqualified_win_claim_allowed").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report.pointer("/scoreboard/evidence_class_counts/live_baseline").and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report.pointer("/scoreboard/metric_basis").and_then(Value::as_str), + Some("produced_evidence_order") + ); + assert_eq!(report.pointer("/scoreboard/retrieval_k").and_then(Value::as_u64), Some(5)); + + assert_scoreboard_rows_expose_quantitative_and_blocker_contract(&report)?; + + let suites = support::array_at(&report, "/suites")?; + let adversarial = support::find_by_field(suites, "/suite_id", "adversarial_quality")?; + + assert_eq!(adversarial.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(adversarial.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + + Ok(()) +} + +fn assert_scoreboard_rows_expose_quantitative_and_blocker_contract(report: &Value) -> Result<()> { + let rows = support::array_at(report, "/scoreboard/rows")?; + let elf = support::find_by_field(rows, "/product_id", "elf_current_report")?; + let qmd = support::find_by_field(rows, "/product_id", "qmd")?; + let pageindex = support::find_by_field(rows, "/product_id", "vectifyai_pageindex")?; + let openkb = support::find_by_field(rows, "/product_id", "vectifyai_openkb")?; + let honcho = support::find_by_field(rows, "/product_id", "plastic_labs_honcho")?; + + assert_eq!(rows.len(), 20); + assert_eq!(elf.pointer("/product_name").and_then(Value::as_str), Some("ELF")); + assert_eq!(elf.pointer("/evidence_class").and_then(Value::as_str), Some("fixture_backed")); + assert_eq!(elf.pointer("/result_state").and_then(Value::as_str), Some("not_comparable")); + assert_eq!(elf.pointer("/comparable").and_then(Value::as_bool), Some(false)); + assert_eq!(elf.pointer("/same_corpus").and_then(Value::as_bool), Some(true)); + assert_eq!(elf.pointer("/source_id_mapped").and_then(Value::as_bool), Some(true)); + assert_eq!(elf.pointer("/held_out").and_then(Value::as_bool), Some(false)); + assert_eq!(elf.pointer("/leakage_audited").and_then(Value::as_bool), Some(false)); + assert_eq!(elf.pointer("/product_runtime").and_then(Value::as_bool), Some(false)); + assert_eq!(elf.pointer("/container_digest_identified").and_then(Value::as_bool), Some(false)); + assert_eq!( + elf.pointer("/metrics/retrieval/metric_basis").and_then(Value::as_str), + Some("produced_evidence_order") + ); + assert_eq!(elf.pointer("/metrics/retrieval/k").and_then(Value::as_u64), Some(5)); + assert!(elf.pointer("/metrics/retrieval/recall_at_k").and_then(Value::as_f64).is_some()); + assert!(elf.pointer("/metrics/retrieval/precision_at_k").and_then(Value::as_f64).is_some()); + assert!(elf.pointer("/metrics/retrieval/mrr").and_then(Value::as_f64).is_some()); + assert!(elf.pointer("/metrics/retrieval/ndcg").and_then(Value::as_f64).is_some()); + assert_eq!( + elf.pointer("/metrics/lifecycle/stale_suppression").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + elf.pointer("/metrics/coverage/source_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert!(support::array_contains_str( + elf, + "/next_evidence", + "Run a Docker-contained product-runtime adapter for this row." + )?); + assert!(support::array_contains_str( + elf, + "/next_evidence", + "Record container image digest evidence." + )?); + assert_eq!(qmd.pointer("/product_name").and_then(Value::as_str), Some("qmd")); + assert_eq!(qmd.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); + assert_eq!(qmd.pointer("/comparable").and_then(Value::as_bool), Some(false)); + assert_eq!(qmd.pointer("/product_runtime").and_then(Value::as_bool), Some(true)); + assert_eq!(qmd.pointer("/container_digest_identified").and_then(Value::as_bool), Some(false)); + assert!(qmd.pointer("/metrics/retrieval/recall_at_k").is_some_and(Value::is_null)); + assert!(support::array_contains_str( + qmd, + "/next_evidence", + "Record container image digest evidence." + )?); + + assert_tracked_external_blocker_row(pageindex, "VectifyAI PageIndex", true)?; + assert_tracked_external_blocker_row(openkb, "VectifyAI OpenKB", true)?; + assert_tracked_external_blocker_row(honcho, "plastic-labs Honcho", false)?; + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/closeout_reports.rs b/apps/elf-eval/tests/real_world_job_benchmark/closeout_reports.rs new file mode 100644 index 00000000..8c7f406b --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/closeout_reports.rs @@ -0,0 +1,712 @@ +use std::fs; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +#[test] +fn agent_knowledge_os_closeout_benchmark_preserves_full_matrix_boundaries() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::agent_knowledge_os_closeout_benchmark_report_json_path()?, + )?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.agent_knowledge_os_closeout_benchmark_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1023")); + assert_eq!( + report.pointer("/summary/strongest_measured_integrated_product").and_then(Value::as_str), + Some("ELF integrated Agent Knowledge OS") + ); + assert_eq!( + report.pointer("/all_project_fixture_rerun/status").and_then(Value::as_str), + Some("pass") + ); + assert_eq!( + report.pointer("/all_project_fixture_rerun/job_count").and_then(Value::as_u64), + Some(62) + ); + assert_eq!(report.pointer("/all_project_fixture_rerun/pass").and_then(Value::as_u64), Some(55)); + assert_eq!(report.pointer("/summary/product_count").and_then(Value::as_u64), Some(19)); + assert_eq!(report.pointer("/summary/scenario_count").and_then(Value::as_u64), Some(6)); + assert_eq!( + report + .pointer("/summary/not_every_product_has_complete_live_coverage") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report.pointer("/summary/evidence_class_counts/pass").and_then(Value::as_u64), + Some(9) + ); + assert_eq!( + report.pointer("/summary/evidence_class_counts/not_tested").and_then(Value::as_u64), + Some(78) + ); + + let scenarios = support::array_at(&report, "/supported_scenarios")?; + let matrix = support::array_at(&report, "/product_matrix")?; + + for scenario in [ + "source_library_ingest_hydration", + "memory_authority_history_read_profiles", + "knowledge_workspace_pages", + "temporal_topic_graph_lite", + "dreaming_review_queue", + "recall_debug_panel", + ] { + support::find_by_field(scenarios, "/id", scenario)?; + } + + let elf = support::find_by_field(matrix, "/product", "ELF")?; + + for scenario in [ + "source_library_ingest_hydration", + "memory_authority_history_read_profiles", + "knowledge_workspace_pages", + "temporal_topic_graph_lite", + "dreaming_review_queue", + "recall_debug_panel", + ] { + assert_eq!( + elf.pointer(&format!("/statuses/{scenario}")).and_then(Value::as_str), + Some("pass") + ); + } + + let qmd = support::find_by_field(matrix, "/product", "qmd")?; + + assert_eq!( + qmd.pointer("/statuses/recall_debug_panel").and_then(Value::as_str), + Some("wrong_result") + ); + assert!( + qmd.pointer("/strongest_advantage") + .and_then(Value::as_str) + .is_some_and(|value| value.contains("weighted fusion")) + ); + + for product in ["VectifyAI PageIndex", "VectifyAI OpenKB"] { + let row = support::find_by_field(matrix, "/product", product)?; + + assert_eq!(row.pointer("/coverage").and_then(Value::as_str), Some("reference_only")); + assert_eq!( + row.pointer("/statuses/knowledge_workspace_pages").and_then(Value::as_str), + Some("not_tested") + ); + } + + assert_eq!( + report.pointer("/claim_boundaries/no_broad_superiority_claim").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report + .pointer("/claim_boundaries/reference_only_projects_do_not_count_as_pass") + .and_then(Value::as_bool), + Some(true) + ); + assert!(support::array_contains_str( + &report, + "/source_evidence", + "https://github.com/VectifyAI/PageIndex" + )?); + assert!(support::array_contains_str( + &report, + "/source_evidence", + "https://github.com/VectifyAI/OpenKB" + )?); + + Ok(()) +} + +#[test] +fn agent_knowledge_os_closeout_benchmark_wires_docs_and_optimization_queue() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::agent_knowledge_os_closeout_benchmark_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::agent_knowledge_os_closeout_benchmark_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let queue = support::array_at(&report, "/optimization_queue")?; + + for item in queue { + assert_eq!(item.pointer("/generated_from_delta").and_then(Value::as_bool), Some(true)); + } + for key in [ + "pageindex_openkb_source_library_adapter", + "qmd_retrieval_knobs_and_short_replay", + "operator_knowledge_library_ui", + "openviking_context_trajectory_artifacts", + "graph_rag_temporal_adapter_matrix", + ] { + let item = support::find_by_field(queue, "/key", key)?; + + assert_eq!(item.pointer("/generated_from_delta").and_then(Value::as_bool), Some(true)); + } + + assert!(markdown.contains("ELF is the strongest measured integrated product")); + assert!(markdown.contains("complete live coverage")); + assert!(markdown.contains("VectifyAI PageIndex")); + assert!(markdown.contains("VectifyAI OpenKB")); + assert!(markdown.contains("Do not claim ELF broadly beats every competitor")); + assert!( + benchmarking_index.contains("2026-06-20-agent-knowledge-os-closeout-benchmark-report.md") + ); + assert!(readme.contains("Agent Knowledge OS closeout after XY-1023")); + assert!(readme.contains("62 jobs, 55 pass")); + assert!(readme.contains("VectifyAI PageIndex/OpenKB")); + assert!(readme.contains("strongest measured integrated")); + + Ok(()) +} + +#[test] +fn p2_knowledge_workspace_closeout_preserves_pageindex_openkb_boundaries() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::p2_knowledge_workspace_pageindex_openkb_closeout_report_json_path()?, + )?)?; + let markdown = fs::read_to_string( + support::p2_knowledge_workspace_pageindex_openkb_closeout_report_markdown_path()?, + )?; + let makefile = fs::read_to_string(support::workspace_root()?.join("Makefile.toml"))?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let benchmark_runbook = fs::read_to_string( + support::workspace_root()? + .join("docs") + .join("runbook") + .join("benchmarking") + .join("real_world_agent_memory_benchmark.md"), + )?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.p2_knowledge_workspace_pageindex_openkb_closeout_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1066")); + assert_eq!( + report.pointer("/self_assessment/verdict").and_then(Value::as_str), + Some("pass_with_reference_only_competitor_boundary") + ); + assert_eq!(report.pointer("/typed_state_summary/pass").and_then(Value::as_u64), Some(2)); + assert_eq!( + report.pointer("/typed_state_summary/wrong_result").and_then(Value::as_u64), + Some(0) + ); + assert_eq!(report.pointer("/typed_state_summary/incomplete").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/typed_state_summary/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/typed_state_summary/not_tested").and_then(Value::as_u64), Some(2)); + + let results = support::array_at(&report, "/elf_same_corpus_results")?; + let source_library = support::find_by_field(results, "/suite", "source_library")?; + let knowledge = support::find_by_field(results, "/suite", "knowledge_compilation")?; + + assert_eq!(source_library.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(source_library.pointer("/jobs").and_then(Value::as_u64), Some(2)); + assert_eq!(knowledge.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(knowledge.pointer("/jobs").and_then(Value::as_u64), Some(3)); + assert!(support::array_contains_str( + knowledge, + "/coverage", + "Changed-source watch/rebuild reports changed, stale, and reviewable memory-candidate outputs without source mutation." + )?); + + let matrix = support::array_at(&report, "/comparison_matrix")?; + let pageindex = support::find_by_field(matrix, "/target", "VectifyAI PageIndex")?; + let openkb = support::find_by_field(matrix, "/target", "VectifyAI OpenKB")?; + let p3 = support::find_by_field(matrix, "/target", "P3 PageIndex/OpenKB adapter queue")?; + + assert_eq!(pageindex.pointer("/status").and_then(Value::as_str), Some("not_tested")); + assert_eq!(openkb.pointer("/status").and_then(Value::as_str), Some("not_tested")); + assert_eq!(p3.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + report + .pointer("/p3_queue_decision/ready_to_queue_after_main_thread_acceptance") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report.pointer("/p3_queue_decision/queued_label_applied").and_then(Value::as_bool), + Some(false) + ); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not claim ELF beats PageIndex or OpenKB." + )?); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not queue a P3 issue in this lane." + )?); + assert!(markdown.contains("P2 Knowledge Workspace PageIndex/OpenKB Closeout Report")); + assert!(markdown.contains("VectifyAI PageIndex")); + assert!(markdown.contains("VectifyAI OpenKB")); + assert!(markdown.contains("This report does not apply `decodex:queued:elf`")); + assert!(makefile.contains("[tasks.real-world-memory-p2-knowledge-closeout]")); + assert!(makefile.contains("\"real-world-memory-source-library-report\"")); + assert!(makefile.contains("\"real-world-memory-knowledge-report\"")); + assert!( + benchmarking_index + .contains("2026-06-22-p2-knowledge-workspace-pageindex-openkb-closeout-report.md") + ); + assert!(readme.contains("P2 Knowledge Workspace PageIndex/OpenKB closeout after XY-1066")); + assert!(readme.contains("real-world-memory-p2-knowledge-closeout")); + assert!(benchmark_runbook.contains("cargo make real-world-memory-p2-knowledge-closeout")); + + Ok(()) +} + +#[test] +fn operator_approved_public_proxy_private_addendum_preserves_boundary() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::operator_approved_public_proxy_private_addendum_report_json_path()?, + )?)?; + let markdown = fs::read_to_string( + support::operator_approved_public_proxy_private_addendum_report_markdown_path()?, + )?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.operator_approved_public_proxy_baseline_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-930")); + assert_eq!(report.pointer("/command/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + report.pointer("/command/run_id").and_then(Value::as_str), + Some("live-baseline-20260619143959") + ); + assert_eq!( + report.pointer("/corpus/profile").and_then(Value::as_str), + Some("production-private") + ); + assert_eq!( + report.pointer("/corpus/runner_track").and_then(Value::as_str), + Some("private_production") + ); + assert_eq!( + report.pointer("/corpus/manifest_kind").and_then(Value::as_str), + Some("operator_approved_public_proxy") + ); + assert_eq!( + report.pointer("/corpus/manifest_id").and_then(Value::as_str), + Some("operator-approved-public-proxy-prod-corpus-2026-06-19") + ); + assert_eq!(report.pointer("/embedding/mode").and_then(Value::as_str), Some("local")); + assert_eq!( + report.pointer("/embedding/provider_backed_quality_proven").and_then(Value::as_bool), + Some(false) + ); + assert_eq!(report.pointer("/summary/project_status").and_then(Value::as_str), Some("pass")); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/check_summary/total").and_then(Value::as_u64), Some(8)); + assert_eq!(report.pointer("/check_summary/pass").and_then(Value::as_u64), Some(8)); + assert_eq!( + report.pointer("/query_summary/wrong_result_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!(report.pointer("/backfill/completed_count").and_then(Value::as_u64), Some(12)); + assert_eq!(report.pointer("/backfill/duplicate_source_notes").and_then(Value::as_u64), Some(0)); + + let queries = support::array_at(&report, "/queries")?; + let provider = support::find_by_field(queries, "/id", "q-explain-provider-blocker")?; + + assert_eq!(queries.len(), 8); + assert_eq!( + provider.pointer("/top_evidence").and_then(Value::as_str), + Some("blocker-provider-missing") + ); + assert_eq!(provider.pointer("/matched").and_then(Value::as_bool), Some(true)); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not call this real private-corpus production proof." + )?); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not claim provider-backed production quality; embedding mode was local." + )?); + assert!(support::array_contains_str( + &report, + "/improvement_regression_readback/unchanged", + "Real private-corpus production quality is still not proven." + )?); + assert!(support::array_contains_str( + &report, + "/next_optimization_direction/when_operator_inputs_exist", + "Run provider-backed embeddings with ELF_BASELINE_ELF_EMBEDDING_MODE=provider and a routed provider setup." + )?); + assert!(markdown.contains("proxy corpus pass")); + assert!(markdown.contains("Do not call this real private-corpus production proof.")); + assert!(markdown.contains("| Embedding mode | `local` |")); + assert!( + benchmarking_index + .contains("2026-06-19-operator-approved-public-proxy-production-private-addendum.md") + ); + assert!(benchmarking_index.contains("not real private-corpus or provider-backed proof")); + assert!(readme.contains("Operator-approved public-proxy addendum after XY-930")); + assert!(readme.contains("8/8 query passes")); + assert!(readme.contains("does not prove real private-corpus production quality")); + + Ok(()) +} + +#[test] +fn openmemory_ui_export_product_recheck_preserves_blocked_boundary() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::openmemory_ui_export_product_readback_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::openmemory_ui_export_product_readback_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.openmemory_ui_export_product_recheck_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-987")); + assert_eq!( + report.pointer("/command/command").and_then(Value::as_str), + Some("cargo make openmemory-ui-export-readback") + ); + assert_eq!(report.pointer("/command/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + report.pointer("/command/probe_artifact").and_then(Value::as_str), + Some("tmp/live-baseline/mem0-openmemory-ui-export.json") + ); + assert_eq!(report.pointer("/run/sdk_check_summary/pass").and_then(Value::as_u64), Some(8)); + assert_eq!(report.pointer("/run/ui_export_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + report.pointer("/run/ui_export_reason_code").and_then(Value::as_str), + Some("DOCKER_UNAVAILABLE_IN_BASELINE_RUNNER") + ); + assert_eq!( + report + .pointer("/same_corpus_boundary/sdk_get_all_is_ui_export_evidence") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report + .pointer("/openmemory_product_surface/export_requires_running_container") + .and_then(Value::as_bool), + Some(true) + ); + assert!( + report + .pointer("/openmemory_probe/attempt/output_excerpt") + .and_then(Value::as_str) + .is_some_and(|excerpt| excerpt.contains("docker: command not found") + && excerpt.contains("Container 'openmemory-openmemory-mcp-1' not found/running")) + ); + assert_eq!( + report.pointer("/classification/comparison_judgment").and_then(Value::as_str), + Some("unchanged") + ); + assert_eq!( + report + .pointer("/claim_boundary/product_browser_or_dashboard_readback_reached") + .and_then(Value::as_bool), + Some(false) + ); + assert!(support::array_contains_str( + &report, + "/improvement_regression_readback/unchanged", + "OpenMemory product UI/export readback remains blocked before same-corpus product app database validation." + )?); + assert!(support::array_contains_str( + &report, + "/next_optimization_direction/required_fields", + "same_corpus_import_into_openmemory_app_database" + )?); + assert!(markdown.contains("OpenMemory UI/export product-readback status is unchanged")); + assert!(markdown.contains("Product browser/dashboard readback reached")); + assert!( + benchmarking_index.contains("2026-06-19-openmemory-ui-export-product-readback-report.md") + ); + assert!(readme.contains("OpenMemory UI/Export Product Readback Report - June 19, 2026")); + assert!(readme.contains("OpenMemory UI/export product recheck after XY-987")); + + Ok(()) +} + +#[test] +fn graph_rag_citation_navigation_promotion_preserves_typed_non_passes() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::graph_rag_citation_navigation_promotion_report_json_path()?, + )?)?; + let markdown = fs::read_to_string( + support::graph_rag_citation_navigation_promotion_report_markdown_path()?, + )?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.graph_rag_citation_navigation_promotion_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-985")); + assert_eq!( + report.pointer("/command/command").and_then(Value::as_str), + Some("cargo make real-world-memory-graph-rag") + ); + assert_eq!(report.pointer("/command/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + report.pointer("/summary/overall_judgment").and_then(Value::as_str), + Some("unchanged_typed_non_pass") + ); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(0.25)); + assert_eq!( + report.pointer("/summary/knowledge_citation_coverage").and_then(Value::as_f64), + Some(0.667) + ); + + let scenarios = support::array_at(&report, "/scenario_outcomes")?; + let ragflow = support::find_by_field(scenarios, "/project", "RAGFlow")?; + let lightrag = support::find_by_field(scenarios, "/project", "LightRAG")?; + let graphrag = support::find_by_field(scenarios, "/project", "GraphRAG")?; + let graphiti = support::find_by_field(scenarios, "/project", "Graphiti/Zep")?; + let graphify = support::find_by_field(scenarios, "/project", "graphify")?; + let llm_wiki = support::find_by_field(scenarios, "/project", "llm-wiki")?; + let gbrain = support::find_by_field(scenarios, "/project", "gbrain")?; + + assert_eq!(ragflow.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!(lightrag.pointer("/current_status").and_then(Value::as_str), Some("incomplete")); + assert_eq!(graphrag.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!(graphiti.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!(graphify.pointer("/current_status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(llm_wiki.pointer("/current_status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(gbrain.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert!(support::array_contains_str( + graphify, + "/produced_evidence", + "graphify-source-location-output" + )?); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not claim graph/RAG parity or broad graph-navigation quality." + )?); + assert!(support::array_contains_str( + &report, + "/next_optimization_direction/required_fields", + "graphrag_output_table_rows_with_generated_evidence_ids" + )?); + assert!(markdown.contains("typed non-pass, no parity claim")); + assert!( + markdown.contains("graphify produces evidence-linked output but still scores wrong_result") + ); + assert!( + benchmarking_index.contains("2026-06-19-graph-rag-citation-navigation-promotion-report.md") + ); + assert!(readme.contains("Graph/RAG Citation and Navigation Promotion Report - June 19, 2026")); + assert!(readme.contains("Graph/RAG citation/navigation promotion after XY-985")); + + Ok(()) +} + +#[test] +fn graph_rag_adapter_matrix_report_preserves_no_parity_claims() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::graph_rag_adapter_matrix_report_json_path()?, + )?)?; + let markdown = fs::read_to_string(support::graph_rag_adapter_matrix_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.graph_rag_adapter_matrix_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1071")); + assert_eq!(report.pointer("/summary/matrix_row_count").and_then(Value::as_u64), Some(18)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(8)); + assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(4)); + assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(6)); + assert_eq!( + report.pointer("/summary/broad_graph_rag_parity").and_then(Value::as_str), + Some("not_proven") + ); + + let rows = support::array_at(&report, "/adapter_matrix")?; + let ragflow_citation = find_matrix_row(rows, "RAGFlow", "citation_quality")?; + let lightrag_retrieval = find_matrix_row(rows, "LightRAG", "retrieval_quality")?; + let graphrag_navigation = find_matrix_row(rows, "GraphRAG", "navigation_quality")?; + let graphrag_retrieval = find_matrix_row(rows, "GraphRAG", "retrieval_quality")?; + + assert_eq!(ragflow_citation.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(lightrag_retrieval.pointer("/status").and_then(Value::as_str), Some("incomplete")); + assert_eq!(graphrag_navigation.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(graphrag_retrieval.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not reposition ELF as a generic RAG platform from this adapter matrix." + )?); + assert!(markdown.contains("The graph/RAG comparison remains typed non-pass")); + assert!(markdown.contains("| RAGFlow | `blocked`: answer text plus selected reference chunks")); + assert!(benchmarking_index.contains("2026-06-23-graph-rag-adapter-matrix-report.md")); + assert!(readme.contains("RAGFlow/GraphRAG/LightRAG adapter matrix after XY-1071")); + assert!(readme.contains("Graph/RAG Adapter Matrix Report - June 23, 2026")); + + Ok(()) +} + +#[test] +fn p3_competitor_strength_absorption_report_preserves_claim_boundaries() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::p3_competitor_strength_absorption_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::p3_competitor_strength_absorption_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.p3_competitor_strength_absorption_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1072")); + assert_eq!( + report.pointer("/self_assessment/verdict").and_then(Value::as_str), + Some("pass_with_p4_queue_ready_after_main_thread_acceptance") + ); + assert_eq!( + report.pointer("/self_assessment/p4_queued_label_applied").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report + .pointer("/self_assessment/typed_non_pass_states_are_not_wins") + .and_then(Value::as_bool), + Some(true) + ); + + let products = support::array_at(&report, "/product_strengths")?; + + for product in [ + "qmd", + "VectifyAI PageIndex", + "VectifyAI OpenKB", + "mem0/OpenMemory", + "Letta", + "Graphiti/Zep", + "OpenViking", + "RAGFlow", + "GraphRAG", + "LightRAG", + ] { + support::find_by_field(products, "/product", product)?; + } + + let qmd = support::find_by_field(products, "/product", "qmd")?; + let pageindex = support::find_by_field(products, "/product", "VectifyAI PageIndex")?; + let mem0 = support::find_by_field(products, "/product", "mem0/OpenMemory")?; + let graphiti = support::find_by_field(products, "/product", "Graphiti/Zep")?; + let lightrag = support::find_by_field(products, "/product", "LightRAG")?; + + assert_eq!(qmd.pointer("/current_status").and_then(Value::as_str), Some("mixed")); + assert!( + qmd.pointer("/remains_stronger_elsewhere") + .and_then(Value::as_str) + .is_some_and(|value| value.contains("top-k JSON")) + ); + assert_eq!(pageindex.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + mem0.pointer("/current_status").and_then(Value::as_str), + Some("split_pass_and_blocked") + ); + assert_eq!(graphiti.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + lightrag.pointer("/current_status").and_then(Value::as_str), + Some("incomplete_or_not_encoded") + ); + + let queue = support::array_at(&report, "/p4_optimization_queue")?; + + for key in [ + "qmd_candidate_replay_parity", + "adapter_outcome_grammar_and_metrics", + "source_library_tree_and_wiki_adapters", + "memory_history_export_and_core_archive", + "temporal_trajectory_graph_rag_adapters", + ] { + let item = support::find_by_field(queue, "/key", key)?; + + assert_eq!( + item.pointer("/ready_after_main_thread_acceptance").and_then(Value::as_bool), + Some(true) + ); + assert_eq!(item.pointer("/queued_label_applied").and_then(Value::as_bool), Some(false)); + } + + assert_product_queue_items_reference_queue(products, queue)?; + + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Typed non-pass states are not wins." + )?); + assert!(support::array_contains_str( + &report, + "/claim_boundaries/not_allowed", + "Do not apply decodex:queued:elf to a P4 issue until the main thread accepts the P3 closeout." + )?); + assert!(markdown.contains("P3 is decision-ready for main-thread inspection")); + assert!(markdown.contains("Typed non-pass states are not wins")); + assert!(markdown.contains("No P4 issue receives `decodex:queued:elf`")); + assert!(benchmarking_index.contains("2026-06-23-p3-competitor-strength-absorption-report.md")); + assert!(readme.contains("P3 competitor-strength absorption closeout after XY-1072")); + assert!(readme.contains("`decodex:queued:elf` label")); + + Ok(()) +} + +fn assert_product_queue_items_reference_queue(products: &[Value], queue: &[Value]) -> Result<()> { + let queue_keys = queue + .iter() + .filter_map(|item| item.pointer("/key").and_then(Value::as_str)) + .collect::>(); + + for product in products { + let product_name = product + .pointer("/product") + .and_then(Value::as_str) + .ok_or_else(|| eyre::eyre!("product row is missing product name"))?; + let queue_item = product + .pointer("/p4_queue_item") + .and_then(Value::as_str) + .ok_or_else(|| eyre::eyre!("product {product_name} is missing p4_queue_item"))?; + + assert!( + queue_keys.contains(&queue_item), + "product {product_name} references missing P4 queue item {queue_item}" + ); + } + + Ok(()) +} + +fn find_matrix_row<'a>(rows: &'a [Value], adapter: &str, dimension: &str) -> Result<&'a Value> { + rows.iter() + .find(|row| { + row.pointer("/adapter").and_then(Value::as_str) == Some(adapter) + && row.pointer("/dimension").and_then(Value::as_str) == Some(dimension) + }) + .ok_or_else(|| eyre::eyre!("missing matrix row for {adapter} {dimension}")) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/competitor_strength.rs b/apps/elf-eval/tests/real_world_job_benchmark/competitor_strength.rs new file mode 100644 index 00000000..0740d8ff --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/competitor_strength.rs @@ -0,0 +1,864 @@ +use std::fs; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +#[test] +fn qmd_openviking_strength_profile_report_preserves_claim_boundaries() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::strength_profile_report_path()?, + )?)?; + let markdown = fs::read_to_string(support::strength_profile_markdown_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let iteration_direction = fs::read_to_string(support::iteration_direction_report_path()?)?; + + assert_strength_profile_summary(&report); + assert_strength_profile_terms(&report)?; + assert_qmd_strength_profile(&report)?; + assert_qmd_wrong_result_diagnosis(&report)?; + assert_openviking_strength_profile(&report)?; + assert_strength_profile_json_claim_boundaries(&report)?; + assert_strength_profile_markdown_boundaries(&markdown); + assert_operator_facing_strength_profile_boundaries( + &readme, + &benchmarking_index, + &iteration_direction, + ); + + Ok(()) +} + +#[test] +fn current_benchmark_reports_preserve_live_sweep_boundaries() -> Result<()> { + let measurement_audit = fs::read_to_string(support::measurement_coverage_audit_path()?)?; + let measurement_audit_json = serde_json::from_str::(&fs::read_to_string( + support::measurement_coverage_audit_json_path()?, + )?)?; + let competitor_matrix = fs::read_to_string(support::competitor_strength_matrix_path()?)?; + let competitor_matrix_json = serde_json::from_str::(&fs::read_to_string( + support::competitor_strength_matrix_json_path()?, + )?)?; + let iteration_direction = fs::read_to_string(support::iteration_direction_report_path()?)?; + let external_manifest = fs::read_to_string(support::external_adapter_manifest_path())?; + let comparison_external_projects = + fs::read_to_string(support::comparison_external_projects_path()?)?; + let retrieval_debug_profile = serde_json::from_str::(&fs::read_to_string( + support::retrieval_debug_profile_json_path()?, + )?)?; + let temporal_history = serde_json::from_str::(&fs::read_to_string( + support::temporal_history_competitor_gap_json_path()?, + )?)?; + + assert_current_report_text_boundaries( + &measurement_audit, + &competitor_matrix, + &iteration_direction, + &external_manifest, + &comparison_external_projects, + ); + + assert!(competitor_matrix.contains("claude-mem work_resume remains `not_encoded`")); + assert!(!competitor_matrix.contains("claude-mem `wrong_result`, OpenViking work_resume")); + + let qmd_live = support::find_by_field( + support::array_at(&measurement_audit_json, "/live_real_world_adapters")?, + "/adapter", + "qmd live CLI adapter", + )?; + + assert_eq!(qmd_live.pointer("/pass").and_then(Value::as_u64), Some(17)); + assert_eq!(qmd_live.pointer("/wrong_result").and_then(Value::as_u64), Some(6)); + assert_eq!(qmd_live.pointer("/expected_evidence_matched").and_then(Value::as_u64), Some(38)); + assert_eq!(qmd_live.pointer("/evidence_covered_count").and_then(Value::as_u64), Some(45)); + + let memory_evolution = support::find_by_field( + support::array_at(&measurement_audit_json, "/live_suite_breakdown")?, + "/suite", + "memory_evolution", + )?; + + assert_eq!( + memory_evolution.pointer("/elf_status_counts/wrong_result").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + memory_evolution.pointer("/qmd_status_counts/wrong_result").and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + retrieval_debug_profile + .pointer("/live_real_world_full_sweep_context/qmd/pass") + .and_then(Value::as_u64), + Some(17) + ); + assert_eq!( + retrieval_debug_profile + .pointer("/live_real_world_full_sweep_context/qmd/wrong_result") + .and_then(Value::as_u64), + Some(6) + ); + + assert_competitor_strength_matrix_json(&competitor_matrix_json)?; + + let openmemory_command = support::find_by_field( + support::array_at(&temporal_history, "/commands")?, + "/command", + "cargo make openmemory-ui-export-readback", + )?; + + assert!( + openmemory_command + .pointer("/artifact") + .and_then(Value::as_str) + .is_some_and(|artifact| artifact.contains("tmp/live-baseline/mem0-checks.json") + && artifact.contains("tmp/live-baseline/mem0-openmemory-ui-export.json")) + ); + + Ok(()) +} + +fn assert_current_report_text_boundaries( + measurement_audit: &str, + competitor_matrix: &str, + iteration_direction: &str, + external_manifest: &str, + comparison_external_projects: &str, +) { + assert!( + measurement_audit.contains( + "| `memory_evolution` | `6` | `pass:1`, `wrong_result:5` | `wrong_result:6` |" + ) + ); + assert!( + measurement_audit + .contains("qmd live fails 6/6 jobs after missing the delete/TTL tombstone evidence") + ); + assert!(measurement_audit.contains("Basic local smoke and local OSS history/readback pass")); + assert!(measurement_audit.contains("claude-mem hook/viewer capture is `blocked`")); + assert!(!measurement_audit.contains("claude-mem hook/viewer capture remains untested")); + assert!(!measurement_audit.contains("blocked or untested")); + + assert_measurement_audit_adapter_status_counts(measurement_audit); + + assert!( + competitor_matrix + .contains("broader live suites remain `wrong_result`, `blocked`, or `not_encoded`") + ); + assert!(competitor_matrix.contains( + "Overall adapter-status counts: 4 `pass`,\n6 `wrong_result`, 1 `lifecycle_fail`, 7 `blocked`, and 5 `not_encoded`." + )); + assert!(!competitor_matrix.contains("5 `blocked`, and 7 `not_encoded`")); + assert!( + competitor_matrix + .contains("mem0/OpenMemory local OSS entity-scoped personalization now passes") + ); + assert!(competitor_matrix.contains("scoped preference behavior is a measured tie")); + assert!( + !competitor_matrix.contains("mem0/OpenMemory and Letta personalization are `not_encoded`") + ); + assert!(external_manifest.contains( + "The record is a full-suite sweep, not a full-suite pass; wrong_result, blocked, and not_encoded states remain visible." + )); + assert!(external_manifest.contains( + "The qmd live real-world sweep covers the current encoded fixture corpus; expanded retrieval-debug strength suites still need their own materialized adapter run." + )); + assert!( + comparison_external_projects + .contains("Benchmark-grounded for scoped local OSS same-corpus retrieval") + ); + assert!( + comparison_external_projects + .contains("Benchmark-grounded for local same-corpus retrieval, reindex/update/delete") + ); + assert!(iteration_direction.contains("| Jobs | `55` |")); + assert!(iteration_direction.contains("| Encoded suites | `15` |")); + assert!(iteration_direction.contains("| Pass | `49` |")); + assert!(iteration_direction.contains("| Evidence coverage | `123/123` |")); + assert!(iteration_direction.contains("| Expected evidence recall | `115/115` |")); + + for stale_phrase in [ + "same live sweep shape as ELF", + "ELF and qmd live fail 5/6 jobs", + "both systems currently fail 5/6 live memory-evolution jobs", + "wrong_result, incomplete, blocked, and not_encoded states remain visible", + "broader live suites remain `wrong_result`, `incomplete`, or `not_encoded`", + "The qmd live real-world slice covers representative jobs only", + "| Jobs | `40` |", + "| Encoded suites | `11` |", + "| Jobs | `50` |", + "| Encoded suites | `14` |", + "| Pass | `38` |", + "| Pass | `45` |", + "| Evidence coverage | `115/115` |", + "| Expected evidence recall | `107/107` |", + "history/UI/hosted/graph behavior remains", + "current local adapter is incomplete/wrong-result", + "current adapter is incomplete/invalid-result", + ] { + assert!(!measurement_audit.contains(stale_phrase)); + assert!(!competitor_matrix.contains(stale_phrase)); + assert!(!iteration_direction.contains(stale_phrase)); + assert!(!external_manifest.contains(stale_phrase)); + assert!(!comparison_external_projects.contains(stale_phrase)); + } +} + +fn assert_competitor_strength_matrix_json(matrix: &Value) -> Result<()> { + let projects = support::array_at(matrix, "/project_matrix")?; + let scenarios = support::array_at(matrix, "/scenario_matrix")?; + + assert_competitor_strength_matrix_manifest_counts(matrix); + assert_competitor_strength_matrix_project_json(projects)?; + assert_competitor_strength_matrix_scenario_json(scenarios)?; + + Ok(()) +} + +fn assert_competitor_strength_matrix_project_json(projects: &[Value]) -> Result<()> { + let qmd = support::find_by_field(projects, "/project", "qmd")?; + let mem0 = support::find_by_field(projects, "/project", "mem0/OpenMemory")?; + let claude_mem = support::find_by_field(projects, "/project", "claude-mem")?; + let openviking = support::find_by_field(projects, "/project", "OpenViking")?; + + assert_eq!( + qmd.pointer("/current_evidence_class").and_then(Value::as_str), + Some("live_real_world") + ); + assert_eq!(qmd.pointer("/measured_status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + qmd.pointer("/unsupported_or_blocked_status/state").and_then(Value::as_str), + Some("not_encoded") + ); + assert!(qmd.pointer("/benchmark_before_claim").and_then(Value::as_str).is_some_and(|claim| { + claim.contains("Keep qmd deep retrieval/debug profiling separate") + && claim.contains("narrow operator-debug live slice") + })); + assert!( + qmd.pointer("/borrow_if_stronger") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("transparent local knobs")) + ); + assert_eq!(mem0.pointer("/measured_status").and_then(Value::as_str), Some("pass")); + assert_eq!( + mem0.pointer("/unsupported_or_blocked_status/state").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!( + mem0.pointer("/unsupported_or_blocked_status/typed_reason").and_then(Value::as_str), + Some("openmemory_export_helper_setup_blocked") + ); + assert!( + mem0.pointer("/benchmark_before_claim") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("OpenMemory product app import/export")) + ); + assert!( + claude_mem + .pointer("/unsupported_or_blocked_status/details") + .and_then(Value::as_str) + .is_some_and(|details| details.contains("rerun/inspection targets") + && details.contains("tmp/live-baseline/claude-mem-checks.json")) + ); + assert_eq!( + openviking.pointer("/current_evidence_class").and_then(Value::as_str), + Some("live_baseline_only") + ); + assert_eq!( + openviking.pointer("/measured_status").and_then(Value::as_str), + Some("wrong_result") + ); + assert_eq!( + openviking.pointer("/unsupported_or_blocked_status/state").and_then(Value::as_str), + Some("blocked") + ); + assert!( + openviking + .pointer("/unsupported_or_blocked_status/details") + .and_then(Value::as_str) + .is_some_and(|details| details.contains("encoded as blocked fixtures")) + ); + assert!( + openviking + .pointer("/benchmark_before_claim") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("evidence-bearing same-corpus output pass")) + ); + + Ok(()) +} + +fn assert_competitor_strength_matrix_scenario_json(scenarios: &[Value]) -> Result<()> { + let retrieval_debug = support::find_by_field(scenarios, "/scenario_id", "retrieval_debug")?; + let work_resume = support::find_by_field(scenarios, "/scenario_id", "work_resume")?; + let operator_debug = support::find_by_field(scenarios, "/scenario_id", "operator_debugging")?; + let context_trajectory = + support::find_by_field(scenarios, "/scenario_id", "context_trajectory")?; + let consolidation = support::find_by_field(scenarios, "/scenario_id", "consolidation")?; + + assert!( + retrieval_debug + .pointer("/current_state") + .and_then(Value::as_str) + .is_some_and(|state| state.contains("Measured tie on encoded retrieval answers")) + ); + assert!(retrieval_debug.pointer("/current_state").and_then(Value::as_str).is_some_and( + |state| state.contains("qmd remains stronger on local debug ergonomics not fully scored") + )); + assert!( + work_resume + .pointer("/current_competitor_evidence") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("claude-mem work_resume remains not_encoded") + && !claim.contains("claude-mem is wrong_result")) + ); + assert!( + operator_debug + .pointer("/current_elf_evidence") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("narrow live_real_world operator-debug slice")) + ); + assert!( + operator_debug + .pointer("/current_competitor_evidence") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("qmd now has a narrow live_real_world")) + ); + assert!( + operator_debug + .pointer("/next_measurement") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("OpenMemory and claude-mem UI/export")) + ); + assert!( + consolidation + .pointer("/current_elf_evidence") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("XY-934 adds live_real_world") + && claim.contains("zero source mutations")) + ); + assert!( + consolidation + .pointer("/current_competitor_evidence") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("qmd remains not_encoded") + && claim.contains("product references only")) + ); + + let personalization = support::find_by_field(scenarios, "/scenario_id", "personalization")?; + + assert_personalization_matrix_record(personalization); + + assert!( + context_trajectory + .pointer("/current_state") + .and_then(Value::as_str) + .is_some_and(|state| state.contains("not a measured live winner")) + ); + assert!( + context_trajectory + .pointer("/next_measurement") + .and_then(Value::as_str) + .is_some_and(|measurement| measurement.contains("evidence-bearing retrieval pass")) + ); + + Ok(()) +} + +fn assert_personalization_matrix_record(personalization: &Value) { + assert!( + personalization + .pointer("/current_competitor_evidence") + .and_then(Value::as_str) + .is_some_and(|claim| claim + .contains("mem0/OpenMemory local OSS entity-scoped personalization now passes") + && claim.contains("Letta personalization is research_gate not_encoded")) + ); + assert!( + personalization + .pointer("/current_state") + .and_then(Value::as_str) + .is_some_and(|state| state.contains("scoped personalization is a tie")) + ); +} + +fn assert_competitor_strength_matrix_manifest_counts(matrix: &Value) { + assert_eq!( + matrix.pointer("/manifest_summary/adapter_records").and_then(Value::as_u64), + Some(23) + ); + assert_eq!( + matrix + .pointer("/manifest_summary/evidence_class_counts/live_real_world") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + matrix.pointer("/manifest_summary/overall_status_counts/pass").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + matrix.pointer("/manifest_summary/overall_status_counts/blocked").and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + matrix + .pointer("/manifest_summary/overall_status_counts/not_encoded") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + matrix + .pointer("/manifest_summary/overall_status_counts/wrong_result") + .and_then(Value::as_u64), + Some(6) + ); +} + +fn assert_strength_profile_summary(report: &Value) { + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.competitor_strength_profile_report/v1") + ); + assert_eq!( + report.pointer("/summary/qmd/retrieval_quality").and_then(Value::as_str), + Some("tie") + ); + assert_eq!( + report.pointer("/summary/qmd/local_query_transparency").and_then(Value::as_str), + Some("not_tested") + ); + assert_eq!( + report.pointer("/summary/qmd/local_replayability").and_then(Value::as_str), + Some("not_tested") + ); + assert_eq!( + report.pointer("/summary/qmd/overall_outcome").and_then(Value::as_str), + Some("not_tested") + ); + assert_eq!( + report.pointer("/summary/openviking/overall_outcome").and_then(Value::as_str), + Some("not_tested") + ); + assert_eq!( + report + .pointer("/qmd_strength_profile/win_tie_loss_summary/elf_win") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/qmd_strength_profile/win_tie_loss_summary/tie").and_then(Value::as_u64), + Some(3) + ); + assert_eq!( + report + .pointer("/qmd_strength_profile/win_tie_loss_summary/elf_loss") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/qmd_strength_profile/win_tie_loss_summary/not_tested") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report + .pointer("/openviking_context_trajectory_profile/win_tie_loss_summary/not_tested") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report + .pointer("/openviking_context_trajectory_profile/win_tie_loss_summary/elf_win") + .and_then(Value::as_u64), + Some(1) + ); +} + +fn assert_strength_profile_terms(report: &Value) -> Result<()> { + let result_terms = support::array_at(report, "/result_type_terms")?; + let coverage_terms = support::array_at(report, "/coverage_status_terms")?; + let outcome_terms = support::array_at(report, "/outcome_terms")?; + let actual_result_terms = support::string_array_at(report, "/result_type_terms")?; + let actual_coverage_terms = support::string_array_at(report, "/coverage_status_terms")?; + + assert_eq!( + actual_result_terms, + [ + "pass", + "wrong_result", + "blocked", + "incomplete", + "lifecycle_fail", + "not_encoded", + "unsupported_claim", + ] + .map(str::to_owned) + ); + assert_eq!( + actual_coverage_terms, + [ + "pass", + "wrong_result", + "blocked", + "incomplete", + "lifecycle_fail", + "not_encoded", + "unsupported", + "unsupported_claim", + ] + .map(str::to_owned) + ); + assert!(!result_terms.iter().any(|term| term.as_str() == Some("unsupported"))); + assert!(!result_terms.iter().any(|term| term.as_str() == Some("partial"))); + assert!(!coverage_terms.iter().any(|term| term.as_str() == Some("partial"))); + assert!(result_terms.iter().any(|term| term.as_str() == Some("unsupported_claim"))); + assert!(coverage_terms.iter().any(|term| term.as_str() == Some("unsupported"))); + + assert_value_in_terms(report, "/summary/qmd/overall_outcome", outcome_terms)?; + assert_value_in_terms(report, "/summary/openviking/overall_outcome", outcome_terms)?; + + for scenario in support::array_at(report, "/qmd_strength_profile/scenario_outcomes")? { + assert_value_in_terms(scenario, "/result_type", result_terms)?; + assert_value_in_terms(scenario, "/elf_status", coverage_terms)?; + assert_value_in_terms(scenario, "/qmd_status", coverage_terms)?; + } + for scenario in + support::array_at(report, "/openviking_context_trajectory_profile/scenario_outcomes")? + { + assert_value_in_terms(scenario, "/result_type", result_terms)?; + assert_value_in_terms(scenario, "/openviking_status", coverage_terms)?; + assert_value_in_terms(scenario, "/elf_equivalent_status", coverage_terms)?; + } + + Ok(()) +} + +fn assert_value_in_terms(value: &Value, pointer: &str, terms: &[Value]) -> Result<()> { + let actual = value + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| eyre::eyre!("missing string at {pointer}"))?; + + assert!( + terms.iter().any(|term| term.as_str() == Some(actual)), + "{actual} at {pointer} is not declared in the report term list" + ); + + Ok(()) +} + +fn assert_qmd_strength_profile(report: &Value) -> Result<()> { + let qmd_scenarios = support::array_at(report, "/qmd_strength_profile/scenario_outcomes")?; + let local_transparency = + support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-local-query-transparency")?; + let retrieval = support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-retrieval-quality")?; + let rerank_controls = support::find_by_field( + qmd_scenarios, + "/scenario_id", + "qmd-expansion-fusion-rerank-controls", + )?; + let stale_isolation = + support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-stale-context-isolation")?; + let lifecycle = + support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-update-delete-cold-start")?; + let operator_debug = + support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-operator-debug-evidence")?; + let replayability = + support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-local-replayability")?; + let wrong_result = + support::find_by_field(qmd_scenarios, "/scenario_id", "qmd-wrong-result-diagnosis")?; + + assert_eq!(qmd_scenarios.len(), 8); + assert_eq!(retrieval.pointer("/elf_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!( + local_transparency.pointer("/elf_outcome").and_then(Value::as_str), + Some("not_tested") + ); + assert_eq!( + local_transparency.pointer("/result_type").and_then(Value::as_str), + Some("not_encoded") + ); + assert_eq!( + rerank_controls.pointer("/result_type").and_then(Value::as_str), + Some("not_encoded") + ); + assert_eq!(stale_isolation.pointer("/result_type").and_then(Value::as_str), Some("pass")); + assert_eq!(stale_isolation.pointer("/elf_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(lifecycle.pointer("/result_type").and_then(Value::as_str), Some("pass")); + assert_eq!(lifecycle.pointer("/elf_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(operator_debug.pointer("/result_type").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(operator_debug.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!(replayability.pointer("/result_type").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(replayability.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!( + wrong_result.pointer("/evidence_class").and_then(Value::as_str), + Some("research_gate") + ); + assert_eq!(wrong_result.pointer("/result_type").and_then(Value::as_str), Some("not_encoded")); + + Ok(()) +} + +fn assert_qmd_wrong_result_diagnosis(report: &Value) -> Result<()> { + let taxonomy = + support::array_at(report, "/qmd_strength_profile/wrong_result_diagnosis/taxonomy")?; + let absent = support::find_by_field(taxonomy, "/class", "evidence_absent")?; + let dropped = support::find_by_field(taxonomy, "/class", "retrieved_but_dropped")?; + let narrated = support::find_by_field(taxonomy, "/class", "selected_but_not_narrated")?; + let lifecycle = + support::find_by_field(taxonomy, "/class", "contradicted_by_lifecycle_evidence")?; + + assert_eq!(absent.pointer("/coverage").and_then(Value::as_str), Some("observed")); + assert_eq!( + dropped.pointer("/coverage").and_then(Value::as_str), + Some("not_observed_candidate_trace_missing") + ); + assert_eq!(narrated.pointer("/coverage").and_then(Value::as_str), Some("observed")); + assert_eq!(lifecycle.pointer("/coverage").and_then(Value::as_str), Some("observed")); + + let qmd_diagnosis_jobs = + support::array_at(report, "/qmd_strength_profile/wrong_result_diagnosis/jobs")?; + let delete_job = + support::find_by_field(qmd_diagnosis_jobs, "/job_id", "memory-evolution-delete-ttl-001")?; + + assert_eq!(qmd_diagnosis_jobs.len(), 6); + assert_eq!(delete_job.pointer("/qmd_status").and_then(Value::as_str), Some("wrong_result")); + assert!(support::array_contains_str(delete_job, "/missing_evidence", "delete-tombstone")?); + assert!( + delete_job + .pointer("/diagnosis") + .and_then(Value::as_str) + .is_some_and(|diagnosis| diagnosis.contains("typed wrong_result")) + ); + + Ok(()) +} + +fn assert_openviking_strength_profile(report: &Value) -> Result<()> { + let openviking_scenarios = + support::array_at(report, "/openviking_context_trajectory_profile/scenario_outcomes")?; + let trajectory = support::find_by_field( + openviking_scenarios, + "/scenario_id", + "openviking-staged-retrieval-trajectory", + )?; + let precondition = support::find_by_field( + openviking_scenarios, + "/scenario_id", + "openviking-evidence-bearing-retrieval-precondition", + )?; + let local_embed_setup = support::find_by_field( + openviking_scenarios, + "/scenario_id", + "openviking-local-embed-setup", + )?; + let missed_terms = support::find_by_field( + openviking_scenarios, + "/scenario_id", + "openviking-missed-expected-terms-evidence", + )?; + let hierarchy = support::find_by_field( + openviking_scenarios, + "/scenario_id", + "openviking-hierarchy-selection", + )?; + let recursive_expansion = support::find_by_field( + openviking_scenarios, + "/scenario_id", + "openviking-recursive-context-expansion", + )?; + + assert_eq!(openviking_scenarios.len(), 6); + assert_eq!( + trajectory.pointer("/evidence_class").and_then(Value::as_str), + Some("fixture_backed") + ); + assert_eq!(trajectory.pointer("/result_type").and_then(Value::as_str), Some("blocked")); + assert_eq!(trajectory.pointer("/openviking_status").and_then(Value::as_str), Some("blocked")); + assert_eq!(local_embed_setup.pointer("/result_type").and_then(Value::as_str), Some("pass")); + assert_eq!( + local_embed_setup.pointer("/elf_outcome").and_then(Value::as_str), + Some("not_tested") + ); + assert_eq!(local_embed_setup.pointer("/typed_blocker"), Some(&Value::Null)); + assert_eq!(precondition.pointer("/result_type").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(precondition.pointer("/elf_outcome").and_then(Value::as_str), Some("elf_win")); + assert_eq!( + precondition.pointer("/typed_blocker").and_then(Value::as_str), + Some("output_missed_expected_terms") + ); + assert_eq!(missed_terms.pointer("/result_type").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(missed_terms.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!(hierarchy.pointer("/result_type").and_then(Value::as_str), Some("blocked")); + assert_eq!(hierarchy.pointer("/elf_outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!( + recursive_expansion.pointer("/result_type").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!( + recursive_expansion.pointer("/elf_outcome").and_then(Value::as_str), + Some("not_tested") + ); + + Ok(()) +} + +fn assert_strength_profile_json_claim_boundaries(report: &Value) -> Result<()> { + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "ELF does not broadly beat qmd; it ties encoded retrieval and lifecycle correctness, keeps qmd query transparency as not_tested for comparative scoring, and leaves replayability not_tested." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "qmd expansion, fusion, and rerank superiority remains not_tested because the current qmd paths use --no-rerank and do not score internals." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "ELF does not beat OpenViking on context trajectory; OpenViking trajectory strengths remain blocked/not_tested behind a wrong_result same-corpus output precondition and missing staged artifacts." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "Research_gate and blocked fixture records are follow-up gates, not pass evidence." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "Missing equivalent surfaces are encoded as unsupported, blocked, or not_encoded rather than fake losses." + )?); + + Ok(()) +} + +fn assert_strength_profile_markdown_boundaries(markdown: &str) { + assert!( + markdown.contains( + "| Wrong-result diagnosis | `research_gate` | `not_encoded` | `not_tested` |" + ) + ); + assert!( + markdown.contains("ELF ties qmd on the current encoded retrieval-correctness surfaces") + ); + assert!(markdown.contains("qmd remains the local retrieval-debug UX reference")); + assert!(markdown.contains("not scored as comparative ELF wins or losses")); + assert!(markdown.contains("ELF currently wins only the equivalent OpenViking same-corpus")); + assert!(markdown.contains("Do not claim ELF broadly beats qmd")); + assert!(markdown.contains( + "Do not claim ELF beats OpenViking on staged retrieval, hierarchy, or recursive" + )); + assert!(markdown.contains( + "Do not turn `research_gate`, `blocked`, `not_encoded`, or `unsupported` surfaces" + )); + assert!(markdown.contains("no pass evidence is claimed")); + assert!(markdown.contains("typed `wrong_result` state")); +} + +fn assert_operator_facing_strength_profile_boundaries( + readme: &str, + benchmarking_index: &str, + iteration_direction: &str, +) { + assert!(readme.contains("Full-suite live real-world adapter sweep after XY-926")); + assert!(readme.contains("all 55 checked-in jobs across 13 suites")); + assert!(readme.contains("ELF now live-scores capture/write-policy")); + assert!(readme.contains("consolidation proposal review")); + assert!(readme.contains("knowledge-page rebuild/lint")); + assert!(readme.contains("operator-debugging fixtures")); + assert!(!readme.contains("memory-evolution wrong results")); + assert!(readme.contains("Live temporal reconciliation after XY-905")); + assert!(readme.contains("now reports ELF live `memory_evolution` as 6/6 pass")); + assert!(readme.contains("broad qmd, Graphiti/Zep, mem0/OpenMemory, Letta")); + assert!(readme.contains("production-ops operator boundaries")); + assert!(readme.contains("core/archival live adapter gap")); + assert!( + support::collapse_whitespace(readme).contains("blocked context-trajectory measurement") + ); + assert!( + readme + .contains("consolidation, knowledge, capture, and core/archival typed non-pass states") + ); + assert!(readme.contains("operator-debug trace hydration")); + assert!(readme.contains("qmd remains the local retrieval-debug UX reference")); + assert!(readme.contains("broad ELF-over-qmd")); + assert!(readme.contains("qmd and OpenViking Strength-Profile Report - June 11, 2026")); + assert!(benchmarking_index.contains("2026-06-11-qmd-openviking-strength-profile-report.md")); + assert!( + benchmarking_index.contains("separates qmd retrieval quality from debug/replay ergonomics") + ); + assert!(benchmarking_index.contains("preserves XY-928 OpenViking")); + assert!( + benchmarking_index + .contains("context-trajectory surfaces as blocked/not-tested until scored staged") + ); + assert!( + iteration_direction + .contains("ELF and qmd are tied on the encoded live retrieval, work-resume, and") + ); + assert!(iteration_direction.contains("ELF does not yet beat qmd's local retrieval-debug")); + + assert_iteration_direction_current_measurement_counts(iteration_direction); + + assert!(iteration_direction.contains( + "ELF beats OpenViking on context trajectory. The scenario is encoded as blocked" + )); + assert!( + iteration_direction + .contains("Do not promote a reference project into a win/loss claim until") + ); +} + +fn assert_measurement_audit_adapter_status_counts(markdown: &str) { + for expected in [ + "| `blocked` | `7` |", + "| `not_encoded` | `5` |", + "The generated JSON report emits `external_project_count: 16`", + ] { + assert!(markdown.contains(expected), "missing measurement audit text: {expected}"); + } + for stale in ["| `blocked` | `6` |", "| `not_encoded` | `6` |"] { + assert!(!markdown.contains(stale), "stale measurement audit text: {stale}"); + } +} + +fn assert_iteration_direction_current_measurement_counts(markdown: &str) { + for expected in [ + "| Jobs | `55` |", + "| Encoded suites | `15` |", + "| Blocked | `6` |", + "| Mean score | `0.891` |", + "| Evidence coverage | `123/123` |", + "| Source-ref coverage | `123/123` |", + "| Quote coverage | `123/123` |", + "| Expected evidence recall | `115/115` |", + "| `blocked` | `7` |", + "| `not_encoded` | `5` |", + "`live_baseline_only`, `fixture_backed`, and `research_gate`", + "`blocked` for fixture-backed trajectory gates", + ] { + assert!(markdown.contains(expected), "missing iteration-direction text: {expected}"); + } + for stale in [ + "| Jobs | `40` |", + "| Encoded suites | `11` |", + "| Jobs | `50` |", + "| Encoded suites | `14` |", + "| Mean score | `0.950` |", + "| Mean score | `0.900` |", + "| Evidence coverage | `88/88` |", + "| Evidence coverage | `115/115` |", + "| Expected evidence recall | `80/80` |", + "| Expected evidence recall | `107/107` |", + "| `blocked` | `5` |", + "| `not_encoded` | `7` |", + "`live_baseline_only` plus `research_gate`", + ] { + assert!(!markdown.contains(stale), "stale iteration-direction text: {stale}"); + } +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/consolidation.rs b/apps/elf-eval/tests/real_world_job_benchmark/consolidation.rs new file mode 100644 index 00000000..a2b0fe4a --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/consolidation.rs @@ -0,0 +1,47 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; + +use crate::support; + +#[test] +fn consolidation_report_renders_markdown_metrics_and_gaps() -> Result<()> { + let report = support::run_json_report_from(support::consolidation_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-consolidation-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("report.json"); + let markdown_path = temp_dir.join("report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("## Consolidation")); + assert!(markdown.contains("Source Mutations")); + assert!(markdown.contains("Proposal Unsupported Claims")); + assert!(markdown.contains("Executable Gaps")); + assert!(markdown.contains("consolidation-contradiction-report-discard-001")); + assert!(!markdown.contains("live_consolidation_worker_generation")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/consolidation_knowledge.rs b/apps/elf-eval/tests/real_world_job_benchmark/consolidation_knowledge.rs new file mode 100644 index 00000000..19a07a21 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/consolidation_knowledge.rs @@ -0,0 +1,762 @@ +use std::{env, fs, path::Path, process}; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +fn real_world_live_adapter_sources(workspace: &Path) -> Result { + let mut source = + fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_live_adapter.rs"))?; + + append_rust_sources( + workspace.join("apps/elf-eval/src/bin/real_world_live_adapter").as_path(), + &mut source, + )?; + + Ok(source) +} + +fn real_world_job_benchmark_sources(workspace: &Path) -> Result { + let mut source = + fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_job_benchmark.rs"))?; + + append_rust_sources( + workspace.join("apps/elf-eval/src/bin/real_world_job_benchmark").as_path(), + &mut source, + )?; + + Ok(source) +} + +fn append_rust_sources(dir: &Path, source: &mut String) -> Result<()> { + let mut entries = Vec::new(); + + for entry in fs::read_dir(dir)? { + entries.push(entry?.path()); + } + + entries.sort(); + + for path in entries { + if path.is_dir() { + append_rust_sources(path.as_path(), source)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + source.push('\n'); + source.push_str(fs::read_to_string(path)?.as_str()); + } + } + + Ok(()) +} + +#[test] +fn declared_not_encoded_consolidation_jobs_do_not_require_fake_proposals() -> Result<()> { + let fixture_path = + support::consolidation_fixture_dir().join("contradiction_report_discard.json"); + let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; + + fixture + .pointer_mut("/corpus/adapter_response") + .and_then(Value::as_object_mut) + .ok_or_else(|| eyre::eyre!("missing adapter_response object"))? + .remove("consolidation"); + + let encoding = serde_json::json!({ + "status": "not_encoded", + "reason": "The qmd live adapter retrieves evidence-linked answers but does not generate or review consolidation proposals." + }); + + fixture + .as_object_mut() + .ok_or_else(|| eyre::eyre!("fixture is not an object"))? + .insert("encoding".to_string(), encoding); + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-not-encoded-consolidation-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write( + temp_dir.join("not_encoded_consolidation.json"), + serde_json::to_vec_pretty(&fixture)?, + )?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = + support::find_by_field(jobs, "/job_id", "consolidation-contradiction-report-discard-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn capture_write_policy_live_report_preserves_competitor_boundaries() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::capture_write_policy_live_report_path()?, + )?)?; + let markdown = fs::read_to_string(support::capture_write_policy_live_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.capture_write_policy_live_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-933")); + assert_eq!( + report + .pointer("/live_capture_results/elf_live_real_world/suite_status") + .and_then(Value::as_str), + Some("pass") + ); + assert_eq!( + report + .pointer("/live_capture_results/elf_live_real_world/encoded_job_count") + .and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report + .pointer("/live_capture_results/elf_live_real_world/redaction_leak_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/live_capture_results/qmd_live_real_world/suite_status") + .and_then(Value::as_str), + Some("not_encoded") + ); + + let jobs = support::array_at(&report, "/jobs")?; + let source_binding = support::find_by_field(jobs, "/job_id", "capture-source-id-binding-001")?; + let source_binding_refs = support::array_at(source_binding, "/runtime_source_refs")?; + let release_summary_ref = + support::find_by_field(source_binding_refs, "/evidence_id", "source-id-release-summary")?; + + assert!(support::array_contains_str( + source_binding, + "/source_ids", + "capture:issue-comment-42" + )?); + assert_eq!( + release_summary_ref.pointer("/source_id").and_then(Value::as_str), + Some("capture:issue-comment-42") + ); + assert_eq!( + release_summary_ref.pointer("/evidence_binding").and_then(Value::as_str), + Some("source_ref") + ); + + let write_policy = + support::find_by_field(jobs, "/job_id", "capture-write-policy-redaction-001")?; + + assert_eq!( + write_policy.pointer("/write_policy_redaction_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + write_policy + .pointer("/runtime_source_refs/0/write_policy_applied") + .and_then(Value::as_bool), + Some(true) + ); + + let boundary = support::find_by_field(jobs, "/job_id", "capture-integration-boundaries-001")?; + + assert!(support::array_contains_str(boundary, "/excluded_evidence_ids", "private-span-trap")?); + assert!(!support::array_contains_str(boundary, "/stored_evidence_ids", "private-span-trap")?); + assert!( + support::array_at(boundary, "/runtime_source_refs")? + .iter() + .all(|item| item.pointer("/evidence_id").and_then(Value::as_str) + != Some("private-span-trap")) + ); + + let positions = support::array_at(&report, "/competitor_positions")?; + let qmd = support::find_by_field(positions, "/project", "qmd")?; + let agentmemory = support::find_by_field(positions, "/project", "agentmemory")?; + let claude_mem = support::find_by_field(positions, "/project", "claude-mem")?; + + assert_eq!(qmd.pointer("/position").and_then(Value::as_str), Some("untested")); + assert!(qmd.pointer("/reason").and_then(Value::as_str).is_some_and(|reason| { + reason.contains("typed not_encoded") && reason.contains("ELF self-check") + })); + assert_eq!(agentmemory.pointer("/position").and_then(Value::as_str), Some("blocked")); + assert!(agentmemory.pointer("/reason").and_then(Value::as_str).is_some_and(|reason| { + reason.contains("process-local StateKV Map") && reason.contains("in-memory index") + })); + assert_eq!(claude_mem.pointer("/position").and_then(Value::as_str), Some("blocked")); + assert!( + claude_mem + .pointer("/reason") + .and_then(Value::as_str) + .is_some_and(|reason| reason.contains("hooks, timeline, observations") + && reason.contains("Docker-contained hook/viewer runner")) + ); + + assert_capture_write_policy_docs(&markdown, &benchmarking_index, &readme); + + Ok(()) +} + +fn assert_capture_write_policy_docs(markdown: &str, benchmarking_index: &str, readme: &str) { + assert!(markdown.contains("ELF now has live capture/write-policy self-check evidence")); + assert!(markdown.contains("not an ELF-over-qmd win")); + assert!(markdown.contains("| claude-mem capture/viewer flows | `blocked` |")); + assert!(!markdown.contains("claude-mem capture breadth is untested")); + assert!(markdown.contains("runtime `source_ref` metadata returned by search")); + assert!(markdown.contains("Do not claim ELF broadly beats agentmemory or claude-mem")); + assert!(benchmarking_index.contains("2026-06-11-capture-write-policy-live-report.md")); + assert!(readme.contains("Capture/Write-Policy Live Report - June 11, 2026")); + assert!(readme.contains("mem0/OpenMemory")); + assert!(readme.contains("and memsearch now pass their scoped local baseline")); + assert!( + support::collapse_whitespace(readme) + .contains("claude-mem hook/viewer capture remains blocked until Docker-contained") + ); +} + +#[test] +fn live_consolidation_report_preserves_reviewable_output_boundaries() -> Result<()> { + let workspace = support::workspace_root()?; + let report = serde_json::from_str::(&fs::read_to_string( + support::live_consolidation_proposal_scoring_report_path()?, + )?)?; + let markdown = + fs::read_to_string(support::live_consolidation_proposal_scoring_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let benchmark_runbook = fs::read_to_string( + workspace + .join("docs") + .join("runbook") + .join("benchmarking") + .join("real_world_agent_memory_benchmark.md"), + )?; + let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; + let live_script = + fs::read_to_string(workspace.join("scripts/real-world-consolidation-live-adapter.sh"))?; + let live_adapter = real_world_live_adapter_sources(&workspace)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.live_consolidation_proposal_scoring_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-934")); + assert_eq!( + report + .pointer("/live_consolidation_results/elf_live_real_world/suite_status") + .and_then(Value::as_str), + Some("pass") + ); + assert_eq!( + report + .pointer("/live_consolidation_results/elf_live_real_world/encoded_job_count") + .and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report + .pointer("/live_consolidation_results/elf_live_real_world/proposal_count") + .and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report + .pointer("/live_consolidation_results/elf_live_real_world/source_mutation_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/live_consolidation_results/elf_live_real_world/review_event_count") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report + .pointer("/live_consolidation_results/qmd_live_real_world/suite_status") + .and_then(Value::as_str), + Some("not_encoded") + ); + + let jobs = support::array_at(&report, "/jobs")?; + let project_summary = + support::find_by_field(jobs, "/job_id", "consolidation-project-summary-apply-001")?; + let preference = + support::find_by_field(jobs, "/job_id", "consolidation-preference-candidate-defer-001")?; + let contradiction = + support::find_by_field(jobs, "/job_id", "consolidation-contradiction-report-discard-001")?; + + assert_eq!( + project_summary.pointer("/final_review_state").and_then(Value::as_str), + Some("applied") + ); + assert_eq!(project_summary.pointer("/review_event_count").and_then(Value::as_u64), Some(2)); + assert_eq!(preference.pointer("/final_review_state").and_then(Value::as_str), Some("archived")); + assert_eq!( + contradiction.pointer("/final_review_state").and_then(Value::as_str), + Some("rejected") + ); + assert_eq!( + contradiction.pointer("/unsupported_claim_flag_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(contradiction.pointer("/source_lineage_count").and_then(Value::as_u64), Some(3)); + + let positions = support::array_at(&report, "/reference_positions")?; + let qmd = support::find_by_field(positions, "/project", "qmd")?; + let managed = support::find_by_field(positions, "/project", "managed_dreaming_memory_systems")?; + let always_on = + support::find_by_field(positions, "/project", "always_on_memory_agent_patterns")?; + + assert_eq!(qmd.pointer("/position").and_then(Value::as_str), Some("untested")); + assert_eq!(managed.pointer("/position").and_then(Value::as_str), Some("product_reference")); + assert_eq!(always_on.pointer("/position").and_then(Value::as_str), Some("product_reference")); + assert!(markdown.contains("ELF now has service-backed live consolidation proposal scoring")); + assert!(markdown.contains("This is not scheduled production consolidation")); + assert!(markdown.contains("Source mutations")); + assert!(markdown.contains("Do not mix knowledge-page rebuild/lint scoring")); + assert!( + benchmarking_index.contains("2026-06-16-live-consolidation-proposal-scoring-report.md") + ); + assert!(readme.contains("Live Consolidation Proposal Scoring Report - June 16, 2026")); + assert!(readme.contains("real-world-memory-live-consolidation")); + assert!(benchmark_runbook.contains("Current live consolidation increment")); + assert!(benchmark_runbook.contains("tmp/real-world-memory/live-consolidation/summary.json")); + assert!(makefile.contains("[tasks.real-world-memory-live-consolidation]")); + assert!(makefile.contains("scripts/real-world-docker.sh")); + + let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; + + assert_live_consolidation_scripts(&docker_script, &live_script, &live_adapter); + + Ok(()) +} + +fn assert_live_consolidation_scripts(docker_script: &str, live_script: &str, live_adapter: &str) { + assert!(docker_script.contains("scripts/real-world-consolidation-live-adapter.sh")); + assert!(live_script.contains("elf.real_world_consolidation_live_adapter_sweep/v1")); + assert!(live_script.contains("real_world_live_adapter -- elf")); + assert!(!live_script.contains("real_world_live_adapter -- qmd")); + assert!(live_adapter.contains("fn materialize_elf_consolidation(")); + assert!(live_adapter.contains("ConsolidationProposalReviewRequest")); +} + +#[test] +fn live_knowledge_page_rebuild_lint_has_dedicated_docker_task() -> Result<()> { + let workspace = support::workspace_root()?; + let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; + let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; + let live_script = + fs::read_to_string(workspace.join("scripts/real-world-knowledge-live-adapter.sh"))?; + let live_adapter = real_world_live_adapter_sources(&workspace)?; + let knowledge_spec = fs::read_to_string( + workspace.join("docs").join("spec").join("system_knowledge_pages_v1.md"), + )?; + let version_diff_report = fs::read_to_string( + workspace + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-20-knowledge-workspace-version-diff-report.md"), + )?; + let benchmark_runbook = fs::read_to_string( + workspace + .join("docs") + .join("runbook") + .join("benchmarking") + .join("real_world_agent_memory_benchmark.md"), + )?; + let live_runbook = fs::read_to_string( + workspace + .join("docs") + .join("runbook") + .join("benchmarking") + .join("live_baseline_benchmark.md"), + )?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert!(makefile.contains("[tasks.real-world-memory-live-knowledge]")); + assert!(makefile.contains("scripts/real-world-docker.sh")); + assert!(makefile.contains("memory-live-knowledge")); + assert!(docker_script.contains("memory-live-knowledge)")); + assert!(docker_script.contains("-e ELF_KNOWLEDGE_LIVE_REPORT_DIR")); + assert!(docker_script.contains("-e ELF_KNOWLEDGE_LIVE_FIXTURES")); + assert!(docker_script.contains("scripts/real-world-knowledge-live-adapter.sh")); + assert!(live_script.contains("elf.real_world_knowledge_live_adapter_sweep/v1")); + assert!(live_script.contains("apps/elf-eval/fixtures/real_world_memory/knowledge")); + assert!(live_script.contains("tmp/real-world-memory/live-knowledge")); + assert!(live_script.contains("real-world-memory-live-knowledge")); + assert!(live_script.contains("ElfService knowledge_page_rebuild")); + assert!(live_script.contains("knowledge_page_lint")); + assert!(live_script.contains("knowledge_pages_search")); + assert!(live_script.contains("pages remain derived benchmark artifacts")); + assert!(live_adapter.contains("\"page_version_diff\"")); + assert!(live_adapter.contains("version_diff_available")); + assert!(live_adapter.contains("fn materialize_elf_knowledge(")); + assert!(live_adapter.contains("KnowledgePageRebuildRequest")); + assert!(live_adapter.contains("KnowledgePageLintRequest")); + assert!(live_adapter.contains("KnowledgePageSearchRequest")); + assert!(real_world_job_benchmark_sources(&workspace)?.contains("version_diff_coverage")); + assert!(knowledge_spec.contains("elf.knowledge_page.version_diff/v1")); + assert!( + version_diff_report.contains("Knowledge Workspace Version-Diff Report - June 20, 2026") + ); + assert!(version_diff_report.contains("version_diff_coverage = 1.000")); + assert!(benchmark_runbook.contains("Current live knowledge-page rebuild/lint increment")); + assert!(benchmark_runbook.contains("cargo make real-world-memory-live-knowledge")); + assert!(benchmark_runbook.contains("tmp/real-world-memory/live-knowledge/summary.json")); + assert!(live_runbook.contains("cargo make real-world-memory-live-knowledge")); + assert!(benchmarking_index.contains("2026-06-20-live-knowledge-page-rebuild-lint-report.md")); + assert!(benchmarking_index.contains("2026-06-20-knowledge-workspace-version-diff-report.md")); + assert!(readme.contains("Live Knowledge-Page Rebuild/Lint Report - June 20, 2026")); + assert!(readme.contains("Knowledge Workspace Version-Diff Report - June 20, 2026")); + + Ok(()) +} + +#[test] +fn runner_discovers_nested_fixture_layout() -> Result<()> { + let report = support::run_json_report_from(support::fixture_root())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(82)); + + Ok(()) +} + +#[test] +fn operator_debug_fixture_reports_trace_links_and_failure_details() -> Result<()> { + let report = support::run_json_report_from(support::operator_debug_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(7)); + assert_eq!( + report.pointer("/summary/operator_debug_job_count").and_then(Value::as_u64), + Some(7) + ); + assert_eq!(report.pointer("/summary/raw_sql_needed_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/trace_incomplete_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/operator_ux_gap_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(7)); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), + Some(3) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let dropped = support::find_by_field(jobs, "/job_id", "operator-debug-dropped-evidence-001")?; + let selected = + support::find_by_field(jobs, "/job_id", "operator-debug-selected-not-narrated-001")?; + let compact = + support::find_by_field(jobs, "/job_id", "operator-debug-qmd-style-compact-replay-001")?; + + assert_eq!(dropped.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + dropped.pointer("/operator_debug/raw_sql_needed").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + dropped.pointer("/operator_debug/dropped_candidate_visibility").and_then(Value::as_str), + Some("visible in Retrieval Funnel and Replay Candidates") + ); + assert_eq!( + dropped.pointer("/operator_debug/viewer_url").and_then(Value::as_str), + Some("/viewer?trace_id=11111111-1111-4111-8111-111111111111") + ); + assert_eq!( + dropped.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("filter.read_profile") + ); + assert!(support::array_contains_str( + dropped, + "/trace_explainability/stages/1/dropped_evidence", + "trace-dropped-expected" + )?); + assert!(support::array_contains_str( + dropped, + "/trace_explainability/stages/1/distractor_evidence", + "trace-dropped-decoy" + )?); + assert!(support::array_contains_str(dropped, "/produced_evidence", "trace-dropped-expected")?); + assert_eq!(selected.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + selected.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("selection.narration") + ); + assert_eq!( + selected.pointer("/operator_debug/failure_mode").and_then(Value::as_str), + Some("selected_but_not_narrated") + ); + assert_eq!(compact.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + compact.pointer("/operator_debug/failure_mode").and_then(Value::as_str), + Some("qmd_style_compact_replay") + ); + assert_eq!( + compact.pointer("/operator_debug/replay_command_available").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + compact.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("recall_debug.compact_replay") + ); + assert!(support::array_contains_str( + compact, + "/trace_explainability/stages/4/kept_evidence", + "compact-replay-artifact" + )?); + assert!(support::array_contains_str( + compact, + "/produced_evidence", + "qmd-short-replay-reference" + )?); + + Ok(()) +} + +#[test] +fn consolidation_fixtures_report_reviewable_proposal_metrics() -> Result<()> { + let report = support::run_json_report_from(support::consolidation_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(4)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); + assert_eq!( + report.pointer("/summary/consolidation/proposal_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/consolidation/source_mutation_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/consolidation/proposal_unsupported_claim_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/consolidation/executable_gap_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/summary/consolidation/lineage_completeness").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/consolidation/review_action_correctness").and_then(Value::as_f64), + Some(1.0) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let project_summary = + support::find_by_field(jobs, "/job_id", "consolidation-project-summary-apply-001")?; + let contradiction = + support::find_by_field(jobs, "/job_id", "consolidation-contradiction-report-discard-001")?; + + assert_eq!( + project_summary + .pointer("/consolidation/proposals/0/actual_review_action") + .and_then(Value::as_str), + Some("apply") + ); + assert_eq!( + contradiction + .pointer("/consolidation/proposals/0/actual_review_action") + .and_then(Value::as_str), + Some("discard") + ); + assert_eq!( + contradiction + .pointer("/consolidation/proposals/0/unsupported_claim_count") + .and_then(Value::as_u64), + Some(1) + ); + + let suites = support::array_at(&report, "/suites")?; + let consolidation_suite = support::find_by_field(suites, "/suite_id", "consolidation")?; + + assert_eq!(consolidation_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + + Ok(()) +} + +#[test] +fn knowledge_fixtures_report_page_metrics() -> Result<()> { + let report = support::run_json_report_from(support::knowledge_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/knowledge/page_count").and_then(Value::as_u64), Some(5)); + assert_eq!( + report.pointer("/summary/knowledge/section_count").and_then(Value::as_u64), + Some(13) + ); + assert_eq!( + report.pointer("/summary/knowledge/citation_coverage").and_then(Value::as_f64), + Some(0.923) + ); + assert_eq!( + report.pointer("/summary/knowledge/stale_claim_detection").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/knowledge/rebuild_determinism").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/knowledge/backlink_count").and_then(Value::as_u64), + Some(11) + ); + assert_eq!( + report.pointer("/summary/knowledge/pages_with_backlinks").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/knowledge/backlink_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/knowledge/page_usefulness").and_then(Value::as_f64), + Some(0.979) + ); + assert_eq!( + report.pointer("/summary/knowledge/pages_with_version_diff").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/knowledge/unsupported_summary_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/knowledge/allowed_variance_count").and_then(Value::as_u64), + Some(1) + ); + + let suites = support::array_at(&report, "/suites")?; + let knowledge_suite = support::find_by_field(suites, "/suite_id", "knowledge_compilation")?; + + assert_eq!(knowledge_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(knowledge_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); + + let jobs = support::array_at(&report, "/jobs")?; + let project_page_job = support::find_by_field(jobs, "/job_id", "knowledge-project-page-001")?; + let watch_rebuild_job = support::find_by_field(jobs, "/job_id", "knowledge-watch-rebuild-003")?; + + assert_eq!( + project_page_job.pointer("/knowledge/unsupported_summary_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + project_page_job.pointer("/knowledge/untraced_section_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + watch_rebuild_job.pointer("/knowledge/pages_with_version_diff").and_then(Value::as_u64), + Some(1) + ); + assert!( + watch_rebuild_job + .pointer("/produced_answer") + .and_then(Value::as_str) + .is_some_and(|answer| answer + .contains("PageIndex/OpenKB adapter claim as lint evidence") + && answer.contains("leaves source documents plus Memory Notes unmodified")) + ); + + Ok(()) +} + +#[test] +fn project_decisions_fixtures_report_decision_policy_cases() -> Result<()> { + let report = support::run_json_report_from(support::project_decisions_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), + Some(1.0) + ); + + let suites = support::array_at(&report, "/suites")?; + let project_decisions = support::find_by_field(suites, "/suite_id", "project_decisions")?; + + assert_eq!(project_decisions.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(project_decisions.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + assert_eq!( + project_decisions.pointer("/update_rationale_available_count").and_then(Value::as_u64), + Some(5) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let accepted = + support::find_by_field(jobs, "/job_id", "project-decision-accepted-typed-failures-001")?; + let reversal = + support::find_by_field(jobs, "/job_id", "project-decision-reversal-live-baseline-001")?; + let validation = + support::find_by_field(jobs, "/job_id", "project-decision-current-validation-gate-001")?; + let tradeoff = + support::find_by_field(jobs, "/job_id", "project-decision-tradeoff-fixture-backed-001")?; + let caveat = + support::find_by_field(jobs, "/job_id", "project-decision-private-manifest-caveat-001")?; + + assert_eq!(accepted.pointer("/answer_type").and_then(Value::as_str), Some("decision_record")); + assert_eq!( + accepted.pointer("/expected_evidence").and_then(Value::as_array).map(Vec::len), + Some(2) + ); + assert_eq!( + reversal.pointer("/evolution/historical_evidence/0").and_then(Value::as_str), + Some("live-baseline-suite-win-old") + ); + assert_eq!( + validation.pointer("/evolution/current_evidence/0").and_then(Value::as_str), + Some("validation-gate-current-decodex") + ); + assert_eq!(tradeoff.pointer("/requires_caveat").and_then(Value::as_bool), Some(true)); + assert_eq!(caveat.pointer("/can_answer_unknown").and_then(Value::as_bool), Some(true)); + + for job in jobs { + let expected_evidence = support::array_at(job, "/expected_evidence")?; + + assert!( + !expected_evidence.is_empty(), + "project decision job {} must declare required evidence", + job.pointer("/job_id").and_then(Value::as_str).unwrap_or("") + ); + } + for entry in fs::read_dir(support::project_decisions_fixture_dir())? { + let path = entry?.path(); + + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + + let fixture = serde_json::from_str::(&fs::read_to_string(path)?)?; + let required_evidence = support::array_at(&fixture, "/required_evidence")?; + let negative_traps = support::array_at(&fixture, "/negative_traps")?; + + assert!(!required_evidence.is_empty()); + assert!(!negative_traps.is_empty()); + } + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/core_archival.rs b/apps/elf-eval/tests/real_world_job_benchmark/core_archival.rs new file mode 100644 index 00000000..76c4dc79 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/core_archival.rs @@ -0,0 +1,240 @@ +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn core_archival_memory_fixtures_score_separate_core_and_archival_jobs() -> Result<()> { + let report = support::run_json_report_from(support::core_archival_memory_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!( + report.pointer("/summary/evidence_required_count").and_then(Value::as_u64), + Some(14) + ); + assert_eq!(report.pointer("/summary/evidence_covered_count").and_then(Value::as_u64), Some(14)); + assert_eq!(report.pointer("/summary/scope_check_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/scope_correct_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/scope_violation_count").and_then(Value::as_u64), Some(0)); + + let suites = support::array_at(&report, "/suites")?; + let core = support::find_by_field(suites, "/suite_id", "core_archival_memory")?; + + assert_eq!(core.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(core.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6)); + + let jobs = support::array_at(&report, "/jobs")?; + + for job_id in [ + "core-archival-core-block-attachment-001", + "core-archival-core-block-scope-001", + "core-archival-core-block-provenance-001", + "core-archival-stale-core-detection-001", + "core-archival-archival-fallback-001", + "core-archival-project-decision-recovery-001", + ] { + let job = support::find_by_field(jobs, "/job_id", job_id)?; + + assert_eq!(job.pointer("/suite_id").and_then(Value::as_str), Some("core_archival_memory")); + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("pass")); + } + + let scope = support::find_by_field(jobs, "/job_id", "core-archival-core-block-scope-001")?; + let decision = + support::find_by_field(jobs, "/job_id", "core-archival-project-decision-recovery-001")?; + + assert_eq!(scope.pointer("/scope_check_count").and_then(Value::as_u64), Some(1)); + assert_eq!(scope.pointer("/scope_correct_count").and_then(Value::as_u64), Some(1)); + assert_eq!(scope.pointer("/scope_violation_count").and_then(Value::as_u64), Some(0)); + assert!( + decision + .pointer("/produced_answer") + .and_then(Value::as_str) + .is_some_and(|content| content.contains("Letta remains blocked or not_tested")) + ); + assert!( + support::array_at(decision, "/produced_evidence")? + .iter() + .any(|id| id.as_str() == Some("decision-letta-export-boundary")) + ); + + Ok(()) +} + +#[test] +fn memory_authority_benchmark_covers_entity_history_and_core_archive_strengths() -> Result<()> { + let report = support::run_json_report_from(support::real_world_memory_fixture_dir())?; + + assert_eq!( + report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64), + Some(4) + ); + + let suites = support::array_at(&report, "/suites")?; + let memory_evolution = support::find_by_field(suites, "/suite_id", "memory_evolution")?; + let core_archival = support::find_by_field(suites, "/suite_id", "core_archival_memory")?; + + assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + memory_evolution.pointer("/history_readback_encoded_count").and_then(Value::as_u64), + Some(3) + ); + assert_eq!(core_archival.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6)); + + let jobs = support::array_at(&report, "/jobs")?; + let preference = support::find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; + let core_attachment = + support::find_by_field(jobs, "/job_id", "core-archival-core-block-attachment-001")?; + let archival_fallback = + support::find_by_field(jobs, "/job_id", "core-archival-archival-fallback-001")?; + + assert_eq!(preference.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + preference.pointer("/evolution/history_readback_encoded").and_then(Value::as_bool), + Some(true) + ); + assert!(support::array_contains_str(preference, "/evolution/history_event_types", "update")?); + assert_eq!(core_attachment.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(archival_fallback.pointer("/status").and_then(Value::as_str), Some("pass")); + + let adapters = support::array_at(&report, "/external_adapters/adapters")?; + let mem0 = support::find_by_field(adapters, "/adapter_id", "mem0_openmemory_live_baseline")?; + let letta = support::find_by_field(adapters, "/adapter_id", "letta_research_gate")?; + let mem0_scenarios = support::array_at(mem0, "/scenarios")?; + let mem0_history = + support::find_by_field(mem0_scenarios, "/scenario_id", "preference_correction_history")?; + let mem0_entity = + support::find_by_field(mem0_scenarios, "/scenario_id", "entity_scoped_personalization")?; + + assert_eq!(mem0_history.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(mem0_entity.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(mem0_history.pointer("/comparison_outcome").and_then(Value::as_str), Some("loss")); + assert_eq!(mem0_entity.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); + + let letta_scenarios = support::array_at(letta, "/scenarios")?; + let letta_core = + support::find_by_field(letta_scenarios, "/scenario_id", "core_block_attachment_readback")?; + let letta_fallback = + support::find_by_field(letta_scenarios, "/scenario_id", "archival_fallback_readback")?; + + for scenario in [letta_core, letta_fallback] { + assert_eq!( + scenario.pointer("/suite_id").and_then(Value::as_str), + Some("core_archival_memory") + ); + assert_eq!(scenario.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + scenario.pointer("/comparison_outcome").and_then(Value::as_str), + Some("blocked") + ); + } + + Ok(()) +} + +#[test] +fn context_trajectory_fixtures_report_blocked_openviking_gates() -> Result<()> { + let report = support::run_json_report_from(support::context_trajectory_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!( + report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), + Some(3) + ); + + let suites = support::array_at(&report, "/suites")?; + let context = support::find_by_field(suites, "/suite_id", "context_trajectory")?; + + assert_eq!(context.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(context.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); + + let jobs = support::array_at(&report, "/jobs")?; + let staged = support::find_by_field( + jobs, + "/job_id", + "context-trajectory-openviking-staged-retrieval-001", + )?; + let hierarchy = support::find_by_field( + jobs, + "/job_id", + "context-trajectory-openviking-hierarchy-selection-001", + )?; + let recursive = support::find_by_field( + jobs, + "/job_id", + "context-trajectory-openviking-recursive-expansion-001", + )?; + + assert_eq!(staged.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(hierarchy.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(recursive.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + staged.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("openviking.stage_artifact_gate") + ); + assert_eq!( + hierarchy.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("openviking.hierarchy_artifact_gate") + ); + assert_eq!( + recursive.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("openviking.recursive_expansion_gate") + ); + + let staged_stages = support::array_at(staged, "/trace_explainability/stages")?; + let staged_gate = + support::find_by_field(staged_stages, "/stage_name", "openviking.stage_artifact_gate")?; + + assert!(support::array_contains_str(staged_gate, "/dropped_evidence", "trajectory-win-decoy")?); + + let hierarchy_stages = support::array_at(hierarchy, "/trace_explainability/stages")?; + let hierarchy_gate = support::find_by_field( + hierarchy_stages, + "/stage_name", + "openviking.hierarchy_artifact_gate", + )?; + + assert!(support::array_contains_str( + hierarchy_gate, + "/dropped_evidence", + "hierarchy-design-win-decoy" + )?); + + let recursive_stages = support::array_at(recursive, "/trace_explainability/stages")?; + let recursive_gate = support::find_by_field( + recursive_stages, + "/stage_name", + "openviking.recursive_expansion_gate", + )?; + + assert!(support::array_contains_str( + recursive_gate, + "/dropped_evidence", + "recursive-expansion-win-decoy" + )?); + assert!( + staged.pointer("/reason").and_then(Value::as_str).is_some_and( + |reason| reason.contains("same-corpus output returns expected evidence ids") + ) + ); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/dreaming_readiness.rs b/apps/elf-eval/tests/real_world_job_benchmark/dreaming_readiness.rs new file mode 100644 index 00000000..b1678820 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/dreaming_readiness.rs @@ -0,0 +1,345 @@ +use std::fs; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +#[test] +fn dreaming_readiness_stage_ledger_preserves_gate_shape() -> Result<()> { + let ledger = serde_json::from_str::(&fs::read_to_string( + support::dreaming_readiness_stage_ledger_json_path()?, + )?)?; + let markdown = fs::read_to_string(support::dreaming_readiness_stage_ledger_markdown_path()?)?; + let stages = support::array_at(&ledger, "/stage_gates")?; + + assert_dreaming_readiness_ledger_header(&ledger)?; + assert_dreaming_readiness_stage_shape(&ledger, stages)?; + assert_dreaming_readiness_baseline_counts(&ledger, stages)?; + assert_dreaming_readiness_markdown_boundaries(&markdown); + + Ok(()) +} + +fn assert_dreaming_readiness_ledger_header(ledger: &Value) -> Result<()> { + assert_eq!( + ledger.pointer("/schema").and_then(Value::as_str), + Some("elf.dreaming_readiness_stage_ledger/v1") + ); + assert_eq!(ledger.pointer("/authority").and_then(Value::as_str), Some("XY-951")); + + for term in ["improved", "regressed", "unchanged", "blocked", "not_tested"] { + assert!(support::array_contains_str(ledger, "/judgment_terms", term)?); + } + for term in ["pass", "wrong_result", "blocked", "not_tested", "not_encoded"] { + assert!(support::array_contains_str(ledger, "/count_fields", term)?); + } + + Ok(()) +} + +fn assert_dreaming_readiness_stage_shape(ledger: &Value, stages: &[Value]) -> Result<()> { + assert_eq!(stages.len(), 8); + + for stage_id in [ + "current_vs_historical_correctness", + "preference_evolution", + "deletion_ttl_tombstone_behavior", + "reviewable_consolidation", + "memory_summary_top_of_mind_behavior", + "proactive_brief_readiness", + "scheduled_memory_task_readiness", + "final_competitor_retest_status", + ] { + support::find_by_field(stages, "/stage_id", stage_id)?; + } + for stage in stages { + let stage_id = + stage.pointer("/stage_id").and_then(Value::as_str).unwrap_or(""); + + assert!( + !support::array_at(stage, "/baseline_commands")?.is_empty(), + "{stage_id} missing baseline commands" + ); + assert!( + !support::array_at(stage, "/post_stage_commands")?.is_empty(), + "{stage_id} missing post-stage commands" + ); + assert!( + !support::array_at(stage, "/evidence_files")?.is_empty(), + "{stage_id} missing evidence files" + ); + + for count_field in support::string_array_at(ledger, "/count_fields")? { + let pointer = format!("/baseline_counts/{count_field}"); + + assert!( + stage.pointer(&pointer).and_then(Value::as_u64).is_some(), + "{stage_id} missing {pointer}" + ); + } + + let judgment = stage + .pointer("/comparison_judgment") + .and_then(Value::as_str) + .ok_or_else(|| eyre::eyre!("{stage_id} missing comparison_judgment"))?; + + assert!(support::array_contains_str(ledger, "/judgment_terms", judgment)?); + } + + Ok(()) +} + +fn assert_dreaming_readiness_baseline_counts(ledger: &Value, stages: &[Value]) -> Result<()> { + let current = support::find_by_field(stages, "/stage_id", "current_vs_historical_correctness")?; + + assert_eq!(current.pointer("/baseline_counts/pass").and_then(Value::as_u64), Some(1)); + assert_eq!(current.pointer("/baseline_counts/wrong_result").and_then(Value::as_u64), Some(5)); + assert_eq!(current.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(current.pointer("/post_stage_counts/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(current.pointer("/comparison_judgment").and_then(Value::as_str), Some("improved")); + assert!( + current + .pointer("/baseline_basis") + .and_then(Value::as_str) + .is_some_and(|basis| basis.contains("five current-vs-historical jobs")) + ); + assert!( + current + .pointer("/post_stage_basis") + .and_then(Value::as_str) + .is_some_and(|basis| basis.contains("passes all six encoded jobs")) + ); + + let preference = support::find_by_field(stages, "/stage_id", "preference_evolution")?; + + assert_eq!( + preference.pointer("/baseline_counts/wrong_result").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(preference.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(1)); + assert_eq!( + preference.pointer("/post_stage_counts/wrong_result").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + preference.pointer("/comparison_judgment").and_then(Value::as_str), + Some("improved") + ); + + let tombstone = support::find_by_field(stages, "/stage_id", "deletion_ttl_tombstone_behavior")?; + + assert_eq!(tombstone.pointer("/baseline_counts/pass").and_then(Value::as_u64), Some(1)); + assert_eq!(tombstone.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(1)); + assert_eq!( + tombstone.pointer("/comparison_judgment").and_then(Value::as_str), + Some("unchanged") + ); + assert!( + tombstone + .pointer("/post_stage_basis") + .and_then(Value::as_str) + .is_some_and(|basis| basis.contains("tombstone and invalidation evidence")) + ); + + let consolidation = support::find_by_field(stages, "/stage_id", "reviewable_consolidation")?; + + assert_eq!( + consolidation.pointer("/comparison_judgment").and_then(Value::as_str), + Some("improved") + ); + assert_eq!( + consolidation.pointer("/baseline_counts/not_encoded").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(consolidation.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(4)); + assert_eq!( + consolidation.pointer("/post_stage_counts/not_encoded").and_then(Value::as_u64), + Some(0) + ); + assert!( + consolidation + .pointer("/post_stage_basis") + .and_then(Value::as_str) + .is_some_and(|basis| basis.contains("apply/defer/discard audit") + && basis.contains("zero source mutations")) + ); + + let scheduled = support::find_by_field(stages, "/stage_id", "scheduled_memory_task_readiness")?; + + assert_eq!(scheduled.pointer("/comparison_judgment").and_then(Value::as_str), Some("improved")); + assert_eq!(scheduled.pointer("/baseline_counts/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(scheduled.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(4)); + assert_eq!(scheduled.pointer("/post_stage_counts/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!( + scheduled.pointer("/post_stage_counts/trace_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + scheduled.pointer("/post_stage_counts/source_mutation_count").and_then(Value::as_u64), + Some(0) + ); + + assert_dreaming_final_competitor_retest_stage(ledger, stages)?; + assert_dreaming_memory_summary_stage(stages)?; + assert_dreaming_proactive_brief_stage(stages)?; + + Ok(()) +} + +fn assert_dreaming_final_competitor_retest_stage(ledger: &Value, stages: &[Value]) -> Result<()> { + let retest = support::find_by_field(stages, "/stage_id", "final_competitor_retest_status")?; + + assert_eq!(retest.pointer("/baseline_counts/pass").and_then(Value::as_u64), Some(22)); + assert_eq!(retest.pointer("/baseline_counts/wrong_result").and_then(Value::as_u64), Some(5)); + assert_eq!(retest.pointer("/baseline_counts/blocked").and_then(Value::as_u64), Some(2)); + assert_eq!(retest.pointer("/baseline_counts/not_tested").and_then(Value::as_u64), Some(11)); + assert_eq!(retest.pointer("/baseline_counts/not_encoded").and_then(Value::as_u64), Some(11)); + assert_eq!(retest.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(40)); + assert_eq!(retest.pointer("/post_stage_counts/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(retest.pointer("/post_stage_counts/blocked").and_then(Value::as_u64), Some(7)); + assert_eq!(retest.pointer("/post_stage_counts/not_encoded").and_then(Value::as_u64), Some(19)); + assert_eq!(retest.pointer("/qmd_post_stage_counts/pass").and_then(Value::as_u64), Some(17)); + assert_eq!( + retest.pointer("/qmd_post_stage_counts/wrong_result").and_then(Value::as_u64), + Some(13) + ); + assert!(retest.pointer("/post_stage_basis").and_then(Value::as_str).is_some_and(|basis| { + basis.contains("XY-955 closeout retest") + && basis.contains("qmd live adapter materialization is 17 pass") + })); + + assert_dreaming_readiness_summary_buckets(ledger) +} + +fn assert_dreaming_readiness_summary_buckets(ledger: &Value) -> Result<()> { + assert!(support::array_contains_str( + ledger, + "/summary/improved", + "current_vs_historical_correctness" + )?); + assert!(support::array_contains_str(ledger, "/summary/improved", "preference_evolution")?); + assert!(support::array_contains_str(ledger, "/summary/improved", "reviewable_consolidation")?); + assert!(support::array_contains_str( + ledger, + "/summary/improved", + "memory_summary_top_of_mind_behavior" + )?); + assert!(support::array_contains_str(ledger, "/summary/improved", "proactive_brief_readiness")?); + assert!(support::array_contains_str( + ledger, + "/summary/improved", + "scheduled_memory_task_readiness" + )?); + assert!(support::array_at(ledger, "/summary/regressed")?.is_empty()); + assert!(support::array_contains_str( + ledger, + "/summary/unchanged", + "deletion_ttl_tombstone_behavior" + )?); + assert!(support::array_contains_str( + ledger, + "/summary/unchanged", + "final_competitor_retest_status" + )?); + assert!(support::array_at(ledger, "/summary/blocked")?.is_empty()); + assert!(support::array_at(ledger, "/summary/not_tested")?.is_empty()); + + Ok(()) +} + +fn assert_dreaming_memory_summary_stage(stages: &[Value]) -> Result<()> { + let summary_stage = + support::find_by_field(stages, "/stage_id", "memory_summary_top_of_mind_behavior")?; + + assert_eq!( + summary_stage.pointer("/comparison_judgment").and_then(Value::as_str), + Some("improved") + ); + assert_eq!(summary_stage.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(9)); + assert_eq!( + summary_stage.pointer("/post_stage_counts/not_tested").and_then(Value::as_u64), + Some(0) + ); + assert!( + summary_stage + .pointer("/post_stage_basis") + .and_then(Value::as_str) + .is_some_and(|basis| basis.contains("fixture-backed memory_summary job") + && basis.contains("unsupported-claim flags")) + ); + + Ok(()) +} + +fn assert_dreaming_proactive_brief_stage(stages: &[Value]) -> Result<()> { + let proactive_stage = support::find_by_field(stages, "/stage_id", "proactive_brief_readiness")?; + + assert_eq!( + proactive_stage.pointer("/comparison_judgment").and_then(Value::as_str), + Some("improved") + ); + assert_eq!(proactive_stage.pointer("/post_stage_counts/pass").and_then(Value::as_u64), Some(4)); + assert_eq!( + proactive_stage.pointer("/post_stage_counts/blocked").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + proactive_stage.pointer("/post_stage_counts/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + proactive_stage.pointer("/post_stage_counts/freshness_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + proactive_stage + .pointer("/post_stage_counts/action_rationale_coverage") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + proactive_stage + .pointer("/post_stage_counts/tombstone_violation_count") + .and_then(Value::as_u64), + Some(0) + ); + assert!( + proactive_stage + .pointer("/post_stage_basis") + .and_then(Value::as_str) + .is_some_and(|basis| basis.contains("five proactive_brief fixture jobs") + && basis.contains("typed private-corpus refresh blocker")) + ); + + Ok(()) +} + +fn assert_dreaming_readiness_markdown_boundaries(markdown: &str) { + assert!( + markdown.contains("`improved`: current-vs-historical correctness, preference evolution") + && markdown.contains("reviewable") + && markdown.contains("proactive brief") + ); + assert!(markdown.contains("memory-summary/top-of-mind fixture readback")); + assert!(markdown.contains("XY-953 adds a direct `proactive_brief` suite")); + assert!(markdown.contains("XY-954 adds a direct `scheduled_memory` suite")); + assert!(markdown.contains( + "Do not claim fixture-backed proactive brief scoring proves OpenAI Pulse parity" + )); + assert!( + markdown + .contains("Do not claim fixture-backed scheduled-memory scoring proves ChatGPT Tasks") + ); + assert!(markdown.contains("`regressed`: none")); + assert!(markdown.contains("the XY-905 run passes all six memory-evolution jobs")); + assert!(markdown.contains("XY-952 adds a reviewable `elf.memory_summary/v1`")); + assert!(markdown.contains("XY-955 closes the final competitor retest row")); + assert!(markdown.contains("XY-905")); + assert!(markdown.contains("qmd live `pass=17`, `wrong_result=13`")); + assert!( + markdown + .contains("Do not claim this ledger proves preference history against mem0/OpenMemory") + ); + assert!(markdown.contains("Reviewable consolidation now has ELF live service-backed")); +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/dreaming_reports.rs b/apps/elf-eval/tests/real_world_job_benchmark/dreaming_reports.rs new file mode 100644 index 00000000..66044c41 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/dreaming_reports.rs @@ -0,0 +1,1095 @@ +use std::{fs, path::Path}; + +use color_eyre::{self, eyre}; +use serde_json::Value; + +use crate::support; + +fn read_rust_module_sources(src_dir: &Path, module_name: &str) -> color_eyre::Result { + let module_root = src_dir.join(format!("{module_name}.rs")); + let module_dir = src_dir.join(module_name); + let mut source = fs::read_to_string(module_root)?; + + if module_dir.is_dir() { + let mut entries = fs::read_dir(module_dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + + entries.retain(|path| path.extension().is_some_and(|extension| extension == "rs")); + entries.sort(); + + for path in entries { + source.push('\n'); + source.push_str(&fs::read_to_string(path)?); + } + } + + Ok(source) +} + +#[test] +fn live_temporal_reconciliation_report_records_xy905_before_after() -> color_eyre::Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::live_temporal_reconciliation_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::live_temporal_reconciliation_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.live_temporal_reconciliation_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-905")); + assert_eq!( + report + .pointer("/baseline/elf_memory_evolution/job_status_counts/pass") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/baseline/elf_memory_evolution/job_status_counts/wrong_result") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report + .pointer("/post_stage/elf_memory_evolution/job_status_counts/pass") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report + .pointer("/post_stage/elf_memory_evolution/job_status_counts/wrong_result") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/post_stage/elf_memory_evolution/suite_status").and_then(Value::as_str), + Some("pass") + ); + assert_eq!( + report.pointer("/post_stage/qmd_memory_evolution/suite_status").and_then(Value::as_str), + Some("wrong_result") + ); + assert_eq!( + report + .pointer("/comparison_judgment/current_vs_historical_correctness") + .and_then(Value::as_str), + Some("improved") + ); + assert_eq!( + report + .pointer("/comparison_judgment/deletion_ttl_tombstone_behavior") + .and_then(Value::as_str), + Some("unchanged") + ); + assert!(support::array_contains_str( + &report, + "/trace_contract/answer_fields", + "selected_historical_evidence" + )?); + assert!(support::array_contains_str( + &report, + "/trace_contract/materialization_fields", + "current_winner_evidence_ids" + )?); + assert!(support::array_contains_str( + &report, + "/trace_contract/trace_stages", + "temporal_reconciliation.conflict_candidates" + )?); + assert!(report.pointer("/trace_contract/negative_gate").and_then(Value::as_str).is_some_and( + |gate| gate.contains("selected conflict evidence id") && gate.contains("wrong_result") + )); + assert!(markdown.contains("ELF passing all six memory-evolution jobs")); + assert!(markdown.contains("selected-but-not-narrated conflicts as `wrong_result`")); + assert!(markdown.contains("Do not claim ELF beats Graphiti/Zep")); + assert!(benchmarking_index.contains("2026-06-16-live-temporal-reconciliation-report.md")); + assert!( + readme.contains("Live Temporal Reconciliation Report - June 16, 2026") + && readme.contains("now reports ELF live `memory_evolution` as 6/6 pass") + ); + + Ok(()) +} + +#[test] +fn dreaming_competitor_strength_retest_report_closes_xy955_without_overclaims() +-> color_eyre::Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::dreaming_competitor_strength_retest_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::dreaming_competitor_strength_retest_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.dreaming_competitor_strength_retest_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-955")); + assert_eq!( + report.pointer("/summary/overall_judgment").and_then(Value::as_str), + Some("locally_and_partially_stronger_only") + ); + assert_eq!( + report.pointer("/summary/broader_superiority").and_then(Value::as_str), + Some("not_proven") + ); + assert_eq!(report.pointer("/summary/regressed_stage_count").and_then(Value::as_u64), Some(0)); + assert!(support::array_contains_str(&report, "/status_terms", "typed_non_pass")?); + assert!(support::array_contains_str( + &report, + "/summary/unsupported_claims_rejected", + "ELF does not broadly beat qmd from this retest." + )?); + + assert_xy955_commands(&report)?; + assert_xy955_stage_closeout(&report)?; + assert_xy955_scenario_retests(&report)?; + assert_xy955_optimization_queue(&report)?; + assert_xy955_follow_up_issue_briefs(&report)?; + + assert!(markdown.contains("ELF is locally and partially stronger")); + assert!( + markdown.contains("The full live-adapter command now has fresh ELF and qmd scored reports") + ); + assert!( + markdown.contains( + "Do not treat qmd full-suite wrong_result counts as a regression of qmd debug" + ) + ); + assert!(markdown.contains("## Follow-Up Issue Briefs")); + assert!(markdown.contains( + "| GraphRAG/LightRAG/RAGFlow/llm-wiki/gbrain/graphify citation/navigation/knowledge surfaces |" + )); + assert!( + benchmarking_index.contains("2026-06-17-dreaming-competitor-strength-retest-report.md") + ); + assert!(readme.contains("Dreaming Competitor-Strength Retest Report - June 17, 2026")); + assert!(readme.contains("17 competitor-strength closeout")); + + Ok(()) +} + +#[test] +fn qmd_debug_ergonomics_dreaming_retest_report_preserves_qmd_edge() -> color_eyre::Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::qmd_debug_ergonomics_dreaming_retest_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::qmd_debug_ergonomics_dreaming_retest_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_qmd_debug_retest_summary(&report)?; + assert_qmd_debug_retest_command_and_adapters(&report)?; + assert_qmd_debug_retest_scenarios(&report)?; + assert_qmd_debug_retest_boundaries(&report)?; + assert_qmd_debug_retest_markdown_and_indexes(&markdown, &benchmarking_index, &readme); + + Ok(()) +} + +fn assert_qmd_debug_retest_summary(report: &Value) -> color_eyre::Result<()> { + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.qmd_debug_ergonomics_dreaming_retest_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-982")); + assert_eq!( + report.pointer("/summary/overall_judgment").and_then(Value::as_str), + Some("unchanged_with_live_operator_debug_confirmation") + ); + assert_eq!( + report.pointer("/summary/debug_ergonomics_edge").and_then(Value::as_str), + Some("qmd_default_top10_and_short_cli_replay_preserved") + ); + assert_eq!( + report.pointer("/summary/broader_superiority").and_then(Value::as_str), + Some("not_proven") + ); + assert_eq!(report.pointer("/summary/improved_scenario_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/regressed_scenario_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/summary/unchanged_scenario_count").and_then(Value::as_u64), + Some(6) + ); + assert!(support::array_contains_str( + report, + "/summary/unsupported_claims_rejected", + "qmd's live operator-debug wrong_result rows do not erase qmd's default top-k and short CLI replay edge." + )?); + + Ok(()) +} + +fn assert_qmd_debug_retest_command_and_adapters(report: &Value) -> color_eyre::Result<()> { + let command = support::find_by_field( + support::array_at(report, "/commands")?, + "/command", + "cargo make real-world-job-operator-ux-live-adapters", + )?; + + assert_eq!(command.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + command.pointer("/summary/schema").and_then(Value::as_str), + Some("elf.real_world_operator_debug_live_adapter_sweep/v1") + ); + + let adapters = support::array_at(report, "/adapter_summaries")?; + let elf = support::find_by_field(adapters, "/adapter_id", "elf_operator_debug_live")?; + let qmd = support::find_by_field(adapters, "/adapter_id", "qmd_operator_debug_live")?; + + assert_eq!(elf.pointer("/job_count").and_then(Value::as_u64), Some(6)); + assert_eq!(elf.pointer("/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(elf.pointer("/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(elf.pointer("/trace_available_count").and_then(Value::as_u64), Some(6)); + assert_eq!(elf.pointer("/replay_command_available_count").and_then(Value::as_u64), Some(6)); + assert_eq!(qmd.pointer("/job_count").and_then(Value::as_u64), Some(6)); + assert_eq!(qmd.pointer("/pass").and_then(Value::as_u64), Some(0)); + assert_eq!(qmd.pointer("/wrong_result").and_then(Value::as_u64), Some(6)); + assert_eq!(qmd.pointer("/trace_available_count").and_then(Value::as_u64), Some(0)); + assert_eq!(qmd.pointer("/trace_incomplete_count").and_then(Value::as_u64), Some(6)); + assert_eq!(qmd.pointer("/replay_command_available_count").and_then(Value::as_u64), Some(6)); + + Ok(()) +} + +fn assert_qmd_debug_retest_scenarios(report: &Value) -> color_eyre::Result<()> { + let scenarios = support::array_at(report, "/scenario_retests")?; + let top10 = + support::find_by_field(scenarios, "/scenario_id", "qmd_default_top10_candidate_artifact")?; + let replay = support::find_by_field(scenarios, "/scenario_id", "qmd_short_cli_replay")?; + let trace = + support::find_by_field(scenarios, "/scenario_id", "elf_operator_debug_trace_hydration")?; + let candidate = support::find_by_field( + scenarios, + "/scenario_id", + "operator_debug_candidate_drop_visibility", + )?; + let expansion = + support::find_by_field(scenarios, "/scenario_id", "query_expansion_attribution")?; + let fusion = support::find_by_field(scenarios, "/scenario_id", "fusion_attribution")?; + let rerank = support::find_by_field(scenarios, "/scenario_id", "rerank_attribution")?; + + assert_eq!(scenarios.len(), 10); + assert_eq!(top10.pointer("/judgment").and_then(Value::as_str), Some("unchanged")); + assert_eq!(top10.pointer("/current_outcome").and_then(Value::as_str), Some("loss")); + assert_eq!(replay.pointer("/current_outcome").and_then(Value::as_str), Some("loss")); + assert_eq!( + trace.pointer("/current_counts/elf_trace_available").and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + trace.pointer("/current_counts/qmd_trace_available").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + candidate + .pointer("/current_counts/qmd_intermediate_stage_visible_jobs") + .and_then(Value::as_u64), + Some(0) + ); + assert!(support::array_contains_str( + candidate, + "/typed_non_pass_states", + "retrieved_but_dropped" + )?); + assert_eq!(expansion.pointer("/judgment").and_then(Value::as_str), Some("not_tested")); + assert_eq!(fusion.pointer("/judgment").and_then(Value::as_str), Some("not_tested")); + assert_eq!(rerank.pointer("/judgment").and_then(Value::as_str), Some("non_goal")); + + Ok(()) +} + +fn assert_qmd_debug_retest_boundaries(report: &Value) -> color_eyre::Result<()> { + assert!(support::array_contains_str( + report, + "/claim_boundaries/allowed", + "qmd's default local-debug edge remains: top-10 candidate rows plus short CLI replay." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries/not_allowed", + "Do not claim ELF broadly beats qmd from this retest." + )?); + assert!(support::array_contains_str( + report, + "/next_optimization_direction/required_fields", + "fusion_rank_deltas" + )?); + + Ok(()) +} + +fn assert_qmd_debug_retest_markdown_and_indexes( + markdown: &str, + benchmarking_index: &str, + readme: &str, +) { + assert!(markdown.contains("The qmd debug-ergonomics outcome is unchanged")); + assert!(markdown.contains("ELF 6 pass/0 wrong_result; qmd 0 pass/6 wrong_result")); + assert!( + markdown.contains("Do not treat qmd's 0 pass/6 wrong_result live operator-debug slice") + ); + assert!(markdown.contains("Immediate top-k rows with source id")); + assert!( + benchmarking_index.contains("2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.md") + ); + assert!(readme.contains("qmd Debug-Ergonomics Dreaming Retest Report - June 19, 2026")); + assert!(readme.contains("Temporal and Trajectory Adapter Coverage Report - June 23, 2026")); + assert!(readme.contains("Latest real-world benchmark report: June 27, 2026")); + assert!(readme.contains("keeps the qmd edge unchanged")); +} + +#[test] +fn openviking_trajectory_materialization_report_preserves_blocked_gates() -> color_eyre::Result<()> +{ + let report = serde_json::from_str::(&fs::read_to_string( + support::openviking_trajectory_materialization_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::openviking_trajectory_materialization_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_openviking_trajectory_materialization_summary(&report)?; + assert_openviking_trajectory_materialization_command(&report)?; + assert_openviking_trajectory_materialization_scenarios(&report)?; + assert_openviking_trajectory_materialization_boundaries(&report)?; + assert_openviking_trajectory_materialization_markdown_and_indexes( + &markdown, + &benchmarking_index, + &readme, + ); + + Ok(()) +} + +#[test] +fn letta_core_archive_export_readback_report_preserves_blocked_gates() -> color_eyre::Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::letta_core_archive_export_readback_report_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::letta_core_archive_export_readback_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.letta_core_archive_export_readback_summary/v1") + ); + assert_eq!( + report.pointer("/adapter_id").and_then(Value::as_str), + Some("letta_core_archive_export_readback") + ); + assert_eq!( + report.pointer("/materialization/status/failure_class").and_then(Value::as_str), + Some("letta_live_run_disabled") + ); + assert_eq!( + report.pointer("/materialization/status/overall").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!( + report.pointer("/materialization/scored_benchmark/status").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!( + report.pointer("/materialization/scored_benchmark/counts/blocked").and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report.pointer("/materialization/scored_benchmark/counts/pass").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/materialization/scored_benchmark/counts/wrong_result") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/materialization/scored_benchmark/evidence_coverage") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/materialization/benchmark_input/core_blocks") + .and_then(Value::as_array) + .map(Vec::len), + Some(9) + ); + assert_eq!( + report + .pointer("/materialization/benchmark_input/archival_passages") + .and_then(Value::as_array) + .map(Vec::len), + Some(6) + ); + assert_eq!( + report + .pointer("/materialization/evidence_mapping/expected_evidence_ids") + .and_then(Value::as_array) + .map(Vec::len), + Some(14) + ); + assert_eq!( + report + .pointer("/materialization/evidence_mapping/mapped_evidence_ids") + .and_then(Value::as_array) + .map(Vec::len), + Some(0) + ); + assert_eq!( + report + .pointer("/materialization/improvement_regression_readback/judgment") + .and_then(Value::as_str), + Some("unchanged") + ); + assert!(support::array_contains_str( + &report, + "/materialization/claim_boundaries/not_allowed", + "Do not claim ELF beats Letta on core-vs-archival memory from fixture-only ELF evidence." + )?); + assert!(markdown.contains("The Letta follow-up is now reproducible")); + assert!(markdown.contains("6 typed blocked")); + assert!(markdown.contains("competitive status is unchanged")); + assert!(benchmarking_index.contains("2026-06-19-letta-core-archive-export-readback-report.md")); + assert!(readme.contains("Letta core/archive materialization after XY-984")); + assert!(readme.contains("smoke-letta-core-archive-export-readback")); + + Ok(()) +} + +#[test] +fn service_native_dreaming_readback_report_materializes_public_jobs() -> color_eyre::Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::service_native_dreaming_readback_report_json_path()?, + )?)?; + let materialization = serde_json::from_str::(&fs::read_to_string( + support::service_native_dreaming_readback_materialization_json_path()?, + )?)?; + let markdown = + fs::read_to_string(support::service_native_dreaming_readback_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + + assert_service_native_dreaming_report_summary(&report)?; + assert_service_native_dreaming_report_jobs(&report)?; + assert_service_native_dreaming_materialization(&materialization)?; + assert_service_native_dreaming_docs(&markdown, &benchmarking_index, &readme); + + Ok(()) +} + +fn assert_service_native_dreaming_report_summary(report: &Value) -> color_eyre::Result<()> { + assert_eq!( + report.pointer("/adapter/adapter_id").and_then(Value::as_str), + Some("elf_service_native_dreaming") + ); + assert_eq!( + report.pointer("/adapter/behavior").and_then(Value::as_str), + Some("service_native_dreaming_readback") + ); + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(11)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(9)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(2)); + assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!(report.pointer("/summary/quote_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!( + report.pointer("/summary/memory_summary/source_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/trace_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/source_mutation_count").and_then(Value::as_u64), + Some(0) + ); + + let suites = support::array_at(report, "/suites")?; + let memory = support::find_by_field(suites, "/suite_id", "memory_summary")?; + let proactive = support::find_by_field(suites, "/suite_id", "proactive_brief")?; + let scheduled = support::find_by_field(suites, "/suite_id", "scheduled_memory")?; + + assert_eq!(memory.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(proactive.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); + + Ok(()) +} + +fn assert_service_native_dreaming_report_jobs(report: &Value) -> color_eyre::Result<()> { + let jobs = support::array_at(report, "/jobs")?; + let memory = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + let daily = support::find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; + let private_brief = + support::find_by_field(jobs, "/job_id", "proactive-private-corpus-refresh-blocked-001")?; + let weekly = + support::find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; + let private_scheduled = support::find_by_field( + jobs, + "/job_id", + "scheduled-private-provider-scheduler-blocked-001", + )?; + + assert_eq!(memory.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(daily.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(weekly.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(private_brief.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(private_scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert!(!support::array_contains_str(memory, "/produced_evidence", "stale-summary-gap")?); + assert!(!support::array_contains_str(memory, "/produced_evidence", "summary-temporary-claim")?); + assert!(!support::array_contains_str(daily, "/produced_evidence", "daily-old-parity-trap")?); + assert!(!support::array_contains_str( + weekly, + "/produced_evidence", + "scheduled-weekly-hosted-parity-trap" + )?); + + Ok(()) +} + +fn assert_service_native_dreaming_materialization( + materialization: &Value, +) -> color_eyre::Result<()> { + assert_eq!( + materialization.pointer("/schema").and_then(Value::as_str), + Some("elf.real_world_live_adapter_materialization/v1") + ); + assert_eq!( + materialization.pointer("/adapter_id").and_then(Value::as_str), + Some("elf_service_native_dreaming") + ); + assert_eq!(materialization.pointer("/status").and_then(Value::as_str), Some("blocked")); + + let jobs = support::array_at(materialization, "/jobs")?; + let memory = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + let daily = support::find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; + let private_brief = + support::find_by_field(jobs, "/job_id", "proactive-private-corpus-refresh-blocked-001")?; + + for job in jobs { + match job.pointer("/status").and_then(Value::as_str) { + Some("pass") => { + assert_eq!( + job.pointer("/dreaming_readback/runtime_path").and_then(Value::as_str), + Some("ElfService::add_note -> ElfService::list -> derived readback artifact") + ); + assert!( + support::array_at(job, "/dreaming_readback/missing_source_refs")?.is_empty() + ); + assert_eq!( + job.pointer("/dreaming_readback/source_mutation_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + job.pointer("/dreaming_readback/no_source_mutation_checked") + .and_then(Value::as_bool), + Some(true) + ); + }, + Some("blocked") => { + assert!(job.pointer("/dreaming_readback").is_none_or(Value::is_null)); + }, + status => { + return Err(eyre::eyre!( + "unexpected service-native materialization status: {status:?}" + )); + }, + } + } + + assert!(support::array_contains_str( + memory, + "/dreaming_readback/selected_source_refs", + "stale-summary-gap" + )?); + assert!(!support::array_contains_str(memory, "/evidence_ids", "stale-summary-gap")?); + assert!(support::array_contains_str( + daily, + "/dreaming_readback/selected_source_refs", + "daily-old-parity-trap" + )?); + assert!(!support::array_contains_str(daily, "/evidence_ids", "daily-old-parity-trap")?); + assert!(private_brief.pointer("/dreaming_readback").is_none_or(Value::is_null)); + + Ok(()) +} + +fn assert_service_native_dreaming_docs(markdown: &str, benchmarking_index: &str, readme: &str) { + assert!(markdown.contains("9 pass")); + assert!(markdown.contains("0 wrong_result")); + assert!(markdown.contains("2 typed blocked")); + assert!(markdown.contains("ElfService::add_note -> ElfService::list")); + assert!(markdown.contains("Do not claim ELF broadly beats OpenAI Pulse")); + assert!(benchmarking_index.contains("2026-06-19-service-native-dreaming-readback-report.md")); + assert!(readme.contains("Service-native Dreaming readback after XY-986")); + assert!(readme.contains("real-world-memory-service-native-dreaming")); +} + +#[test] +fn dreaming_review_queue_report_wires_reviewable_policy_contract() -> color_eyre::Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::dreaming_review_queue_report_json_path()?, + )?)?; + let markdown = fs::read_to_string(support::dreaming_review_queue_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let workspace = support::workspace_root()?; + let service = read_rust_module_sources( + &workspace.join("packages/elf-service/src"), + "dreaming_review_queue", + )?; + let service_lib = fs::read_to_string(workspace.join("packages/elf-service/src/lib.rs"))?; + let routes = read_rust_module_sources(&workspace.join("apps/elf-api/src"), "routes")?; + let mcp = fs::read_to_string(workspace.join("apps/elf-mcp/src/server.rs"))?; + let consolidation_spec = + fs::read_to_string(workspace.join("docs/spec/system_consolidation_proposals_v1.md"))?; + let service_spec = + fs::read_to_string(workspace.join("docs/spec/system_elf_memory_service_v2.md"))?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.dreaming_review_queue_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1021")); + assert_eq!( + report.pointer("/summary/queue_schema").and_then(Value::as_str), + Some("elf.dreaming_review_queue/v1") + ); + assert_eq!( + report.pointer("/summary/source_mutation_allowed").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report.pointer("/summary/high_impact_requires_review").and_then(Value::as_bool), + Some(true) + ); + assert_eq!(report.pointer("/summary/variant_count").and_then(Value::as_u64), Some(9)); + + for suite in ["memory_summary", "proactive_brief", "scheduled_memory", "consolidation"] { + assert!(support::array_contains_str(&report, "/summary/covered_existing_suites", suite)?); + } + for variant in + ["tag", "duplicate_merge", "page_rebuild", "memory_promotion", "graph_fact", "correction"] + { + assert!(support::array_contains_str(&report, "/summary/covered_future_variants", variant)?); + + support::find_by_field( + support::array_at(&report, "/queue_variants")?, + "/variant", + variant, + )?; + } + for field in [ + "source_refs", + "affected_refs", + "confidence", + "unsupported_claim_flags", + "diff", + "policy", + "review_audit", + ] { + assert!(support::array_contains_str(&report, "/required_item_fields", field)?); + } + + assert!(service.contains("ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1")); + assert!(service.contains("pub async fn dreaming_review_queue")); + assert!(service.contains("source_mutation_allowed: false")); + assert!(service.contains("low_risk_derived_organization")); + assert!(service.contains("available_review_actions")); + assert!(service_lib.contains("pub mod dreaming_review_queue")); + assert!(service_lib.contains("DreamingReviewQueueResponse")); + assert!(routes.contains("/v2/admin/dreaming/review-queue")); + assert!(routes.contains("DreamingReviewQueueRequest")); + assert!(routes.contains("async fn dreaming_review_queue")); + assert!(mcp.contains("elf_dreaming_review_queue")); + assert!(mcp.contains("dreaming_review_queue_schema")); + assert!(mcp.contains("/v2/admin/dreaming/review-queue")); + assert!(consolidation_spec.contains("elf.dreaming_review_queue/v1")); + assert!(consolidation_spec.contains("source_mutation_allowed")); + assert!(consolidation_spec.contains("duplicate_merge")); + assert!(service_spec.contains("GET /v2/admin/dreaming/review-queue")); + assert!(service_spec.contains("source refs, affected refs, confidence")); + assert!(markdown.contains("Dreaming Review Queue Report")); + assert!(markdown.contains("Auto-apply is limited to approved low-risk")); + assert!(benchmarking_index.contains("2026-06-20-dreaming-review-queue-report.md")); + assert!(readme.contains("Dreaming review queue after XY-1021")); + assert!(readme.contains("elf.dreaming_review_queue/v1")); + + Ok(()) +} + +fn assert_openviking_trajectory_materialization_summary(report: &Value) -> color_eyre::Result<()> { + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.openviking_trajectory_materialization_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-983")); + assert_eq!( + report.pointer("/summary/overall_judgment").and_then(Value::as_str), + Some("materialized_blocked_context_trajectory_evidence") + ); + assert_eq!( + report.pointer("/summary/broader_superiority").and_then(Value::as_str), + Some("not_proven") + ); + assert_eq!(report.pointer("/summary/blockers_removed_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked_scenario_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/pass_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/regressed_scenario_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert!(support::array_contains_str( + report, + "/summary/unsupported_claims_rejected", + "ELF does not beat OpenViking staged retrieval trajectory from fixture-only blocked rows." + )?); + + Ok(()) +} + +fn assert_openviking_trajectory_materialization_command(report: &Value) -> color_eyre::Result<()> { + let command = support::find_by_field( + support::array_at(report, "/commands")?, + "/command", + "cargo make real-world-memory-context-trajectory", + )?; + let summary = + command.pointer("/summary").ok_or_else(|| eyre::eyre!("missing command summary"))?; + + assert_eq!(command.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + command.pointer("/artifact_json").and_then(Value::as_str), + Some("tmp/real-world-memory/context-trajectory/report.json") + ); + assert_eq!(summary.pointer("/job_count").and_then(Value::as_u64), Some(3)); + assert_eq!(summary.pointer("/pass").and_then(Value::as_u64), Some(0)); + assert_eq!(summary.pointer("/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(summary.pointer("/blocked").and_then(Value::as_u64), Some(3)); + assert_eq!(summary.pointer("/evidence_covered_count").and_then(Value::as_u64), Some(9)); + assert_eq!(summary.pointer("/source_ref_covered_count").and_then(Value::as_u64), Some(9)); + assert_eq!(summary.pointer("/quote_covered_count").and_then(Value::as_u64), Some(9)); + + Ok(()) +} + +fn assert_openviking_trajectory_materialization_scenarios( + report: &Value, +) -> color_eyre::Result<()> { + let scenarios = support::array_at(report, "/scenario_materialization")?; + let staged = support::find_by_field( + scenarios, + "/scenario_id", + "openviking_staged_retrieval_trajectory", + )?; + let hierarchy = + support::find_by_field(scenarios, "/scenario_id", "openviking_hierarchy_selection")?; + let recursive = support::find_by_field( + scenarios, + "/scenario_id", + "openviking_recursive_context_expansion", + )?; + + assert_eq!(scenarios.len(), 3); + + for scenario in [staged, hierarchy, recursive] { + assert_eq!(scenario.pointer("/previous_status").and_then(Value::as_str), Some("blocked")); + assert_eq!(scenario.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!(scenario.pointer("/judgment").and_then(Value::as_str), Some("unchanged")); + } + + assert!(support::array_contains_str( + staged, + "/produced_evidence", + "openviking-evidence-id-output-contract" + )?); + assert!(support::array_contains_str( + hierarchy, + "/produced_evidence", + "hierarchy-selection-output-contract" + )?); + assert!(support::array_contains_str( + recursive, + "/produced_evidence", + "recursive-expansion-output-contract" + )?); + assert_eq!( + staged.pointer("/claim_boundary").and_then(Value::as_str), + Some( + "No ELF win, tie, or loss is allowed until both systems publish comparable stage artifacts for the same context-trajectory scenario." + ) + ); + assert_eq!( + hierarchy.pointer("/blocker").and_then(Value::as_str), + Some("selected_hierarchy_nodes_and_evidence_ids_missing") + ); + assert_eq!( + recursive.pointer("/blocker").and_then(Value::as_str), + Some("expansion_paths_and_same_corpus_evidence_ids_missing") + ); + + Ok(()) +} + +fn assert_openviking_trajectory_materialization_boundaries( + report: &Value, +) -> color_eyre::Result<()> { + assert_eq!( + report.pointer("/improvement_regression_readback/improved").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/improvement_regression_readback/blocked").and_then(Value::as_u64), + Some(3) + ); + assert!(support::array_contains_str( + report, + "/claim_boundaries/allowed", + "The context-trajectory slice is now reproducible through cargo make real-world-memory-context-trajectory." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries/not_allowed", + "Do not claim ELF beats OpenViking on staged retrieval trajectory." + )?); + assert!(support::array_contains_str( + report, + "/next_optimization_direction/required_fields", + "expansion_path" + )?); + assert_eq!( + report.pointer("/next_optimization_direction/non_goal").and_then(Value::as_str), + Some( + "No ELF product change or superiority claim is authorized by this materialization-only report." + ) + ); + + Ok(()) +} + +fn assert_openviking_trajectory_materialization_markdown_and_indexes( + markdown: &str, + benchmarking_index: &str, + readme: &str, +) { + assert!(markdown.contains("The OpenViking trajectory follow-up is now materialized")); + assert!(markdown.contains("3 encoded jobs, 0 pass, 3 blocked, 9/9 evidence coverage")); + assert!(markdown.contains("Do not claim ELF beats OpenViking on staged retrieval trajectory.")); + assert!(markdown.contains("OpenViking context-trajectory job can move from `blocked`")); + assert!( + benchmarking_index.contains("2026-06-19-openviking-trajectory-materialization-report.md") + ); + assert!(readme.contains("OpenViking Trajectory Materialization Report - June 19, 2026")); + assert!(readme.contains("cargo make real-world-memory-context-trajectory")); + assert!(readme.contains("3 typed blockers with 9/9 evidence coverage")); +} + +fn assert_xy955_commands(report: &Value) -> color_eyre::Result<()> { + let commands = support::array_at(report, "/commands")?; + let aggregate = support::find_by_field(commands, "/command", "cargo make real-world-memory")?; + let graph_rag = + support::find_by_field(commands, "/command", "cargo make real-world-memory-graph-rag")?; + let first_generation = + support::find_by_field(commands, "/command", "cargo make real-world-first-generation-oss")?; + let live = + support::find_by_field(commands, "/command", "cargo make real-world-memory-live-adapters")?; + + assert_eq!(aggregate.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(aggregate.pointer("/summary/pass").and_then(Value::as_u64), Some(53)); + assert_eq!(aggregate.pointer("/summary/blocked").and_then(Value::as_u64), Some(7)); + assert_eq!(graph_rag.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(graph_rag.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + assert_eq!(graph_rag.pointer("/summary/incomplete").and_then(Value::as_u64), Some(1)); + assert_eq!(graph_rag.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); + assert_eq!(first_generation.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); + assert_eq!(first_generation.pointer("/summary/blocked").and_then(Value::as_u64), Some(2)); + assert_eq!(live.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + live.pointer("/partial_summary/elf_live_real_world/pass").and_then(Value::as_u64), + Some(40) + ); + assert_eq!( + live.pointer("/partial_summary/elf_live_real_world/wrong_result").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + live.pointer("/partial_summary/qmd_live_real_world/pass").and_then(Value::as_u64), + Some(17) + ); + assert_eq!( + live.pointer("/partial_summary/qmd_live_real_world/wrong_result").and_then(Value::as_u64), + Some(13) + ); + + Ok(()) +} + +fn assert_xy955_stage_closeout(report: &Value) -> color_eyre::Result<()> { + let stages = support::array_at(report, "/stage_closeout")?; + + assert_eq!(stages.len(), 8); + + let current = support::find_by_field(stages, "/stage_id", "current_vs_historical_correctness")?; + let proactive = support::find_by_field(stages, "/stage_id", "proactive_brief_readiness")?; + let scheduled = support::find_by_field(stages, "/stage_id", "scheduled_memory_task_readiness")?; + let final_retest = + support::find_by_field(stages, "/stage_id", "final_competitor_retest_status")?; + + assert_eq!(current.pointer("/judgment").and_then(Value::as_str), Some("improved")); + assert_eq!(current.pointer("/current_counts/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(current.pointer("/current_counts/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(proactive.pointer("/judgment").and_then(Value::as_str), Some("improved")); + assert_eq!(proactive.pointer("/current_counts/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(scheduled.pointer("/current_counts/pass").and_then(Value::as_u64), Some(4)); + assert_eq!(scheduled.pointer("/current_counts/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(final_retest.pointer("/judgment").and_then(Value::as_str), Some("unchanged")); + assert_eq!(final_retest.pointer("/current_counts/pass").and_then(Value::as_u64), Some(40)); + assert_eq!( + final_retest.pointer("/current_counts/wrong_result").and_then(Value::as_u64), + Some(0) + ); + assert_eq!(final_retest.pointer("/current_counts/blocked").and_then(Value::as_u64), Some(7)); + assert_eq!( + final_retest.pointer("/current_counts/not_encoded").and_then(Value::as_u64), + Some(19) + ); + assert!(final_retest.pointer("/boundary").and_then(Value::as_str).is_some_and(|boundary| { + boundary.contains("qmd now has a fresh scored live report") + && boundary.contains("broader superiority is not proven") + })); + assert_eq!(final_retest.pointer("/qmd_current_counts/pass").and_then(Value::as_u64), Some(17)); + assert_eq!( + final_retest.pointer("/qmd_current_counts/wrong_result").and_then(Value::as_u64), + Some(13) + ); + + Ok(()) +} + +fn assert_xy955_scenario_retests(report: &Value) -> color_eyre::Result<()> { + let scenarios = support::array_at(report, "/scenario_retests")?; + let qmd = support::find_by_field(scenarios, "/scenario_id", "qmd_debug_ergonomics")?; + let mem0 = support::find_by_field( + scenarios, + "/scenario_id", + "mem0_openmemory_preference_history_export", + )?; + let letta = support::find_by_field(scenarios, "/scenario_id", "letta_core_archive")?; + let graph_rag = support::find_by_field( + scenarios, + "/scenario_id", + "graph_rag_citation_navigation_knowledge_surfaces", + )?; + let private_provider = + support::find_by_field(scenarios, "/scenario_id", "private_provider_production_gates")?; + + assert_eq!(qmd.pointer("/current_outcome").and_then(Value::as_str), Some("unchanged")); + assert_eq!(qmd.pointer("/current_status").and_then(Value::as_str), Some("pass")); + assert!(qmd.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { + evidence.contains("17 pass") + && evidence.contains("13 wrong_result") + && evidence.contains("does not retest or erase") + })); + assert_eq!(mem0.pointer("/current_outcome").and_then(Value::as_str), Some("unchanged")); + assert!(mem0.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { + evidence.contains("mem0/OpenMemory local OSS history") + && evidence.contains("OpenMemory UI/export remains setup-blocked") + })); + assert_eq!(letta.pointer("/current_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + graph_rag.pointer("/current_status").and_then(Value::as_str), + Some("typed_non_pass") + ); + assert!(graph_rag.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { + evidence.contains("0 pass") + && evidence.contains("1 wrong_result") + && evidence.contains("3 blocked") + })); + assert_eq!(private_provider.pointer("/follow_up").and_then(Value::as_str), Some("XY-930")); + + Ok(()) +} + +fn assert_xy955_optimization_queue(report: &Value) -> color_eyre::Result<()> { + let queue = support::array_at(report, "/optimization_queue")?; + let qmd = support::find_by_field(queue, "/issue", "XY-923")?; + let private_provider = support::find_by_field(queue, "/issue", "XY-930")?; + let openviking = support::find_by_field(queue, "/issue", "XY-928")?; + let letta = support::find_by_field(queue, "/issue", "letta-core-archive-adapter-brief")?; + let service_native = + support::find_by_field(queue, "/issue", "service-native-dreaming-outputs-brief")?; + + assert_eq!(qmd.pointer("/status").and_then(Value::as_str), Some("existing")); + assert_eq!(private_provider.pointer("/status").and_then(Value::as_str), Some("existing")); + assert_eq!(openviking.pointer("/status").and_then(Value::as_str), Some("existing")); + assert_eq!(letta.pointer("/status").and_then(Value::as_str), Some("proposed")); + assert_eq!(service_native.pointer("/status").and_then(Value::as_str), Some("proposed")); + assert!(support::array_contains_str( + report, + "/claim_boundaries/not_allowed", + "Do not treat qmd full-suite wrong_result counts as a regression of qmd debug ergonomics." + )?); + + Ok(()) +} + +fn assert_xy955_follow_up_issue_briefs(report: &Value) -> color_eyre::Result<()> { + let existing = support::array_at(report, "/follow_up_issue_briefs/existing")?; + let proposed = support::array_at(report, "/follow_up_issue_briefs/proposed")?; + let qmd = support::find_by_field(existing, "/issue", "XY-923")?; + let private_provider = support::find_by_field(existing, "/issue", "XY-930")?; + let letta = support::find_by_field(proposed, "/issue", "letta-core-archive-adapter-brief")?; + let service_native = + support::find_by_field(proposed, "/issue", "service-native-dreaming-outputs-brief")?; + + assert!(qmd.pointer("/scope").and_then(Value::as_str).is_some_and(|scope| { + scope.contains("immediate top-k") && scope.contains("candidate-drop artifacts") + })); + assert!(qmd.pointer("/non_goal").and_then(Value::as_str).is_some_and(|non_goal| { + non_goal.contains("qmd full-suite wrong_result counts") + && non_goal.contains("debug ergonomics") + })); + assert!( + private_provider + .pointer("/non_goal") + .and_then(Value::as_str) + .is_some_and(|non_goal| non_goal.contains("Do not infer credentials")) + ); + assert!(letta.pointer("/validation").and_then(Value::as_str).is_some_and(|validation| { + validation.contains("Letta core block JSON") && validation.contains("typed outcome states") + })); + assert!( + service_native + .pointer("/non_goal") + .and_then(Value::as_str) + .is_some_and(|non_goal| non_goal.contains("Pulse clone")) + ); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/external_adapters.rs b/apps/elf-eval/tests/real_world_job_benchmark/external_adapters.rs new file mode 100644 index 00000000..33d0fbd1 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/external_adapters.rs @@ -0,0 +1,1475 @@ +use std::{ + env, fs, + path::Path, + process::{self, Command}, +}; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +#[test] +fn real_world_report_includes_external_adapter_coverage_manifest() -> Result<()> { + let report = support::run_json_report_from(support::real_world_memory_fixture_dir())?; + + assert_external_adapter_manifest_summary(&report); + assert_external_adapter_manifest_records(&report)?; + + Ok(()) +} + +#[test] +fn external_adapter_run_summarizes_nonzero_scenario_losses() -> Result<()> { + let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("real_world_external_adapters") + .join("memory_projects_manifest.json"); + let mut manifest = serde_json::from_str::(&fs::read_to_string(manifest_path)?)?; + let adapters = manifest + .pointer_mut("/adapters") + .and_then(Value::as_array_mut) + .ok_or_else(|| eyre::eyre!("missing manifest adapters"))?; + let adapter = adapters + .iter_mut() + .find(|adapter| { + adapter.pointer("/adapter_id").and_then(Value::as_str) + == Some("agentmemory_live_baseline") + }) + .ok_or_else(|| eyre::eyre!("missing agentmemory adapter"))?; + + support::set_json_pointer(adapter, "/scenarios/0/elf_position", serde_json::json!("loses"))?; + support::set_json_pointer( + adapter, + "/scenarios/0/comparison_outcome", + serde_json::json!("loss"), + )?; + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-loss-manifest-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let manifest_path = temp_dir.join("memory_projects_manifest.json"); + + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("run") + .arg("--fixtures") + .arg(support::fixture_dir()) + .arg("--external-adapter-manifest") + .arg(&manifest_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job runner failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let report = serde_json::from_slice::(&output.stdout)?; + + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_position_counts/loses") + .and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_position_counts/untested") + .and_then(Value::as_u64), + Some(52) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/loss") + .and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/not_tested") + .and_then(Value::as_u64), + Some(18) + ); + + let adapters = support::array_at(&report, "/external_adapters/adapters")?; + let agentmemory = support::find_by_field(adapters, "/adapter_id", "agentmemory_live_baseline")?; + + assert_eq!( + agentmemory.pointer("/scenarios/0/elf_position").and_then(Value::as_str), + Some("loses") + ); + + Ok(()) +} + +fn assert_external_adapter_manifest_summary(report: &Value) { + assert_eq!( + report.pointer("/external_adapters/schema").and_then(Value::as_str), + Some("elf.real_world_external_adapter_report/v1") + ); + assert_eq!( + report.pointer("/external_adapters/manifest_id").and_then(Value::as_str), + Some( + "real-world-memory-project-adapters-2026-06-11-first-generation-continuity-source-store" + ) + ); + assert_eq!( + report.pointer("/external_adapters/docker_isolation/default").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report + .pointer("/external_adapters/docker_isolation/host_global_installs_required") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report.pointer("/external_adapters/summary/adapter_count").and_then(Value::as_u64), + Some(26) + ); + assert_eq!( + report.pointer("/external_adapters/summary/external_project_count").and_then(Value::as_u64), + Some(19) + ); + assert_eq!( + report.pointer("/external_adapters/summary/fixture_backed_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/live_baseline_only_count") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report.pointer("/external_adapters/summary/live_real_world_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/external_adapters/summary/research_gate_count").and_then(Value::as_u64), + Some(14) + ); + + assert_external_adapter_manifest_status_summary(report); + assert_external_adapter_manifest_scenario_summary(report); +} + +fn assert_external_adapter_manifest_status_summary(report: &Value) { + assert_eq!( + report + .pointer("/external_adapters/summary/overall_status_counts/pass") + .and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/overall_status_counts/wrong_result") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/overall_status_counts/lifecycle_fail") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/overall_status_counts/incomplete") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/overall_status_counts/blocked") + .and_then(Value::as_u64), + Some(10) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/overall_status_counts/not_encoded") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/capability_status_counts/mocked") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/capability_status_counts/unsupported") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/suite_status_counts/blocked") + .and_then(Value::as_u64), + Some(29) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/suite_status_counts/pass") + .and_then(Value::as_u64), + Some(27) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/suite_status_counts/incomplete") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/suite_status_counts/not_encoded") + .and_then(Value::as_u64), + Some(37) + ); +} + +fn assert_external_adapter_manifest_scenario_summary(report: &Value) { + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/real") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/mocked") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/unsupported") + .and_then(Value::as_u64), + Some(3) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/blocked") + .and_then(Value::as_u64), + Some(24) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/incomplete") + .and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/wrong_result") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/lifecycle_fail") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/pass") + .and_then(Value::as_u64), + Some(23) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_status_counts/not_encoded") + .and_then(Value::as_u64), + Some(13) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_position_counts/wins") + .and_then(Value::as_u64), + Some(10) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_position_counts/ties") + .and_then(Value::as_u64), + Some(11) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_position_counts/loses") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_position_counts/untested") + .and_then(Value::as_u64), + Some(53) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/win") + .and_then(Value::as_u64), + Some(10) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/tie") + .and_then(Value::as_u64), + Some(11) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/loss") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/not_tested") + .and_then(Value::as_u64), + Some(19) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/blocked") + .and_then(Value::as_u64), + Some(29) + ); + assert_eq!( + report + .pointer("/external_adapters/summary/scenario_outcome_counts/non_goal") + .and_then(Value::as_u64), + Some(5) + ); +} + +fn assert_external_adapter_manifest_records(report: &Value) -> Result<()> { + let adapters = support::array_at(report, "/external_adapters/adapters")?; + let elf = support::find_by_field(adapters, "/adapter_id", "elf_real_world_memory_fixture")?; + let elf_live = support::find_by_field(adapters, "/adapter_id", "elf_live_real_world")?; + let elf_operator_debug = + support::find_by_field(adapters, "/adapter_id", "elf_operator_debug_live")?; + let qmd = support::find_by_field(adapters, "/adapter_id", "qmd_live_baseline")?; + let qmd_live = support::find_by_field(adapters, "/adapter_id", "qmd_live_real_world")?; + let qmd_operator_debug = + support::find_by_field(adapters, "/adapter_id", "qmd_operator_debug_live")?; + let agentmemory = support::find_by_field(adapters, "/adapter_id", "agentmemory_live_baseline")?; + let mem0 = support::find_by_field(adapters, "/adapter_id", "mem0_openmemory_live_baseline")?; + let memsearch = support::find_by_field(adapters, "/adapter_id", "memsearch_live_baseline")?; + let openviking = support::find_by_field(adapters, "/adapter_id", "openviking_live_baseline")?; + let claude_mem = support::find_by_field(adapters, "/adapter_id", "claude_mem_live_baseline")?; + let ragflow = support::find_by_field(adapters, "/adapter_id", "ragflow_research_gate")?; + let lightrag = support::find_by_field(adapters, "/adapter_id", "lightrag_research_gate")?; + let graphrag = support::find_by_field(adapters, "/adapter_id", "graphrag_research_gate")?; + let graphiti_zep = + support::find_by_field(adapters, "/adapter_id", "graphiti_zep_research_gate")?; + let graphify = support::find_by_field(adapters, "/adapter_id", "graphify_docker_smoke")?; + let qmd_deep = support::find_by_field(adapters, "/adapter_id", "qmd_deep_profile_gate")?; + let openviking_deep = + support::find_by_field(adapters, "/adapter_id", "openviking_deep_profile_gate")?; + let letta = support::find_by_field(adapters, "/adapter_id", "letta_research_gate")?; + + assert_elf_fixture_adapter_record(elf)?; + + assert_eq!( + elf_live.pointer("/evidence_class").and_then(Value::as_str), + Some("live_real_world") + ); + assert_eq!(elf_live.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); + + assert_live_sweep_record(elf_live, "blocked")?; + assert_operator_debug_live_adapter_records(elf_operator_debug, qmd_operator_debug)?; + + assert_eq!(qmd.pointer("/overall_status").and_then(Value::as_str), Some("pass")); + assert_eq!(qmd.pointer("/suites/0/status").and_then(Value::as_str), Some("not_encoded")); + + assert_qmd_live_baseline_record(qmd); + + assert_eq!( + qmd_live.pointer("/evidence_class").and_then(Value::as_str), + Some("live_real_world") + ); + assert_eq!(qmd_live.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); + + assert_live_sweep_record(qmd_live, "blocked")?; + + assert_eq!( + agentmemory.pointer("/capabilities/1/status").and_then(Value::as_str), + Some("mocked") + ); + + assert_first_generation_adapter_records(agentmemory, mem0, memsearch, claude_mem); + + assert_eq!(openviking.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); + + assert_graph_rag_research_gate_records(ragflow, lightrag, graphrag); + assert_graphiti_zep_adapter(graphiti_zep); + assert_graphify_adapter(graphify)?; + assert_graph_rag_representative_scenarios(ragflow, lightrag, graphrag, graphiti_zep, graphify)?; + assert_letta_core_archival_gate(letta)?; + assert_qmd_deep_profile_gate(qmd_deep); + + assert_eq!( + qmd_deep.pointer("/capabilities/2/status").and_then(Value::as_str), + Some("unsupported") + ); + assert_eq!( + qmd_deep.pointer("/result/artifact").and_then(Value::as_str), + Some("docs/evidence/benchmarking/2026-06-11-qmd-openviking-strength-profile-report.md") + ); + assert_eq!( + openviking_deep.pointer("/adapter_kind").and_then(Value::as_str), + Some("docker_local_embed_context_trajectory_gate") + ); + + assert_openviking_deep_profile_gate(openviking_deep); + + assert_eq!( + openviking_deep.pointer("/result/artifact").and_then(Value::as_str), + Some("docs/evidence/benchmarking/2026-06-11-qmd-openviking-strength-profile-report.md") + ); + + Ok(()) +} + +fn assert_graph_rag_research_gate_records(ragflow: &Value, lightrag: &Value, graphrag: &Value) { + assert_eq!(ragflow.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); + assert_eq!(ragflow.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + ragflow.pointer("/execution_metadata/research_depth").and_then(Value::as_str), + Some( + "D2 feasibility verdict plus XY-885 evidence-smoke implementation and XY-900 scored smoke promotion; checked-in record remains research_gate unless a generated artifact reaches query output" + ) + ); + assert_eq!( + ragflow.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make smoke-ragflow-docker") + ); + assert_eq!( + ragflow.pointer("/result/artifact").and_then(Value::as_str), + Some("tmp/real-world-memory/ragflow-smoke/ragflow-report.json") + ); + assert_eq!( + ragflow.pointer("/execution_metadata/sources/0/url").and_then(Value::as_str), + Some("https://github.com/infiniflow/ragflow") + ); + assert_eq!(lightrag.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); + assert_eq!(lightrag.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + lightrag.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make smoke-lightrag-docker-context") + ); + assert_eq!( + lightrag.pointer("/run/command").and_then(Value::as_str), + Some("ELF_LIGHTRAG_CONTEXT_START=1 cargo make smoke-lightrag-docker-context") + ); + assert_eq!( + lightrag.pointer("/capabilities/3/status").and_then(Value::as_str), + Some("not_encoded") + ); + assert_eq!(graphrag.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); + assert_eq!( + graphrag.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make smoke-graphrag-docker") + ); + assert_eq!(graphrag.pointer("/suites/1/status").and_then(Value::as_str), Some("not_encoded")); +} + +fn assert_letta_core_archival_gate(adapter: &Value) -> Result<()> { + assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); + assert!( + adapter + .pointer("/setup/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("smoke-letta-core-archive-export-readback") + && evidence.contains("Docker-only benchmark-created agent export/readback")) + ); + assert_eq!( + adapter.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make smoke-letta-core-archive-export-readback") + ); + assert_eq!( + adapter.pointer("/run/command").and_then(Value::as_str), + Some( + "ELF_LETTA_SMOKE_START=1 ELF_LETTA_SMOKE_RUN=1 cargo make smoke-letta-core-archive-export-readback" + ) + ); + assert!(adapter.pointer("/execution_metadata/setup_path").and_then(Value::as_str).is_some_and( + |setup| setup.contains("exports core block JSON plus archival search/readback JSON") + && setup.contains("typed artifact") + )); + + let suites = support::array_at(adapter, "/suites")?; + let core_suite = support::find_by_field(suites, "/suite_id", "core_archival_memory")?; + + assert_eq!(core_suite.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + adapter.pointer("/capabilities/2/capability").and_then(Value::as_str), + Some("real_world_job_adapter") + ); + assert_eq!(adapter.pointer("/capabilities/2/status").and_then(Value::as_str), Some("blocked")); + + let scenarios = support::array_at(adapter, "/scenarios")?; + let attachment = + support::find_by_field(scenarios, "/scenario_id", "core_block_attachment_readback")?; + let scope = support::find_by_field(scenarios, "/scenario_id", "core_block_scope_readback")?; + let provenance = + support::find_by_field(scenarios, "/scenario_id", "core_block_provenance_readback")?; + let stale = support::find_by_field(scenarios, "/scenario_id", "stale_core_detection")?; + let fallback = support::find_by_field(scenarios, "/scenario_id", "archival_fallback_readback")?; + let decision = support::find_by_field( + scenarios, + "/scenario_id", + "core_archival_project_decision_recovery", + )?; + + assert_eq!(scenarios.len(), 6); + + for scenario in [attachment, scope, provenance, stale, fallback, decision] { + assert_eq!(scenario.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(scenario.pointer("/elf_position").and_then(Value::as_str), Some("untested")); + assert_eq!( + scenario.pointer("/comparison_outcome").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!( + scenario.pointer("/command").and_then(Value::as_str), + Some("cargo make smoke-letta-core-archive-export-readback") + ); + assert_eq!( + scenario.pointer("/artifact").and_then(Value::as_str), + Some("tmp/real-world-memory/letta-core-archive/summary.json") + ); + } + + assert_eq!(attachment.pointer("/comparison_outcome").and_then(Value::as_str), Some("blocked")); + assert_eq!(stale.pointer("/comparison_outcome").and_then(Value::as_str), Some("blocked")); + assert_eq!(fallback.pointer("/comparison_outcome").and_then(Value::as_str), Some("blocked")); + + Ok(()) +} + +fn assert_elf_fixture_adapter_record(adapter: &Value) -> Result<()> { + assert_eq!(adapter.pointer("/evidence_class").and_then(Value::as_str), Some("fixture_backed")); + assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); + assert!(adapter.pointer("/run/evidence").and_then(Value::as_str).is_some_and(|evidence| { + evidence.contains("82 jobs across 19 suites") + && evidence.contains("75 pass") + && evidence.contains("7 blocked") + && evidence.contains("core_archival_memory") + && evidence.contains("memory_summary") + && evidence.contains("proactive_brief") + && evidence.contains("scheduled_memory") + && evidence.contains("context_trajectory") + })); + + let suites = support::array_at(adapter, "/suites")?; + let core_archival = support::find_by_field(suites, "/suite_id", "core_archival_memory")?; + let scheduled = support::find_by_field(suites, "/suite_id", "scheduled_memory")?; + let context_trajectory = support::find_by_field(suites, "/suite_id", "context_trajectory")?; + + assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("pass")); + assert!(core_archival.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { + evidence.contains("core block attachment") + && evidence.contains("project-decision recovery") + && evidence.contains("archival note search") + })); + assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert!(scheduled.pointer("/evidence").and_then(Value::as_str).is_some_and(|evidence| { + evidence.contains("4 passing source-linked task readbacks") + && evidence.contains("private/provider scheduler blocker") + })); + assert_eq!(context_trajectory.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert!( + adapter + .pointer("/notes/1") + .and_then(Value::as_str) + .is_some_and(|note| note.contains("OpenViking context-trajectory measurement gates")) + ); + + Ok(()) +} + +fn assert_qmd_deep_profile_gate(adapter: &Value) { + assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(adapter.pointer("/run/status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(adapter.pointer("/result/status").and_then(Value::as_str), Some("not_encoded")); +} + +fn assert_qmd_live_baseline_record(adapter: &Value) { + let result_evidence = adapter.pointer("/result/evidence").and_then(Value::as_str); + let retrieval_evidence = adapter.pointer("/suites/0/evidence").and_then(Value::as_str); + + assert!(result_evidence.is_some_and(|evidence| { + evidence.contains("This live_baseline_only record is same-corpus evidence only") + && evidence.contains("cite qmd_live_real_world for the full live real-world sweep") + && !evidence.contains("no real_world_job qmd adapter is encoded yet") + })); + assert!(retrieval_evidence.is_some_and(|evidence| { + evidence.contains("does not execute real_world_job retrieval prompts") + && evidence.contains("cite qmd_live_real_world for the live retrieval adapter run") + && !evidence.contains("no real_world_job retrieval adapter run is encoded") + })); +} + +fn assert_operator_debug_live_adapter_records(elf: &Value, qmd: &Value) -> Result<()> { + assert_eq!(elf.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); + assert_eq!(elf.pointer("/overall_status").and_then(Value::as_str), Some("pass")); + assert_eq!( + elf.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make real-world-job-operator-ux-live-adapters") + ); + assert_eq!( + elf.pointer("/suites/0/suite_id").and_then(Value::as_str), + Some("operator_debugging_ux") + ); + assert_eq!(elf.pointer("/suites/0/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + elf.pointer("/capabilities/1/capability").and_then(Value::as_str), + Some("trace_hydration_metadata") + ); + assert_eq!(elf.pointer("/capabilities/1/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + elf.pointer("/capabilities/2/capability").and_then(Value::as_str), + Some("replay_command_metadata") + ); + assert_eq!(elf.pointer("/capabilities/2/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + elf.pointer("/capabilities/3/capability").and_then(Value::as_str), + Some("candidate_drop_visibility") + ); + assert_eq!(elf.pointer("/capabilities/3/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + elf.pointer("/capabilities/4/capability").and_then(Value::as_str), + Some("openmemory_or_claude_mem_ui_runner") + ); + assert_eq!(elf.pointer("/capabilities/4/status").and_then(Value::as_str), Some("not_encoded")); + + let elf_scenarios = support::array_at(elf, "/scenarios")?; + let elf_trace = + support::find_by_field(elf_scenarios, "/scenario_id", "operator_debug_trace_hydration")?; + let elf_replay = + support::find_by_field(elf_scenarios, "/scenario_id", "operator_debug_replay_command")?; + let elf_candidate = support::find_by_field( + elf_scenarios, + "/scenario_id", + "operator_debug_candidate_drop_visibility", + )?; + let elf_repair = support::find_by_field( + elf_scenarios, + "/scenario_id", + "operator_debug_repair_action_clarity", + )?; + let elf_selected = support::find_by_field( + elf_scenarios, + "/scenario_id", + "operator_debug_selected_but_not_narrated", + )?; + + assert_eq!(elf_scenarios.len(), 5); + assert_eq!(elf_trace.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(elf_trace.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); + assert_eq!(elf_replay.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(elf_candidate.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); + assert_eq!(elf_repair.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(elf_selected.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); + + assert_operator_debug_qmd_adapter_record(qmd)?; + + assert!(support::array_at(elf, "/notes")?.iter().any(|note| { + note.as_str().is_some_and(|text| text.contains("narrow operator-debug live slice")) + })); + assert!(support::array_at(qmd, "/notes")?.iter().any(|note| { + note.as_str().is_some_and(|text| text.contains("narrow operator-debug live slice")) + })); + + Ok(()) +} + +fn assert_operator_debug_qmd_adapter_record(qmd: &Value) -> Result<()> { + assert_eq!(qmd.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); + assert_eq!(qmd.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + qmd.pointer("/suites/0/suite_id").and_then(Value::as_str), + Some("operator_debugging_ux") + ); + assert_eq!(qmd.pointer("/suites/0/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + qmd.pointer("/capabilities/1/capability").and_then(Value::as_str), + Some("local_replay_command_metadata") + ); + assert_eq!(qmd.pointer("/capabilities/1/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + qmd.pointer("/capabilities/2/capability").and_then(Value::as_str), + Some("trace_hydration_metadata") + ); + assert_eq!(qmd.pointer("/capabilities/2/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + qmd.pointer("/capabilities/3/capability").and_then(Value::as_str), + Some("candidate_drop_visibility") + ); + assert_eq!(qmd.pointer("/capabilities/3/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(qmd.pointer("/capabilities/4/status").and_then(Value::as_str), Some("not_encoded")); + + let qmd_scenarios = support::array_at(qmd, "/scenarios")?; + let qmd_trace = + support::find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_trace_hydration")?; + let qmd_replay = + support::find_by_field(qmd_scenarios, "/scenario_id", "operator_debug_replay_command")?; + let qmd_candidate = support::find_by_field( + qmd_scenarios, + "/scenario_id", + "operator_debug_candidate_drop_visibility", + )?; + let qmd_repair = support::find_by_field( + qmd_scenarios, + "/scenario_id", + "operator_debug_repair_action_clarity", + )?; + let qmd_selected = support::find_by_field( + qmd_scenarios, + "/scenario_id", + "operator_debug_selected_but_not_narrated", + )?; + + assert_eq!(qmd_scenarios.len(), 5); + assert_eq!(qmd_trace.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(qmd_trace.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); + assert_eq!(qmd_replay.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(qmd_replay.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(qmd_candidate.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(qmd_candidate.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); + assert_eq!(qmd_repair.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(qmd_repair.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(qmd_selected.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(qmd_selected.pointer("/comparison_outcome").and_then(Value::as_str), Some("win")); + + Ok(()) +} + +fn assert_openviking_deep_profile_gate(adapter: &Value) { + let trajectory_evidence = adapter.pointer("/capabilities/1/evidence").and_then(Value::as_str); + + assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); + assert!(trajectory_evidence.is_some_and(|evidence| { + evidence.contains("evidence-bearing same-corpus output") + && evidence.contains("selected hierarchy/expansion artifacts") + && !evidence.contains("setup reaches runnable OpenViking APIs") + })); +} + +fn assert_first_generation_adapter_records( + agentmemory: &Value, + mem0: &Value, + memsearch: &Value, + claude_mem: &Value, +) { + assert_agentmemory_first_generation_records(agentmemory); + assert_mem0_first_generation_records(mem0); + assert_memsearch_first_generation_records(memsearch); + assert_claude_mem_first_generation_records(claude_mem); +} + +fn assert_agentmemory_first_generation_records(agentmemory: &Value) { + assert_eq!( + agentmemory.pointer("/scenarios/1/status").and_then(Value::as_str), + Some("lifecycle_fail") + ); + assert_eq!( + agentmemory.pointer("/scenarios/1/elf_position").and_then(Value::as_str), + Some("wins") + ); + assert_eq!(agentmemory.pointer("/scenarios/2/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + agentmemory.pointer("/scenarios/2/comparison_outcome").and_then(Value::as_str), + Some("blocked") + ); +} + +fn assert_mem0_first_generation_records(mem0: &Value) { + assert_eq!( + mem0.pointer("/capabilities/2/capability").and_then(Value::as_str), + Some("local_lifecycle_update_delete_reload") + ); + assert_eq!(mem0.pointer("/capabilities/2/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + mem0.pointer("/capabilities/3/capability").and_then(Value::as_str), + Some("preference_correction_history") + ); + assert_eq!(mem0.pointer("/capabilities/3/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + mem0.pointer("/capabilities/7/capability").and_then(Value::as_str), + Some("openmemory_ui_readback") + ); + assert_eq!(mem0.pointer("/capabilities/7/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + mem0.pointer("/capabilities/8/capability").and_then(Value::as_str), + Some("hosted_managed_memory_claims") + ); + assert_eq!(mem0.pointer("/capabilities/8/status").and_then(Value::as_str), Some("unsupported")); + assert_eq!(mem0.pointer("/scenarios/0/status").and_then(Value::as_str), Some("pass")); + assert_eq!(mem0.pointer("/scenarios/0/elf_position").and_then(Value::as_str), Some("ties")); + assert_eq!( + mem0.pointer("/scenarios/1/scenario_id").and_then(Value::as_str), + Some("preference_correction_history") + ); + assert_eq!(mem0.pointer("/scenarios/1/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + mem0.pointer("/scenarios/1/comparison_outcome").and_then(Value::as_str), + Some("loss") + ); + assert_eq!( + mem0.pointer("/scenarios/5/scenario_id").and_then(Value::as_str), + Some("openmemory_ui_export_readback") + ); + assert_eq!(mem0.pointer("/scenarios/5/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + mem0.pointer("/scenarios/5/command").and_then(Value::as_str), + Some("cargo make openmemory-ui-export-readback") + ); + assert_eq!( + mem0.pointer("/scenarios/5/artifact").and_then(Value::as_str), + Some("tmp/live-baseline/mem0-openmemory-ui-export.json") + ); + assert!( + mem0.pointer("/capabilities/7/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("export-helper setup probe") + && evidence.contains("requires Docker access")) + ); + assert_eq!( + mem0.pointer("/scenarios/6/comparison_outcome").and_then(Value::as_str), + Some("non_goal") + ); +} + +fn assert_memsearch_first_generation_records(memsearch: &Value) { + assert_eq!( + memsearch.pointer("/capabilities/2/capability").and_then(Value::as_str), + Some("reindex_update_delete_reload") + ); + assert_eq!(memsearch.pointer("/capabilities/2/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + memsearch.pointer("/scenarios/0/scenario_id").and_then(Value::as_str), + Some("canonical_markdown_reindex_reload") + ); + assert_eq!( + memsearch.pointer("/scenarios/0/elf_position").and_then(Value::as_str), + Some("untested") + ); + assert_eq!(memsearch.pointer("/suites/0/status").and_then(Value::as_str), Some("not_encoded")); + assert!(memsearch.pointer("/suites/0/evidence").and_then(Value::as_str).is_some_and( + |evidence| evidence.contains("fixture-backed source-of-truth prompt coverage") + && evidence.contains("No live memsearch runtime adapter executes prompt scoring yet") + && evidence.contains("not a suite pass") + )); + assert_eq!(memsearch.pointer("/suites/1/status").and_then(Value::as_str), Some("not_encoded")); + assert!(memsearch.pointer("/suites/1/evidence").and_then(Value::as_str).is_some_and( + |evidence| evidence.contains("fixture-backed retrieval-debug prompt coverage") + && evidence.contains( + "No live memsearch runtime adapter executes retrieval prompt scoring yet" + ) && evidence.contains("not a suite pass") + )); + assert_eq!(memsearch.pointer("/scenarios/1/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + memsearch.pointer("/scenarios/1/elf_position").and_then(Value::as_str), + Some("untested") + ); + assert_eq!( + memsearch.pointer("/scenarios/3/status").and_then(Value::as_str), + Some("unsupported") + ); + assert_eq!( + memsearch.pointer("/capabilities/4/capability").and_then(Value::as_str), + Some("markdown_source_store_prompt_jobs") + ); + assert_eq!(memsearch.pointer("/capabilities/4/status").and_then(Value::as_str), Some("pass")); +} + +fn assert_claude_mem_first_generation_records(claude_mem: &Value) { + assert_eq!(claude_mem.pointer("/capabilities/1/status").and_then(Value::as_str), Some("real")); + assert_eq!( + claude_mem.pointer("/capabilities/3/capability").and_then(Value::as_str), + Some("repository_progressive_disclosure") + ); + assert_eq!(claude_mem.pointer("/capabilities/4/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + claude_mem.pointer("/capabilities/6/status").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!(claude_mem.pointer("/suites/0/status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(claude_mem.pointer("/suites/1/status").and_then(Value::as_str), Some("blocked")); + assert!( + claude_mem + .pointer("/suites/1/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("fixture-backed progressive-disclosure") + && evidence.contains("viewer/operator workflow remains blocked")) + ); + assert_eq!(claude_mem.pointer("/suites/2/status").and_then(Value::as_str), Some("blocked")); + assert!( + claude_mem + .pointer("/suites/2/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("hook capture remains blocked")) + ); + assert_eq!( + claude_mem.pointer("/scenarios/0/status").and_then(Value::as_str), + Some("wrong_result") + ); + assert_eq!( + claude_mem.pointer("/scenarios/1/scenario_id").and_then(Value::as_str), + Some("retrieval_repair_artifact_path") + ); + assert_eq!( + claude_mem.pointer("/scenarios/1/status").and_then(Value::as_str), + Some("wrong_result") + ); + assert!( + claude_mem + .pointer("/scenarios/1/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("rerun/inspection targets") + && evidence.contains("tmp/live-baseline/claude-mem-checks.json")) + ); + assert_eq!(claude_mem.pointer("/scenarios/2/status").and_then(Value::as_str), Some("pass")); + assert_eq!(claude_mem.pointer("/scenarios/4/status").and_then(Value::as_str), Some("pass")); + assert_eq!(claude_mem.pointer("/scenarios/5/status").and_then(Value::as_str), Some("blocked")); +} + +fn assert_graphiti_zep_adapter(adapter: &Value) { + assert_eq!(adapter.pointer("/evidence_class").and_then(Value::as_str), Some("research_gate")); + assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + adapter.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make smoke-graphiti-zep-docker-temporal") + ); + assert_eq!( + adapter.pointer("/run/command").and_then(Value::as_str), + Some( + "ELF_GRAPHITI_ZEP_SMOKE_START=1 ELF_GRAPHITI_ZEP_SMOKE_RUN=1 cargo make smoke-graphiti-zep-docker-temporal" + ) + ); + assert_eq!( + adapter.pointer("/suites/0/suite_id").and_then(Value::as_str), + Some("memory_evolution") + ); + assert_eq!(adapter.pointer("/suites/0/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + adapter.pointer("/execution_metadata/research_depth").and_then(Value::as_str), + Some( + "D2 feasibility plus XY-888 Docker temporal smoke implementation and XY-900 scored smoke promotion; checked-in record remains research_gate unless a generated artifact reaches Graphiti search output" + ) + ); +} + +fn assert_graphify_adapter(adapter: &Value) -> Result<()> { + assert_eq!(adapter.pointer("/evidence_class").and_then(Value::as_str), Some("live_real_world")); + assert_eq!(adapter.pointer("/overall_status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(adapter.pointer("/setup/status").and_then(Value::as_str), Some("pass")); + assert_eq!(adapter.pointer("/run/status").and_then(Value::as_str), Some("pass")); + assert_eq!(adapter.pointer("/result/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + adapter.pointer("/setup/command").and_then(Value::as_str), + Some("cargo make smoke-graphify-docker-graph-report") + ); + assert_eq!( + adapter.pointer("/suites/0/suite_id").and_then(Value::as_str), + Some("knowledge_compilation") + ); + assert_eq!(adapter.pointer("/suites/0/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(adapter.pointer("/suites/1/suite_id").and_then(Value::as_str), Some("retrieval")); + assert_eq!(adapter.pointer("/suites/1/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + adapter.pointer("/execution_metadata/research_depth").and_then(Value::as_str), + Some( + "D1 feasibility verdict plus XY-889 Docker graph/report smoke implementation and XY-900 scored smoke promotion; current Docker validation reaches graphify output and scores the tiny knowledge_compilation job as wrong_result" + ) + ); + + let capabilities = support::array_at(adapter, "/capabilities")?; + let quality = support::find_by_field(capabilities, "/capability", "quality_or_scale_claim")?; + + assert_eq!(quality.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + assert!(support::array_at(adapter, "/notes")?.iter().any(|note| { + note.as_str().is_some_and(|text| text.contains("tiny smoke") && text.contains("non-pass")) + })); + + Ok(()) +} + +fn assert_graph_rag_representative_scenarios( + ragflow: &Value, + lightrag: &Value, + graphrag: &Value, + graphiti_zep: &Value, + graphify: &Value, +) -> Result<()> { + let ragflow_scenarios = support::array_at(ragflow, "/scenarios")?; + let lightrag_scenarios = support::array_at(lightrag, "/scenarios")?; + let graphrag_scenarios = support::array_at(graphrag, "/scenarios")?; + let graphiti_scenarios = support::array_at(graphiti_zep, "/scenarios")?; + let graphify_scenarios = support::array_at(graphify, "/scenarios")?; + let ragflow_chunk = support::find_by_field( + ragflow_scenarios, + "/scenario_id", + "reference_chunk_citation_mapping", + )?; + let lightrag_context = support::find_by_field( + lightrag_scenarios, + "/scenario_id", + "context_source_reference_mapping", + )?; + let graphrag_tables = support::find_by_field( + graphrag_scenarios, + "/scenario_id", + "output_table_citation_mapping", + )?; + let graphiti_temporal = support::find_by_field( + graphiti_scenarios, + "/scenario_id", + "temporal_validity_window_mapping", + )?; + let graphify_lint = + support::find_by_field(graphify_scenarios, "/scenario_id", "graph_report_navigation_lint")?; + + assert_eq!( + ragflow_chunk.pointer("/comparison_outcome").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!(lightrag_context.pointer("/status").and_then(Value::as_str), Some("incomplete")); + assert_eq!( + lightrag_context.pointer("/comparison_outcome").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!( + graphrag_tables.pointer("/artifact").and_then(Value::as_str), + Some( + "apps/elf-eval/fixtures/real_world_external_adapters/graph_rag/graphrag_output_tables_blocked.json" + ) + ); + assert_eq!( + graphiti_temporal.pointer("/comparison_outcome").and_then(Value::as_str), + Some("blocked") + ); + assert_eq!(graphify_lint.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + graphify_lint.pointer("/comparison_outcome").and_then(Value::as_str), + Some("not_tested") + ); + assert!( + graphify_lint + .pointer("/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("not an ELF victory claim")) + ); + + assert_adapter_matrix_rows( + ragflow_scenarios, + &[ + ("reference_chunk_citation_mapping", "blocked", "blocked"), + ("retrieval_quality_reference_recall", "blocked", "blocked"), + ("navigation_quality_document_chunks", "blocked", "blocked"), + ("answer_faithfulness_reference_chunks", "blocked", "blocked"), + ("stale_source_behavior", "not_encoded", "not_tested"), + ("knowledge_compilation_quality", "not_encoded", "not_tested"), + ], + )?; + assert_adapter_matrix_rows( + lightrag_scenarios, + &[ + ("context_source_reference_mapping", "incomplete", "blocked"), + ("retrieval_quality_context_recall", "incomplete", "blocked"), + ("citation_quality_context_references", "incomplete", "blocked"), + ("navigation_quality_graph_context", "incomplete", "blocked"), + ("answer_faithfulness_context_refs", "incomplete", "blocked"), + ("stale_source_behavior", "not_encoded", "not_tested"), + ("knowledge_compilation_quality", "not_encoded", "not_tested"), + ], + )?; + assert_adapter_matrix_rows( + graphrag_scenarios, + &[ + ("output_table_citation_mapping", "blocked", "blocked"), + ("retrieval_quality_local_search", "not_encoded", "not_tested"), + ("navigation_quality_community_graph", "blocked", "blocked"), + ("answer_faithfulness_output_tables", "blocked", "blocked"), + ("stale_source_behavior", "not_encoded", "not_tested"), + ("graph_summary_synthesis_quality", "not_encoded", "not_tested"), + ], + )?; + + Ok(()) +} + +fn assert_adapter_matrix_rows(scenarios: &[Value], expected: &[(&str, &str, &str)]) -> Result<()> { + for (scenario_id, status, outcome) in expected { + let row = support::find_by_field(scenarios, "/scenario_id", scenario_id)?; + + assert_eq!(row.pointer("/status").and_then(Value::as_str), Some(*status)); + assert_eq!(row.pointer("/comparison_outcome").and_then(Value::as_str), Some(*outcome)); + assert!( + row.pointer("/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| !evidence.trim().is_empty()) + ); + } + + Ok(()) +} + +#[test] +fn graphify_generated_manifest_keeps_retrieval_unscored() -> Result<()> { + let manifest = serde_json::json!({ + "schema": "elf.real_world_external_adapter_manifest/v1", + "manifest_id": "graphify-generated-manifest-test", + "docker_isolation": { + "default": true, + "compose_file": "docker-compose.baseline.yml", + "runner": "scripts/graphify-docker-graph-report-smoke.py", + "artifact_dir": "tmp/real-world-memory/graphify-smoke", + "host_global_installs_required": false, + "notes": ["Synthetic graphify generated-manifest regression test."] + }, + "adapters": [{ + "adapter_id": "graphify_docker_smoke", + "project": "graphify", + "adapter_kind": "docker_cli_graph_report_smoke", + "evidence_class": "live_real_world", + "docker_default": true, + "host_global_installs_required": false, + "overall_status": "wrong_result", + "setup": { + "status": "pass", + "evidence": "setup evidence", + "command": "cargo make smoke-graphify-docker-graph-report", + "artifact": "tmp/real-world-memory/graphify-smoke/graphify-smoke.json" + }, + "run": { + "status": "pass", + "evidence": "run evidence", + "command": "cargo make smoke-graphify-docker-graph-report", + "artifact": "tmp/real-world-memory/graphify-smoke/summary.json" + }, + "result": { + "status": "wrong_result", + "evidence": "result evidence", + "artifact": "tmp/real-world-memory/graphify-smoke/graphify-report.json" + }, + "capabilities": [{ + "capability": "quality_or_scale_claim", + "status": "not_encoded", + "evidence": "No broad graph quality claim." + }], + "suites": [ + { + "suite_id": "knowledge_compilation", + "status": "wrong_result", + "evidence": "Only the generated graph/report evidence-mapping job is represented." + }, + { + "suite_id": "retrieval", + "status": "blocked", + "evidence": "The smoke uses graphify query output only to support source mapping; broad retrieval quality is not scored." + } + ], + "evidence": [], + "execution_metadata": { + "setup_path": "cargo make smoke-graphify-docker-graph-report", + "runtime_boundary": "Docker-only generated graph/report smoke.", + "resource_expectation": "Tiny generated corpus only.", + "retry_guidance": [], + "sources": [{ + "label": "graphify", + "url": "https://github.com/safishamsi/graphify", + "evidence": "Synthetic generated-manifest regression source." + }], + "research_depth": "Generated smoke manifest path" + }, + "notes": ["tiny smoke non-pass"] + }] + }); + let temp_dir = + env::temp_dir().join(format!("elf-real-world-graphify-manifest-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let manifest_path = temp_dir.join("manifest.json"); + let report_path = temp_dir.join("report.json"); + + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("run") + .arg("--fixtures") + .arg(support::fixture_dir()) + .arg("--out") + .arg(&report_path) + .arg("--external-adapter-manifest") + .arg(&manifest_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job runner failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let report: Value = serde_json::from_slice(&fs::read(&report_path)?)?; + let adapters = support::array_at(&report, "/external_adapters/adapters")?; + let graphify = support::find_by_field(adapters, "/adapter_id", "graphify_docker_smoke")?; + let suites = support::array_at(graphify, "/suites")?; + let retrieval = support::find_by_field(suites, "/suite_id", "retrieval")?; + + assert_eq!(retrieval.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert!( + retrieval + .pointer("/evidence") + .and_then(Value::as_str) + .is_some_and(|text| { text.contains("broad retrieval quality is not scored") }) + ); + + Ok(()) +} + +#[test] +fn graph_rag_representative_fixtures_report_typed_non_pass_states() -> Result<()> { + let report = support::run_json_report_from(support::graph_rag_external_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(3)); + assert_eq!( + report.pointer("/summary/knowledge/citation_coverage").and_then(Value::as_f64), + Some(0.667) + ); + assert_eq!( + report.pointer("/summary/knowledge/stale_claim_detection").and_then(Value::as_f64), + Some(0.0) + ); + assert_eq!( + report.pointer("/summary/knowledge/unsupported_summary_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/temporal_validity_not_encoded_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), + Some(1) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let ragflow = + support::find_by_field(jobs, "/job_id", "graph-rag-ragflow-reference-chunks-001")?; + let lightrag = + support::find_by_field(jobs, "/job_id", "graph-rag-lightrag-context-sources-001")?; + let graphrag = support::find_by_field(jobs, "/job_id", "graph-rag-graphrag-output-tables-001")?; + let graphiti = + support::find_by_field(jobs, "/job_id", "graph-rag-graphiti-temporal-validity-001")?; + let graphify = support::find_by_field(jobs, "/job_id", "graph-rag-graphify-graph-report-001")?; + + assert_eq!(ragflow.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(lightrag.pointer("/status").and_then(Value::as_str), Some("incomplete")); + assert_eq!(graphrag.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(graphiti.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(graphify.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + graphify.pointer("/knowledge/stale_claim_detection").and_then(Value::as_f64), + Some(0.0) + ); + assert_eq!( + graphify.pointer("/knowledge/unsupported_summary_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + graphiti.pointer("/evolution/temporal_validity_not_encoded").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + graphiti.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("graphiti.provider_boundary") + ); + assert!(support::array_contains_str( + graphiti, + "/produced_evidence", + "graphiti-current-fact-contract" + )?); + assert!(support::array_contains_str( + graphiti, + "/produced_evidence", + "graphiti-historical-fact-contract" + )?); + assert!(support::array_contains_str( + graphiti, + "/produced_evidence", + "graphiti-provider-boundary" + )?); + assert!(support::array_contains_str( + graphify, + "/produced_evidence", + "graphify-source-location-output" + )?); + + Ok(()) +} + +#[test] +fn external_adapter_manifest_rejects_unmeasured_win_loss_scenario_outcomes() -> Result<()> { + let output = support::run_external_manifest_with_letta_attachment_mutation( + "invalid-scenario-outcome-test", + |scenario| { + support::set_json_pointer(scenario, "/status", serde_json::json!("not_encoded"))?; + + support::set_json_pointer(scenario, "/comparison_outcome", serde_json::json!("win")) + }, + )?; + + assert!(!output.status.success(), "invalid scenario outcome unexpectedly passed"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("not_encoded status with win outcome") + ); + + Ok(()) +} + +#[test] +fn external_adapter_manifest_rejects_unmeasured_win_loss_scenario_positions() -> Result<()> { + let output = support::run_external_manifest_with_letta_attachment_mutation( + "invalid-scenario-position-test", + |scenario| { + support::set_json_pointer(scenario, "/status", serde_json::json!("not_encoded"))?; + support::set_json_pointer(scenario, "/elf_position", serde_json::json!("wins"))?; + + support::set_json_pointer( + scenario, + "/comparison_outcome", + serde_json::json!("not_tested"), + ) + }, + )?; + + assert!(!output.status.success(), "invalid scenario position unexpectedly passed"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("not_encoded status with wins position") + ); + + Ok(()) +} + +#[test] +fn external_adapter_manifest_rejects_blocked_status_without_blocked_outcome() -> Result<()> { + let output = support::run_external_manifest_scenario_mutation( + "invalid-blocked-scenario-outcome-test", + "letta_research_gate", + "stale_core_detection", + |scenario| { + scenario + .as_object_mut() + .ok_or_else(|| eyre::eyre!("scenario is not an object"))? + .remove("comparison_outcome"); + + Ok(()) + }, + )?; + + assert!(!output.status.success(), "invalid blocked scenario unexpectedly passed"); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("blocked status without blocked comparison outcome") + ); + + Ok(()) +} + +#[test] +fn external_adapter_manifest_rejects_conflicting_scenario_position_and_outcome() -> Result<()> { + let output = support::run_external_manifest_with_letta_attachment_mutation( + "invalid-scenario-position-outcome-test", + |scenario| { + support::set_json_pointer(scenario, "/status", serde_json::json!("pass"))?; + support::set_json_pointer(scenario, "/elf_position", serde_json::json!("ties"))?; + + support::set_json_pointer(scenario, "/comparison_outcome", serde_json::json!("loss")) + }, + )?; + + assert!(!output.status.success(), "conflicting scenario unexpectedly passed"); + assert!(String::from_utf8_lossy(&output.stderr).contains("ties position with loss outcome")); + + Ok(()) +} + +fn assert_live_sweep_record(adapter: &Value, production_ops_status: &str) -> Result<()> { + let suites = support::array_at(adapter, "/suites")?; + let capabilities = support::array_at(adapter, "/capabilities")?; + let adapter_id = adapter.pointer("/adapter_id").and_then(Value::as_str).unwrap_or_default(); + let targeted = support::find_by_field(capabilities, "/capability", "targeted_live_pass")?; + let full_pass = support::find_by_field(capabilities, "/capability", "full_suite_live_pass")?; + let work_resume = support::find_by_field(suites, "/suite_id", "work_resume")?; + let memory_evolution = support::find_by_field(suites, "/suite_id", "memory_evolution")?; + let production_ops = support::find_by_field(suites, "/suite_id", "production_ops")?; + let consolidation = support::find_by_field(suites, "/suite_id", "consolidation")?; + let knowledge = support::find_by_field(suites, "/suite_id", "knowledge_compilation")?; + let operator_debug = support::find_by_field(suites, "/suite_id", "operator_debugging_ux")?; + let capture = support::find_by_field(suites, "/suite_id", "capture_integration")?; + let personalization = support::find_by_field(suites, "/suite_id", "personalization")?; + let core_archival = support::find_by_field(suites, "/suite_id", "core_archival_memory")?; + let context_trajectory = support::find_by_field(suites, "/suite_id", "context_trajectory")?; + let trust_sot = support::find_by_field(suites, "/suite_id", "trust_source_of_truth")?; + let retrieval = support::find_by_field(suites, "/suite_id", "retrieval")?; + let project_decisions = support::find_by_field(suites, "/suite_id", "project_decisions")?; + + assert_eq!(suites.len(), 13); + assert_eq!(targeted.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(full_pass.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert!( + adapter + .pointer("/result/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("55 jobs across all 13 checked-in suites")) + ); + assert_eq!(trust_sot.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(work_resume.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(retrieval.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(project_decisions.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + production_ops.pointer("/status").and_then(Value::as_str), + Some(production_ops_status) + ); + + if adapter_id == "elf_live_real_world" { + assert_eq!(consolidation.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(knowledge.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(operator_debug.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(capture.pointer("/status").and_then(Value::as_str), Some("pass")); + assert!( + capture + .pointer("/evidence") + .and_then(Value::as_str) + .is_some_and(|evidence| evidence.contains("4/4 capture_integration jobs")) + ); + } else { + assert_eq!(consolidation.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(knowledge.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(operator_debug.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(capture.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + } + + assert_eq!(personalization.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("not_encoded")); + assert_eq!(context_trajectory.pointer("/status").and_then(Value::as_str), Some("blocked")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/live_adapter_tasks.rs b/apps/elf-eval/tests/real_world_job_benchmark/live_adapter_tasks.rs new file mode 100644 index 00000000..69edc0e0 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/live_adapter_tasks.rs @@ -0,0 +1,217 @@ +use std::{fs, path::Path}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +fn real_world_live_adapter_sources(workspace: &Path) -> Result { + let mut source = + fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_live_adapter.rs"))?; + + append_rust_sources( + workspace.join("apps/elf-eval/src/bin/real_world_live_adapter").as_path(), + &mut source, + )?; + + Ok(source) +} + +fn real_world_job_benchmark_sources(workspace: &Path) -> Result { + let mut source = + fs::read_to_string(workspace.join("apps/elf-eval/src/bin/real_world_job_benchmark.rs"))?; + + append_rust_sources( + workspace.join("apps/elf-eval/src/bin/real_world_job_benchmark").as_path(), + &mut source, + )?; + + Ok(source) +} + +fn append_rust_sources(dir: &Path, source: &mut String) -> Result<()> { + let mut entries = Vec::new(); + + for entry in fs::read_dir(dir)? { + entries.push(entry?.path()); + } + + entries.sort(); + + for path in entries { + if path.is_dir() { + append_rust_sources(path.as_path(), source)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + source.push('\n'); + source.push_str(fs::read_to_string(path)?.as_str()); + } + } + + Ok(()) +} + +#[test] +fn live_adapter_aggregate_forwards_graph_rag_smoke_controls() -> Result<()> { + let workspace = support::workspace_root()?; + let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; + let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; + + assert!( + makefile.contains("[tasks.real-world-memory-live-adapters]") + && makefile.contains("scripts/real-world-docker.sh") + && makefile.contains("memory-live-adapters"), + "Makefile should expose the live-adapter command and delegate Docker details to a script", + ); + + for env_name in [ + "ELF_REAL_WORLD_LIVE_ENABLE_RAGFLOW", + "ELF_REAL_WORLD_LIVE_ENABLE_LIGHTRAG", + "ELF_REAL_WORLD_LIVE_ENABLE_GRAPHRAG", + "ELF_REAL_WORLD_LIVE_ENABLE_GRAPHITI_ZEP", + "ELF_REAL_WORLD_LIVE_ENABLE_GRAPHIFY", + "ELF_RAGFLOW_SMOKE_START", + "ELF_RAGFLOW_SMOKE_ACCEPT_RESOURCE_ENVELOPE", + "ELF_GRAPHRAG_SMOKE_RUN", + "ELF_GRAPHRAG_API_KEY", + "ELF_GRAPHITI_ZEP_SMOKE_START", + "ELF_GRAPHITI_ZEP_SMOKE_RUN", + "ELF_GRAPHITI_ZEP_API_KEY", + "ELF_GRAPHIFY_SMOKE_RUN", + ] { + assert!( + docker_script.contains(&format!("-e {env_name}")), + "real-world-memory-live-adapters must forward {env_name}", + ); + } + + assert!( + docker_script.contains("--profile lightrag up -d lightrag"), + "aggregate task should start LightRAG profile when ELF_LIGHTRAG_CONTEXT_START=1", + ); + assert!( + docker_script.contains("--profile graphiti-zep up -d graphiti-falkordb"), + "aggregate task should start Graphiti/Zep profile when ELF_GRAPHITI_ZEP_SMOKE_START=1", + ); + + Ok(()) +} + +#[test] +fn openmemory_ui_export_probe_has_dedicated_docker_task() -> Result<()> { + let workspace_root = support::workspace_root()?; + let makefile = fs::read_to_string(workspace_root.join("Makefile.toml"))?; + let docker_script = fs::read_to_string(workspace_root.join("scripts/baseline-docker.sh"))?; + let compose = fs::read_to_string(workspace_root.join("docker-compose.baseline.yml"))?; + let script = fs::read_to_string(workspace_root.join("scripts/live-baseline-benchmark.sh"))?; + let report = serde_json::from_str::(&fs::read_to_string(workspace_root.join( + "apps/elf-eval/fixtures/report_snapshots/2026-06-11-xy-931-openmemory-ui-export-readback.json", + ))?)?; + + assert!(makefile.contains("[tasks.openmemory-ui-export-readback]")); + assert!(makefile.contains("scripts/baseline-docker.sh")); + assert!(makefile.contains("openmemory-ui-export-readback")); + assert!(docker_script.contains("export ELF_BASELINE_PROJECTS=mem0")); + assert!(compose.contains("ELF_MEM0_OPENMEMORY_EXPORT_USER_ID")); + assert!(compose.contains("ELF_MEM0_OPENMEMORY_EXPORT_CONTAINER")); + assert!(script.contains("probe_mem0_openmemory_ui_export")); + assert!(script.contains("mem0-openmemory-ui-export.json")); + assert!(script.contains("DOCKER_UNAVAILABLE_IN_BASELINE_RUNNER")); + assert!(script.contains("sdk_get_all_is_ui_export_evidence: false")); + assert!( + script.contains("SDK same-corpus retrieval and every encoded SDK behavior check passed") + ); + assert_eq!(report.pointer("/classification/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + report.pointer("/classification/reason_code").and_then(Value::as_str), + Some("DOCKER_UNAVAILABLE_IN_BASELINE_RUNNER") + ); + assert_eq!( + report + .pointer("/same_corpus_boundary/sdk_get_all_is_ui_export_evidence") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report + .pointer("/claim_boundary/elf_can_compare_against_openmemory_ui_export_after_this_run") + .and_then(Value::as_bool), + Some(false) + ); + + Ok(()) +} + +#[test] +fn operator_debug_live_adapter_task_is_docker_scoped() -> Result<()> { + let workspace = support::workspace_root()?; + let makefile = fs::read_to_string(workspace.join("Makefile.toml"))?; + let docker_script = fs::read_to_string(workspace.join("scripts/real-world-docker.sh"))?; + let script = fs::read_to_string( + workspace.join("scripts").join("real-world-operator-debug-live-adapters.sh"), + )?; + let live_adapter = real_world_live_adapter_sources(&workspace)?; + let benchmark = real_world_job_benchmark_sources(&workspace)?; + + assert!(makefile.contains("[tasks.real-world-job-operator-ux-live-adapters]")); + assert!(makefile.contains("scripts/real-world-docker.sh")); + assert!(makefile.contains("job-operator-ux-live-adapters")); + assert!( + docker_script.contains("docker compose -f docker-compose.baseline.yml run --build --rm") + ); + assert!(docker_script.contains("scripts/real-world-operator-debug-live-adapters.sh")); + assert!(script.contains("apps/elf-eval/fixtures/real_world_job/operator_debugging_ux")); + assert!(script.contains("elf_operator_debug_live")); + assert!(script.contains("qmd_operator_debug_live")); + assert!(script.contains("elf.real_world_operator_debug_live_adapter_sweep/v1")); + assert!(script.contains("trace_available")); + assert!(script.contains("replay_command_available")); + assert!(live_adapter.contains("fn operator_debug_output(")); + assert!(live_adapter.contains("fn qmd_replay_command(")); + assert!(live_adapter.contains("fn elf_replay_command(")); + assert!( + !live_adapter + .contains("does not yet hydrate full operator trace/viewer diagnostics for this suite") + ); + assert!(benchmark.contains("Replay command:")); + assert!(benchmark.contains("replay_command_available")); + + Ok(()) +} + +#[test] +fn live_adapter_supports_elf_capture_write_policy_without_external_hook_claims() -> Result<()> { + let workspace = support::workspace_root()?; + let live_adapter = real_world_live_adapter_sources(&workspace)?; + let live_script = + fs::read_to_string(workspace.join("scripts").join("real-world-live-adapters.sh"))?; + let manifest = fs::read_to_string( + workspace + .join("apps/elf-eval/fixtures/real_world_external_adapters") + .join("memory_projects_manifest.json"), + )?; + + assert!(live_adapter.contains("fn is_elf_capture_live_adapter(")); + assert!(live_adapter.contains("suite == \"capture_integration\"")); + assert!(live_adapter.contains("write_policy_audit_count")); + assert!(live_adapter.contains("excluded_evidence_ids")); + assert!(live_adapter.contains("source_id")); + assert!(live_adapter.contains("runtime_source_refs")); + assert!(live_adapter.contains("validate_capture_runtime_evidence")); + assert!(live_adapter.contains("capture_failure")); + assert!(live_adapter.contains("fn materialize_elf_consolidation(")); + assert!(live_adapter.contains("ConsolidationProposalReviewRequest")); + assert!(live_adapter.contains("fn materialize_elf_knowledge(")); + assert!(live_adapter.contains("KnowledgePageLintRequest")); + assert!(live_script.contains("OPERATOR_FIXTURE_DIR")); + assert!(live_script.contains("INPUT_FIXTURE_DIR")); + assert!(live_script.contains("operator_debugging_ux")); + assert!(manifest.contains("\"scenario_id\": \"live_capture_write_policy\"")); + assert!(manifest.contains("\"scenario_id\": \"capture_write_policy_hooks\"")); + assert!(manifest.contains("\"comparison_outcome\": \"blocked\"")); + assert!(manifest.contains("Four redaction, exclusion, source-id, evidence-binding")); + assert!(manifest.contains("durable upstream agentmemory session/capture path")); + assert!(manifest.contains("Docker-contained session directory")); + assert!(manifest.contains("claude-mem hooks, viewer, timeline, and observation workflows")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/markdown_rendering.rs b/apps/elf-eval/tests/real_world_job_benchmark/markdown_rendering.rs new file mode 100644 index 00000000..825ce9d8 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/markdown_rendering.rs @@ -0,0 +1,228 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +#[test] +fn generated_json_report_renders_markdown() -> Result<()> { + let report = support::run_json_report()?; + let temp_dir = env::temp_dir().join(format!("elf-real-world-job-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("report.json"); + let markdown_path = temp_dir.join("report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("# Real-World Job Benchmark Report")); + assert!(markdown.contains("work_resume")); + assert!(markdown.contains("Capture And Integration Coverage")); + assert!(markdown.contains("External Adapter Coverage")); + assert!(markdown.contains("live-baseline-only")); + assert!(markdown.contains("live real-world")); + assert!(markdown.contains("does not convert live-baseline retrieval results")); + assert!(markdown.contains("fixture-backed")); + assert!(markdown.contains("Answer Type")); + assert!(markdown.contains("Caveat Required")); + assert!(markdown.contains("Refusal Required")); + assert!(markdown.contains("agentmemory-style hook capture")); + assert!(markdown.contains("xy844-current-worktree")); + assert!(markdown.contains("Existing live-baseline reports remain valid")); + assert!(markdown.contains("### Adapter Scenario Judgments")); + assert!(markdown.contains("ELF scenario positions: `wins=10, ties=11, loses=1, untested=53`")); + assert!(markdown.contains( + "Scenario comparison outcomes: `win=10, tie=11, loss=1, not_tested=19, blocked=29, non_goal=5`" + )); + assert!(markdown.contains("| `claude_mem_live_baseline` | `same_corpus_retrieval`")); + assert!(markdown.contains("| `memsearch_live_baseline` | `ttl_expiry_lifecycle`")); + + Ok(()) +} + +#[test] +fn external_adapter_markdown_renders_nonzero_scenario_losses() -> Result<()> { + let mut report = support::run_json_report()?; + let adapters = report + .pointer_mut("/external_adapters/adapters") + .and_then(Value::as_array_mut) + .ok_or_else(|| eyre::eyre!("missing external adapter records"))?; + let adapter = adapters + .iter_mut() + .find(|adapter| { + adapter.pointer("/adapter_id").and_then(Value::as_str) + == Some("agentmemory_live_baseline") + }) + .ok_or_else(|| eyre::eyre!("missing agentmemory adapter"))?; + + support::set_json_pointer(adapter, "/scenarios/0/elf_position", serde_json::json!("loses"))?; + support::set_json_pointer( + adapter, + "/scenarios/0/comparison_outcome", + serde_json::json!("loss"), + )?; + support::set_json_pointer( + &mut report, + "/external_adapters/summary/scenario_position_counts", + serde_json::json!({ + "wins": 2, + "ties": 4, + "loses": 2, + "untested": 10 + }), + )?; + support::set_json_pointer( + &mut report, + "/external_adapters/summary/scenario_outcome_counts", + serde_json::json!({ + "win": 2, + "tie": 4, + "loss": 2, + "not_tested": 7, + "blocked": 1, + "non_goal": 2 + }), + )?; + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-loss-scenario-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("report.json"); + let markdown_path = temp_dir.join("report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("ELF scenario positions: `wins=2, ties=4, loses=2, untested=10`")); + assert!(markdown.contains( + "Scenario comparison outcomes: `win=2, tie=4, loss=2, not_tested=7, blocked=1, non_goal=2`" + )); + assert!(markdown.contains( + "| `agentmemory_live_baseline` | `basic_same_corpus_retrieval` | `retrieval` | `pass` | `loss` |" + )); + + Ok(()) +} + +#[test] +fn external_adapter_markdown_omits_scenario_summary_when_manifest_has_no_scenarios() -> Result<()> { + let mut report = support::run_json_report()?; + let adapters = report + .pointer_mut("/external_adapters/adapters") + .and_then(Value::as_array_mut) + .ok_or_else(|| eyre::eyre!("missing external adapter records"))?; + + for adapter in adapters { + support::set_json_pointer(adapter, "/scenarios", serde_json::json!([]))?; + } + + support::set_json_pointer( + &mut report, + "/external_adapters/summary/scenario_status_counts", + serde_json::json!({ + "real": 0, + "mocked": 0, + "unsupported": 0, + "blocked": 0, + "incomplete": 0, + "wrong_result": 0, + "lifecycle_fail": 0, + "pass": 0, + "not_encoded": 0 + }), + )?; + support::set_json_pointer( + &mut report, + "/external_adapters/summary/scenario_position_counts", + serde_json::json!({ + "wins": 0, + "ties": 0, + "loses": 0, + "untested": 0 + }), + )?; + support::set_json_pointer( + &mut report, + "/external_adapters/summary/scenario_outcome_counts", + serde_json::json!({ + "win": 0, + "tie": 0, + "loss": 0, + "not_tested": 0, + "blocked": 0, + "non_goal": 0 + }), + )?; + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-no-scenario-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("report.json"); + let markdown_path = temp_dir.join("report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("External Adapter Coverage")); + assert!(!markdown.contains("Scenario coverage statuses:")); + assert!(!markdown.contains("ELF scenario positions:")); + assert!(!markdown.contains("Scenario comparison outcomes:")); + assert!(!markdown.contains("### Adapter Scenario Judgments")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/memory_evolution.rs b/apps/elf-eval/tests/real_world_job_benchmark/memory_evolution.rs new file mode 100644 index 00000000..86f83564 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/memory_evolution.rs @@ -0,0 +1,215 @@ +use std::{env, fs, process}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn memory_evolution_fixtures_report_temporal_and_staleness_metrics() -> Result<()> { + let report = support::run_json_report_from(support::evolution_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/temporal_validity_not_encoded_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/evolution/temporal_validity_not_encoded_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/evolution/history_readback_encoded_count").and_then(Value::as_u64), + Some(1) + ); + + let suites = support::array_at(&report, "/suites")?; + let memory_evolution = support::find_by_field(suites, "/suite_id", "memory_evolution")?; + + assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + memory_evolution.pointer("/temporal_validity_not_encoded_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + memory_evolution.pointer("/history_readback_encoded_count").and_then(Value::as_u64), + Some(1) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let preference_job = + support::find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; + let relation_job = + support::find_by_field(jobs, "/job_id", "memory-evolution-relation-temporal-001")?; + + assert_eq!( + preference_job.pointer("/evolution/history_readback_encoded").and_then(Value::as_bool), + Some(true) + ); + assert!(support::array_contains_str(preference_job, "/evolution/history_event_types", "add")?); + assert!(support::array_contains_str( + preference_job, + "/evolution/history_event_types", + "update" + )?); + assert!(support::array_contains_str( + preference_job, + "/evolution/history_event_types", + "ignore" + )?); + assert_eq!( + preference_job + .pointer("/evolution/history_requires_note_version_links") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + preference_job.pointer("/evolution/selected_current_evidence/0").and_then(Value::as_str), + Some("pref-current-concise-rationale") + ); + assert_eq!( + preference_job.pointer("/evolution/selected_historical_evidence/0").and_then(Value::as_str), + Some("pref-old-terse-bullets") + ); + assert_eq!( + preference_job.pointer("/evolution/selected_rationale_evidence/0").and_then(Value::as_str), + Some("pref-update-rationale") + ); + assert_eq!(relation_job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + relation_job.pointer("/evolution/temporal_validity_not_encoded").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + relation_job.pointer("/evolution/temporal_validity_encoded").and_then(Value::as_bool), + Some(true) + ); + + let follow_ups = support::array_at(&report, "/follow_ups")?; + + assert!(follow_ups.is_empty()); + + Ok(()) +} + +#[test] +fn memory_evolution_conflict_still_fails_when_selected_evidence_is_not_narrated() -> Result<()> { + let fixture_path = + support::evolution_fixture_dir().join("preference_changed_current_vs_historical.json"); + let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; + + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/evidence_ids", + serde_json::json!([ + "pref-current-concise-rationale", + "pref-old-terse-bullets", + "pref-update-rationale" + ]), + )?; + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/claims", + serde_json::json!([ + { + "claim_id": "current_preference", + "text": "Use concise prose with explicit evidence before bullets.", + "evidence_ids": ["pref-current-concise-rationale", "pref-update-rationale"], + "confidence": "high" + }, + { + "claim_id": "preference_update_rationale", + "text": "The preference changed because terse bullets hid rationale.", + "evidence_ids": ["pref-update-rationale"], + "confidence": "high" + } + ]), + )?; + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-memory-conflict-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("conflict.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(job.pointer("/evolution/conflict_detection_count").and_then(Value::as_u64), Some(0)); + assert!(support::array_contains_str( + job, + "/evolution/selected_but_not_narrated_evidence", + "pref-old-terse-bullets" + )?); + + Ok(()) +} + +#[test] +fn memory_evolution_counts_stale_answer_when_old_fact_is_answered_as_current() -> Result<()> { + let fixture_path = + support::evolution_fixture_dir().join("preference_changed_current_vs_historical.json"); + let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; + + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/content", + Value::String( + "Use terse bullet-only benchmark updates as the current preference.".to_string(), + ), + )?; + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/evidence_ids", + serde_json::json!(["pref-old-terse-bullets"]), + )?; + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/claims", + serde_json::json!([ + { + "claim_id": "current_preference", + "text": "Use terse bullet-only benchmark updates as the current preference.", + "evidence_ids": ["pref-old-terse-bullets"], + "confidence": "high" + } + ]), + )?; + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-memory-stale-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("stale_preference.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + + assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!(job.pointer("/evolution/stale_answer_count").and_then(Value::as_u64), Some(1)); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/memory_summary.rs b/apps/elf-eval/tests/real_world_job_benchmark/memory_summary.rs new file mode 100644 index 00000000..42a1efc1 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/memory_summary.rs @@ -0,0 +1,275 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn memory_summary_fixtures_score_reviewable_source_trace_contract() -> Result<()> { + let report = support::run_json_report_from(support::memory_summary_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/memory_summary/summary_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/memory_summary/entry_count").and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + report + .pointer("/summary/memory_summary/covered_required_category_count") + .and_then(Value::as_u64), + Some(6) + ); + assert_eq!( + report.pointer("/summary/memory_summary/source_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/memory_summary/freshness_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/memory_summary/rationale_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/memory_summary/unsupported_derived_entry_count") + .and_then(Value::as_u64), + Some(1) + ); + + let suites = support::array_at(&report, "/suites")?; + let memory_summary = support::find_by_field(suites, "/suite_id", "memory_summary")?; + + assert_eq!(memory_summary.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(memory_summary.pointer("/encoded_job_count").and_then(Value::as_u64), Some(1)); + + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(job.pointer("/memory_summary/top_of_mind_count").and_then(Value::as_u64), Some(1)); + assert_eq!(job.pointer("/memory_summary/tombstone_ref_count").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn memory_summary_markdown_renders_source_trace_metrics() -> Result<()> { + let report = support::run_json_report_from(support::memory_summary_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-memory-summary-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("memory-summary-report.json"); + let markdown_path = temp_dir.join("memory-summary-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("Memory Summary Metrics")); + assert!(markdown.contains("memory-summary-source-trace-001")); + assert!(markdown.contains("Memory summary source-ref coverage")); + assert!(markdown.contains("Invalid Top-of-Mind")); + assert!(markdown.contains("Derived Unsupported")); + + Ok(()) +} + +#[test] +fn memory_summary_fixture_fails_stale_top_of_mind_entries() -> Result<()> { + let fixture_path = + support::memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][2]["category"] = + Value::String("top_of_mind".to_string()); + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][2]["freshness"] + ["status"] = Value::String("current".to_string()); + + let temp_dir = + env::temp_dir().join(format!("elf-memory-summary-stale-current-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("stale_current_summary.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn memory_summary_fixture_fails_tombstoned_top_of_mind_entries() -> Result<()> { + let fixture_path = + support::memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][4]["category"] = + Value::String("top_of_mind".to_string()); + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][4]["freshness"] + ["status"] = Value::String("current".to_string()); + + let temp_dir = env::temp_dir() + .join(format!("elf-memory-summary-tombstone-current-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write( + temp_dir.join("tombstone_current_summary.json"), + serde_json::to_vec_pretty(&fixture)?, + )?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn memory_summary_fixture_fails_untraced_derived_profile_entries() -> Result<()> { + let fixture_path = + support::memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["unsupported_claim_flags"] = + Value::Array(Vec::new()); + + let temp_dir = + env::temp_dir().join(format!("elf-memory-summary-untraced-derived-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write( + temp_dir.join("untraced_derived_summary.json"), + serde_json::to_vec_pretty(&fixture)?, + )?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("unsupported_claim")); + assert_eq!( + job.pointer("/memory_summary/derived_missing_source_or_unsupported_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn memory_summary_fixture_fails_unsupported_current_derived_entries() -> Result<()> { + let fixture_path = + support::memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["source_refs"] = + Value::Array(vec![Value::String("summary-contract-non-parity-boundary".to_string())]); + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["freshness"] + ["status"] = Value::String("current".to_string()); + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][6]["rationale"] + ["decision"] = Value::String("included".to_string()); + + let temp_dir = env::temp_dir() + .join(format!("elf-memory-summary-unsupported-current-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write( + temp_dir.join("unsupported_current_summary.json"), + serde_json::to_vec_pretty(&fixture)?, + )?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/memory_summary/unsupported_current_entry_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn memory_summary_fixture_fails_tombstone_entries_without_tombstone_refs() -> Result<()> { + let fixture_path = + support::memory_summary_fixture_dir().join("reviewable_summary_source_trace.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["memory_summaries"][0]["entries"][4]["freshness"] + ["tombstone_refs"] = Value::Array(Vec::new()); + + let temp_dir = + env::temp_dir().join(format!("elf-memory-summary-tombstone-refs-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write( + temp_dir.join("missing_tombstone_refs_summary.json"), + serde_json::to_vec_pretty(&fixture)?, + )?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "memory-summary-source-trace-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/memory_summary/freshness_coverage").and_then(Value::as_f64), + Some(0.857) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/misc_reports.rs b/apps/elf-eval/tests/real_world_job_benchmark/misc_reports.rs new file mode 100644 index 00000000..84bfbe01 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/misc_reports.rs @@ -0,0 +1,75 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; + +use crate::support; + +#[test] +fn mem0_delete_audit_probe_requires_explicit_delete_history_event() -> Result<()> { + let script = fs::read_to_string( + support::workspace_root()?.join("scripts").join("live-baseline-benchmark.sh"), + )?; + + assert!(script.contains("def history_has_event")); + assert!(script.contains("str(entry.get(\"event\", \"\")).upper() == expected")); + assert!(script.contains( + "history_has_event(\n preference_history[\"history\"],\n \"ADD\"," + )); + assert!(script.contains( + "history_has_event(\n preference_history[\"history\"],\n \"UPDATE\"," + )); + assert!( + script.contains( + "history_has_event(\n delete_history[\"history\"],\n \"DELETE\"," + ) + ); + assert!( + !script.contains( + "contains_terms(\n delete_history[\"history\"],\n [\"delete\"]," + ) + ); + + Ok(()) +} + +#[test] +fn knowledge_json_report_renders_markdown_metrics() -> Result<()> { + let report = support::run_json_report_from(support::knowledge_fixture_dir())?; + let temp_dir = env::temp_dir().join(format!("elf-real-world-knowledge-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("knowledge-report.json"); + let markdown_path = temp_dir.join("knowledge-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("Knowledge Page Metrics")); + assert!(markdown.contains("Knowledge citation coverage")); + assert!(markdown.contains("Backlinks: `11` total")); + assert!(markdown.contains("Unsupported summary count")); + assert!(markdown.contains("knowledge-project-page-001")); + assert!(markdown.contains("knowledge-entity-concept-002")); + assert!(markdown.contains("knowledge-watch-rebuild-003")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/operator_debug.rs b/apps/elf-eval/tests/real_world_job_benchmark/operator_debug.rs new file mode 100644 index 00000000..d1f3574f --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/operator_debug.rs @@ -0,0 +1,83 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; + +use crate::support; + +#[test] +fn operator_debug_json_report_renders_markdown_links() -> Result<()> { + let report = support::run_json_report_from(support::operator_debug_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-job-operator-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("operator.json"); + let markdown_path = temp_dir.join("operator.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("operator-debug-dropped-evidence-001")); + assert!(markdown.contains("/viewer?trace_id=11111111-1111-4111-8111-111111111111")); + assert!(markdown.contains("Raw SQL")); + assert!(markdown.contains("Replay Candidates")); + assert!(markdown.contains("Root cause")); + + Ok(()) +} + +#[test] +fn memory_evolution_report_renders_markdown_counters() -> Result<()> { + let report = support::run_json_report_from(support::evolution_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-memory-evolution-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("evolution-report.json"); + let markdown_path = temp_dir.join("evolution-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("## Memory Evolution")); + assert!(markdown.contains("Temporal validity not encoded: `0`")); + assert!(markdown.contains("| memory_evolution | memory-evolution-relation-temporal-001")); + assert!(markdown.contains("`encoded`")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/proactive_brief.rs b/apps/elf-eval/tests/real_world_job_benchmark/proactive_brief.rs new file mode 100644 index 00000000..2e434fb7 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/proactive_brief.rs @@ -0,0 +1,213 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn proactive_brief_fixtures_score_source_linked_suggestions() -> Result<()> { + let report = support::run_json_report_from(support::proactive_brief_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/proactive_brief/brief_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/suggestion_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/freshness_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/proactive_brief/action_rationale_coverage") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/proactive_brief/invalid_current_suggestion_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/proactive_brief/tombstone_violation_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/rejected_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/deferred_count").and_then(Value::as_u64), + Some(2) + ); + + let suites = support::array_at(&report, "/suites")?; + let proactive = support::find_by_field(suites, "/suite_id", "proactive_brief")?; + + assert_eq!(proactive.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(proactive.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + + let jobs = support::array_at(&report, "/jobs")?; + let daily = support::find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; + let private = + support::find_by_field(jobs, "/job_id", "proactive-private-corpus-refresh-blocked-001")?; + + assert_eq!(daily.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + daily.pointer("/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!(private.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert!( + report + .pointer("/follow_ups/0/title") + .and_then(Value::as_str) + .is_some_and(|title| title.contains("XY-930")) + ); + + Ok(()) +} + +#[test] +fn proactive_brief_markdown_renders_source_and_freshness_metrics() -> Result<()> { + let report = support::run_json_report_from(support::proactive_brief_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-proactive-brief-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("proactive-brief-report.json"); + let markdown_path = temp_dir.join("proactive-brief-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("Proactive Brief Metrics")); + assert!(markdown.contains("proactive-daily-project-brief-001")); + assert!(markdown.contains("Proactive evidence-ref coverage")); + assert!(markdown.contains("Invalid Current")); + assert!(markdown.contains("Tombstone Violations")); + + Ok(()) +} + +#[test] +fn proactive_brief_fixture_fails_unsupported_suggestions() -> Result<()> { + let fixture_path = support::proactive_brief_fixture_dir().join("daily_project_brief.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["evidence_refs"] = + Value::Array(Vec::new()); + + let temp_dir = + env::temp_dir().join(format!("elf-proactive-unsupported-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("unsupported_brief.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "proactive-daily-project-brief-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("unsupported_claim")); + assert_eq!( + job.pointer("/proactive_brief/untraced_suggestion_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn proactive_brief_fixture_fails_stale_decisions_presented_current() -> Result<()> { + let fixture_path = support::proactive_brief_fixture_dir().join("stale_decision_audit.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["freshness"] + ["status"] = Value::String("current".to_string()); + + let temp_dir = + env::temp_dir().join(format!("elf-proactive-stale-current-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("stale_current_brief.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "proactive-stale-decision-audit-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/proactive_brief/invalid_current_suggestion_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn proactive_brief_fixture_fails_tombstone_ttl_violations() -> Result<()> { + let fixture_path = + support::proactive_brief_fixture_dir().join("stale_plan_preference_warning.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["freshness"] + ["status"] = Value::String("current".to_string()); + fixture["corpus"]["adapter_response"]["answer"]["proactive_briefs"][0]["suggestions"][0]["action"] + ["decision"] = Value::String("recommend".to_string()); + + let temp_dir = env::temp_dir().join(format!("elf-proactive-tombstone-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("tombstone_current_brief.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = + support::find_by_field(jobs, "/job_id", "proactive-stale-plan-preference-warning-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/proactive_brief/tombstone_violation_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/production_ops.rs b/apps/elf-eval/tests/real_world_job_benchmark/production_ops.rs new file mode 100644 index 00000000..cb53b97c --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/production_ops.rs @@ -0,0 +1,341 @@ +use std::{env, fs, process}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn production_ops_fixtures_report_bounded_typed_states() -> Result<()> { + let report = support::run_json_report_from(support::production_ops_fixture_dir())?; + + assert_production_ops_summary(&report)?; + assert_production_ops_jobs(&report)?; + assert_production_ops_operational_evidence(&report)?; + + Ok(()) +} + +fn assert_production_ops_summary(report: &Value) -> Result<()> { + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(8)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(2)); + assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!( + report.pointer("/summary/qdrant_rebuild_case_count").and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + report.pointer("/private_corpus_redaction/private_fixture_count").and_then(Value::as_u64), + Some(1) + ); + + let suites = support::array_at(report, "/suites")?; + let production_ops = support::find_by_field(suites, "/suite_id", "production_ops")?; + + assert_eq!(production_ops.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(production_ops.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); + + Ok(()) +} + +fn assert_production_ops_jobs(report: &Value) -> Result<()> { + let jobs = support::array_at(report, "/jobs")?; + let authority_recovery = + support::find_by_field(jobs, "/job_id", "production-ops-authority-plane-recovery-001")?; + let backfill = support::find_by_field(jobs, "/job_id", "production-ops-backfill-resume-001")?; + let restore = support::find_by_field(jobs, "/job_id", "production-ops-restore-cold-start-001")?; + let public_proxy = + support::find_by_field(jobs, "/job_id", "production-ops-public-proxy-addendum-001")?; + let private_manifest = + support::find_by_field(jobs, "/job_id", "production-ops-private-manifest-blocked-001")?; + let credentials = + support::find_by_field(jobs, "/job_id", "production-ops-credential-boundary-001")?; + let dependency = + support::find_by_field(jobs, "/job_id", "production-ops-cold-start-dependency-001")?; + + assert_authority_recovery_job(authority_recovery)?; + + assert_eq!(authority_recovery.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(backfill.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(restore.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(restore.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), Some(true)); + assert_eq!(public_proxy.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + public_proxy.pointer("/operational_evidence_tier").and_then(Value::as_str), + Some("public_proxy") + ); + assert_eq!(private_manifest.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + private_manifest.pointer("/operational_evidence_tier").and_then(Value::as_str), + Some("private_corpus") + ); + assert_eq!(credentials.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!( + credentials.pointer("/operational_evidence_tier").and_then(Value::as_str), + Some("provider_backed") + ); + assert_eq!(dependency.pointer("/status").and_then(Value::as_str), Some("pass")); + + Ok(()) +} + +fn assert_authority_recovery_job(job: &Value) -> Result<()> { + assert_eq!(job.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), Some(true)); + assert_eq!(job.pointer("/requires_caveat").and_then(Value::as_bool), Some(true)); + assert_eq!( + job.pointer("/recovery_drills/0/contract_schema").and_then(Value::as_str), + Some("elf.authority_recovery_drill/v1") + ); + assert!(support::array_at(job, "/hard_fail_hits")?.is_empty()); + + Ok(()) +} + +fn assert_production_ops_operational_evidence(report: &Value) -> Result<()> { + assert_eq!( + report.pointer("/operational_evidence/schema").and_then(Value::as_str), + Some("elf.operational_evidence_gates/v1") + ); + assert_eq!( + report + .pointer("/operational_evidence/missing_private_provider_inputs_are_typed_blockers") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report + .pointer("/operational_evidence/private_corpus_pass_claim_allowed") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report + .pointer("/operational_evidence/provider_backed_pass_claim_allowed") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + report.pointer("/operational_evidence/latency/measured_job_count").and_then(Value::as_u64), + Some(8) + ); + assert_eq!( + report.pointer("/operational_evidence/cost/jobs_with_cost_report").and_then(Value::as_u64), + Some(8) + ); + assert_eq!( + report + .pointer("/operational_evidence/resource/resource_envelope_job_count") + .and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + report + .pointer("/operational_evidence/cold_start_restore_rebuild/qdrant_rebuild_pass_count") + .and_then(Value::as_u64), + Some(2) + ); + + assert_authority_recovery_operational_evidence(report); + + let tiers = support::array_at(report, "/operational_evidence/tiers")?; + let local_fixture = support::find_by_field(tiers, "/tier", "local_fixture")?; + let public_proxy_tier = support::find_by_field(tiers, "/tier", "public_proxy")?; + let private_corpus = support::find_by_field(tiers, "/tier", "private_corpus")?; + let provider_backed = support::find_by_field(tiers, "/tier", "provider_backed")?; + + assert_eq!(local_fixture.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(local_fixture.pointer("/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(public_proxy_tier.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(public_proxy_tier.pointer("/job_count").and_then(Value::as_u64), Some(1)); + assert_eq!(private_corpus.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(private_corpus.pointer("/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(provider_backed.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(provider_backed.pointer("/blocked").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +fn assert_authority_recovery_operational_evidence(report: &Value) { + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/drill_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/authority_plane_count") + .and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/backup_pitr_restored_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/record_count_preserved_count") + .and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/source_ref_preserved_count") + .and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/lifecycle_history_preserved_count") + .and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/rpo_met_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/rto_met_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/idempotent_outbox_replay_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/qdrant_rebuild_complete_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/migration_repair_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report + .pointer("/operational_evidence/authority_recovery/dead_letter_handled_count") + .and_then(Value::as_u64), + Some(1) + ); +} + +#[test] +fn authority_recovery_fixture_rejects_incomplete_recovery_predicates() -> Result<()> { + for (slug, pointer, replacement, expected_error) in authority_recovery_failure_cases() { + assert_authority_recovery_fixture_failure( + slug, + |fixture| support::set_json_pointer(fixture, pointer, replacement), + expected_error, + )?; + } + + Ok(()) +} + +fn authority_recovery_failure_cases() -> Vec<(&'static str, &'static str, Value, &'static str)> { + vec![ + ( + "unrestored-backup", + "/corpus/adapter_response/answer/recovery_drills/0/backup_pitr/restored", + serde_json::json!(false), + "incomplete backup/PITR drill evidence", + ), + ( + "record-count-loss", + "/corpus/adapter_response/answer/recovery_drills/0/authority_record_counts/0/after_count", + serde_json::json!(2), + "lost or gained source authority records", + ), + ( + "source-ref-loss", + "/corpus/adapter_response/answer/recovery_drills/0/authority_record_counts/0/source_refs_preserved", + serde_json::json!(false), + "did not preserve source authority source refs", + ), + ( + "lifecycle-history-loss", + "/corpus/adapter_response/answer/recovery_drills/0/authority_record_counts/0/lifecycle_history_preserved", + serde_json::json!(false), + "did not preserve source authority lifecycle history", + ), + ( + "hidden-source-of-truth", + "/corpus/adapter_response/answer/recovery_drills/0/degraded_read/source_of_truth_visible", + serde_json::json!(false), + "hidden source-of-truth records during degraded read", + ), + ( + "rpo-miss", + "/corpus/adapter_response/answer/recovery_drills/0/rpo/measured_seconds", + serde_json::json!(61.0), + "exceeded rpo recovery target", + ), + ( + "non-idempotent-outbox", + "/corpus/adapter_response/answer/recovery_drills/0/outbox_replay/duplicate_write_count", + serde_json::json!(1), + "incomplete outbox replay drill evidence", + ), + ( + "incomplete-qdrant-rebuild", + "/corpus/adapter_response/answer/recovery_drills/0/qdrant_rebuild/complete", + serde_json::json!(false), + "incomplete Qdrant rebuild drill evidence", + ), + ( + "missing-migration-repair", + "/corpus/adapter_response/answer/recovery_drills/0/migration_repair/applied", + serde_json::json!(false), + "incomplete migration repair drill evidence", + ), + ( + "dead-letter-underhandled", + "/corpus/adapter_response/answer/recovery_drills/0/dead_letter/handled_count", + serde_json::json!(1), + "incomplete dead-letter handling drill evidence", + ), + ] +} + +fn assert_authority_recovery_fixture_failure( + slug: &str, + mutate: F, + expected_error: &str, +) -> Result<()> +where + F: FnOnce(&mut Value) -> Result<()>, +{ + let fixture_path = + support::production_ops_fixture_dir().join("authority_plane_recovery_drill.json"); + let mut fixture = support::load_json(&fixture_path)?; + + mutate(&mut fixture)?; + + let temp_dir = env::temp_dir().join(format!("elf-authority-recovery-{slug}-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("fixture.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let stderr = support::run_json_report_from_failure(temp_dir)?; + + assert!( + stderr.contains(expected_error), + "missing expected error `{expected_error}` in stderr: {stderr}", + ); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/recall_debug_reports.rs b/apps/elf-eval/tests/real_world_job_benchmark/recall_debug_reports.rs new file mode 100644 index 00000000..129fa87c --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/recall_debug_reports.rs @@ -0,0 +1,193 @@ +use std::{fs, path::Path}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support::{self, RecallDebugSourceContract}; + +fn rust_module_sources(workspace: &Path, root_file: &str, module_dir: &str) -> Result { + let mut source = fs::read_to_string(workspace.join(root_file))?; + + append_rust_sources(workspace.join(module_dir).as_path(), &mut source)?; + + Ok(source) +} + +fn append_rust_sources(dir: &Path, source: &mut String) -> Result<()> { + let mut entries = Vec::new(); + + for entry in fs::read_dir(dir)? { + entries.push(entry?.path()); + } + + entries.sort(); + + for path in entries { + if path.is_dir() { + append_rust_sources(path.as_path(), source)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + source.push('\n'); + source.push_str(fs::read_to_string(path)?.as_str()); + } + } + + Ok(()) +} + +fn assert_recall_debug_source_contract(sources: &RecallDebugSourceContract<'_>) { + assert!(sources.service.contains("ELF_RECALL_DEBUG_PANEL_SCHEMA_V1")); + assert!(sources.service.contains("ELF_RECALL_TRACE_SCHEMA_V1")); + assert!(sources.service.contains("pub async fn recall_debug_panel")); + assert!(sources.service.contains("build_recall_trace")); + assert!(sources.service.contains("not_requested_layer")); + assert!(sources.service.contains("blocked_layer")); + assert!(sources.service.contains("public_error_class")); + assert!(sources.service.contains("candidate_identity")); + assert!(sources.service.contains("ORG_PROJECT_ID")); + assert!(sources.service.contains("trace_bundle_get")); + assert!(sources.service.contains("docs_search_l0")); + assert!(sources.service.contains("knowledge_pages_search")); + assert!(sources.service.contains("graph_report")); + assert!(sources.service.contains("dreaming_review_queue")); + assert!(sources.service_lib.contains("pub mod recall_debug")); + assert!(sources.service_lib.contains("RecallDebugPanelResponse")); + assert!(sources.service_lib.contains("RecallTrace")); + assert!(sources.routes.contains("/v2/recall-debug/panel")); + assert!(sources.routes.contains("/v2/admin/recall-debug/panel")); + assert!(sources.routes.contains("async fn recall_debug_panel")); + assert!(sources.routes.contains("RecallDebugPanelRequest")); + assert!(sources.mcp.contains("elf_recall_debug_panel")); + assert!(sources.mcp.contains("recall_debug_panel_schema")); + assert!(sources.mcp.contains("/v2/recall-debug/panel")); + assert!(sources.recall_spec.contains("elf.recall_debug_panel/v1")); + assert!(sources.recall_spec.contains("elf.recall_trace/v1")); + assert!(sources.recall_spec.contains("not_requested")); + assert!(sources.recall_spec.contains("evidence_class = \"blocked\"")); + assert!(sources.recall_spec.contains("effective `top_k` cap of 32")); + assert!(sources.recall_spec.contains("context_state = \"stale\"")); + assert!(sources.recall_spec.contains("selected`, `dropped`, `available`, or `reviewable`")); + assert!(sources.service_spec.contains("POST /v2/recall-debug/panel")); + assert!(sources.service_spec.contains("POST /v2/admin/recall-debug/panel")); + assert!(sources.service_spec.contains("elf.recall_trace/v1")); + assert!(sources.service_spec.contains("system_recall_debug_panel_v1.md")); + assert!(sources.version_registry.contains("elf.recall_debug_panel/v1")); + assert!(sources.version_registry.contains("elf.recall_trace/v1")); + assert!(sources.markdown.contains("Recall Debug Panel Report")); + assert!(sources.markdown.contains("POST /v2/recall-debug/panel")); + assert!(sources.markdown.contains("`elf.recall_trace/v1`")); + assert!(sources.markdown.contains("Missing anchors stay visible as `not_requested`")); + assert!(sources.markdown.contains("retained dropped replay candidates")); + assert!(sources.markdown.contains("effective cap of 32 rows")); + assert!(sources.benchmarking_index.contains("2026-06-20-recall-debug-panel-report.md")); + assert!(sources.readme.contains("Recall/debug panel after XY-1022")); + assert!(sources.readme.contains("elf.recall_debug_panel/v1")); + assert!(sources.readme.contains("retained dropped replay candidates")); +} + +#[test] +fn recall_debug_panel_report_wires_cross_layer_debug_contract() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::recall_debug_panel_report_json_path()?, + )?)?; + let markdown = fs::read_to_string(support::recall_debug_panel_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let workspace = support::workspace_root()?; + let service = rust_module_sources( + &workspace, + "packages/elf-service/src/recall_debug.rs", + "packages/elf-service/src/recall_debug", + )?; + let service_lib = fs::read_to_string(workspace.join("packages/elf-service/src/lib.rs"))?; + let routes = + rust_module_sources(&workspace, "apps/elf-api/src/routes.rs", "apps/elf-api/src/routes")?; + let mcp = + rust_module_sources(&workspace, "apps/elf-mcp/src/server.rs", "apps/elf-mcp/src/server")?; + let recall_spec = + fs::read_to_string(workspace.join("docs/spec/system_recall_debug_panel_v1.md"))?; + let service_spec = + fs::read_to_string(workspace.join("docs/spec/system_elf_memory_service_v2.md"))?; + let version_registry = + fs::read_to_string(workspace.join("docs/spec/system_version_registry.md"))?; + + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.recall_debug_panel_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-1022")); + assert_eq!( + report.pointer("/service_contract/response_schema").and_then(Value::as_str), + Some("elf.recall_debug_panel/v1") + ); + assert_eq!( + report.pointer("/service_contract/trace_schema").and_then(Value::as_str), + Some("elf.recall_trace/v1") + ); + assert_eq!( + report.pointer("/service_contract/read_model_only").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report.pointer("/service_contract/raw_sql_needed").and_then(Value::as_bool), + Some(false) + ); + assert_eq!(report.pointer("/layer_contract/layer_count").and_then(Value::as_u64), Some(5)); + + let layers = support::array_at(&report, "/layer_contract/layers")?; + + for (layer, authority, replay) in [ + ("memory_notes", "memory_note", "elf_admin_trace_bundle_get"), + ("source_documents", "source_library", "elf_docs_search_l0"), + ("knowledge_pages", "derived_knowledge_page", "elf_recall_debug_panel"), + ("graph_facts", "graph_fact", "elf_graph_report"), + ("dreaming_proposals", "reviewable_dreaming_proposal", "elf_dreaming_review_queue"), + ] { + let row = support::find_by_field(layers, "/layer", layer)?; + + assert_eq!(row.pointer("/authority_layer").and_then(Value::as_str), Some(authority)); + assert_eq!(row.pointer("/replay_surface").and_then(Value::as_str), Some(replay)); + assert_eq!(row.pointer("/evidence_class").and_then(Value::as_str), Some("pass")); + } + + let memory = support::find_by_field(layers, "/layer", "memory_notes")?; + let docs = support::find_by_field(layers, "/layer", "source_documents")?; + + assert!(support::array_contains_str(memory, "/selection_states", "selected")?); + assert!(support::array_contains_str(memory, "/selection_states", "dropped")?); + assert_eq!(docs.pointer("/effective_limit").and_then(Value::as_u64), Some(32)); + assert_eq!( + report.pointer("/debug_invariants/not_requested_layers_preserved").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report + .pointer("/debug_invariants/selected_and_dropped_memory_candidates") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report + .pointer("/debug_invariants/requested_layer_failures_preserved_as_blocked") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + report.pointer("/debug_invariants/no_source_mutation").and_then(Value::as_bool), + Some(true) + ); + + assert_recall_debug_source_contract(&RecallDebugSourceContract { + service: &service, + service_lib: &service_lib, + routes: &routes, + mcp: &mcp, + recall_spec: &recall_spec, + service_spec: &service_spec, + version_registry: &version_registry, + markdown: &markdown, + benchmarking_index: &benchmarking_index, + readme: &readme, + }); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/retrieval.rs b/apps/elf-eval/tests/real_world_job_benchmark/retrieval.rs new file mode 100644 index 00000000..9170126d --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/retrieval.rs @@ -0,0 +1,152 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn retrieval_fixtures_report_quality_and_trace_attribution() -> Result<()> { + let report = support::run_json_report_from(support::retrieval_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(6)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/irrelevant_context_ratio").and_then(Value::as_f64), + Some(0.0) + ); + assert_eq!( + report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/wrong_result_stage_attribution_count").and_then(Value::as_u64), + Some(0) + ); + + let suites = support::array_at(&report, "/suites")?; + let retrieval_suite = support::find_by_field(suites, "/suite_id", "retrieval")?; + let debug_suite = support::find_by_field(suites, "/suite_id", "operator_debugging_ux")?; + + assert_eq!(retrieval_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(retrieval_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(debug_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + + let jobs = support::array_at(&report, "/jobs")?; + let stage_job = + support::find_by_field(jobs, "/job_id", "operator-debug-stage-attribution-001")?; + + assert_eq!(stage_job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + stage_job.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("rerank.score") + ); + assert_eq!( + stage_job.pointer("/retrieval_quality/expected_evidence_recall").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + stage_job.pointer("/retrieval_quality/irrelevant_context_ratio").and_then(Value::as_f64), + Some(0.0) + ); + + Ok(()) +} + +#[test] +fn stage_attribution_fixture_still_fails_when_decoy_is_used() -> Result<()> { + let fixture_path = + support::retrieval_fixture_dir().join("stage_explainability_wrong_result.json"); + let mut fixture = serde_json::from_str::(&fs::read_to_string(fixture_path)?)?; + + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/content", + Value::String( + "The trace shows the expected evidence was present in recall.candidates but demoted at rerank.score; however, the selected answer followed the stale top-k smoke-only evidence.".to_string(), + ), + )?; + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/claims", + serde_json::json!([]), + )?; + support::set_json_pointer( + &mut fixture, + "/corpus/adapter_response/answer/evidence_ids", + serde_json::json!(["stage-decoy"]), + )?; + + let temp_dir = + env::temp_dir().join(format!("elf-real-world-stage-decoy-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("stage_decoy.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + assert_eq!( + report.pointer("/summary/wrong_result_stage_attribution_count").and_then(Value::as_u64), + Some(1) + ); + + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "operator-debug-stage-attribution-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("rerank.score") + ); + assert_eq!( + job.pointer("/retrieval_quality/trap_context_count").and_then(Value::as_u64), + Some(1) + ); + + Ok(()) +} + +#[test] +fn retrieval_report_markdown_includes_quality_metrics() -> Result<()> { + let report = support::run_json_report_from(support::retrieval_fixture_dir())?; + let temp_dir = env::temp_dir().join(format!("elf-real-world-retrieval-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("retrieval-report.json"); + let markdown_path = temp_dir.join("retrieval-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("Expected evidence recall")); + assert!(markdown.contains("Irrelevant context ratio")); + assert!(markdown.contains("Trace Explainability")); + assert!(markdown.contains("rerank.score")); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/root_aggregate.rs b/apps/elf-eval/tests/real_world_job_benchmark/root_aggregate.rs new file mode 100644 index 00000000..15c4b0bc --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/root_aggregate.rs @@ -0,0 +1,537 @@ +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +fn assert_root_knowledge_summary(report: &Value) { + assert_eq!(report.pointer("/summary/knowledge/job_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/knowledge/page_count").and_then(Value::as_u64), Some(5)); + assert_eq!( + report.pointer("/summary/knowledge/page_usefulness").and_then(Value::as_f64), + Some(0.979) + ); +} + +fn assert_root_aggregate_summary(report: &Value) -> Result<()> { + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(82)); + assert_eq!(report.pointer("/summary/encoded_suite_count").and_then(Value::as_u64), Some(19)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(75)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/incomplete").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(7)); + assert_eq!(report.pointer("/summary/not_encoded").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/unsupported_claim_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/wrong_result_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/expected_evidence_recall").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/irrelevant_context_ratio").and_then(Value::as_f64), + Some(0.0) + ); + assert_eq!(report.pointer("/summary/stale_retrieval_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/stale_answer_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/conflict_detection_count").and_then(Value::as_u64), + Some(11) + ); + assert_eq!( + report.pointer("/summary/update_rationale_available_count").and_then(Value::as_u64), + Some(16) + ); + assert_eq!( + report.pointer("/summary/temporal_validity_not_encoded_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!(report.pointer("/summary/redaction_leak_count").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/scope_check_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/scope_correct_count").and_then(Value::as_u64), Some(3)); + assert_eq!(report.pointer("/summary/scope_violation_count").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/qdrant_rebuild_case_count").and_then(Value::as_u64), + Some(3) + ); + assert_eq!( + report.pointer("/summary/qdrant_rebuild_pass_count").and_then(Value::as_u64), + Some(3) + ); + assert_eq!( + report.pointer("/summary/evidence_required_count").and_then(Value::as_u64), + Some(180) + ); + assert_eq!( + report.pointer("/summary/evidence_covered_count").and_then(Value::as_u64), + Some(180) + ); + assert_eq!(report.pointer("/summary/evidence_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!(report.pointer("/summary/source_ref_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!(report.pointer("/summary/quote_coverage").and_then(Value::as_f64), Some(1.0)); + assert_eq!( + report.pointer("/summary/trace_explainability_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/wrong_result_stage_attribution_count").and_then(Value::as_u64), + Some(0) + ); + + assert_root_scoreboard_summary(report)?; + + assert_eq!( + report.pointer("/summary/consolidation/proposal_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/consolidation/source_mutation_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/consolidation/proposal_unsupported_claim_count") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/memory_summary/job_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + report.pointer("/summary/memory_summary/invalid_top_of_mind_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/summary/memory_summary/source_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + + assert_root_knowledge_summary(report); + assert_root_proactive_brief_summary(report); + assert_root_scheduled_memory_summary(report); + assert_root_work_continuity_summary(report); + + Ok(()) +} + +fn assert_root_scoreboard_summary(report: &Value) -> Result<()> { + assert_eq!( + report.pointer("/scoreboard/summary_claim").and_then(Value::as_str), + Some("typed_non_pass_present") + ); + assert_eq!( + report.pointer("/scoreboard/job_summary_claim").and_then(Value::as_str), + Some("typed_non_pass_present") + ); + assert_eq!( + report.pointer("/scoreboard/job_typed_non_pass_count").and_then(Value::as_u64), + Some(7) + ); + assert_eq!( + report.pointer("/scoreboard/external_adapter_typed_non_pass_count").and_then(Value::as_u64), + Some(240) + ); + assert_eq!( + report.pointer("/scoreboard/typed_non_pass_count").and_then(Value::as_u64), + Some(247) + ); + assert_eq!( + report.pointer("/scoreboard/unqualified_win_claim_allowed").and_then(Value::as_bool), + Some(false) + ); + assert!(support::array_contains_str(report, "/scoreboard/result_states", "not_comparable")?); + assert_eq!( + report.pointer("/scoreboard/metric_basis").and_then(Value::as_str), + Some("produced_evidence_order") + ); + assert_eq!(report.pointer("/scoreboard/retrieval_k").and_then(Value::as_u64), Some(5)); + + assert_root_scoreboard_rows(report)?; + + for state in ["blocked", "incomplete", "not_encoded", "not_tested", "wrong_result"] { + assert!(support::array_contains_str( + report, + "/scoreboard/typed_non_pass_states_present", + state + )?); + } + + assert_eq!( + support::string_array_at(report, "/scoreboard/job_typed_non_pass_states_present")?, + ["blocked"].map(str::to_owned) + ); + + for state in ["blocked", "incomplete", "not_encoded", "not_tested", "wrong_result"] { + assert!(support::array_contains_str( + report, + "/scoreboard/external_adapter_typed_non_pass_states_present", + state + )?); + } + + Ok(()) +} + +fn assert_root_scoreboard_rows(report: &Value) -> Result<()> { + let rows = support::array_at(report, "/scoreboard/rows")?; + let elf = support::find_by_field(rows, "/product_id", "elf_current_report")?; + let qmd = support::find_by_field(rows, "/product_id", "qmd")?; + let graphify = support::find_by_field(rows, "/product_id", "graphify")?; + let pageindex = support::find_by_field(rows, "/product_id", "vectifyai_pageindex")?; + let openkb = support::find_by_field(rows, "/product_id", "vectifyai_openkb")?; + let honcho = support::find_by_field(rows, "/product_id", "plastic_labs_honcho")?; + + assert_eq!(rows.len(), 20); + assert_eq!(elf.pointer("/result_state").and_then(Value::as_str), Some("blocked")); + assert_eq!(elf.pointer("/evidence_class").and_then(Value::as_str), Some("fixture_backed")); + assert_eq!(elf.pointer("/comparable").and_then(Value::as_bool), Some(false)); + assert_eq!(elf.pointer("/same_corpus").and_then(Value::as_bool), Some(true)); + assert_eq!(elf.pointer("/source_id_mapped").and_then(Value::as_bool), Some(true)); + assert_eq!(elf.pointer("/product_runtime").and_then(Value::as_bool), Some(false)); + assert_eq!(elf.pointer("/metrics/retrieval/recall_at_k").and_then(Value::as_f64), Some(0.988)); + assert_eq!( + elf.pointer("/metrics/retrieval/precision_at_k").and_then(Value::as_f64), + Some(0.415) + ); + assert_eq!(elf.pointer("/metrics/retrieval/mrr").and_then(Value::as_f64), Some(0.988)); + assert_eq!(elf.pointer("/metrics/retrieval/ndcg").and_then(Value::as_f64), Some(0.985)); + assert_eq!( + elf.pointer("/metrics/lifecycle/stale_suppression").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + elf.pointer("/metrics/lifecycle/update_correctness").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + elf.pointer("/metrics/lifecycle/delete_correctness").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + elf.pointer("/metrics/coverage/typed_non_pass_count").and_then(Value::as_u64), + Some(7) + ); + assert!(support::array_contains_str( + elf, + "/next_evidence", + "Run a Docker-contained product-runtime adapter for this row." + )?); + + for competitor in [qmd, graphify] { + assert_eq!( + competitor.pointer("/evidence_class").and_then(Value::as_str), + Some("live_real_world") + ); + assert_eq!( + competitor.pointer("/result_state").and_then(Value::as_str), + Some("wrong_result") + ); + assert_eq!(competitor.pointer("/product_runtime").and_then(Value::as_bool), Some(true)); + assert_eq!( + competitor.pointer("/container_digest_identified").and_then(Value::as_bool), + Some(false) + ); + assert!(competitor.pointer("/metrics/retrieval/recall_at_k").is_some_and(Value::is_null)); + assert!(support::array_contains_str( + competitor, + "/next_evidence", + "Record container image digest evidence." + )?); + } + + super::assert_tracked_external_blocker_row(pageindex, "VectifyAI PageIndex", true)?; + super::assert_tracked_external_blocker_row(openkb, "VectifyAI OpenKB", true)?; + super::assert_tracked_external_blocker_row(honcho, "plastic-labs Honcho", false)?; + + Ok(()) +} + +fn assert_root_proactive_brief_summary(report: &Value) { + assert_eq!( + report.pointer("/summary/proactive_brief/job_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/suggestion_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/proactive_brief/freshness_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/proactive_brief/action_rationale_coverage") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/proactive_brief/invalid_current_suggestion_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/proactive_brief/tombstone_violation_count") + .and_then(Value::as_u64), + Some(0) + ); +} + +fn assert_root_scheduled_memory_summary(report: &Value) { + assert_eq!( + report.pointer("/summary/scheduled_memory/job_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/task_run_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/output_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/freshness_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/scheduled_memory/action_rationale_coverage") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/trace_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/scheduled_memory/invalid_current_output_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/scheduled_memory/tombstone_violation_count") + .and_then(Value::as_u64), + Some(0) + ); +} + +fn assert_root_work_continuity_summary(report: &Value) { + assert_eq!( + report.pointer("/summary/work_continuity/job_count").and_then(Value::as_u64), + Some(8) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/reset_resume_success_rate") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/decision_rationale_recall_rate") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/rejected_option_suppression_rate") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/inferred_step_instruction_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/sensitive_marker_persistence_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/janitor_false_promotion_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/work_continuity/journal_only_authority_claim_count") + .and_then(Value::as_u64), + Some(0) + ); +} + +fn assert_root_aggregate_suites(report: &Value) -> Result<()> { + let suites = support::array_at(report, "/suites")?; + + for suite_id in [ + "trust_source_of_truth", + "work_resume", + "project_decisions", + "retrieval", + "capture_integration", + "personalization", + "consolidation", + "memory_summary", + "knowledge_compilation", + "operator_debugging_ux", + "memory_evolution", + "adversarial_quality", + "core_archival_memory", + "work_continuity", + ] { + let suite = support::find_by_field(suites, "/suite_id", suite_id)?; + + assert_eq!(suite.pointer("/status").and_then(Value::as_str), Some("pass")); + } + + let memory_evolution = support::find_by_field(suites, "/suite_id", "memory_evolution")?; + + assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass")); + + let project_decisions = support::find_by_field(suites, "/suite_id", "project_decisions")?; + + assert_eq!(project_decisions.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + assert_eq!( + project_decisions.pointer("/update_rationale_available_count").and_then(Value::as_u64), + Some(5) + ); + + let debug_suite = support::find_by_field(suites, "/suite_id", "operator_debugging_ux")?; + + assert_eq!(debug_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + + let core_suite = support::find_by_field(suites, "/suite_id", "core_archival_memory")?; + + assert_eq!(core_suite.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(core_suite.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6)); + + let adversarial = support::find_by_field(suites, "/suite_id", "adversarial_quality")?; + + assert_eq!(adversarial.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(adversarial.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + + let production_ops = support::find_by_field(suites, "/suite_id", "production_ops")?; + + assert_eq!(production_ops.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(production_ops.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); + + let proactive = support::find_by_field(suites, "/suite_id", "proactive_brief")?; + + assert_eq!(proactive.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(proactive.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + + let scheduled = support::find_by_field(suites, "/suite_id", "scheduled_memory")?; + + assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(scheduled.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + + let source_library = support::find_by_field(suites, "/suite_id", "source_library")?; + + assert_eq!(source_library.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(source_library.pointer("/encoded_job_count").and_then(Value::as_u64), Some(2)); + + let context_trajectory = support::find_by_field(suites, "/suite_id", "context_trajectory")?; + + assert_eq!(context_trajectory.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(context_trajectory.pointer("/encoded_job_count").and_then(Value::as_u64), Some(3)); + + let work_continuity = support::find_by_field(suites, "/suite_id", "work_continuity")?; + + assert_eq!(work_continuity.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(work_continuity.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); + + Ok(()) +} + +fn assert_root_aggregate_jobs(report: &Value) -> Result<()> { + let jobs = support::array_at(report, "/jobs")?; + let rebuild = support::find_by_field(jobs, "/job_id", "trust-sot-rebuild-001")?; + let redaction = support::find_by_field(jobs, "/job_id", "capture-redaction-exclusion-001")?; + let personalization = + support::find_by_field(jobs, "/job_id", "personalization-scoped-preference-001")?; + let relation_job = + support::find_by_field(jobs, "/job_id", "memory-evolution-relation-temporal-001")?; + let delete_job = support::find_by_field(jobs, "/job_id", "memory-evolution-delete-ttl-001")?; + let stage_job = + support::find_by_field(jobs, "/job_id", "operator-debug-stage-attribution-001")?; + let production_restore = + support::find_by_field(jobs, "/job_id", "production-ops-restore-cold-start-001")?; + let production_authority = + support::find_by_field(jobs, "/job_id", "production-ops-authority-plane-recovery-001")?; + let core_fallback = + support::find_by_field(jobs, "/job_id", "core-archival-archival-fallback-001")?; + let stale_core = + support::find_by_field(jobs, "/job_id", "core-archival-stale-core-detection-001")?; + let scheduled_weekly = + support::find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; + + assert_eq!(rebuild.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), Some(true)); + assert_eq!( + production_restore.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + production_authority.pointer("/qdrant_rebuild_case").and_then(Value::as_bool), + Some(true) + ); + assert_eq!(production_authority.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + production_authority.pointer("/recovery_drills/0/contract_schema").and_then(Value::as_str), + Some("elf.authority_recovery_drill/v1") + ); + assert_eq!(redaction.pointer("/redaction_leak_count").and_then(Value::as_u64), Some(0)); + assert_eq!(personalization.pointer("/scope_check_count").and_then(Value::as_u64), Some(1)); + assert_eq!(personalization.pointer("/scope_correct_count").and_then(Value::as_u64), Some(1)); + assert_eq!(stage_job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(relation_job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(delete_job.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + delete_job.pointer("/evolution/selected_tombstone_evidence/0").and_then(Value::as_str), + Some("delete-tombstone") + ); + assert_eq!( + delete_job.pointer("/evolution/selected_invalidation_evidence/0").and_then(Value::as_str), + Some("delete-tombstone") + ); + assert_eq!(core_fallback.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(stale_core.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(scheduled_weekly.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + scheduled_weekly.pointer("/scheduled_memory/trace_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + stage_job.pointer("/trace_explainability/failure_stage").and_then(Value::as_str), + Some("rerank.score") + ); + assert!(support::array_contains_str(stage_job, "/produced_evidence", "stage-target")?); + + Ok(()) +} + +#[test] +fn real_world_memory_fixtures_report_aggregate_metrics() -> Result<()> { + let report = support::run_json_report_from(support::real_world_memory_fixture_dir())?; + + assert_root_aggregate_summary(&report)?; + assert_root_aggregate_suites(&report)?; + assert_root_aggregate_jobs(&report)?; + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/scheduled_memory.rs b/apps/elf-eval/tests/real_world_job_benchmark/scheduled_memory.rs new file mode 100644 index 00000000..3095f487 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/scheduled_memory.rs @@ -0,0 +1,261 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +use crate::support; + +#[test] +fn scheduled_memory_fixtures_score_task_trace_gate() -> Result<()> { + let report = support::run_json_report_from(support::scheduled_memory_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(4)); + assert_eq!(report.pointer("/summary/blocked").and_then(Value::as_u64), Some(1)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(0)); + assert_eq!( + report.pointer("/summary/scheduled_memory/job_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/task_run_count").and_then(Value::as_u64), + Some(4) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/output_count").and_then(Value::as_u64), + Some(5) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/evidence_ref_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/freshness_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/scheduled_memory/action_rationale_coverage") + .and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/trace_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!( + report + .pointer("/summary/scheduled_memory/invalid_current_output_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report + .pointer("/summary/scheduled_memory/tombstone_violation_count") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + report.pointer("/summary/scheduled_memory/source_mutation_count").and_then(Value::as_u64), + Some(0) + ); + + let suites = support::array_at(&report, "/suites")?; + let scheduled = support::find_by_field(suites, "/suite_id", "scheduled_memory")?; + + assert_eq!(scheduled.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert_eq!(scheduled.pointer("/encoded_job_count").and_then(Value::as_u64), Some(5)); + + let jobs = support::array_at(&report, "/jobs")?; + let weekly = + support::find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; + let private = support::find_by_field( + jobs, + "/job_id", + "scheduled-private-provider-scheduler-blocked-001", + )?; + + assert_eq!(weekly.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!( + weekly.pointer("/scheduled_memory/trace_coverage").and_then(Value::as_f64), + Some(1.0) + ); + assert_eq!(private.pointer("/status").and_then(Value::as_str), Some("blocked")); + assert!( + report + .pointer("/follow_ups/0/title") + .and_then(Value::as_str) + .is_some_and(|title| title.contains("XY-930")) + ); + + Ok(()) +} + +#[test] +fn scheduled_memory_markdown_renders_trace_metrics() -> Result<()> { + let report = support::run_json_report_from(support::scheduled_memory_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-scheduled-memory-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("scheduled-memory-report.json"); + let markdown_path = temp_dir.join("scheduled-memory-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("Scheduled Memory Metrics")); + assert!(markdown.contains("scheduled-weekly-project-status-summary-001")); + assert!(markdown.contains("Scheduled memory evidence-ref coverage")); + assert!(markdown.contains("Trace Coverage")); + assert!(markdown.contains("Source Mutations")); + + Ok(()) +} + +#[test] +fn scheduled_memory_fixture_fails_missing_execution_trace() -> Result<()> { + let fixture_path = + support::scheduled_memory_fixture_dir().join("weekly_project_status_summary.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0] + .as_object_mut() + .ok_or_else(|| eyre::eyre!("missing scheduled task object"))? + .remove("execution_trace"); + + let temp_dir = + env::temp_dir().join(format!("elf-scheduled-missing-trace-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("missing_trace.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = + support::find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/scheduled_memory/trace_complete_count").and_then(Value::as_u64), + Some(0) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn scheduled_memory_fixture_fails_untraced_outputs() -> Result<()> { + let fixture_path = + support::scheduled_memory_fixture_dir().join("weekly_project_status_summary.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["outputs"][0]["evidence_refs"] = + Value::Array(Vec::new()); + + let temp_dir = + env::temp_dir().join(format!("elf-scheduled-untraced-output-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("untraced_output.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = + support::find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("unsupported_claim")); + assert_eq!( + job.pointer("/scheduled_memory/untraced_output_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/unsupported_claim").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn scheduled_memory_fixture_fails_superseded_sources_presented_current() -> Result<()> { + let fixture_path = support::scheduled_memory_fixture_dir().join("stale_decision_audit.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["outputs"][0]["evidence_refs"] = + serde_json::json!(["scheduled-old-consolidation-only-decision"]); + fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["outputs"][0]["freshness"] + ["status"] = Value::String("current".to_string()); + + let temp_dir = + env::temp_dir().join(format!("elf-scheduled-superseded-current-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("superseded_current.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = support::find_by_field(jobs, "/job_id", "scheduled-stale-decision-audit-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer("/scheduled_memory/invalid_current_output_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(1)); + + Ok(()) +} + +#[test] +fn scheduled_memory_fixture_fails_source_mutation() -> Result<()> { + let fixture_path = + support::scheduled_memory_fixture_dir().join("weekly_project_status_summary.json"); + let mut fixture = support::load_json(&fixture_path)?; + + fixture["corpus"]["adapter_response"]["answer"]["scheduled_tasks"][0]["source_mutations"] = serde_json::json!([ + { + "table": "memory_notes", + "op": "update", + "note_id": "scheduled-weekly-current-gate" + } + ]); + + let temp_dir = + env::temp_dir().join(format!("elf-scheduled-source-mutation-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join("source_mutation.json"), serde_json::to_vec_pretty(&fixture)?)?; + + let report = support::run_json_report_from(temp_dir)?; + let jobs = support::array_at(&report, "/jobs")?; + let job = + support::find_by_field(jobs, "/job_id", "scheduled-weekly-project-status-summary-001")?; + + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("lifecycle_fail")); + assert_eq!( + job.pointer("/scheduled_memory/source_mutation_count").and_then(Value::as_u64), + Some(1) + ); + assert_eq!(report.pointer("/summary/lifecycle_fail").and_then(Value::as_u64), Some(1)); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/support.rs b/apps/elf-eval/tests/real_world_job_benchmark/support.rs new file mode 100644 index 00000000..354e64bd --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/support.rs @@ -0,0 +1,615 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{self, Command, Output}, +}; + +use color_eyre::{Result, eyre}; +use serde_json::Value; + +pub(super) struct RecallDebugSourceContract<'a> { + pub(super) service: &'a str, + pub(super) service_lib: &'a str, + pub(super) routes: &'a str, + pub(super) mcp: &'a str, + pub(super) recall_spec: &'a str, + pub(super) service_spec: &'a str, + pub(super) version_registry: &'a str, + pub(super) markdown: &'a str, + pub(super) benchmarking_index: &'a str, + pub(super) readme: &'a str, +} + +pub(super) fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("real_world_memory") + .join("work_resume") +} + +pub(super) fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures").join("real_world_memory") +} + +pub(super) fn real_world_memory_fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures").join("real_world_memory") +} + +pub(super) fn evolution_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("evolution") +} + +pub(super) fn operator_debug_fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("real_world_job") + .join("operator_debugging_ux") +} + +pub(super) fn project_decisions_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("project_decisions") +} + +pub(super) fn retrieval_fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("real_world_memory") + .join("retrieval") +} + +pub(super) fn capture_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("capture_integration") +} + +pub(super) fn consolidation_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("consolidation") +} + +pub(super) fn memory_summary_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("memory_summary") +} + +pub(super) fn proactive_brief_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("proactive_brief") +} + +pub(super) fn scheduled_memory_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("scheduled_memory") +} + +pub(super) fn work_continuity_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("work_continuity") +} + +pub(super) fn knowledge_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("knowledge") +} + +pub(super) fn source_library_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("source_library") +} + +pub(super) fn production_ops_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("production_ops") +} + +pub(super) fn core_archival_memory_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("core_archival_memory") +} + +pub(super) fn context_trajectory_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("context_trajectory") +} + +pub(super) fn adversarial_quality_fixture_dir() -> PathBuf { + real_world_memory_fixture_dir().join("adversarial_quality") +} + +pub(super) fn graph_rag_external_fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("real_world_external_adapters") + .join("graph_rag") +} + +pub(super) fn workspace_root() -> Result { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = manifest_dir + .parent() + .and_then(Path::parent) + .ok_or_else(|| eyre::eyre!("could not resolve workspace root"))?; + + Ok(root.to_path_buf()) +} + +pub(super) fn collapse_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +pub(super) fn report_snapshot_path(file_name: &str) -> Result { + Ok(workspace_root()? + .join("apps") + .join("elf-eval") + .join("fixtures") + .join("report_snapshots") + .join(file_name)) +} + +pub(super) fn strength_profile_report_path() -> Result { + report_snapshot_path("2026-06-11-qmd-openviking-strength-profile-report.json") +} + +pub(super) fn strength_profile_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-qmd-openviking-strength-profile-report.md")) +} + +pub(super) fn measurement_coverage_audit_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-measurement-coverage-audit.md")) +} + +pub(super) fn measurement_coverage_audit_json_path() -> Result { + report_snapshot_path("2026-06-11-measurement-coverage-audit.json") +} + +pub(super) fn retrieval_debug_profile_json_path() -> Result { + report_snapshot_path("2026-06-11-elf-qmd-retrieval-debug-profile.json") +} + +pub(super) fn trace_replay_diagnostics_report_path() -> Result { + report_snapshot_path("2026-06-11-elf-qmd-trace-replay-diagnostics-report.json") +} + +pub(super) fn trace_replay_diagnostics_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-elf-qmd-trace-replay-diagnostics-report.md")) +} + +pub(super) fn competitor_strength_adoption_report_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-competitor-strength-adoption-report.md")) +} + +pub(super) fn competitor_strength_adoption_report_json_path() -> Result { + report_snapshot_path("2026-06-11-competitor-strength-adoption-report.json") +} + +pub(super) fn capture_write_policy_live_report_path() -> Result { + report_snapshot_path("2026-06-11-capture-write-policy-live-report.json") +} + +pub(super) fn capture_write_policy_live_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-capture-write-policy-live-report.md")) +} + +pub(super) fn live_consolidation_proposal_scoring_report_path() -> Result { + report_snapshot_path("2026-06-16-live-consolidation-proposal-scoring-report.json") +} + +pub(super) fn live_consolidation_proposal_scoring_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-16-live-consolidation-proposal-scoring-report.md")) +} + +pub(super) fn temporal_history_competitor_gap_json_path() -> Result { + report_snapshot_path("2026-06-11-temporal-history-competitor-gap-report.json") +} + +pub(super) fn dreaming_readiness_stage_ledger_json_path() -> Result { + report_snapshot_path("2026-06-16-dreaming-readiness-stage-ledger.json") +} + +pub(super) fn dreaming_readiness_stage_ledger_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-16-dreaming-readiness-stage-ledger.md")) +} + +pub(super) fn dreaming_competitor_strength_retest_report_json_path() -> Result { + report_snapshot_path("2026-06-17-dreaming-competitor-strength-retest-report.json") +} + +pub(super) fn dreaming_competitor_strength_retest_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-17-dreaming-competitor-strength-retest-report.md")) +} + +pub(super) fn qmd_debug_ergonomics_dreaming_retest_report_json_path() -> Result { + report_snapshot_path("2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.json") +} + +pub(super) fn qmd_debug_ergonomics_dreaming_retest_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.md")) +} + +pub(super) fn openviking_trajectory_materialization_report_json_path() -> Result { + report_snapshot_path("2026-06-19-openviking-trajectory-materialization-report.json") +} + +pub(super) fn letta_core_archive_export_readback_report_json_path() -> Result { + report_snapshot_path("2026-06-19-letta-core-archive-export-readback-report.json") +} + +pub(super) fn service_native_dreaming_readback_report_json_path() -> Result { + report_snapshot_path("2026-06-19-service-native-dreaming-readback-report.json") +} + +pub(super) fn service_native_dreaming_readback_materialization_json_path() -> Result { + report_snapshot_path("2026-06-19-service-native-dreaming-readback-materialization.json") +} + +pub(super) fn dreaming_review_queue_report_json_path() -> Result { + report_snapshot_path("2026-06-20-dreaming-review-queue-report.json") +} + +pub(super) fn recall_debug_panel_report_json_path() -> Result { + report_snapshot_path("2026-06-20-recall-debug-panel-report.json") +} + +pub(super) fn agent_knowledge_os_closeout_benchmark_report_json_path() -> Result { + report_snapshot_path("2026-06-20-agent-knowledge-os-closeout-benchmark-report.json") +} + +pub(super) fn p2_knowledge_workspace_pageindex_openkb_closeout_report_json_path() -> Result +{ + report_snapshot_path("2026-06-22-p2-knowledge-workspace-pageindex-openkb-closeout-report.json") +} + +pub(super) fn openmemory_ui_export_product_readback_report_json_path() -> Result { + report_snapshot_path("2026-06-19-openmemory-ui-export-product-readback-report.json") +} + +pub(super) fn graph_rag_citation_navigation_promotion_report_json_path() -> Result { + report_snapshot_path("2026-06-19-graph-rag-citation-navigation-promotion-report.json") +} + +pub(super) fn graph_rag_adapter_matrix_report_json_path() -> Result { + report_snapshot_path("2026-06-23-graph-rag-adapter-matrix-report.json") +} + +pub(super) fn p3_competitor_strength_absorption_report_json_path() -> Result { + report_snapshot_path("2026-06-23-p3-competitor-strength-absorption-report.json") +} + +pub(super) fn operator_approved_public_proxy_private_addendum_report_json_path() -> Result +{ + report_snapshot_path( + "2026-06-19-operator-approved-public-proxy-production-private-addendum.json", + ) +} + +pub(super) fn openviking_trajectory_materialization_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-openviking-trajectory-materialization-report.md")) +} + +pub(super) fn letta_core_archive_export_readback_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-letta-core-archive-export-readback-report.md")) +} + +pub(super) fn service_native_dreaming_readback_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-service-native-dreaming-readback-report.md")) +} + +pub(super) fn dreaming_review_queue_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-20-dreaming-review-queue-report.md")) +} + +pub(super) fn recall_debug_panel_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-20-recall-debug-panel-report.md")) +} + +pub(super) fn agent_knowledge_os_closeout_benchmark_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-20-agent-knowledge-os-closeout-benchmark-report.md")) +} + +pub(super) fn p2_knowledge_workspace_pageindex_openkb_closeout_report_markdown_path() +-> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-22-p2-knowledge-workspace-pageindex-openkb-closeout-report.md")) +} + +pub(super) fn openmemory_ui_export_product_readback_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-openmemory-ui-export-product-readback-report.md")) +} + +pub(super) fn graph_rag_citation_navigation_promotion_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-graph-rag-citation-navigation-promotion-report.md")) +} + +pub(super) fn graph_rag_adapter_matrix_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-23-graph-rag-adapter-matrix-report.md")) +} + +pub(super) fn p3_competitor_strength_absorption_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-23-p3-competitor-strength-absorption-report.md")) +} + +pub(super) fn graph_topic_map_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-20-graph-topic-map-report.md")) +} + +pub(super) fn operator_approved_public_proxy_private_addendum_report_markdown_path() +-> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-19-operator-approved-public-proxy-production-private-addendum.md")) +} + +pub(super) fn live_temporal_reconciliation_report_json_path() -> Result { + report_snapshot_path("2026-06-16-live-temporal-reconciliation-report.json") +} + +pub(super) fn live_temporal_reconciliation_report_markdown_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-16-live-temporal-reconciliation-report.md")) +} + +pub(super) fn competitor_strength_matrix_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-competitor-strength-evidence-matrix.md")) +} + +pub(super) fn competitor_strength_matrix_json_path() -> Result { + report_snapshot_path("2026-06-11-xy-897-competitor-strength-matrix.json") +} + +pub(super) fn readme_path() -> Result { + Ok(workspace_root()?.join("README.md")) +} + +pub(super) fn comparison_external_projects_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("external_memory") + .join("comparison_external_projects.md")) +} + +pub(super) fn benchmarking_index_path() -> Result { + Ok(workspace_root()?.join("docs").join("evidence").join("benchmarking").join("index.md")) +} + +pub(super) fn iteration_direction_report_path() -> Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-11-elf-iteration-direction-from-competitor-benchmarks.md")) +} + +pub(super) fn external_adapter_manifest_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("real_world_external_adapters") + .join("memory_projects_manifest.json") +} + +pub(super) fn run_json_report_from(fixtures: PathBuf) -> Result { + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("run") + .arg("--fixtures") + .arg(fixtures) + .output()?; + + assert!( + output.status.success(), + "real_world_job runner failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + Ok(serde_json::from_slice(&output.stdout)?) +} + +pub(super) fn run_json_report_from_failure(fixtures: PathBuf) -> Result { + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("run") + .arg("--fixtures") + .arg(fixtures) + .output()?; + + assert!( + !output.status.success(), + "real_world_job runner unexpectedly passed: {}", + String::from_utf8_lossy(&output.stdout), + ); + + Ok(String::from_utf8_lossy(&output.stderr).to_string()) +} + +pub(super) fn run_json_report() -> Result { + run_json_report_from(fixture_dir()) +} + +pub(super) fn load_json(path: &Path) -> Result { + Ok(serde_json::from_str::(&fs::read_to_string(path)?)?) +} + +pub(super) fn array_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a Vec> { + value + .pointer(pointer) + .and_then(Value::as_array) + .ok_or_else(|| eyre::eyre!("missing array at {pointer}")) +} + +pub(super) fn find_by_field<'a>( + items: &'a [Value], + field: &str, + expected: &str, +) -> Result<&'a Value> { + items + .iter() + .find(|item| item.pointer(field).and_then(Value::as_str) == Some(expected)) + .ok_or_else(|| eyre::eyre!("missing item with {field} = {expected}")) +} + +pub(super) fn array_contains_str(value: &Value, pointer: &str, expected: &str) -> Result { + Ok(array_at(value, pointer)?.iter().any(|item| item.as_str() == Some(expected))) +} + +pub(super) fn string_array_at(value: &Value, pointer: &str) -> Result> { + array_at(value, pointer)? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| eyre::eyre!("non-string entry at {pointer}")) + }) + .collect() +} + +pub(super) fn set_json_pointer(value: &mut Value, pointer: &str, replacement: Value) -> Result<()> { + let target = + value.pointer_mut(pointer).ok_or_else(|| eyre::eyre!("missing JSON pointer {pointer}"))?; + + *target = replacement; + + Ok(()) +} + +pub(super) fn run_external_manifest_with_letta_attachment_mutation( + slug: &str, + mutation: F, +) -> Result +where + F: FnOnce(&mut Value) -> Result<()>, +{ + run_external_manifest_scenario_mutation( + slug, + "letta_research_gate", + "core_block_attachment_readback", + mutation, + ) +} + +pub(super) fn run_external_manifest_scenario_mutation( + slug: &str, + adapter_id: &str, + scenario_id: &str, + mutation: F, +) -> Result +where + F: FnOnce(&mut Value) -> Result<()>, +{ + let mut manifest = + serde_json::from_str::(&fs::read_to_string(external_adapter_manifest_path())?)?; + let adapters = manifest + .pointer_mut("/adapters") + .and_then(Value::as_array_mut) + .ok_or_else(|| eyre::eyre!("missing manifest adapters"))?; + let adapter = adapters + .iter_mut() + .find(|adapter| adapter.pointer("/adapter_id").and_then(Value::as_str) == Some(adapter_id)) + .ok_or_else(|| eyre::eyre!("missing {adapter_id} adapter"))?; + let scenarios = adapter + .pointer_mut("/scenarios") + .and_then(Value::as_array_mut) + .ok_or_else(|| eyre::eyre!("missing {adapter_id} scenarios"))?; + let scenario = scenarios + .iter_mut() + .find(|scenario| { + scenario.pointer("/scenario_id").and_then(Value::as_str) == Some(scenario_id) + }) + .ok_or_else(|| eyre::eyre!("missing {scenario_id} scenario"))?; + + mutation(scenario)?; + + let temp_dir = env::temp_dir().join(format!("elf-real-world-{slug}-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let manifest_path = temp_dir.join("memory_projects_manifest.json"); + + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; + + Ok(Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("run") + .arg("--fixtures") + .arg(fixture_dir()) + .arg("--external-adapter-manifest") + .arg(&manifest_path) + .output()?) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs b/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs new file mode 100644 index 00000000..358032b7 --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs @@ -0,0 +1,378 @@ +use std::{fs, path::Path}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +fn graph_report_service_sources(workspace: &Path) -> Result { + let mut source = + fs::read_to_string(workspace.join("packages/elf-service/src/graph_report.rs"))?; + + append_rust_sources( + workspace.join("packages/elf-service/src/graph_report").as_path(), + &mut source, + )?; + + Ok(source) +} + +fn append_rust_sources(dir: &Path, source: &mut String) -> Result<()> { + let mut entries = Vec::new(); + + for entry in fs::read_dir(dir)? { + entries.push(entry?.path()); + } + + entries.sort(); + + for path in entries { + if path.is_dir() { + append_rust_sources(path.as_path(), source)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + source.push('\n'); + source.push_str(fs::read_to_string(path)?.as_str()); + } + } + + Ok(()) +} + +#[test] +fn graph_topic_map_report_wires_source_backed_graph_lite_readback() -> Result<()> { + let markdown = fs::read_to_string(support::graph_topic_map_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let workspace = support::workspace_root()?; + let graph_report_service = graph_report_service_sources(&workspace)?; + let api_routes = + fs::read_to_string(support::workspace_root()?.join("apps/elf-api/src/routes.rs"))?; + let mcp_server = + fs::read_to_string(support::workspace_root()?.join("apps/elf-mcp/src/server.rs"))?; + let graph_spec = fs::read_to_string( + support::workspace_root()?.join("docs/spec/system_graph_memory_postgres_v1.md"), + )?; + + assert!(markdown.contains("Graph Topic-Map Report - June 20, 2026")); + assert!(markdown.contains("elf.graph_report/v1")); + assert!(markdown.contains("sourced")); + assert!(markdown.contains("inferred")); + assert!(markdown.contains("ambiguous")); + assert!(markdown.contains("stale")); + assert!(markdown.contains("superseded")); + assert!(markdown.contains("valid_from")); + assert!(markdown.contains("valid_to")); + assert!(markdown.contains("valid_at")); + assert!(markdown.contains("invalid_at")); + assert!(graph_report_service.contains("ELF_GRAPH_REPORT_SCHEMA_V1")); + assert!(graph_report_service.contains("GraphReportSummary")); + assert!(graph_report_service.contains("build_topic_map")); + assert!(api_routes.contains("/v2/graph/report")); + assert!(mcp_server.contains("elf_graph_report")); + assert!(graph_spec.contains("elf.graph_report/v1")); + assert!(graph_spec.contains("Graphiti/Zep `valid_at` and `invalid_at`")); + assert!(benchmarking_index.contains("2026-06-20-graph-topic-map-report.md")); + assert!(readme.contains("Graph Topic-Map Report - June 20, 2026")); + assert!(readme.contains("Graph topic-map reports after XY-1020")); + + Ok(()) +} + +#[test] +fn qmd_trace_replay_diagnostics_report_preserves_claim_boundaries() -> Result<()> { + let report = serde_json::from_str::(&fs::read_to_string( + support::trace_replay_diagnostics_report_path()?, + )?)?; + let markdown = fs::read_to_string(support::trace_replay_diagnostics_markdown_path()?)?; + let readme = fs::read_to_string(support::readme_path()?)?; + let benchmarking_index = fs::read_to_string(support::benchmarking_index_path()?)?; + let adoption_report = fs::read_to_string(support::competitor_strength_adoption_report_path()?)?; + let adoption_json = serde_json::from_str::(&fs::read_to_string( + support::competitor_strength_adoption_report_json_path()?, + )?)?; + + assert_trace_replay_diagnostics_json(&report)?; + assert_trace_replay_diagnostics_markdown(&markdown); + + assert!(readme.contains("ELF/qmd Trace Replay Diagnostics Report - June 11, 2026")); + assert!(benchmarking_index.contains("2026-06-11-elf-qmd-trace-replay-diagnostics-report.md")); + assert!(benchmarking_index.contains("qmd top-10/replay artifact")); + assert!(benchmarking_index.contains("ELF trace/admin surfaces")); + assert!(adoption_report.contains("| Retrieval quality and local debug UX | `loss` |")); + assert!(adoption_report.contains("Letta scenario rows remain")); + assert!(adoption_report.contains("blocked or `not_tested`")); + + assert_trace_replay_viewer_blocker_boundaries( + &readme, + &markdown, + &adoption_report, + &report, + &adoption_json, + )?; + + assert!( + adoption_report + .contains("Do not claim qmd's trace/replay artifact win is a broad qmd-over-ELF") + ); + assert!(support::array_at(&adoption_json, "/adoption_decision/remaining_caveats")?.iter().any( + |caveat| { + caveat.as_str().is_some_and(|text| { + text.contains("Letta scenario rows remain blocked or not_tested") + }) + } + )); + + assert_trace_replay_adoption_json(&adoption_json)?; + + Ok(()) +} + +fn assert_trace_replay_diagnostics_json(report: &Value) -> Result<()> { + assert_eq!( + report.pointer("/schema").and_then(Value::as_str), + Some("elf.trace_replay_diagnostics_report/v1") + ); + assert_eq!(report.pointer("/authority").and_then(Value::as_str), Some("XY-923")); + assert_eq!( + support::string_array_at(report, "/outcome_terms")?, + ["win", "tie", "loss", "not_tested", "blocked", "non_goal"].map(str::to_owned) + ); + assert_eq!( + report.pointer("/summary/retrieval_correctness").and_then(Value::as_str), + Some("tie") + ); + assert_eq!(report.pointer("/summary/outcome_counts/loss").and_then(Value::as_u64), Some(2)); + assert_eq!( + report.pointer("/summary/outcome_counts/not_tested").and_then(Value::as_u64), + Some(4) + ); + assert_eq!(report.pointer("/summary/outcome_counts/win").and_then(Value::as_u64), Some(4)); + assert_eq!(report.pointer("/summary/outcome_counts/tie").and_then(Value::as_u64), Some(5)); + assert_eq!(report.pointer("/summary/outcome_counts/non_goal").and_then(Value::as_u64), Some(1)); + + assert_trace_replay_diagnostics_scenarios(report) +} + +fn assert_trace_replay_diagnostics_scenarios(report: &Value) -> Result<()> { + let scenarios = support::array_at(report, "/scenario_outcomes")?; + let retrieval = + support::find_by_field(scenarios, "/scenario_id", "retrieval_correctness_guardrail")?; + let top10 = + support::find_by_field(scenarios, "/scenario_id", "default_top10_candidate_artifact")?; + let replay = support::find_by_field(scenarios, "/scenario_id", "replay_command_locality")?; + let trace_surface = support::find_by_field( + scenarios, + "/scenario_id", + "trace_admin_replay_surface_availability", + )?; + let operator_trace = + support::find_by_field(scenarios, "/scenario_id", "operator_debug_trace_hydration")?; + let operator_replay = support::find_by_field( + scenarios, + "/scenario_id", + "operator_debug_replay_command_availability", + )?; + let operator_candidate = support::find_by_field( + scenarios, + "/scenario_id", + "operator_debug_candidate_drop_visibility", + )?; + let operator_repair = + support::find_by_field(scenarios, "/scenario_id", "operator_debug_repair_action_clarity")?; + let operator_selected = support::find_by_field( + scenarios, + "/scenario_id", + "operator_debug_selected_but_not_narrated", + )?; + let expansion = + support::find_by_field(scenarios, "/scenario_id", "query_expansion_attribution")?; + let dense_sparse = + support::find_by_field(scenarios, "/scenario_id", "dense_sparse_channel_attribution")?; + let fusion = support::find_by_field(scenarios, "/scenario_id", "fusion_attribution")?; + let rerank = support::find_by_field(scenarios, "/scenario_id", "rerank_attribution")?; + let candidate_drop = + support::find_by_field(scenarios, "/scenario_id", "candidate_drop_diagnostics")?; + let selected = support::find_by_field( + scenarios, + "/scenario_id", + "selected_but_not_narrated_wrong_results", + )?; + let tombstone = + support::find_by_field(scenarios, "/scenario_id", "evidence_absent_tombstone_diagnostics")?; + + assert_eq!(scenarios.len(), 16); + assert_eq!(retrieval.pointer("/outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(top10.pointer("/outcome").and_then(Value::as_str), Some("loss")); + assert_eq!(replay.pointer("/outcome").and_then(Value::as_str), Some("loss")); + assert_eq!(trace_surface.pointer("/outcome").and_then(Value::as_str), Some("tie")); + assert_eq!( + operator_trace.pointer("/evidence_class").and_then(Value::as_str), + Some("live_real_world") + ); + assert_eq!(operator_trace.pointer("/result_type").and_then(Value::as_str), Some("pass")); + assert_eq!(operator_trace.pointer("/outcome").and_then(Value::as_str), Some("win")); + assert_eq!(operator_replay.pointer("/outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(operator_candidate.pointer("/outcome").and_then(Value::as_str), Some("win")); + assert!(support::array_contains_str( + operator_candidate, + "/typed_non_pass_states", + "retrieved_but_dropped" + )?); + assert_eq!(operator_repair.pointer("/outcome").and_then(Value::as_str), Some("tie")); + assert_eq!(operator_selected.pointer("/outcome").and_then(Value::as_str), Some("win")); + assert!(support::array_contains_str( + operator_selected, + "/typed_non_pass_states", + "selected_but_not_narrated" + )?); + assert_eq!(expansion.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!(dense_sparse.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!(fusion.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); + assert_eq!(rerank.pointer("/result_type").and_then(Value::as_str), Some("non_goal")); + assert_eq!(rerank.pointer("/outcome").and_then(Value::as_str), Some("non_goal")); + assert_eq!(candidate_drop.pointer("/outcome").and_then(Value::as_str), Some("not_tested")); + assert!(support::array_contains_str( + candidate_drop, + "/typed_non_pass_states", + "retrieved_but_dropped" + )?); + assert_eq!(selected.pointer("/result_type").and_then(Value::as_str), Some("wrong_result")); + assert!(support::array_contains_str( + selected, + "/typed_non_pass_states", + "selected_but_not_narrated" + )?); + assert_eq!(tombstone.pointer("/outcome").and_then(Value::as_str), Some("win")); + assert_eq!(tombstone.pointer("/qmd_status").and_then(Value::as_str), Some("wrong_result")); + assert!(support::array_contains_str( + report, + "/wrong_result_diagnostics/qmd_missing_evidence", + "delete-tombstone" + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "qmd currently wins the default local-debug artifact surface: top-10 rows plus short CLI replay." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "ELF narrowly wins the live operator-debug trace hydration and candidate-drop visibility slice against qmd; qmd still ties replay-command and repair-action clarity." + )?); + assert!(support::array_contains_str( + report, + "/claim_boundaries", + "Do not claim qmd beats ELF as a memory system overall." + )?); + + Ok(()) +} + +fn assert_trace_replay_diagnostics_markdown(markdown: &str) { + assert!(markdown.contains("Retrieval correctness is still tied")); + assert!(markdown.contains("| Default top-10 candidate artifact |")); + assert!(markdown.contains("| Replay command locality |")); + assert!( + markdown + .contains("| Operator-debug trace hydration | `live_real_world` | `pass` | `win` |") + ); + assert!(markdown.contains( + "| Operator-debug replay command availability | `live_real_world` | `pass` | `tie` |" + )); + assert!(markdown.contains( + "| Operator-debug candidate-drop visibility | `live_real_world` | `pass` | `win` |" + )); + assert!(markdown.contains("| Rerank attribution | `live_baseline_only` | `non_goal` |")); + assert!(markdown.contains("| Candidate-drop diagnostics | `research_gate` | `not_encoded` |")); + assert!(markdown.contains("`retrieved_but_dropped` | Defined globally as `not_tested`")); + assert!(markdown.contains("npx tsx src/cli/qmd.ts query")); + assert!(markdown.contains("cargo run -p elf-eval -- --config-a")); + assert!(markdown.contains("cargo make real-world-job-operator-ux-live-adapters")); + assert!(markdown.contains("Do not claim qmd beats ELF as a memory system overall")); + assert!(markdown.contains("Do not score rerank superiority from a qmd `--no-rerank` run")); +} + +fn assert_trace_replay_viewer_blocker_boundaries( + readme: &str, + markdown: &str, + adoption_report: &str, + report: &Value, + adoption_json: &Value, +) -> Result<()> { + let checked_surfaces = [ + support::collapse_whitespace(readme), + support::collapse_whitespace(markdown), + support::collapse_whitespace(adoption_report), + report.to_string(), + adoption_json.to_string(), + ]; + + for surface in checked_surfaces { + assert!(!surface.contains("blocked or not encoded")); + } + + assert!( + support::collapse_whitespace(readme) + .contains("claude-mem viewer flows remain blocked until Docker-contained") + ); + assert!( + support::collapse_whitespace(markdown) + .contains("claude-mem UI repair paths remain blocked until Docker-contained") + ); + assert!( + support::collapse_whitespace(adoption_report) + .contains("claude-mem viewer workflows remain blocked until Docker-contained") + ); + + Ok(()) +} + +fn assert_trace_replay_adoption_json(adoption: &Value) -> Result<()> { + let local_debug = support::find_by_field( + support::array_at(adoption, "/scenario_outcomes")?, + "/scenario_id", + "local_debug_replay_ux", + )?; + let operator_debug = support::find_by_field( + support::array_at(adoption, "/scenario_outcomes")?, + "/scenario_id", + "operator_debugging_viewer_ux", + )?; + + assert_eq!(local_debug.pointer("/outcome").and_then(Value::as_str), Some("loss")); + assert!( + local_debug + .pointer("/measured_claim") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("qmd stronger on immediate top-10")) + ); + assert!(support::array_contains_str( + local_debug, + "/command_artifacts", + "docs/evidence/benchmarking/2026-06-11-elf-qmd-trace-replay-diagnostics-report.md" + )?); + assert!(support::array_contains_str( + adoption, + "/claim_boundaries/not_allowed", + "Do not claim qmd's trace/replay artifact win is a broad qmd-over-ELF memory-system or retrieval-quality win." + )?); + assert_eq!(operator_debug.pointer("/outcome").and_then(Value::as_str), Some("win")); + assert!( + operator_debug + .pointer("/measured_claim") + .and_then(Value::as_str) + .is_some_and(|claim| claim.contains("narrow live operator-debug win over qmd")) + ); + assert!(support::array_contains_str( + operator_debug, + "/command_artifacts", + "tmp/real-world-job/operator-ux-live-adapters/summary.json" + )?); + assert!(support::array_contains_str( + adoption, + "/claim_boundaries/not_allowed", + "Do not claim ELF broadly beats OpenMemory or claude-mem viewer UX from the narrow ELF/qmd operator-debug slice." + )?); + + Ok(()) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/work_continuity.rs b/apps/elf-eval/tests/real_world_job_benchmark/work_continuity.rs new file mode 100644 index 00000000..8af1f48f --- /dev/null +++ b/apps/elf-eval/tests/real_world_job_benchmark/work_continuity.rs @@ -0,0 +1,330 @@ +use std::{ + env, fs, + process::{self, Command}, +}; + +use color_eyre::Result; +use serde_json::Value; + +use crate::support; + +#[test] +fn work_continuity_fixtures_score_required_metrics() -> Result<()> { + let report = support::run_json_report_from(support::work_continuity_fixture_dir())?; + + assert_eq!(report.pointer("/summary/job_count").and_then(Value::as_u64), Some(8)); + assert_eq!(report.pointer("/summary/pass").and_then(Value::as_u64), Some(8)); + assert_eq!(report.pointer("/summary/wrong_result").and_then(Value::as_u64), Some(0)); + + assert_work_continuity_summary_counts(&report); + + let suites = support::array_at(&report, "/suites")?; + let work_continuity = support::find_by_field(suites, "/suite_id", "work_continuity")?; + + assert_eq!(work_continuity.pointer("/status").and_then(Value::as_str), Some("pass")); + assert_eq!(work_continuity.pointer("/encoded_job_count").and_then(Value::as_u64), Some(8)); + + Ok(()) +} + +fn assert_work_continuity_summary_counts(report: &Value) { + for (field, expected) in [ + ("readback_count", 8), + ("entry_count", 8), + ("reset_resume_required_count", 1), + ("reset_resume_success_count", 1), + ("decision_rationale_required_count", 1), + ("decision_rationale_recalled_count", 1), + ("rejected_option_required_count", 1), + ("rejected_option_suppressed_count", 1), + ("rejected_option_resurrection_count", 0), + ("explicit_next_step_required_count", 1), + ("explicit_next_step_returned_count", 1), + ("explicit_next_step_correct_count", 1), + ("inferred_next_step_required_count", 1), + ("inferred_next_step_labeled_count", 1), + ("inferred_step_instruction_count", 0), + ("handoff_source_ref_required_count", 1), + ("handoff_source_ref_covered_count", 1), + ("redaction_required_count", 1), + ("redaction_applied_count", 1), + ("sensitive_marker_persistence_count", 0), + ("janitor_candidate_count", 1), + ("janitor_false_promotion_count", 0), + ("journal_only_authority_claim_count", 0), + ] { + assert_work_continuity_summary_u64(report, field, expected); + } + for (field, expected) in [ + ("reset_resume_success_rate", 1.0), + ("decision_rationale_recall_rate", 1.0), + ("rejected_option_suppression_rate", 1.0), + ("explicit_next_step_precision", 1.0), + ("inferred_next_step_labeling_rate", 1.0), + ("handoff_source_ref_coverage", 1.0), + ("redaction_rate", 1.0), + ("janitor_false_promotion_rate", 0.0), + ] { + assert_work_continuity_summary_f64(report, field, expected); + } +} + +fn assert_work_continuity_summary_u64(report: &Value, field: &str, expected: u64) { + assert_eq!( + report.pointer(&format!("/summary/work_continuity/{field}")).and_then(Value::as_u64), + Some(expected), + "unexpected Work Continuity summary field {field}", + ); +} + +fn assert_work_continuity_summary_f64(report: &Value, field: &str, expected: f64) { + assert_eq!( + report.pointer(&format!("/summary/work_continuity/{field}")).and_then(Value::as_f64), + Some(expected), + "unexpected Work Continuity summary field {field}", + ); +} + +#[test] +fn work_continuity_markdown_renders_required_metrics() -> Result<()> { + let report = support::run_json_report_from(support::work_continuity_fixture_dir())?; + let temp_dir = + env::temp_dir().join(format!("elf-real-world-work-continuity-test-{}", process::id())); + + fs::create_dir_all(&temp_dir)?; + + let report_path = temp_dir.join("work-continuity-report.json"); + let markdown_path = temp_dir.join("work-continuity-report.md"); + + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + + let output = Command::new(env!("CARGO_BIN_EXE_real_world_job_benchmark")) + .arg("publish") + .arg("--report") + .arg(&report_path) + .arg("--out") + .arg(&markdown_path) + .output()?; + + assert!( + output.status.success(), + "real_world_job publisher failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let markdown = fs::read_to_string(markdown_path)?; + + assert!(markdown.contains("Work Continuity Metrics")); + assert!(markdown.contains("work-continuity-redaction-001")); + assert!(markdown.contains("work-continuity-janitor-false-promotion-001")); + assert!(markdown.contains("Janitor False Promotion")); + assert!(markdown.contains("Sensitive Persistence")); + assert!(markdown.contains("Journal Authority Claims")); + assert!(markdown.contains("| work-continuity-reset-resume-001 | 1 | 1 | `1/1` (`1.000`)")); + assert!(markdown.contains( + "| work-continuity-explicit-next-step-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `1/1` (`1.000`)" + )); + assert!(markdown.contains( + "| work-continuity-handoff-source-ref-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`1.000`) | `0/0` (`0.000`) | `1/1` (`1.000`)" + )); + assert!(markdown.contains( + "| work-continuity-redaction-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`1.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `1/1` (`1.000`)" + )); + assert!(markdown.contains( + "| work-continuity-janitor-false-promotion-001 | 1 | 1 | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`1.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/0` (`0.000`) | `0/1` (`0.000`)" + )); + + Ok(()) +} + +#[test] +fn work_continuity_fixture_fails_sensitive_marker_persistence() -> Result<()> { + let report = run_work_continuity_mutation( + "redaction_sensitive_marker.json", + "sensitive_marker_persistence.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["items"] + [0]["redaction_audit"]["persisted_sensitive_marker_ids"] = + serde_json::json!(["secret-demo-token"]); + }, + )?; + let job = single_work_continuity_job(&report, "work-continuity-redaction-001")?; + + assert_work_continuity_wrong_result(job, "sensitive_marker_persistence_count", 1); + + Ok(()) +} + +#[test] +fn work_continuity_fixture_fails_rejected_option_resurrection() -> Result<()> { + let report = run_work_continuity_mutation( + "rejected_option_suppression.json", + "rejected_option_resurrection.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["items"] + [0]["rejected_options"][0]["resurrected_as_current"] = Value::Bool(true); + }, + )?; + let job = single_work_continuity_job(&report, "work-continuity-rejected-option-001")?; + + assert_work_continuity_wrong_result(job, "rejected_option_resurrection_count", 1); + + Ok(()) +} + +#[test] +fn work_continuity_fixture_fails_inferred_step_instruction() -> Result<()> { + let report = run_work_continuity_mutation( + "inferred_next_step_labeling.json", + "inferred_step_instruction.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["items"] + [0]["inferred_next_steps"][0]["instruction"] = Value::Bool(true); + }, + )?; + let job = single_work_continuity_job(&report, "work-continuity-inferred-next-step-001")?; + + assert_work_continuity_wrong_result(job, "inferred_step_instruction_count", 1); + + Ok(()) +} + +#[test] +fn work_continuity_fixture_fails_journal_only_authority_claim() -> Result<()> { + let report = run_work_continuity_mutation( + "handoff_source_ref_coverage.json", + "journal_only_authority_claim.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["where_stopped"] + ["journal_only_authority_claims"] = serde_json::json!(["wj-handoff-source-ref"]); + }, + )?; + let job = single_work_continuity_job(&report, "work-continuity-handoff-source-ref-001")?; + + assert_work_continuity_wrong_result(job, "journal_only_authority_claim_count", 1); + + Ok(()) +} + +#[test] +fn work_continuity_fixture_fails_janitor_promotion_or_missing_review() -> Result<()> { + let promoted = run_work_continuity_mutation( + "janitor_false_promotion_guard.json", + "janitor_promoted.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["janitor_candidates"] + [0]["promoted_to_memory"] = Value::Bool(true); + }, + )?; + let promoted_job = + single_work_continuity_job(&promoted, "work-continuity-janitor-false-promotion-001")?; + + assert_work_continuity_wrong_result(promoted_job, "janitor_false_promotion_count", 1); + assert_hard_fail_hit(promoted_job, "janitor Work Journal candidate promoted without review"); + + let missing_review = run_work_continuity_mutation( + "janitor_false_promotion_guard.json", + "janitor_missing_review_required.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["janitor_candidates"] + [0]["review_required"] = Value::Bool(false); + }, + )?; + let missing_review_job = + single_work_continuity_job(&missing_review, "work-continuity-janitor-false-promotion-001")?; + + assert_work_continuity_wrong_result(missing_review_job, "janitor_false_promotion_count", 1); + assert_hard_fail_hit( + missing_review_job, + "janitor Work Journal candidate promoted without review", + ); + + let extra_bad_candidate = run_work_continuity_mutation( + "janitor_false_promotion_guard.json", + "janitor_extra_bad_candidate.json", + |fixture| { + fixture["corpus"]["adapter_response"]["answer"]["work_journal_readbacks"][0]["janitor_candidates"] = serde_json::json!([ + { + "candidate_id": "wj-janitor-candidate", + "evidence_refs": ["wj-janitor-candidate-source"], + "review_required": true, + "promoted_to_memory": false + }, + { + "candidate_id": "wj-extra-janitor-candidate", + "evidence_refs": ["wj-janitor-candidate-source"], + "review_required": true, + "promoted_to_memory": true + } + ]); + }, + )?; + let extra_bad_candidate_job = single_work_continuity_job( + &extra_bad_candidate, + "work-continuity-janitor-false-promotion-001", + )?; + + assert_work_continuity_wrong_result( + extra_bad_candidate_job, + "janitor_false_promotion_count", + 1, + ); + assert_hard_fail_hit( + extra_bad_candidate_job, + "janitor Work Journal candidate promoted without review", + ); + + assert_eq!( + extra_bad_candidate_job + .pointer("/work_continuity/janitor_candidate_count") + .and_then(Value::as_u64), + Some(2) + ); + + Ok(()) +} + +fn run_work_continuity_mutation( + fixture_name: &str, + output_name: &str, + mutate: impl FnOnce(&mut Value), +) -> Result { + let fixture_path = support::work_continuity_fixture_dir().join(fixture_name); + let temp_dir = + env::temp_dir().join(format!("elf-work-continuity-{output_name}-{}", process::id())); + let mut fixture = support::load_json(&fixture_path)?; + + mutate(&mut fixture); + + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir)?; + } + + fs::create_dir_all(&temp_dir)?; + fs::write(temp_dir.join(output_name), serde_json::to_vec_pretty(&fixture)?)?; + + support::run_json_report_from(temp_dir) +} + +fn single_work_continuity_job<'a>(report: &'a Value, job_id: &str) -> Result<&'a Value> { + let jobs = support::array_at(report, "/jobs")?; + + support::find_by_field(jobs, "/job_id", job_id) +} + +fn assert_work_continuity_wrong_result(job: &Value, metric_name: &str, expected: u64) { + assert_eq!(job.pointer("/status").and_then(Value::as_str), Some("wrong_result")); + assert_eq!( + job.pointer(&format!("/work_continuity/{metric_name}")).and_then(Value::as_u64), + Some(expected) + ); +} + +fn assert_hard_fail_hit(job: &Value, expected_hit: &str) { + let hits = job.pointer("/hard_fail_hits").and_then(Value::as_array).expect("hard_fail_hits"); + + assert!( + hits.iter().filter_map(Value::as_str).any(|hit| hit == expected_hit), + "missing hard_fail_hits marker: {expected_hit}" + ); +} diff --git a/apps/elf-mcp/src/server.rs b/apps/elf-mcp/src/server.rs index ba0b9867..1b655fb9 100644 --- a/apps/elf-mcp/src/server.rs +++ b/apps/elf-mcp/src/server.rs @@ -1,29 +1,37 @@ -use std::{net::SocketAddr, sync::Arc}; - -use axum::{ - Router, - body::Body, - extract::State, - http::{HeaderMap, Request, StatusCode}, - middleware::{self, Next}, - response::IntoResponse, -}; +#[path = "server/runtime.rs"] mod runtime; +#[path = "server/schemas.rs"] mod schemas; +#[path = "server/state.rs"] mod state; +#[path = "server/support.rs"] mod support; + +pub use runtime::serve_mcp; + use color_eyre::Result; -use reqwest::{Client, RequestBuilder}; use rmcp::{ - ErrorData, ServerHandler, - handler::server::router::tool::ToolRouter, - model::{CallToolResult, JsonObject, ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, + ErrorData, + model::{CallToolResult, JsonObject}, }; -use serde_json::Value; -use tokio::net::TcpListener; -use uuid::Uuid; -use crate::app::McpAuthState; -use elf_config::McpContext; +use schemas::{ + admin_ingestion_profile_default_get_schema, admin_ingestion_profile_default_set_schema, + admin_ingestion_profile_get_schema, admin_ingestion_profile_versions_list_schema, + admin_ingestion_profiles_create_schema, admin_ingestion_profiles_list_schema, + admin_memory_history_get_schema, admin_note_provenance_get_schema, + admin_trace_bundle_get_schema, admin_trace_get_schema, admin_trace_item_get_schema, + admin_traces_recent_list_schema, admin_trajectory_get_schema, core_blocks_get_schema, + docs_excerpts_get_schema, docs_get_schema, docs_put_schema, docs_search_l0_schema, + dreaming_review_queue_schema, entity_memory_get_schema, events_ingest_schema, + graph_query_schema, graph_report_schema, notes_get_schema, notes_ingest_schema, + notes_list_schema, notes_patch_schema, notes_publish_schema, notes_unpublish_schema, + recall_debug_panel_schema, searches_create_schema, searches_get_schema, searches_notes_schema, + searches_timeline_schema, space_grant_revoke_schema, space_grant_upsert_schema, + space_grants_list_schema, work_journal_entry_create_schema, work_journal_entry_get_schema, + work_journal_session_readback_schema, +}; +use state::{ElfContextHeaders, ElfMcp, HttpMethod}; +#[cfg(test)] use support::is_authorized; +use support::{ + handle_response, is_admin_path, mcp_auth_middleware, normalize_api_base, params_to_query, +}; const HEADER_TENANT_ID: &str = "X-ELF-Tenant-Id"; const HEADER_PROJECT_ID: &str = "X-ELF-Project-Id"; @@ -32,222 +40,6 @@ const HEADER_READ_PROFILE: &str = "X-ELF-Read-Profile"; const HEADER_REQUEST_ID: &str = "X-ELF-Request-Id"; const HEADER_AUTHORIZATION: &str = "Authorization"; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HttpMethod { - Get, - Post, - Put, - Patch, - Delete, -} - -#[derive(Clone)] -struct ElfContextHeaders { - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, -} -impl ElfContextHeaders { - fn new(cfg: &McpContext) -> Self { - Self { - tenant_id: cfg.tenant_id.clone(), - project_id: cfg.project_id.clone(), - agent_id: cfg.agent_id.clone(), - read_profile: cfg.read_profile.clone(), - } - } -} - -#[derive(Clone)] -struct ElfMcp { - http_api_base: String, - admin_api_base: String, - client: Client, - context: ElfContextHeaders, - auth_state: McpAuthState, - tool_router: ToolRouter, -} -impl ElfMcp { - fn new( - http_api_base: String, - admin_api_base: String, - context: ElfContextHeaders, - auth_state: McpAuthState, - ) -> Self { - Self { - http_api_base, - admin_api_base, - client: Client::new(), - context, - auth_state, - tool_router: Self::tool_router(), - } - } - - fn api_base_for_path(&self, path: &str) -> &str { - if is_admin_path(path) { &self.admin_api_base } else { &self.http_api_base } - } - - fn apply_context_headers( - &self, - builder: RequestBuilder, - read_profile_override: Option<&str>, - request_id: Uuid, - ) -> RequestBuilder { - let read_profile = read_profile_override.unwrap_or(self.context.read_profile.as_str()); - let builder = builder - .header(HEADER_TENANT_ID, self.context.tenant_id.as_str()) - .header(HEADER_PROJECT_ID, self.context.project_id.as_str()) - .header(HEADER_AGENT_ID, self.context.agent_id.as_str()) - .header(HEADER_READ_PROFILE, read_profile); - let builder = builder.header(HEADER_REQUEST_ID, request_id.to_string()); - - match &self.auth_state { - McpAuthState::Off => builder, - McpAuthState::StaticKeys { bearer_token } => - builder.header(HEADER_AUTHORIZATION, format!("Bearer {bearer_token}")), - } - } - - async fn forward_post( - &self, - path: &str, - body: Value, - read_profile_override: Option<&str>, - request_id: Uuid, - ) -> Result { - let url = format!("{}{}", self.api_base_for_path(path), path); - let response = self - .apply_context_headers( - self.client.post(url).json(&body), - read_profile_override, - request_id, - ) - .send() - .await - .map_err(|err| { - ErrorData::internal_error(format!("ELF API request failed: {err}"), None) - })?; - - handle_response(response).await - } - - async fn forward_patch( - &self, - path: &str, - body: Value, - read_profile_override: Option<&str>, - request_id: Uuid, - ) -> Result { - let url = format!("{}{}", self.api_base_for_path(path), path); - let response = self - .apply_context_headers( - self.client.patch(url).json(&body), - read_profile_override, - request_id, - ) - .send() - .await - .map_err(|err| { - ErrorData::internal_error(format!("ELF API request failed: {err}"), None) - })?; - - handle_response(response).await - } - - async fn forward_put( - &self, - path: &str, - body: Value, - read_profile_override: Option<&str>, - request_id: Uuid, - ) -> Result { - let url = format!("{}{}", self.api_base_for_path(path), path); - let response = self - .apply_context_headers( - self.client.put(url).json(&body), - read_profile_override, - request_id, - ) - .send() - .await - .map_err(|err| { - ErrorData::internal_error(format!("ELF API request failed: {err}"), None) - })?; - - handle_response(response).await - } - - async fn forward_delete( - &self, - path: &str, - read_profile_override: Option<&str>, - request_id: Uuid, - ) -> Result { - let url = format!("{}{}", self.api_base_for_path(path), path); - let response = self - .apply_context_headers(self.client.delete(url), read_profile_override, request_id) - .send() - .await - .map_err(|err| { - ErrorData::internal_error(format!("ELF API request failed: {err}"), None) - })?; - - handle_response(response).await - } - - async fn forward_get( - &self, - path: &str, - params: JsonObject, - read_profile_override: Option<&str>, - request_id: Uuid, - ) -> Result { - let url = format!("{}{}", self.api_base_for_path(path), path); - let query = params_to_query(params); - let response = self - .apply_context_headers( - self.client.get(url).query(&query), - read_profile_override, - request_id, - ) - .send() - .await - .map_err(|err| { - ErrorData::internal_error(format!("ELF API request failed: {err}"), None) - })?; - - handle_response(response).await - } - - async fn forward( - &self, - method: HttpMethod, - path: &str, - params: JsonObject, - read_profile_override: Option<&str>, - ) -> Result { - let request_id = Uuid::new_v4(); - - match method { - HttpMethod::Post => - self.forward_post(path, Value::Object(params), read_profile_override, request_id) - .await, - HttpMethod::Get => - self.forward_get(path, params, read_profile_override, request_id).await, - HttpMethod::Put => - self.forward_put(path, Value::Object(params), read_profile_override, request_id) - .await, - HttpMethod::Patch => - self.forward_patch(path, Value::Object(params), read_profile_override, request_id) - .await, - HttpMethod::Delete => - self.forward_delete(path, read_profile_override, request_id).await, - } - } -} - #[rmcp::tool_router] impl ElfMcp { #[rmcp::tool( @@ -301,7 +93,7 @@ impl ElfMcp { input_schema = docs_get_schema() )] async fn elf_docs_get(&self, mut params: JsonObject) -> Result { - let doc_id = take_required_string(&mut params, "doc_id")?; + let doc_id = support::take_required_string(&mut params, "doc_id")?; let path = format!("/v2/docs/{doc_id}"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -313,7 +105,7 @@ impl ElfMcp { input_schema = docs_get_schema() )] async fn elf_docs_delete(&self, mut params: JsonObject) -> Result { - let doc_id = take_required_string(&mut params, "doc_id")?; + let doc_id = support::take_required_string(&mut params, "doc_id")?; let path = format!("/v2/docs/{doc_id}"); self.forward(HttpMethod::Delete, &path, JsonObject::new(), None).await @@ -329,7 +121,7 @@ impl ElfMcp { mut params: JsonObject, ) -> Result { // read_profile is part of the MCP server configuration and is not client-controlled. - let _ = take_optional_string(&mut params, "read_profile")?; + let _ = support::take_optional_string(&mut params, "read_profile")?; self.forward(HttpMethod::Post, "/v2/docs/search/l0", params, None).await } @@ -364,7 +156,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let entry_id = take_required_string(&mut params, "entry_id")?; + let entry_id = support::take_required_string(&mut params, "entry_id")?; let path = format!("/v2/work-journal/entries/{entry_id}"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -380,7 +172,7 @@ impl ElfMcp { mut params: JsonObject, ) -> Result { // read_profile is part of the MCP server configuration and is not client-controlled. - let _ = take_optional_string(&mut params, "read_profile")?; + let _ = support::take_optional_string(&mut params, "read_profile")?; self.forward(HttpMethod::Post, "/v2/work-journal/readback", params, None).await } @@ -395,7 +187,7 @@ impl ElfMcp { mut params: JsonObject, ) -> Result { // read_profile is part of the MCP server configuration and is not client-controlled. - let _ = take_optional_string(&mut params, "read_profile")?; + let _ = support::take_optional_string(&mut params, "read_profile")?; self.forward(HttpMethod::Get, "/v2/core-blocks", params, None).await } @@ -410,7 +202,7 @@ impl ElfMcp { mut params: JsonObject, ) -> Result { // read_profile is part of the MCP server configuration and is not client-controlled. - let _ = take_optional_string(&mut params, "read_profile")?; + let _ = support::take_optional_string(&mut params, "read_profile")?; self.forward(HttpMethod::Get, "/v2/entity-memory", params, None).await } @@ -436,7 +228,7 @@ impl ElfMcp { &self, params: JsonObject, ) -> Result { - reject_context_override_params(¶ms)?; + support::reject_context_override_params(¶ms)?; self.forward(HttpMethod::Post, "/v2/recall-debug/panel", params, None).await } @@ -451,7 +243,7 @@ impl ElfMcp { mut params: JsonObject, ) -> Result { // read_profile is part of the MCP server configuration and is not client-controlled. - let _ = take_optional_string(&mut params, "read_profile")?; + let _ = support::take_optional_string(&mut params, "read_profile")?; self.forward(HttpMethod::Post, "/v2/searches", params, None).await } @@ -462,7 +254,7 @@ impl ElfMcp { input_schema = searches_get_schema() )] async fn elf_searches_get(&self, mut params: JsonObject) -> Result { - let search_id = take_required_string(&mut params, "search_id")?; + let search_id = support::take_required_string(&mut params, "search_id")?; let path = format!("/v2/searches/{search_id}"); self.forward(HttpMethod::Get, &path, params, None).await @@ -477,7 +269,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let search_id = take_required_string(&mut params, "search_id")?; + let search_id = support::take_required_string(&mut params, "search_id")?; let path = format!("/v2/searches/{search_id}/timeline"); self.forward(HttpMethod::Get, &path, params, None).await @@ -492,7 +284,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let search_id = take_required_string(&mut params, "search_id")?; + let search_id = support::take_required_string(&mut params, "search_id")?; let path = format!("/v2/searches/{search_id}/notes"); self.forward(HttpMethod::Post, &path, params, None).await @@ -513,7 +305,7 @@ impl ElfMcp { input_schema = notes_get_schema() )] async fn elf_notes_get(&self, mut params: JsonObject) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/notes/{note_id}"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -525,7 +317,7 @@ impl ElfMcp { input_schema = notes_patch_schema() )] async fn elf_notes_patch(&self, mut params: JsonObject) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/notes/{note_id}"); self.forward(HttpMethod::Patch, &path, params, None).await @@ -537,7 +329,7 @@ impl ElfMcp { input_schema = notes_get_schema() )] async fn elf_notes_delete(&self, mut params: JsonObject) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/notes/{note_id}"); self.forward(HttpMethod::Delete, &path, JsonObject::new(), None).await @@ -549,7 +341,7 @@ impl ElfMcp { input_schema = notes_publish_schema() )] async fn elf_notes_publish(&self, mut params: JsonObject) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/notes/{note_id}/publish"); self.forward(HttpMethod::Post, &path, params, None).await @@ -564,7 +356,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/notes/{note_id}/unpublish"); self.forward(HttpMethod::Post, &path, params, None).await @@ -579,7 +371,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let space = take_required_string(&mut params, "space")?; + let space = support::take_required_string(&mut params, "space")?; let path = format!("/v2/spaces/{space}/grants"); self.forward(HttpMethod::Get, &path, params, None).await @@ -594,7 +386,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let space = take_required_string(&mut params, "space")?; + let space = support::take_required_string(&mut params, "space")?; let path = format!("/v2/spaces/{space}/grants"); self.forward(HttpMethod::Post, &path, params, None).await @@ -609,7 +401,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let space = take_required_string(&mut params, "space")?; + let space = support::take_required_string(&mut params, "space")?; let path = format!("/v2/spaces/{space}/grants/revoke"); self.forward(HttpMethod::Post, &path, params, None).await @@ -636,7 +428,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let trace_id = take_required_string(&mut params, "trace_id")?; + let trace_id = support::take_required_string(&mut params, "trace_id")?; let path = format!("/v2/admin/traces/{trace_id}"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -651,7 +443,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let trace_id = take_required_string(&mut params, "trace_id")?; + let trace_id = support::take_required_string(&mut params, "trace_id")?; let path = format!("/v2/admin/trajectories/{trace_id}"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -666,7 +458,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let item_id = take_required_string(&mut params, "item_id")?; + let item_id = support::take_required_string(&mut params, "item_id")?; let path = format!("/v2/admin/trace-items/{item_id}"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -681,7 +473,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/admin/notes/{note_id}/provenance"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -696,7 +488,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let note_id = take_required_string(&mut params, "note_id")?; + let note_id = support::take_required_string(&mut params, "note_id")?; let path = format!("/v2/admin/notes/{note_id}/history"); self.forward(HttpMethod::Get, &path, JsonObject::new(), None).await @@ -711,7 +503,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let trace_id = take_required_string(&mut params, "trace_id")?; + let trace_id = support::take_required_string(&mut params, "trace_id")?; let path = format!("/v2/admin/traces/{trace_id}/bundle"); self.forward(HttpMethod::Get, &path, params, None).await @@ -756,7 +548,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let profile_id = take_required_string(&mut params, "profile_id")?; + let profile_id = support::take_required_string(&mut params, "profile_id")?; let path = format!("/v2/admin/events/ingestion-profiles/{profile_id}"); self.forward(HttpMethod::Get, &path, params, None).await @@ -771,7 +563,7 @@ impl ElfMcp { &self, mut params: JsonObject, ) -> Result { - let profile_id = take_required_string(&mut params, "profile_id")?; + let profile_id = support::take_required_string(&mut params, "profile_id")?; let path = format!("/v2/admin/events/ingestion-profiles/{profile_id}/versions"); self.forward(HttpMethod::Get, &path, params, None).await @@ -809,1905 +601,6 @@ impl ElfMcp { } } -#[rmcp::tool_handler(router = self.tool_router)] -impl ServerHandler for ElfMcp { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("ELF MCP adapter that forwards tool calls to the ELF HTTP API.") - } -} - -pub async fn serve_mcp( - bind_addr: &str, - api_base: &str, - admin_base: &str, - auth_state: McpAuthState, - mcp_context: &McpContext, -) -> Result<()> { - let bind_addr: SocketAddr = bind_addr.parse()?; - let api_base = normalize_api_base(api_base); - let admin_base = normalize_api_base(admin_base); - let context = ElfContextHeaders::new(mcp_context); - let middleware_auth_state = auth_state.clone(); - let client_auth_state = auth_state.clone(); - let session_manager: Arc = Default::default(); - let service = StreamableHttpService::new( - move || { - Ok(ElfMcp::new( - api_base.clone(), - admin_base.clone(), - context.clone(), - client_auth_state.clone(), - )) - }, - session_manager, - StreamableHttpServerConfig::default(), - ); - let router = Router::new() - .fallback_service(service) - .layer(middleware::from_fn_with_state(middleware_auth_state, mcp_auth_middleware)); - let listener = TcpListener::bind(bind_addr).await?; - - axum::serve(listener, router).await?; - - Ok(()) -} - -fn is_admin_path(path: &str) -> bool { - path.starts_with("/v2/admin/") -} - -fn is_authorized(headers: &HeaderMap, auth_state: &McpAuthState) -> bool { - match auth_state { - McpAuthState::Off => true, - McpAuthState::StaticKeys { bearer_token } => - read_bearer_token(headers).is_some_and(|token| token == bearer_token), - } -} - -fn read_bearer_token(headers: &HeaderMap) -> Option<&str> { - let raw = headers.get(HEADER_AUTHORIZATION)?; - let value = raw.to_str().ok()?.trim(); - let token = value.strip_prefix("Bearer ")?.trim(); - - if token.is_empty() { None } else { Some(token) } -} - -fn normalize_api_base(raw: &str) -> String { - let trimmed = raw.trim().trim_end_matches('/'); - let (scheme, rest) = if let Some(value) = trimmed.strip_prefix("http://") { - ("http://", value) - } else if let Some(value) = trimmed.strip_prefix("https://") { - ("https://", value) - } else { - ("http://", trimmed) - }; - // elf-mcp runs on the same host as elf-api. If elf-api binds to a wildcard address, use - // loopback for forwarding. - let rest = if let Some(value) = rest.strip_prefix("0.0.0.0:") { - format!("127.0.0.1:{value}") - } else if let Some(value) = rest.strip_prefix("[::]:") { - format!("127.0.0.1:{value}") - } else { - rest.to_string() - }; - - format!("{scheme}{rest}") -} - -fn params_to_query(params: JsonObject) -> Vec<(String, String)> { - params - .into_iter() - .filter_map(|(key, value)| match value { - Value::Null => None, - Value::String(text) => Some((key, text)), - other => Some((key, other.to_string())), - }) - .collect() -} - -fn take_required_string(params: &mut JsonObject, key: &str) -> Result { - let value = params - .remove(key) - .ok_or_else(|| ErrorData::invalid_params(format!("{key} is required."), None))?; - let text = value - .as_str() - .ok_or_else(|| ErrorData::invalid_params(format!("{key} must be a string."), None))? - .trim(); - - if text.is_empty() { - return Err(ErrorData::invalid_params(format!("{key} must be non-empty."), None)); - } - - Ok(text.to_string()) -} - -fn take_optional_string(params: &mut JsonObject, key: &str) -> Result, ErrorData> { - let Some(value) = params.remove(key) else { return Ok(None) }; - let text = value - .as_str() - .ok_or_else(|| ErrorData::invalid_params(format!("{key} must be a string."), None))? - .trim(); - - if text.is_empty() { - return Err(ErrorData::invalid_params(format!("{key} must be non-empty."), None)); - } - - Ok(Some(text.to_string())) -} - -fn reject_context_override_params(params: &JsonObject) -> Result<(), ErrorData> { - for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { - if params.contains_key(key) { - return Err(ErrorData::invalid_params( - format!("{key} is configured by the MCP server and must not be supplied."), - None, - )); - } - } - - Ok(()) -} - -fn notes_structured_entity_schema() -> Value { - serde_json::json!({ - "type": "object", - "additionalProperties": true, - "required": ["canonical"], - "properties": { - "canonical": { "type": "string" }, - "kind": { "type": ["string", "null"] }, - "aliases": { - "type": ["array", "null"], - "items": { "type": "string" } - } - } - }) -} - -fn notes_structured_relation_object_schema() -> Value { - serde_json::json!({ - "type": "object", - "additionalProperties": true, - "oneOf": [ - { - "type": "object", - "required": ["entity"], - "properties": { - "entity": notes_structured_entity_schema(), - "value": { "type": "null" } - } - }, - { - "type": "object", - "required": ["value"], - "properties": { - "entity": { "type": ["object", "null"] }, - "value": { "type": "string" } - } - } - ] - }) -} - -fn notes_structured_schema() -> Value { - serde_json::json!({ - "type": ["object", "null"], - "additionalProperties": true, - "properties": { - "summary": { "type": ["string", "null"] }, - "facts": { - "type": ["array", "null"], - "items": { "type": "string" } - }, - "concepts": { - "type": ["array", "null"], - "items": { "type": "string" } - }, - "entities": { - "type": ["array", "null"], - "items": notes_structured_entity_schema() - }, - "relations": { - "type": ["array", "null"], - "items": { - "type": "object", - "additionalProperties": true, - "required": ["subject", "predicate", "object"], - "properties": { - "subject": notes_structured_entity_schema(), - "predicate": { "type": "string" }, - "object": notes_structured_relation_object_schema(), - "valid_from": { "type": ["string", "null"], "format": "date-time" }, - "valid_to": { "type": ["string", "null"], "format": "date-time" } - } - } - } - } - }) -} - -fn notes_ingest_schema() -> Arc { - Arc::new( - serde_json::from_value(serde_json::json!({ - "type": "object", - "additionalProperties": true, - "required": ["scope", "notes"], - "properties": { - "scope": { "type": "string" }, - "notes": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true, - "required": ["type", "text", "importance", "confidence", "source_ref"], - "properties": { - "type": { "type": "string" }, - "key": { "type": ["string", "null"] }, - "text": { "type": "string" }, - "write_policy": { "type": ["object", "null"] }, - "importance": { "type": "number" }, - "confidence": { "type": "number" }, - "ttl_days": { "type": ["integer", "null"] }, - "source_ref": { "type": "object", "additionalProperties": true }, - "structured": notes_structured_schema() - } - } - } - } - })) - .expect("notes_ingest_schema must be valid JSON object"), - ) -} - -fn graph_query_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["subject"], - "properties": { - "subject": { - "oneOf": [ - { - "type": "object", - "required": ["entity_id"], - "properties": { - "entity_id": { - "type": "string", - "format": "uuid" - } - } - }, - { - "type": "object", - "required": ["surface"], - "properties": { - "surface": { "type": "string" } - } - } - ] - }, - "predicate": { - "oneOf": [ - { - "type": "object", - "required": ["predicate_id"], - "properties": { - "predicate_id": { - "type": "string", - "format": "uuid" - } - } - }, - { - "type": "object", - "required": ["surface"], - "properties": { - "surface": { "type": "string" } - } - } - ] - }, - "scopes": { - "type": ["array", "null"], - "items": { "type": "string" } - }, - "as_of": { - "type": ["string", "null"], - "format": "date-time" - }, - "limit": { - "type": ["integer", "null"], - "minimum": 1, - "maximum": 200 - }, - "explain": { "type": ["boolean", "null"] } - } - })) -} - -fn graph_report_schema() -> Arc { - graph_query_schema() -} - -fn events_ingest_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["messages"], - "properties": { - "scope": { "type": ["string", "null"] }, - "dry_run": { "type": ["boolean", "null"] }, - "ingestion_profile": { - "type": "object", - "additionalProperties": true, - "required": ["id"], - "properties": { - "id": { "type": "string" }, - "version": { "type": ["integer", "null"] }, - }, - }, - "messages": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true, - "required": ["role", "content"], - "properties": { - "role": { "type": "string" }, - "content": { "type": "string" }, - "ts": { "type": ["string", "null"] }, - "msg_id": { "type": ["string", "null"] }, - "write_policy": { "type": ["object", "null"] } - } - } - } - } - })) -} - -fn docs_put_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["scope", "content", "source_ref"], - "properties": { - "scope": { "type": "string", "enum": ["agent_private", "project_shared", "org_shared"] }, - "doc_type": { - "type": ["string", "null"], - "enum": ["knowledge", "chat", "search", "dev", null] - }, - "title": { "type": ["string", "null"] }, - "source_ref": { - "type": "object", - "additionalProperties": true, - "required": ["schema", "doc_type", "ts"], - "properties": { - "schema": { "type": "string", "enum": ["doc_source_ref/v1"] }, - "doc_type": { - "type": "string", - "enum": ["knowledge", "chat", "search", "dev"], - }, - "ts": { "type": "string", "format": "date-time" }, - "thread_id": { "type": "string" }, - "role": { "type": "string" }, - "query": { "type": "string" }, - "url": { "type": "string" }, - "domain": { "type": "string" }, - "repo": { "type": "string" }, - "commit_sha": { "type": "string" }, - "pr_number": { "type": "integer" }, - "issue_number": { "type": "integer" }, - "source_kind": { - "type": "string", - "enum": ["article", "social_thread", "pdf", "text_export", "repo_file", "chat_excerpt", "web_page"] - }, - "canonical_uri": { "type": "string" }, - "captured_at": { "type": "string", "format": "date-time" }, - "source_created_at": { "type": "string", "format": "date-time" }, - "trust_label": { - "type": "string", - "enum": ["trusted", "user_captured", "public_web", "third_party", "unverified"] - }, - "author": { "type": "string" }, - "handle": { "type": "string" }, - "source_content_hash": { "type": "string" }, - "excerpt_locator": { - "type": "object", - "additionalProperties": true, - "properties": { - "quote": { - "type": "object", - "required": ["exact"], - "properties": { - "exact": { "type": "string" }, - "prefix": { "type": "string" }, - "suffix": { "type": "string" } - } - }, - "position": { - "type": "object", - "required": ["start", "end"], - "properties": { - "start": { "type": "integer" }, - "end": { "type": "integer" } - } - } - } - } - }, - "allOf": [ - { - "if": { "properties": { "doc_type": { "const": "chat" } }, "required": ["doc_type"] }, - "then": { - "required": ["thread_id", "role"] - } - }, - { - "if": { "properties": { "doc_type": { "const": "search" } }, "required": ["doc_type"] }, - "then": { - "required": ["query", "url", "domain"] - } - }, - { - "if": { "properties": { "doc_type": { "const": "dev" } }, "required": ["doc_type"] }, - "then": { - "required": ["repo"], - "oneOf": [ - { "required": ["commit_sha"] }, - { "required": ["pr_number"] }, - { "required": ["issue_number"] } - ] - } - } - ] - }, - "write_policy": { "type": ["object", "null"] }, - "content": { "type": "string" } - }, - })) -} - -fn docs_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["doc_id"], - "properties": { - "doc_id": { "type": "string" } - } - })) -} - -fn docs_search_l0_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["query"], - "properties": { - "query": { "type": "string" }, - "scope": { "type": ["string", "null"], "enum": ["agent_private", "project_shared", "org_shared", null] }, - "status": { "type": ["string", "null"], "enum": ["active", "deleted", null] }, - "doc_type": { - "type": ["string", "null"], - "enum": ["knowledge", "chat", "search", "dev", null] - }, - "agent_id": { "type": ["string", "null"] }, - "thread_id": { "type": ["string", "null"] }, - "updated_after": { "type": ["string", "null"], "format": "date-time" }, - "updated_before": { "type": ["string", "null"], "format": "date-time" }, - "ts_gte": { "type": ["string", "null"], "format": "date-time" }, - "ts_lte": { "type": ["string", "null"], "format": "date-time" }, - "top_k": { "type": ["integer", "null"] }, - "candidate_k": { "type": ["integer", "null"] }, - "sparse_mode": { - "type": ["string", "null"], - "enum": ["auto", "on", "off", null] - }, - "domain": { "type": ["string", "null"] }, - "repo": { "type": ["string", "null"] }, - "explain": { "type": ["boolean", "null"] }, - "read_profile": { "type": ["string", "null"] } - } - })) -} - -fn docs_excerpts_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["doc_id", "level"], - "properties": { - "doc_id": { "type": "string" }, - "level": { "type": "string", "enum": ["L0", "L1", "L2"] }, - "explain": { "type": ["boolean", "null"] }, - "chunk_id": { "type": ["string", "null"] }, - "quote": { - "type": ["object", "null"], - "additionalProperties": true, - "required": ["exact"], - "properties": { - "exact": { "type": "string" }, - "prefix": { "type": ["string", "null"] }, - "suffix": { "type": ["string", "null"] } - } - }, - "position": { - "type": ["object", "null"], - "additionalProperties": true, - "required": ["start", "end"], - "properties": { - "start": { "type": "integer" }, - "end": { "type": "integer" } - } - } - } - })) -} - -fn work_journal_entry_create_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["scope", "session_id", "family", "body", "source_refs"], - "properties": { - "entry_id": { "type": ["string", "null"] }, - "scope": { "type": "string", "enum": ["agent_private", "project_shared", "org_shared"] }, - "session_id": { "type": "string" }, - "family": { - "type": "string", - "enum": [ - "session_log", - "handoff_brief", - "janitor_report", - "explicit_next_step", - "inferred_next_step", - "rejected_option" - ] - }, - "title": { "type": ["string", "null"] }, - "body": { "type": "string" }, - "source_refs": { - "type": "array", - "items": { "type": "object", "additionalProperties": true }, - "minItems": 1 - }, - "write_policy": { "type": ["object", "null"] }, - "explicit_next_steps": { - "type": "array", - "items": { "type": "string" } - }, - "inferred_next_steps": { - "type": "array", - "items": { "type": "string" } - }, - "rejected_options": { - "type": "array", - "items": { "type": "string" } - }, - "promotion_boundary": { - "type": ["object", "null"], - "additionalProperties": true, - "properties": { - "authoritative_memory_allowed": { "type": "boolean" }, - "accepted_memory_authority_ref": { "type": "object", "additionalProperties": true }, - "accepted_dreaming_review_ref": { "type": "object", "additionalProperties": true } - } - } - } - })) -} - -fn work_journal_entry_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["entry_id"], - "properties": { - "entry_id": { "type": "string" } - } - })) -} - -fn work_journal_session_readback_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["session_id"], - "properties": { - "session_id": { "type": "string" }, - "families": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "session_log", - "handoff_brief", - "janitor_report", - "explicit_next_step", - "inferred_next_step", - "rejected_option" - ] - } - }, - "limit": { "type": ["integer", "null"] }, - "read_profile": { "type": ["string", "null"] } - } - })) -} - -fn core_blocks_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "properties": { - "read_profile": { "type": ["string", "null"] } - } - })) -} - -fn entity_memory_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "properties": { - "entity_id": { "type": ["string", "null"], "format": "uuid" }, - "entity_surface": { "type": ["string", "null"] }, - "read_profile": { "type": ["string", "null"] } - } - })) -} - -fn dreaming_review_queue_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "properties": { - "run_id": { "type": ["string", "null"], "format": "uuid" }, - "review_state": { - "type": ["string", "null"], - "enum": ["proposed", "approved", "rejected", "applied", "archived", null] - }, - "limit": { - "type": ["integer", "null"], - "minimum": 1, - "maximum": 200 - } - } - })) -} - -fn recall_debug_panel_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": false, - "properties": { - "trace_id": { "type": ["string", "null"], "format": "uuid" }, - "query": { "type": ["string", "null"] }, - "docs_query": { "type": ["string", "null"] }, - "knowledge_query": { "type": ["string", "null"] }, - "graph_subject": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["entity_id"], - "properties": { - "entity_id": { - "type": "string", - "format": "uuid" - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["surface"], - "properties": { - "surface": { "type": "string" } - } - }, - { "type": "null" } - ] - }, - "graph_predicate": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["predicate_id"], - "properties": { - "predicate_id": { - "type": "string", - "format": "uuid" - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["surface"], - "properties": { - "surface": { "type": "string" } - } - }, - { "type": "null" } - ] - }, - "include_dreaming": { "type": ["boolean", "null"] }, - "limit": { - "type": ["integer", "null"], - "minimum": 1, - "maximum": 100 - } - } - })) -} - -fn searches_create_schema() -> Arc { - let filter_schema = rmcp::object!({ - "type": "object", - "required": ["schema", "expr"], - "properties": { - "schema": { - "type": "string", - "const": "search_filter_expr/v1", - }, - "expr": { - "type": "object", - "additionalProperties": true, - }, - }, - "additionalProperties": true, - }); - - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["query", "mode"], - "properties": { - "query": { "type": "string" }, - "mode": { "type": "string", "enum": ["quick_find", "planned_search"] }, - "payload_level": { - "type": ["string", "null"], - "enum": ["l0", "l1", "l2", null] - }, - "top_k": { "type": ["integer", "null"] }, - "candidate_k": { "type": ["integer", "null"] }, - "filter": filter_schema, - "read_profile": { "type": ["string", "null"] } - } - })) -} - -fn searches_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["search_id"], - "properties": { - "search_id": { "type": "string" }, - "payload_level": { - "type": ["string", "null"], - "enum": ["l0", "l1", "l2", null] - }, - "top_k": { "type": ["integer", "null"] }, - "touch": { "type": ["boolean", "null"] } - } - })) -} - -fn searches_timeline_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["search_id"], - "properties": { - "search_id": { "type": "string" }, - "payload_level": { - "type": ["string", "null"], - "enum": ["l0", "l1", "l2", null] - }, - "group_by": { "type": ["string", "null"] } - } - })) -} - -fn searches_notes_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["search_id", "note_ids"], - "properties": { - "search_id": { "type": "string" }, - "payload_level": { - "type": ["string", "null"], - "enum": ["l0", "l1", "l2", null] - }, - "note_ids": { "type": "array", "items": { "type": "string" } }, - "record_hits": { "type": ["boolean", "null"] } - } - })) -} - -fn notes_list_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "properties": { - "scope": { "type": ["string", "null"] }, - "status": { "type": ["string", "null"] }, - "type": { "type": ["string", "null"] } - } - })) -} - -fn notes_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["note_id"], - "properties": { - "note_id": { "type": "string" } - } - })) -} - -fn notes_patch_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["note_id"], - "properties": { - "note_id": { "type": "string" }, - "text": { "type": ["string", "null"] }, - "importance": { "type": ["number", "null"] }, - "confidence": { "type": ["number", "null"] }, - "ttl_days": { "type": ["integer", "null"] } - } - })) -} - -fn notes_publish_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["note_id", "space"], - "properties": { - "note_id": { "type": "string" }, - "space": { "type": "string", "enum": ["team_shared", "org_shared"] } - } - })) -} - -fn notes_unpublish_schema() -> Arc { - notes_publish_schema() -} - -fn space_grants_list_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["space"], - "properties": { - "space": { "type": "string", "enum": ["team_shared", "org_shared"] } - } - })) -} - -fn space_grant_upsert_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["space", "grantee_kind"], - "properties": { - "space": { "type": "string", "enum": ["team_shared", "org_shared"] }, - "grantee_kind": { "type": "string", "enum": ["project", "agent"] }, - "grantee_agent_id": { "type": ["string", "null"] } - } - })) -} - -fn space_grant_revoke_schema() -> Arc { - space_grant_upsert_schema() -} - -fn admin_traces_recent_list_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": [], - "properties": { - "limit": { - "type": ["integer", "null"], - "minimum": 1, - "maximum": 200 - }, - "cursor_created_at": { "type": ["string", "null"], "format": "date-time" }, - "cursor_trace_id": { "type": ["string", "null"] }, - "agent_id": { "type": ["string", "null"] }, - "read_profile": { "type": ["string", "null"] }, - "created_after": { "type": ["string", "null"], "format": "date-time" }, - "created_before": { "type": ["string", "null"], "format": "date-time" } - } - })) -} - -fn admin_trace_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["trace_id"], - "properties": { - "trace_id": { "type": "string" } - } - })) -} - -fn admin_trajectory_get_schema() -> Arc { - admin_trace_get_schema() -} - -fn admin_trace_item_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["item_id"], - "properties": { - "item_id": { "type": "string" } - } - })) -} - -fn admin_note_provenance_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["note_id"], - "properties": { - "note_id": { "type": "string" } - } - })) -} - -fn admin_memory_history_get_schema() -> Arc { - admin_note_provenance_get_schema() -} - -fn admin_trace_bundle_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["trace_id"], - "properties": { - "trace_id": { "type": "string" }, - "mode": { "type": ["string", "null"], "enum": ["bounded", "full", null] }, - "stage_items_limit": { - "type": ["integer", "null"], - "minimum": 0, - "maximum": 256 - }, - "candidates_limit": { - "type": ["integer", "null"], - "minimum": 0, - "maximum": 1_000 - } - } - })) -} - -fn admin_ingestion_profiles_list_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": [], - "properties": {} - })) -} - -fn admin_ingestion_profiles_create_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["profile_id", "profile", "created_by"], - "properties": { - "profile_id": { "type": "string" }, - "version": { "type": ["integer", "null"] }, - "profile": { "type": "object", "additionalProperties": true }, - "created_by": { "type": "string" }, - } - })) -} - -fn admin_ingestion_profile_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["profile_id"], - "properties": { - "profile_id": { "type": "string" }, - "version": { "type": ["integer", "null"] }, - } - })) -} - -fn admin_ingestion_profile_versions_list_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["profile_id"], - "properties": { - "profile_id": { "type": "string" } - } - })) -} - -fn admin_ingestion_profile_default_get_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": [], - "properties": {} - })) -} - -fn admin_ingestion_profile_default_set_schema() -> Arc { - Arc::new(rmcp::object!({ - "type": "object", - "additionalProperties": true, - "required": ["profile_id"], - "properties": { - "profile_id": { "type": "string" }, - "version": { "type": ["integer", "null"] }, - } - })) -} - -async fn handle_response(response: reqwest::Response) -> Result { - let status = response.status(); - let bytes = response - .bytes() - .await - .map_err(|err| ErrorData::internal_error(format!("ELF API response error: {err}"), None))?; - let parsed = serde_json::from_slice::(&bytes).unwrap_or_else(|_| { - let raw = String::from_utf8_lossy(&bytes).to_string(); - - serde_json::json!({ "raw": raw }) - }); - - if status.is_success() { - Ok(CallToolResult::structured(parsed)) - } else { - Ok(CallToolResult::structured_error(parsed)) - } -} - -async fn mcp_auth_middleware( - State(auth_state): State, - req: Request, - next: Next, -) -> axum::response::Response { - if !is_authorized(req.headers(), &auth_state) { - return ( - StatusCode::UNAUTHORIZED, - "Authentication required for security.auth_mode=static_keys with a Bearer token.", - ) - .into_response(); - } - - next.run(req).await -} - #[cfg(test)] -mod tests { - use std::{ - collections::HashMap, - sync::{Arc, Mutex}, - time::Duration, - }; - - use axum::{ - Json, Router, - extract::State, - http::{HeaderMap, Method, Uri}, - routing, - }; - use serde_json::Value; - use tokio::{net::TcpListener, sync::oneshot, time}; - - use crate::app::{ - McpAuthState, - server::{ElfContextHeaders, ElfMcp, HttpMethod}, - }; - use elf_config::McpContext; - - type RequestRecorder = Arc>>>; - - const ALL_TOOL_DEFINITIONS: [ToolDefinition; 37] = [ - ToolDefinition::new( - "elf_notes_ingest", - HttpMethod::Post, - "/v2/notes/ingest", - "Ingest deterministic notes into ELF. This tool never calls an LLM.", - ), - ToolDefinition::new( - "elf_graph_query", - HttpMethod::Post, - "/v2/graph/query", - "Query graph entities and relations by structured criteria.", - ), - ToolDefinition::new( - "elf_graph_report", - HttpMethod::Post, - "/v2/graph/report", - "Build a source-backed graph topic map with current, historical, future, inferred, ambiguous, stale, and superseded fact markers.", - ), - ToolDefinition::new( - "elf_events_ingest", - HttpMethod::Post, - "/v2/events/ingest", - "Ingest an event by extracting evidence-bound notes using the configured LLM extractor.", - ), - ToolDefinition::new( - "elf_searches_create", - HttpMethod::Post, - "/v2/searches", - "Create a search session using quick-find or planned-search mode. Response includes optional trajectory_summary.", - ), - ToolDefinition::new( - "elf_core_blocks_get", - HttpMethod::Get, - "/v2/core-blocks", - "Fetch core memory blocks explicitly attached to the configured agent and read profile.", - ), - ToolDefinition::new( - "elf_entity_memory_get", - HttpMethod::Get, - "/v2/entity-memory", - "Fetch an entity-scoped memory view across attached core blocks and graph-linked archival notes.", - ), - ToolDefinition::new( - "elf_dreaming_review_queue", - HttpMethod::Get, - "/v2/admin/dreaming/review-queue", - "List source-backed Dreaming review queue proposals with variants, affected refs, lint flags, policy gates, and review audit.", - ), - ToolDefinition::new( - "elf_recall_debug_panel", - HttpMethod::Post, - "/v2/recall-debug/panel", - "Build an agent-facing cross-layer recall/debug panel and deterministic recall_trace over memory traces, source documents, knowledge pages, graph facts, and Dreaming proposals.", - ), - ToolDefinition::new( - "elf_work_journal_entry_create", - HttpMethod::Post, - "/v2/work-journal/entries", - "Capture one source-adjacent Work Journal entry with source refs, redaction, next-step, rejected-option, and promotion-boundary metadata.", - ), - ToolDefinition::new( - "elf_work_journal_entry_get", - HttpMethod::Get, - "/v2/work-journal/entries/{entry_id}", - "Fetch one readable Work Journal entry by entry_id.", - ), - ToolDefinition::new( - "elf_work_journal_session_readback", - HttpMethod::Post, - "/v2/work-journal/readback", - "Read newest Work Journal entries for a session and return a where_stopped projection with journal evidence.", - ), - ToolDefinition::new( - "elf_searches_get", - HttpMethod::Get, - "/v2/searches/{search_id}", - "Fetch a search session index view by search_id, including optional trajectory_summary.", - ), - ToolDefinition::new( - "elf_searches_timeline", - HttpMethod::Get, - "/v2/searches/{search_id}/timeline", - "Build a timeline view from a search session.", - ), - ToolDefinition::new( - "elf_searches_notes", - HttpMethod::Post, - "/v2/searches/{search_id}/notes", - "Fetch note details for selected note_ids from a search session. l0/l1 strip evidence/source_ref/structured; l2 returns full detail.", - ), - ToolDefinition::new( - "elf_notes_list", - HttpMethod::Get, - "/v2/notes", - "List notes in a tenant and project with optional filters.", - ), - ToolDefinition::new( - "elf_notes_get", - HttpMethod::Get, - "/v2/notes/{note_id}", - "Fetch a single note by note_id.", - ), - ToolDefinition::new( - "elf_notes_patch", - HttpMethod::Patch, - "/v2/notes/{note_id}", - "Patch a note by note_id. Only provided fields are updated.", - ), - ToolDefinition::new( - "elf_notes_delete", - HttpMethod::Delete, - "/v2/notes/{note_id}", - "Delete a note by note_id.", - ), - ToolDefinition::new( - "elf_notes_publish", - HttpMethod::Post, - "/v2/notes/{note_id}/publish", - "Publish a note from agent_private into a shared space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_notes_unpublish", - HttpMethod::Post, - "/v2/notes/{note_id}/unpublish", - "Unpublish a shared note back into agent_private scope.", - ), - ToolDefinition::new( - "elf_space_grants_list", - HttpMethod::Get, - "/v2/spaces/{space}/grants", - "List sharing grants for a space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_space_grant_upsert", - HttpMethod::Post, - "/v2/spaces/{space}/grants", - "Upsert a sharing grant for a space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_space_grant_revoke", - HttpMethod::Post, - "/v2/spaces/{space}/grants/revoke", - "Revoke a sharing grant for a space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_admin_traces_recent_list", - HttpMethod::Get, - "/v2/admin/traces/recent", - "List recent traces by tenant/project with optional cursor and filters.", - ), - ToolDefinition::new( - "elf_admin_trace_get", - HttpMethod::Get, - "/v2/admin/traces/{trace_id}", - "Fetch trace metadata, items, and optional trajectory summary by trace_id.", - ), - ToolDefinition::new( - "elf_admin_trajectory_get", - HttpMethod::Get, - "/v2/admin/trajectories/{trace_id}", - "Fetch trace trajectory and stage payload by trace_id.", - ), - ToolDefinition::new( - "elf_admin_trace_item_get", - HttpMethod::Get, - "/v2/admin/trace-items/{item_id}", - "Fetch a trace item explain payload by item_id.", - ), - ToolDefinition::new( - "elf_admin_note_provenance_get", - HttpMethod::Get, - "/v2/admin/notes/{note_id}/provenance", - "Fetch provenance bundle for a note.", - ), - ToolDefinition::new( - "elf_admin_memory_history_get", - HttpMethod::Get, - "/v2/admin/notes/{note_id}/history", - "Fetch chronological memory history for a note.", - ), - ToolDefinition::new( - "elf_admin_trace_bundle_get", - HttpMethod::Get, - "/v2/admin/traces/{trace_id}/bundle", - "Fetch trace bundle for replay and diagnostics by trace_id.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profiles_list", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles", - "List latest ingestion profiles for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profiles_create", - HttpMethod::Post, - "/v2/admin/events/ingestion-profiles", - "Create a new ingestion profile version for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_get", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles/{profile_id}", - "Get a single ingestion profile by id/version for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_versions_list", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles/{profile_id}/versions", - "List all versions of one ingestion profile for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_default_get", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles/default", - "Get the active default ingestion profile for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_default_set", - HttpMethod::Put, - "/v2/admin/events/ingestion-profiles/default", - "Set the default ingestion profile for add_event.", - ), - ]; - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - struct ToolDefinition { - name: &'static str, - method: HttpMethod, - path: &'static str, - description: &'static str, - streaming: bool, - } - - struct RecordedRequest { - method: Method, - path: String, - body: Value, - } - - impl ToolDefinition { - const fn new( - name: &'static str, - method: HttpMethod, - path: &'static str, - description: &'static str, - ) -> Self { - Self { name, method, path, description, streaming: true } - } - } - - fn build_tools() -> HashMap<&'static str, ToolDefinition> { - ALL_TOOL_DEFINITIONS.into_iter().map(|tool| (tool.name, tool)).collect() - } - - #[test] - fn registers_all_tools() { - let tools = build_tools(); - let expected = [ - "elf_notes_ingest", - "elf_graph_query", - "elf_graph_report", - "elf_events_ingest", - "elf_core_blocks_get", - "elf_entity_memory_get", - "elf_searches_create", - "elf_searches_get", - "elf_searches_timeline", - "elf_searches_notes", - "elf_notes_list", - "elf_notes_get", - "elf_notes_patch", - "elf_notes_delete", - "elf_notes_publish", - "elf_notes_unpublish", - "elf_space_grants_list", - "elf_space_grant_upsert", - "elf_space_grant_revoke", - "elf_admin_traces_recent_list", - "elf_dreaming_review_queue", - "elf_recall_debug_panel", - "elf_work_journal_entry_create", - "elf_work_journal_entry_get", - "elf_work_journal_session_readback", - "elf_admin_trace_get", - "elf_admin_trajectory_get", - "elf_admin_trace_item_get", - "elf_admin_note_provenance_get", - "elf_admin_memory_history_get", - "elf_admin_trace_bundle_get", - "elf_admin_events_ingestion_profiles_list", - "elf_admin_events_ingestion_profiles_create", - "elf_admin_events_ingestion_profile_get", - "elf_admin_events_ingestion_profile_versions_list", - "elf_admin_events_ingestion_profile_default_get", - "elf_admin_events_ingestion_profile_default_set", - ]; - - for name in expected { - assert!(tools.contains_key(name), "Missing tool registration: {name}."); - } - - assert_eq!(tools.len(), expected.len(), "Unexpected tool count for MCP registration."); - } - - #[test] - fn notes_ingest_schema_includes_structured_entities_relations() { - let schema = super::notes_ingest_schema(); - let notes = schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("notes ingest schema is missing properties.") - .get("notes") - .and_then(serde_json::Value::as_object) - .expect("notes schema is missing notes."); - let note_items = notes - .get("items") - .and_then(serde_json::Value::as_object) - .expect("notes schema is missing items."); - let note_properties = note_items - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("notes schema is missing note item properties."); - let structured = note_properties - .get("structured") - .and_then(serde_json::Value::as_object) - .expect("notes schema is missing structured."); - let structured_type = structured - .get("type") - .and_then(serde_json::Value::as_array) - .expect("structured.type is not an array."); - - assert!( - structured_type.contains(&serde_json::Value::String("object".to_string())) - && structured_type.contains(&serde_json::Value::String("null".to_string())) - ); - - let structured_properties = structured - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("structured schema is missing properties."); - - assert!(structured_properties.contains_key("entities")); - assert!(structured_properties.contains_key("relations")); - - let relation_object = structured_properties - .get("relations") - .and_then(serde_json::Value::as_object) - .and_then(|relations| relations.get("items")) - .and_then(serde_json::Value::as_object) - .and_then(|items| items.get("properties")) - .and_then(serde_json::Value::as_object) - .expect("relations schema is missing properties.") - .get("object") - .and_then(serde_json::Value::as_object) - .expect("relation schema is missing object."); - let one_of = relation_object - .get("oneOf") - .and_then(serde_json::Value::as_array) - .expect("relation object is missing oneOf."); - - assert_eq!(one_of.len(), 2, "relation object should have entity/value oneOf variants."); - assert!(one_of.iter().any(|variant| { - variant.as_object().is_some_and(|branch| { - branch - .get("required") - .and_then(serde_json::Value::as_array) - .is_some_and(|required| required.iter().any(|value| value == "entity")) - }) - })); - assert!(one_of.iter().any(|variant| { - variant.as_object().is_some_and(|branch| { - branch - .get("required") - .and_then(serde_json::Value::as_array) - .is_some_and(|required| required.iter().any(|value| value == "value")) - }) - })); - } - - #[test] - fn admin_paths_use_admin_api_base() { - let context = McpContext { - tenant_id: "tenant-a".to_string(), - project_id: "project-a".to_string(), - agent_id: "agent-a".to_string(), - read_profile: "private_plus_project".to_string(), - }; - let mcp = ElfMcp::new( - "http://127.0.0.1:9000".to_string(), - "http://127.0.0.1:9001".to_string(), - ElfContextHeaders::new(&context), - McpAuthState::Off, - ); - - assert_eq!(mcp.api_base_for_path("/v2/admin/traces/recent"), "http://127.0.0.1:9001"); - assert_eq!( - mcp.api_base_for_path("/v2/admin/notes/abcd/provenance"), - "http://127.0.0.1:9001" - ); - assert_eq!(mcp.api_base_for_path("/v2/admin/notes/abcd/history"), "http://127.0.0.1:9001"); - assert_eq!(mcp.api_base_for_path("/v2/searches"), "http://127.0.0.1:9000"); - assert_eq!(mcp.api_base_for_path("/v2/recall-debug/panel"), "http://127.0.0.1:9000"); - } - - #[test] - fn recall_debug_tool_uses_public_agent_route() { - let tools = build_tools(); - let tool = tools.get("elf_recall_debug_panel").expect("Missing recall debug panel tool."); - - assert_eq!(tool.path, "/v2/recall-debug/panel"); - assert!(tool.description.contains("recall_trace")); - } - - #[test] - fn recall_debug_panel_schema_rejects_context_override_fields() { - let schema = super::recall_debug_panel_schema(); - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("recall debug panel schema is missing properties."); - - assert_eq!(schema.get("additionalProperties"), Some(&Value::Bool(false))); - - for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { - assert!(!properties.contains_key(key), "{key} must not be a tool param."); - } - for key in ["graph_subject", "graph_predicate"] { - let one_of = properties - .get(key) - .and_then(Value::as_object) - .and_then(|schema| schema.get("oneOf")) - .and_then(Value::as_array) - .expect("selector schema is missing oneOf."); - - for branch in one_of.iter().filter_map(Value::as_object) { - if branch.get("type").and_then(Value::as_str) == Some("object") { - assert_eq!( - branch.get("additionalProperties"), - Some(&Value::Bool(false)), - "{key} selector object branches must be closed." - ); - } - } - } - } - - #[test] - fn off_mode_allows_requests_without_auth_header() { - let headers = HeaderMap::new(); - - assert!(super::is_authorized(&headers, &McpAuthState::Off)); - } - - #[test] - fn static_keys_mode_requires_authorization_bearer_header() { - let mut headers = HeaderMap::new(); - - headers - .insert(super::HEADER_AUTHORIZATION, "Bearer token-a".parse().expect("valid header")); - - assert!(super::is_authorized( - &headers, - &McpAuthState::StaticKeys { bearer_token: "token-a".to_string() } - )); - } - - #[test] - fn static_keys_mode_rejects_non_bearer_schemes() { - let mut headers = HeaderMap::new(); - - headers - .insert(super::HEADER_AUTHORIZATION, "bearer token-a".parse().expect("valid header")); - - assert!(!super::is_authorized( - &headers, - &McpAuthState::StaticKeys { bearer_token: "token-a".to_string() } - )); - } - - #[test] - fn docs_search_l0_schema_includes_filter_fields() { - let schema = super::docs_search_l0_schema(); - let properties = schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("docs_search_l0 schema is missing properties."); - let required = ["query"]; - let expected = [ - "scope", - "status", - "doc_type", - "agent_id", - "thread_id", - "updated_after", - "updated_before", - "ts_gte", - "ts_lte", - "sparse_mode", - "domain", - "repo", - "explain", - ]; - - for field in required { - assert!( - schema.get("required").and_then(serde_json::Value::as_array).is_some_and( - |fields| { fields.iter().any(|value| value.as_str() == Some(field)) } - ), - "Missing required field {field}." - ); - } - for field in expected { - assert!(properties.contains_key(field), "Missing schema field: {field}."); - } - - assert_eq!( - properties.get("status").and_then(serde_json::Value::as_object).and_then(|status| { - status.get("enum").and_then(serde_json::Value::as_array).map(|vals| vals.to_vec()) - }), - Some(vec![ - serde_json::Value::String("active".to_string()), - serde_json::Value::String("deleted".to_string()), - serde_json::Value::Null, - ]) - ); - assert_eq!( - properties.get("sparse_mode").and_then(serde_json::Value::as_object).and_then( - |field| { - field - .get("enum") - .and_then(serde_json::Value::as_array) - .map(|vals| vals.to_vec()) - } - ), - Some(vec![ - serde_json::Value::String("auto".to_string()), - serde_json::Value::String("on".to_string()), - serde_json::Value::String("off".to_string()), - serde_json::Value::Null, - ]) - ); - } - - #[test] - fn docs_put_schema_includes_required_fields_and_write_policy() { - let schema = super::docs_put_schema(); - let properties = schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("docs_put schema is missing properties."); - let required = ["scope", "content", "source_ref"]; - let expected = ["scope", "doc_type", "title", "source_ref", "write_policy", "content"]; - - for field in required { - assert!( - schema.get("required").and_then(serde_json::Value::as_array).is_some_and( - |fields| { fields.iter().any(|value| value.as_str() == Some(field)) } - ), - "Missing required field {field}." - ); - } - for field in expected { - assert!(properties.contains_key(field), "Missing schema field: {field}."); - } - - let write_policy = properties.get("write_policy").and_then(serde_json::Value::as_object); - let source_ref_properties = properties - .get("source_ref") - .and_then(|value| value.get("properties")) - .and_then(serde_json::Value::as_object) - .expect("docs_put source_ref schema is missing properties."); - - assert!( - write_policy.is_some_and(|field| { - field.get("type").and_then(serde_json::Value::as_array).is_some_and(|types| { - types.contains(&serde_json::Value::String("object".to_string())) - && types.contains(&serde_json::Value::String("null".to_string())) - }) - }), - "Missing write_policy object/null type in docs_put schema." - ); - - for field in - ["source_kind", "canonical_uri", "captured_at", "trust_label", "excerpt_locator"] - { - assert!( - source_ref_properties.contains_key(field), - "Missing source_ref field: {field}." - ); - } - } - - #[test] - fn work_journal_schemas_include_families_and_source_refs() { - let create_schema = super::work_journal_entry_create_schema(); - let create_properties = create_schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("work_journal_entry_create schema is missing properties."); - let readback_schema = super::work_journal_session_readback_schema(); - let readback_properties = readback_schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("work_journal_session_readback schema is missing properties."); - - for field in ["scope", "session_id", "family", "body", "source_refs"] { - assert!( - create_schema.get("required").and_then(serde_json::Value::as_array).is_some_and( - |fields| { fields.iter().any(|value| value.as_str() == Some(field)) } - ), - "Missing Work Journal required field {field}." - ); - } - - assert!(create_properties.contains_key("write_policy")); - assert!(create_properties.contains_key("promotion_boundary")); - assert!(readback_properties.contains_key("session_id")); - assert!(readback_properties.contains_key("families")); - } - - #[test] - fn docs_excerpts_get_schema_includes_l0_level_and_optional_explain() { - let schema = super::docs_excerpts_get_schema(); - let properties = schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("docs_excerpts_get schema is missing properties."); - let level_values = properties - .get("level") - .and_then(|level| level.get("enum")) - .and_then(|values| values.as_array()) - .expect("docs_excerpts_get level schema is missing enum."); - - assert!(level_values.contains(&serde_json::Value::String("L0".to_string()))); - assert!(properties.contains_key("explain")); - } - - #[test] - fn payload_level_schema_for_search_tools_is_l0_l1_l2() { - for schema in [ - super::searches_create_schema(), - super::searches_get_schema(), - super::searches_timeline_schema(), - super::searches_notes_schema(), - ] { - let properties = schema - .get("properties") - .and_then(serde_json::Value::as_object) - .expect("Search schema is missing properties."); - let payload_level = properties - .get("payload_level") - .and_then(serde_json::Value::as_object) - .expect("payload_level field is missing from search schema."); - let payload_level_values = payload_level - .get("enum") - .and_then(serde_json::Value::as_array) - .expect("payload_level enum is missing."); - - assert_eq!(payload_level_values.len(), 4, "Unexpected payload_level enum length."); - assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l0"))); - assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l1"))); - assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l2"))); - assert!(payload_level_values.iter().any(|value| value.is_null())); - } - } - - #[test] - fn searches_notes_tool_description_mentions_payload_level_shapes() { - let tools = build_tools(); - let tool = - tools.get("elf_searches_notes").expect("Missing elf_searches_notes tool definition."); - let description = tool.description.to_lowercase(); - - assert_eq!(tool.path, "/v2/searches/{search_id}/notes"); - assert!(description.contains("l0")); - assert!(description.contains("l1")); - assert!(description.contains("l2")); - assert!(description.contains("source_ref")); - assert!(description.contains("structured")); - } - - #[tokio::test] - async fn recall_debug_panel_rejects_context_override_params() { - let context = McpContext { - tenant_id: "tenant-a".to_string(), - project_id: "project-a".to_string(), - agent_id: "agent-a".to_string(), - read_profile: "private_plus_project".to_string(), - }; - let mcp = ElfMcp::new( - "http://127.0.0.1:1".to_string(), - "http://127.0.0.1:1".to_string(), - ElfContextHeaders::new(&context), - McpAuthState::Off, - ); - let params = serde_json::Map::from_iter([( - "tenant_id".to_string(), - Value::String("tenant-override".to_string()), - )]); - let result = mcp.elf_recall_debug_panel(params).await; - let err = result.expect_err("context override params must fail before forwarding."); - - assert!(format!("{err:?}").contains("tenant_id")); - } - - #[tokio::test] - async fn default_ingestion_profile_set_uses_put_admin_default_path() { - let (admin_base, received) = spawn_recording_admin_server().await; - let context = McpContext { - tenant_id: "tenant-a".to_string(), - project_id: "project-a".to_string(), - agent_id: "agent-a".to_string(), - read_profile: "private_plus_project".to_string(), - }; - let mcp = ElfMcp::new( - "http://127.0.0.1:9000".to_string(), - admin_base, - ElfContextHeaders::new(&context), - McpAuthState::Off, - ); - let params = serde_json::Map::from_iter([ - ("profile_id".to_string(), Value::String("profile-a".to_string())), - ("version".to_string(), Value::Number(2.into())), - ]); - let result = mcp.elf_admin_events_ingestion_profile_default_set(params).await; - - assert!(result.is_ok(), "default setter should forward successfully: {result:?}"); - - let request = receive_recorded_request(received).await; - - assert_eq!(request.method, Method::PUT); - assert_eq!(request.path, "/v2/admin/events/ingestion-profiles/default"); - assert_eq!(request.body.get("profile_id").and_then(Value::as_str), Some("profile-a")); - assert_eq!(request.body.get("version").and_then(Value::as_i64), Some(2)); - } - - async fn spawn_recording_admin_server() -> (String, oneshot::Receiver) { - let (tx, rx) = oneshot::channel(); - let app = Router::new() - .route("/v2/admin/events/ingestion-profiles/default", routing::any(record_request)) - .with_state(Arc::new(Mutex::new(Some(tx)))); - let listener = match TcpListener::bind("127.0.0.1:0").await { - Ok(listener) => listener, - Err(err) => panic!("Failed to bind MCP recording admin server: {err}."), - }; - let addr = match listener.local_addr() { - Ok(addr) => addr, - Err(err) => panic!("Failed to read MCP recording admin server address: {err}."), - }; - - tokio::spawn(async move { - if let Err(err) = axum::serve(listener, app).await { - panic!("MCP recording admin server failed: {err}."); - } - }); - - (format!("http://{addr}"), rx) - } - - async fn record_request( - State(recorder): State, - method: Method, - uri: Uri, - Json(body): Json, - ) -> Json { - let mut sender = match recorder.lock() { - Ok(sender) => sender, - Err(err) => panic!("MCP recording admin server mutex was poisoned: {err}."), - }; - - if let Some(tx) = sender.take() { - let _ = tx.send(RecordedRequest { method, path: uri.path().to_string(), body }); - } - - Json(serde_json::json!({ "ok": true })) - } - - async fn receive_recorded_request( - received: oneshot::Receiver, - ) -> RecordedRequest { - match time::timeout(Duration::from_secs(3), received).await { - Ok(Ok(request)) => request, - Ok(Err(err)) => panic!("MCP recording admin server closed before recording: {err}."), - Err(err) => panic!("Timed out waiting for MCP recording admin server: {err}."), - } - } -} +#[path = "server/tests.rs"] +mod tests; diff --git a/apps/elf-mcp/src/server/runtime.rs b/apps/elf-mcp/src/server/runtime.rs new file mode 100644 index 00000000..c959c712 --- /dev/null +++ b/apps/elf-mcp/src/server/runtime.rs @@ -0,0 +1,62 @@ +use std::{net::SocketAddr, sync::Arc}; + +use axum::{Router, middleware}; +use color_eyre::Result; +use rmcp::{ + ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio::net::TcpListener; + +use crate::app::{ + McpAuthState, + server::{self, ElfContextHeaders, ElfMcp}, +}; +use elf_config::McpContext; + +#[rmcp::tool_handler(router = self.tool_router)] +impl ServerHandler for ElfMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("ELF MCP adapter that forwards tool calls to the ELF HTTP API.") + } +} + +pub async fn serve_mcp( + bind_addr: &str, + api_base: &str, + admin_base: &str, + auth_state: McpAuthState, + mcp_context: &McpContext, +) -> Result<()> { + let bind_addr: SocketAddr = bind_addr.parse()?; + let api_base = server::normalize_api_base(api_base); + let admin_base = server::normalize_api_base(admin_base); + let context = ElfContextHeaders::new(mcp_context); + let middleware_auth_state = auth_state.clone(); + let client_auth_state = auth_state.clone(); + let session_manager: Arc = Default::default(); + let service = StreamableHttpService::new( + move || { + Ok(ElfMcp::new( + api_base.clone(), + admin_base.clone(), + context.clone(), + client_auth_state.clone(), + )) + }, + session_manager, + StreamableHttpServerConfig::default(), + ); + let router = Router::new() + .fallback_service(service) + .layer(middleware::from_fn_with_state(middleware_auth_state, server::mcp_auth_middleware)); + let listener = TcpListener::bind(bind_addr).await?; + + axum::serve(listener, router).await?; + + Ok(()) +} diff --git a/apps/elf-mcp/src/server/schemas.rs b/apps/elf-mcp/src/server/schemas.rs new file mode 100644 index 00000000..db1ca4a0 --- /dev/null +++ b/apps/elf-mcp/src/server/schemas.rs @@ -0,0 +1,40 @@ +#[path = "schemas/admin.rs"] mod admin; +#[path = "schemas/docs.rs"] mod docs; +#[path = "schemas/events.rs"] mod events; +#[path = "schemas/graph.rs"] mod graph; +#[path = "schemas/memory.rs"] mod memory; +#[path = "schemas/notes.rs"] mod notes; +#[path = "schemas/search.rs"] mod search; +#[path = "schemas/sharing.rs"] mod sharing; +#[path = "schemas/work_journal.rs"] mod work_journal; + +pub(in crate::app::server) use self::{ + admin::{ + admin_ingestion_profile_default_get_schema, admin_ingestion_profile_default_set_schema, + admin_ingestion_profile_get_schema, admin_ingestion_profile_versions_list_schema, + admin_ingestion_profiles_create_schema, admin_ingestion_profiles_list_schema, + admin_memory_history_get_schema, admin_note_provenance_get_schema, + admin_trace_bundle_get_schema, admin_trace_get_schema, admin_trace_item_get_schema, + admin_traces_recent_list_schema, admin_trajectory_get_schema, + }, + docs::{docs_excerpts_get_schema, docs_get_schema, docs_put_schema, docs_search_l0_schema}, + events::events_ingest_schema, + graph::{graph_query_schema, graph_report_schema}, + memory::{ + core_blocks_get_schema, dreaming_review_queue_schema, entity_memory_get_schema, + recall_debug_panel_schema, + }, + notes::{ + notes_get_schema, notes_ingest_schema, notes_list_schema, notes_patch_schema, + notes_publish_schema, notes_unpublish_schema, + }, + search::{ + searches_create_schema, searches_get_schema, searches_notes_schema, + searches_timeline_schema, + }, + sharing::{space_grant_revoke_schema, space_grant_upsert_schema, space_grants_list_schema}, + work_journal::{ + work_journal_entry_create_schema, work_journal_entry_get_schema, + work_journal_session_readback_schema, + }, +}; diff --git a/apps/elf-mcp/src/server/schemas/admin.rs b/apps/elf-mcp/src/server/schemas/admin.rs new file mode 100644 index 00000000..7a247021 --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/admin.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn admin_traces_recent_list_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": [], + "properties": { + "limit": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 200 + }, + "cursor_created_at": { "type": ["string", "null"], "format": "date-time" }, + "cursor_trace_id": { "type": ["string", "null"] }, + "agent_id": { "type": ["string", "null"] }, + "read_profile": { "type": ["string", "null"] }, + "created_after": { "type": ["string", "null"], "format": "date-time" }, + "created_before": { "type": ["string", "null"], "format": "date-time" } + } + })) +} + +pub(in crate::app::server) fn admin_trace_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["trace_id"], + "properties": { + "trace_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn admin_trajectory_get_schema() -> Arc { + admin_trace_get_schema() +} + +pub(in crate::app::server) fn admin_trace_item_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["item_id"], + "properties": { + "item_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn admin_note_provenance_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["note_id"], + "properties": { + "note_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn admin_memory_history_get_schema() -> Arc { + admin_note_provenance_get_schema() +} + +pub(in crate::app::server) fn admin_trace_bundle_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["trace_id"], + "properties": { + "trace_id": { "type": "string" }, + "mode": { "type": ["string", "null"], "enum": ["bounded", "full", null] }, + "stage_items_limit": { + "type": ["integer", "null"], + "minimum": 0, + "maximum": 256 + }, + "candidates_limit": { + "type": ["integer", "null"], + "minimum": 0, + "maximum": 1_000 + } + } + })) +} + +pub(in crate::app::server) fn admin_ingestion_profiles_list_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": [], + "properties": {} + })) +} + +pub(in crate::app::server) fn admin_ingestion_profiles_create_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["profile_id", "profile", "created_by"], + "properties": { + "profile_id": { "type": "string" }, + "version": { "type": ["integer", "null"] }, + "profile": { "type": "object", "additionalProperties": true }, + "created_by": { "type": "string" }, + } + })) +} + +pub(in crate::app::server) fn admin_ingestion_profile_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["profile_id"], + "properties": { + "profile_id": { "type": "string" }, + "version": { "type": ["integer", "null"] }, + } + })) +} + +pub(in crate::app::server) fn admin_ingestion_profile_versions_list_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["profile_id"], + "properties": { + "profile_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn admin_ingestion_profile_default_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": [], + "properties": {} + })) +} + +pub(in crate::app::server) fn admin_ingestion_profile_default_set_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["profile_id"], + "properties": { + "profile_id": { "type": "string" }, + "version": { "type": ["integer", "null"] }, + } + })) +} diff --git a/apps/elf-mcp/src/server/schemas/docs.rs b/apps/elf-mcp/src/server/schemas/docs.rs new file mode 100644 index 00000000..961232fa --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/docs.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn docs_put_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["scope", "content", "source_ref"], + "properties": { + "scope": { "type": "string", "enum": ["agent_private", "project_shared", "org_shared"] }, + "doc_type": { + "type": ["string", "null"], + "enum": ["knowledge", "chat", "search", "dev", null] + }, + "title": { "type": ["string", "null"] }, + "source_ref": { + "type": "object", + "additionalProperties": true, + "required": ["schema", "doc_type", "ts"], + "properties": { + "schema": { "type": "string", "enum": ["doc_source_ref/v1"] }, + "doc_type": { + "type": "string", + "enum": ["knowledge", "chat", "search", "dev"], + }, + "ts": { "type": "string", "format": "date-time" }, + "thread_id": { "type": "string" }, + "role": { "type": "string" }, + "query": { "type": "string" }, + "url": { "type": "string" }, + "domain": { "type": "string" }, + "repo": { "type": "string" }, + "commit_sha": { "type": "string" }, + "pr_number": { "type": "integer" }, + "issue_number": { "type": "integer" }, + "source_kind": { + "type": "string", + "enum": ["article", "social_thread", "pdf", "text_export", "repo_file", "chat_excerpt", "web_page"] + }, + "canonical_uri": { "type": "string" }, + "captured_at": { "type": "string", "format": "date-time" }, + "source_created_at": { "type": "string", "format": "date-time" }, + "trust_label": { + "type": "string", + "enum": ["trusted", "user_captured", "public_web", "third_party", "unverified"] + }, + "author": { "type": "string" }, + "handle": { "type": "string" }, + "source_content_hash": { "type": "string" }, + "excerpt_locator": { + "type": "object", + "additionalProperties": true, + "properties": { + "quote": { + "type": "object", + "required": ["exact"], + "properties": { + "exact": { "type": "string" }, + "prefix": { "type": "string" }, + "suffix": { "type": "string" } + } + }, + "position": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { "type": "integer" }, + "end": { "type": "integer" } + } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "doc_type": { "const": "chat" } }, "required": ["doc_type"] }, + "then": { + "required": ["thread_id", "role"] + } + }, + { + "if": { "properties": { "doc_type": { "const": "search" } }, "required": ["doc_type"] }, + "then": { + "required": ["query", "url", "domain"] + } + }, + { + "if": { "properties": { "doc_type": { "const": "dev" } }, "required": ["doc_type"] }, + "then": { + "required": ["repo"], + "oneOf": [ + { "required": ["commit_sha"] }, + { "required": ["pr_number"] }, + { "required": ["issue_number"] } + ] + } + } + ] + }, + "write_policy": { "type": ["object", "null"] }, + "content": { "type": "string" } + }, + })) +} + +pub(in crate::app::server) fn docs_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["doc_id"], + "properties": { + "doc_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn docs_search_l0_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["query"], + "properties": { + "query": { "type": "string" }, + "scope": { "type": ["string", "null"], "enum": ["agent_private", "project_shared", "org_shared", null] }, + "status": { "type": ["string", "null"], "enum": ["active", "deleted", null] }, + "doc_type": { + "type": ["string", "null"], + "enum": ["knowledge", "chat", "search", "dev", null] + }, + "agent_id": { "type": ["string", "null"] }, + "thread_id": { "type": ["string", "null"] }, + "updated_after": { "type": ["string", "null"], "format": "date-time" }, + "updated_before": { "type": ["string", "null"], "format": "date-time" }, + "ts_gte": { "type": ["string", "null"], "format": "date-time" }, + "ts_lte": { "type": ["string", "null"], "format": "date-time" }, + "top_k": { "type": ["integer", "null"] }, + "candidate_k": { "type": ["integer", "null"] }, + "sparse_mode": { + "type": ["string", "null"], + "enum": ["auto", "on", "off", null] + }, + "domain": { "type": ["string", "null"] }, + "repo": { "type": ["string", "null"] }, + "explain": { "type": ["boolean", "null"] }, + "read_profile": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn docs_excerpts_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["doc_id", "level"], + "properties": { + "doc_id": { "type": "string" }, + "level": { "type": "string", "enum": ["L0", "L1", "L2"] }, + "explain": { "type": ["boolean", "null"] }, + "chunk_id": { "type": ["string", "null"] }, + "quote": { + "type": ["object", "null"], + "additionalProperties": true, + "required": ["exact"], + "properties": { + "exact": { "type": "string" }, + "prefix": { "type": ["string", "null"] }, + "suffix": { "type": ["string", "null"] } + } + }, + "position": { + "type": ["object", "null"], + "additionalProperties": true, + "required": ["start", "end"], + "properties": { + "start": { "type": "integer" }, + "end": { "type": "integer" } + } + } + } + })) +} diff --git a/apps/elf-mcp/src/server/schemas/events.rs b/apps/elf-mcp/src/server/schemas/events.rs new file mode 100644 index 00000000..fe4e16ac --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/events.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn events_ingest_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["messages"], + "properties": { + "scope": { "type": ["string", "null"] }, + "dry_run": { "type": ["boolean", "null"] }, + "ingestion_profile": { + "type": "object", + "additionalProperties": true, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "version": { "type": ["integer", "null"] }, + }, + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["role", "content"], + "properties": { + "role": { "type": "string" }, + "content": { "type": "string" }, + "ts": { "type": ["string", "null"] }, + "msg_id": { "type": ["string", "null"] }, + "write_policy": { "type": ["object", "null"] } + } + } + } + } + })) +} diff --git a/apps/elf-mcp/src/server/schemas/graph.rs b/apps/elf-mcp/src/server/schemas/graph.rs new file mode 100644 index 00000000..c64efece --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/graph.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn graph_query_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["subject"], + "properties": { + "subject": { + "oneOf": [ + { + "type": "object", + "required": ["entity_id"], + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "required": ["surface"], + "properties": { + "surface": { "type": "string" } + } + } + ] + }, + "predicate": { + "oneOf": [ + { + "type": "object", + "required": ["predicate_id"], + "properties": { + "predicate_id": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "required": ["surface"], + "properties": { + "surface": { "type": "string" } + } + } + ] + }, + "scopes": { + "type": ["array", "null"], + "items": { "type": "string" } + }, + "as_of": { + "type": ["string", "null"], + "format": "date-time" + }, + "limit": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 200 + }, + "explain": { "type": ["boolean", "null"] } + } + })) +} + +pub(in crate::app::server) fn graph_report_schema() -> Arc { + graph_query_schema() +} diff --git a/apps/elf-mcp/src/server/schemas/memory.rs b/apps/elf-mcp/src/server/schemas/memory.rs new file mode 100644 index 00000000..0dc1dc09 --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/memory.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn core_blocks_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "properties": { + "read_profile": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn entity_memory_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "properties": { + "entity_id": { "type": ["string", "null"], "format": "uuid" }, + "entity_surface": { "type": ["string", "null"] }, + "read_profile": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn dreaming_review_queue_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "properties": { + "run_id": { "type": ["string", "null"], "format": "uuid" }, + "review_state": { + "type": ["string", "null"], + "enum": ["proposed", "approved", "rejected", "applied", "archived", null] + }, + "limit": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 200 + } + } + })) +} + +pub(in crate::app::server) fn recall_debug_panel_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": false, + "properties": { + "trace_id": { "type": ["string", "null"], "format": "uuid" }, + "query": { "type": ["string", "null"] }, + "docs_query": { "type": ["string", "null"] }, + "knowledge_query": { "type": ["string", "null"] }, + "graph_subject": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["entity_id"], + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["surface"], + "properties": { + "surface": { "type": "string" } + } + }, + { "type": "null" } + ] + }, + "graph_predicate": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["predicate_id"], + "properties": { + "predicate_id": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["surface"], + "properties": { + "surface": { "type": "string" } + } + }, + { "type": "null" } + ] + }, + "include_dreaming": { "type": ["boolean", "null"] }, + "limit": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 100 + } + } + })) +} diff --git a/apps/elf-mcp/src/server/schemas/notes.rs b/apps/elf-mcp/src/server/schemas/notes.rs new file mode 100644 index 00000000..ba886918 --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/notes.rs @@ -0,0 +1,169 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; +use serde_json::Value; + +pub(in crate::app::server) fn notes_ingest_schema() -> Arc { + Arc::new( + serde_json::from_value(serde_json::json!({ + "type": "object", + "additionalProperties": true, + "required": ["scope", "notes"], + "properties": { + "scope": { "type": "string" }, + "notes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["type", "text", "importance", "confidence", "source_ref"], + "properties": { + "type": { "type": "string" }, + "key": { "type": ["string", "null"] }, + "text": { "type": "string" }, + "write_policy": { "type": ["object", "null"] }, + "importance": { "type": "number" }, + "confidence": { "type": "number" }, + "ttl_days": { "type": ["integer", "null"] }, + "source_ref": { "type": "object", "additionalProperties": true }, + "structured": notes_structured_schema() + } + } + } + } + })) + .expect("notes_ingest_schema must be valid JSON object"), + ) +} + +pub(in crate::app::server) fn notes_list_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "properties": { + "scope": { "type": ["string", "null"] }, + "status": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn notes_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["note_id"], + "properties": { + "note_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn notes_patch_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["note_id"], + "properties": { + "note_id": { "type": "string" }, + "text": { "type": ["string", "null"] }, + "importance": { "type": ["number", "null"] }, + "confidence": { "type": ["number", "null"] }, + "ttl_days": { "type": ["integer", "null"] } + } + })) +} + +pub(in crate::app::server) fn notes_publish_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["note_id", "space"], + "properties": { + "note_id": { "type": "string" }, + "space": { "type": "string", "enum": ["team_shared", "org_shared"] } + } + })) +} + +pub(in crate::app::server) fn notes_unpublish_schema() -> Arc { + notes_publish_schema() +} + +fn notes_structured_entity_schema() -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": true, + "required": ["canonical"], + "properties": { + "canonical": { "type": "string" }, + "kind": { "type": ["string", "null"] }, + "aliases": { + "type": ["array", "null"], + "items": { "type": "string" } + } + } + }) +} + +fn notes_structured_relation_object_schema() -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": true, + "oneOf": [ + { + "type": "object", + "required": ["entity"], + "properties": { + "entity": notes_structured_entity_schema(), + "value": { "type": "null" } + } + }, + { + "type": "object", + "required": ["value"], + "properties": { + "entity": { "type": ["object", "null"] }, + "value": { "type": "string" } + } + } + ] + }) +} + +fn notes_structured_schema() -> Value { + serde_json::json!({ + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "summary": { "type": ["string", "null"] }, + "facts": { + "type": ["array", "null"], + "items": { "type": "string" } + }, + "concepts": { + "type": ["array", "null"], + "items": { "type": "string" } + }, + "entities": { + "type": ["array", "null"], + "items": notes_structured_entity_schema() + }, + "relations": { + "type": ["array", "null"], + "items": { + "type": "object", + "additionalProperties": true, + "required": ["subject", "predicate", "object"], + "properties": { + "subject": notes_structured_entity_schema(), + "predicate": { "type": "string" }, + "object": notes_structured_relation_object_schema(), + "valid_from": { "type": ["string", "null"], "format": "date-time" }, + "valid_to": { "type": ["string", "null"], "format": "date-time" } + } + } + } + } + }) +} diff --git a/apps/elf-mcp/src/server/schemas/search.rs b/apps/elf-mcp/src/server/schemas/search.rs new file mode 100644 index 00000000..06b3f79b --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/search.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn searches_create_schema() -> Arc { + let filter_schema = rmcp::object!({ + "type": "object", + "required": ["schema", "expr"], + "properties": { + "schema": { + "type": "string", + "const": "search_filter_expr/v1", + }, + "expr": { + "type": "object", + "additionalProperties": true, + }, + }, + "additionalProperties": true, + }); + + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["query", "mode"], + "properties": { + "query": { "type": "string" }, + "mode": { "type": "string", "enum": ["quick_find", "planned_search"] }, + "payload_level": { + "type": ["string", "null"], + "enum": ["l0", "l1", "l2", null] + }, + "top_k": { "type": ["integer", "null"] }, + "candidate_k": { "type": ["integer", "null"] }, + "filter": filter_schema, + "read_profile": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn searches_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["search_id"], + "properties": { + "search_id": { "type": "string" }, + "payload_level": { + "type": ["string", "null"], + "enum": ["l0", "l1", "l2", null] + }, + "top_k": { "type": ["integer", "null"] }, + "touch": { "type": ["boolean", "null"] } + } + })) +} + +pub(in crate::app::server) fn searches_timeline_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["search_id"], + "properties": { + "search_id": { "type": "string" }, + "payload_level": { + "type": ["string", "null"], + "enum": ["l0", "l1", "l2", null] + }, + "group_by": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn searches_notes_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["search_id", "note_ids"], + "properties": { + "search_id": { "type": "string" }, + "payload_level": { + "type": ["string", "null"], + "enum": ["l0", "l1", "l2", null] + }, + "note_ids": { "type": "array", "items": { "type": "string" } }, + "record_hits": { "type": ["boolean", "null"] } + } + })) +} diff --git a/apps/elf-mcp/src/server/schemas/sharing.rs b/apps/elf-mcp/src/server/schemas/sharing.rs new file mode 100644 index 00000000..bfd61de3 --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/sharing.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn space_grants_list_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["space"], + "properties": { + "space": { "type": "string", "enum": ["team_shared", "org_shared"] } + } + })) +} + +pub(in crate::app::server) fn space_grant_upsert_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["space", "grantee_kind"], + "properties": { + "space": { "type": "string", "enum": ["team_shared", "org_shared"] }, + "grantee_kind": { "type": "string", "enum": ["project", "agent"] }, + "grantee_agent_id": { "type": ["string", "null"] } + } + })) +} + +pub(in crate::app::server) fn space_grant_revoke_schema() -> Arc { + space_grant_upsert_schema() +} diff --git a/apps/elf-mcp/src/server/schemas/work_journal.rs b/apps/elf-mcp/src/server/schemas/work_journal.rs new file mode 100644 index 00000000..c22e81d5 --- /dev/null +++ b/apps/elf-mcp/src/server/schemas/work_journal.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; + +use rmcp::model::JsonObject; + +pub(in crate::app::server) fn work_journal_entry_create_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["scope", "session_id", "family", "body", "source_refs"], + "properties": { + "entry_id": { "type": ["string", "null"] }, + "scope": { "type": "string", "enum": ["agent_private", "project_shared", "org_shared"] }, + "session_id": { "type": "string" }, + "family": { + "type": "string", + "enum": [ + "session_log", + "handoff_brief", + "janitor_report", + "explicit_next_step", + "inferred_next_step", + "rejected_option" + ] + }, + "title": { "type": ["string", "null"] }, + "body": { "type": "string" }, + "source_refs": { + "type": "array", + "items": { "type": "object", "additionalProperties": true }, + "minItems": 1 + }, + "write_policy": { "type": ["object", "null"] }, + "explicit_next_steps": { + "type": "array", + "items": { "type": "string" } + }, + "inferred_next_steps": { + "type": "array", + "items": { "type": "string" } + }, + "rejected_options": { + "type": "array", + "items": { "type": "string" } + }, + "promotion_boundary": { + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "authoritative_memory_allowed": { "type": "boolean" }, + "accepted_memory_authority_ref": { "type": "object", "additionalProperties": true }, + "accepted_dreaming_review_ref": { "type": "object", "additionalProperties": true } + } + } + } + })) +} + +pub(in crate::app::server) fn work_journal_entry_get_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["entry_id"], + "properties": { + "entry_id": { "type": "string" } + } + })) +} + +pub(in crate::app::server) fn work_journal_session_readback_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": true, + "required": ["session_id"], + "properties": { + "session_id": { "type": "string" }, + "families": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "session_log", + "handoff_brief", + "janitor_report", + "explicit_next_step", + "inferred_next_step", + "rejected_option" + ] + } + }, + "limit": { "type": ["integer", "null"] }, + "read_profile": { "type": ["string", "null"] } + } + })) +} diff --git a/apps/elf-mcp/src/server/state.rs b/apps/elf-mcp/src/server/state.rs new file mode 100644 index 00000000..36b490eb --- /dev/null +++ b/apps/elf-mcp/src/server/state.rs @@ -0,0 +1,234 @@ +use color_eyre::Result; +use reqwest::{Client, RequestBuilder}; +use rmcp::{ + ErrorData, + handler::server::router::tool::ToolRouter, + model::{CallToolResult, JsonObject}, +}; +use serde_json::Value; +use uuid::Uuid; + +use crate::app::{ + McpAuthState, + server::{ + self, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, HEADER_READ_PROFILE, + HEADER_REQUEST_ID, HEADER_TENANT_ID, + }, +}; +use elf_config::McpContext; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, +} + +#[derive(Clone)] +pub(super) struct ElfContextHeaders { + tenant_id: String, + project_id: String, + agent_id: String, + read_profile: String, +} +impl ElfContextHeaders { + pub(super) fn new(cfg: &McpContext) -> Self { + Self { + tenant_id: cfg.tenant_id.clone(), + project_id: cfg.project_id.clone(), + agent_id: cfg.agent_id.clone(), + read_profile: cfg.read_profile.clone(), + } + } +} + +#[derive(Clone)] +pub(super) struct ElfMcp { + pub(super) http_api_base: String, + pub(super) admin_api_base: String, + client: Client, + context: ElfContextHeaders, + auth_state: McpAuthState, + pub(super) tool_router: ToolRouter, +} +impl ElfMcp { + pub(super) fn new( + http_api_base: String, + admin_api_base: String, + context: ElfContextHeaders, + auth_state: McpAuthState, + ) -> Self { + Self { + http_api_base, + admin_api_base, + client: Client::new(), + context, + auth_state, + tool_router: Self::tool_router(), + } + } + + pub(super) fn api_base_for_path(&self, path: &str) -> &str { + if server::is_admin_path(path) { &self.admin_api_base } else { &self.http_api_base } + } + + fn apply_context_headers( + &self, + builder: RequestBuilder, + read_profile_override: Option<&str>, + request_id: Uuid, + ) -> RequestBuilder { + let read_profile = read_profile_override.unwrap_or(self.context.read_profile.as_str()); + let builder = builder + .header(HEADER_TENANT_ID, self.context.tenant_id.as_str()) + .header(HEADER_PROJECT_ID, self.context.project_id.as_str()) + .header(HEADER_AGENT_ID, self.context.agent_id.as_str()) + .header(HEADER_READ_PROFILE, read_profile); + let builder = builder.header(HEADER_REQUEST_ID, request_id.to_string()); + + match &self.auth_state { + McpAuthState::Off => builder, + McpAuthState::StaticKeys { bearer_token } => + builder.header(HEADER_AUTHORIZATION, format!("Bearer {bearer_token}")), + } + } + + async fn forward_post( + &self, + path: &str, + body: Value, + read_profile_override: Option<&str>, + request_id: Uuid, + ) -> Result { + let url = format!("{}{}", self.api_base_for_path(path), path); + let response = self + .apply_context_headers( + self.client.post(url).json(&body), + read_profile_override, + request_id, + ) + .send() + .await + .map_err(|err| { + ErrorData::internal_error(format!("ELF API request failed: {err}"), None) + })?; + + server::handle_response(response).await + } + + async fn forward_patch( + &self, + path: &str, + body: Value, + read_profile_override: Option<&str>, + request_id: Uuid, + ) -> Result { + let url = format!("{}{}", self.api_base_for_path(path), path); + let response = self + .apply_context_headers( + self.client.patch(url).json(&body), + read_profile_override, + request_id, + ) + .send() + .await + .map_err(|err| { + ErrorData::internal_error(format!("ELF API request failed: {err}"), None) + })?; + + server::handle_response(response).await + } + + async fn forward_put( + &self, + path: &str, + body: Value, + read_profile_override: Option<&str>, + request_id: Uuid, + ) -> Result { + let url = format!("{}{}", self.api_base_for_path(path), path); + let response = self + .apply_context_headers( + self.client.put(url).json(&body), + read_profile_override, + request_id, + ) + .send() + .await + .map_err(|err| { + ErrorData::internal_error(format!("ELF API request failed: {err}"), None) + })?; + + server::handle_response(response).await + } + + async fn forward_delete( + &self, + path: &str, + read_profile_override: Option<&str>, + request_id: Uuid, + ) -> Result { + let url = format!("{}{}", self.api_base_for_path(path), path); + let response = self + .apply_context_headers(self.client.delete(url), read_profile_override, request_id) + .send() + .await + .map_err(|err| { + ErrorData::internal_error(format!("ELF API request failed: {err}"), None) + })?; + + server::handle_response(response).await + } + + async fn forward_get( + &self, + path: &str, + params: JsonObject, + read_profile_override: Option<&str>, + request_id: Uuid, + ) -> Result { + let url = format!("{}{}", self.api_base_for_path(path), path); + let query = server::params_to_query(params); + let response = self + .apply_context_headers( + self.client.get(url).query(&query), + read_profile_override, + request_id, + ) + .send() + .await + .map_err(|err| { + ErrorData::internal_error(format!("ELF API request failed: {err}"), None) + })?; + + server::handle_response(response).await + } + + pub(super) async fn forward( + &self, + method: HttpMethod, + path: &str, + params: JsonObject, + read_profile_override: Option<&str>, + ) -> Result { + let request_id = Uuid::new_v4(); + + match method { + HttpMethod::Post => + self.forward_post(path, Value::Object(params), read_profile_override, request_id) + .await, + HttpMethod::Get => + self.forward_get(path, params, read_profile_override, request_id).await, + HttpMethod::Put => + self.forward_put(path, Value::Object(params), read_profile_override, request_id) + .await, + HttpMethod::Patch => + self.forward_patch(path, Value::Object(params), read_profile_override, request_id) + .await, + HttpMethod::Delete => + self.forward_delete(path, read_profile_override, request_id).await, + } + } +} diff --git a/apps/elf-mcp/src/server/support.rs b/apps/elf-mcp/src/server/support.rs new file mode 100644 index 00000000..b9c36bd9 --- /dev/null +++ b/apps/elf-mcp/src/server/support.rs @@ -0,0 +1,154 @@ +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, Request, StatusCode}, + middleware::Next, + response::IntoResponse, +}; +use color_eyre::Result; +use rmcp::{ + ErrorData, + model::{CallToolResult, JsonObject}, +}; +use serde_json::Value; + +use crate::app::{McpAuthState, server::HEADER_AUTHORIZATION}; + +pub(super) fn is_admin_path(path: &str) -> bool { + path.starts_with("/v2/admin/") +} + +pub(super) fn is_authorized(headers: &HeaderMap, auth_state: &McpAuthState) -> bool { + match auth_state { + McpAuthState::Off => true, + McpAuthState::StaticKeys { bearer_token } => + read_bearer_token(headers).is_some_and(|token| token == bearer_token), + } +} + +pub(super) fn read_bearer_token(headers: &HeaderMap) -> Option<&str> { + let raw = headers.get(HEADER_AUTHORIZATION)?; + let value = raw.to_str().ok()?.trim(); + let token = value.strip_prefix("Bearer ")?.trim(); + + if token.is_empty() { None } else { Some(token) } +} + +pub(super) fn normalize_api_base(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + let (scheme, rest) = if let Some(value) = trimmed.strip_prefix("http://") { + ("http://", value) + } else if let Some(value) = trimmed.strip_prefix("https://") { + ("https://", value) + } else { + ("http://", trimmed) + }; + // elf-mcp runs on the same host as elf-api. If elf-api binds to a wildcard address, use + // loopback for forwarding. + let rest = if let Some(value) = rest.strip_prefix("0.0.0.0:") { + format!("127.0.0.1:{value}") + } else if let Some(value) = rest.strip_prefix("[::]:") { + format!("127.0.0.1:{value}") + } else { + rest.to_string() + }; + + format!("{scheme}{rest}") +} + +pub(super) fn params_to_query(params: JsonObject) -> Vec<(String, String)> { + params + .into_iter() + .filter_map(|(key, value)| match value { + Value::Null => None, + Value::String(text) => Some((key, text)), + other => Some((key, other.to_string())), + }) + .collect() +} + +pub(super) fn take_required_string( + params: &mut JsonObject, + key: &str, +) -> Result { + let value = params + .remove(key) + .ok_or_else(|| ErrorData::invalid_params(format!("{key} is required."), None))?; + let text = value + .as_str() + .ok_or_else(|| ErrorData::invalid_params(format!("{key} must be a string."), None))? + .trim(); + + if text.is_empty() { + return Err(ErrorData::invalid_params(format!("{key} must be non-empty."), None)); + } + + Ok(text.to_string()) +} + +pub(super) fn take_optional_string( + params: &mut JsonObject, + key: &str, +) -> Result, ErrorData> { + let Some(value) = params.remove(key) else { return Ok(None) }; + let text = value + .as_str() + .ok_or_else(|| ErrorData::invalid_params(format!("{key} must be a string."), None))? + .trim(); + + if text.is_empty() { + return Err(ErrorData::invalid_params(format!("{key} must be non-empty."), None)); + } + + Ok(Some(text.to_string())) +} + +pub(super) fn reject_context_override_params(params: &JsonObject) -> Result<(), ErrorData> { + for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { + if params.contains_key(key) { + return Err(ErrorData::invalid_params( + format!("{key} is configured by the MCP server and must not be supplied."), + None, + )); + } + } + + Ok(()) +} + +pub(super) async fn handle_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|err| ErrorData::internal_error(format!("ELF API response error: {err}"), None))?; + let parsed = serde_json::from_slice::(&bytes).unwrap_or_else(|_| { + let raw = String::from_utf8_lossy(&bytes).to_string(); + + serde_json::json!({ "raw": raw }) + }); + + if status.is_success() { + Ok(CallToolResult::structured(parsed)) + } else { + Ok(CallToolResult::structured_error(parsed)) + } +} + +pub(super) async fn mcp_auth_middleware( + State(auth_state): State, + req: Request, + next: Next, +) -> axum::response::Response { + if !is_authorized(req.headers(), &auth_state) { + return ( + StatusCode::UNAUTHORIZED, + "Authentication required for security.auth_mode=static_keys with a Bearer token.", + ) + .into_response(); + } + + next.run(req).await +} diff --git a/apps/elf-mcp/src/server/tests.rs b/apps/elf-mcp/src/server/tests.rs new file mode 100644 index 00000000..49f8d7de --- /dev/null +++ b/apps/elf-mcp/src/server/tests.rs @@ -0,0 +1,805 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::State, + http::{HeaderMap, Method, Uri}, + routing, +}; +use serde_json::Map; +use tokio::{ + net::TcpListener, + sync::{ + oneshot, + oneshot::{Receiver, Sender}, + }, + time, +}; + +use crate::app::{ + McpAuthState, + server::{ElfContextHeaders, ElfMcp, HEADER_AUTHORIZATION, HttpMethod}, +}; +use elf_config::McpContext; + +type RequestRecorder = Arc>>>; + +const ALL_TOOL_DEFINITIONS: [ToolDefinition; 37] = [ + ToolDefinition::new( + "elf_notes_ingest", + HttpMethod::Post, + "/v2/notes/ingest", + "Ingest deterministic notes into ELF. This tool never calls an LLM.", + ), + ToolDefinition::new( + "elf_graph_query", + HttpMethod::Post, + "/v2/graph/query", + "Query graph entities and relations by structured criteria.", + ), + ToolDefinition::new( + "elf_graph_report", + HttpMethod::Post, + "/v2/graph/report", + "Build a source-backed graph topic map with current, historical, future, inferred, ambiguous, stale, and superseded fact markers.", + ), + ToolDefinition::new( + "elf_events_ingest", + HttpMethod::Post, + "/v2/events/ingest", + "Ingest an event by extracting evidence-bound notes using the configured LLM extractor.", + ), + ToolDefinition::new( + "elf_searches_create", + HttpMethod::Post, + "/v2/searches", + "Create a search session using quick-find or planned-search mode. Response includes optional trajectory_summary.", + ), + ToolDefinition::new( + "elf_core_blocks_get", + HttpMethod::Get, + "/v2/core-blocks", + "Fetch core memory blocks explicitly attached to the configured agent and read profile.", + ), + ToolDefinition::new( + "elf_entity_memory_get", + HttpMethod::Get, + "/v2/entity-memory", + "Fetch an entity-scoped memory view across attached core blocks and graph-linked archival notes.", + ), + ToolDefinition::new( + "elf_dreaming_review_queue", + HttpMethod::Get, + "/v2/admin/dreaming/review-queue", + "List source-backed Dreaming review queue proposals with variants, affected refs, lint flags, policy gates, and review audit.", + ), + ToolDefinition::new( + "elf_recall_debug_panel", + HttpMethod::Post, + "/v2/recall-debug/panel", + "Build an agent-facing cross-layer recall/debug panel and deterministic recall_trace over memory traces, source documents, knowledge pages, graph facts, and Dreaming proposals.", + ), + ToolDefinition::new( + "elf_work_journal_entry_create", + HttpMethod::Post, + "/v2/work-journal/entries", + "Capture one source-adjacent Work Journal entry with source refs, redaction, next-step, rejected-option, and promotion-boundary metadata.", + ), + ToolDefinition::new( + "elf_work_journal_entry_get", + HttpMethod::Get, + "/v2/work-journal/entries/{entry_id}", + "Fetch one readable Work Journal entry by entry_id.", + ), + ToolDefinition::new( + "elf_work_journal_session_readback", + HttpMethod::Post, + "/v2/work-journal/readback", + "Read newest Work Journal entries for a session and return a where_stopped projection with journal evidence.", + ), + ToolDefinition::new( + "elf_searches_get", + HttpMethod::Get, + "/v2/searches/{search_id}", + "Fetch a search session index view by search_id, including optional trajectory_summary.", + ), + ToolDefinition::new( + "elf_searches_timeline", + HttpMethod::Get, + "/v2/searches/{search_id}/timeline", + "Build a timeline view from a search session.", + ), + ToolDefinition::new( + "elf_searches_notes", + HttpMethod::Post, + "/v2/searches/{search_id}/notes", + "Fetch note details for selected note_ids from a search session. l0/l1 strip evidence/source_ref/structured; l2 returns full detail.", + ), + ToolDefinition::new( + "elf_notes_list", + HttpMethod::Get, + "/v2/notes", + "List notes in a tenant and project with optional filters.", + ), + ToolDefinition::new( + "elf_notes_get", + HttpMethod::Get, + "/v2/notes/{note_id}", + "Fetch a single note by note_id.", + ), + ToolDefinition::new( + "elf_notes_patch", + HttpMethod::Patch, + "/v2/notes/{note_id}", + "Patch a note by note_id. Only provided fields are updated.", + ), + ToolDefinition::new( + "elf_notes_delete", + HttpMethod::Delete, + "/v2/notes/{note_id}", + "Delete a note by note_id.", + ), + ToolDefinition::new( + "elf_notes_publish", + HttpMethod::Post, + "/v2/notes/{note_id}/publish", + "Publish a note from agent_private into a shared space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_notes_unpublish", + HttpMethod::Post, + "/v2/notes/{note_id}/unpublish", + "Unpublish a shared note back into agent_private scope.", + ), + ToolDefinition::new( + "elf_space_grants_list", + HttpMethod::Get, + "/v2/spaces/{space}/grants", + "List sharing grants for a space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_space_grant_upsert", + HttpMethod::Post, + "/v2/spaces/{space}/grants", + "Upsert a sharing grant for a space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_space_grant_revoke", + HttpMethod::Post, + "/v2/spaces/{space}/grants/revoke", + "Revoke a sharing grant for a space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_admin_traces_recent_list", + HttpMethod::Get, + "/v2/admin/traces/recent", + "List recent traces by tenant/project with optional cursor and filters.", + ), + ToolDefinition::new( + "elf_admin_trace_get", + HttpMethod::Get, + "/v2/admin/traces/{trace_id}", + "Fetch trace metadata, items, and optional trajectory summary by trace_id.", + ), + ToolDefinition::new( + "elf_admin_trajectory_get", + HttpMethod::Get, + "/v2/admin/trajectories/{trace_id}", + "Fetch trace trajectory and stage payload by trace_id.", + ), + ToolDefinition::new( + "elf_admin_trace_item_get", + HttpMethod::Get, + "/v2/admin/trace-items/{item_id}", + "Fetch a trace item explain payload by item_id.", + ), + ToolDefinition::new( + "elf_admin_note_provenance_get", + HttpMethod::Get, + "/v2/admin/notes/{note_id}/provenance", + "Fetch provenance bundle for a note.", + ), + ToolDefinition::new( + "elf_admin_memory_history_get", + HttpMethod::Get, + "/v2/admin/notes/{note_id}/history", + "Fetch chronological memory history for a note.", + ), + ToolDefinition::new( + "elf_admin_trace_bundle_get", + HttpMethod::Get, + "/v2/admin/traces/{trace_id}/bundle", + "Fetch trace bundle for replay and diagnostics by trace_id.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profiles_list", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles", + "List latest ingestion profiles for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profiles_create", + HttpMethod::Post, + "/v2/admin/events/ingestion-profiles", + "Create a new ingestion profile version for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_get", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles/{profile_id}", + "Get a single ingestion profile by id/version for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_versions_list", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles/{profile_id}/versions", + "List all versions of one ingestion profile for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_default_get", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles/default", + "Get the active default ingestion profile for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_default_set", + HttpMethod::Put, + "/v2/admin/events/ingestion-profiles/default", + "Set the default ingestion profile for add_event.", + ), +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ToolDefinition { + name: &'static str, + method: HttpMethod, + path: &'static str, + description: &'static str, + streaming: bool, +} +impl ToolDefinition { + const fn new( + name: &'static str, + method: HttpMethod, + path: &'static str, + description: &'static str, + ) -> Self { + Self { name, method, path, description, streaming: true } + } +} + +struct RecordedRequest { + method: Method, + path: String, + body: serde_json::Value, +} + +fn build_tools() -> HashMap<&'static str, ToolDefinition> { + ALL_TOOL_DEFINITIONS.into_iter().map(|tool| (tool.name, tool)).collect() +} + +#[test] +fn registers_all_tools() { + let tools = build_tools(); + let expected = [ + "elf_notes_ingest", + "elf_graph_query", + "elf_graph_report", + "elf_events_ingest", + "elf_core_blocks_get", + "elf_entity_memory_get", + "elf_searches_create", + "elf_searches_get", + "elf_searches_timeline", + "elf_searches_notes", + "elf_notes_list", + "elf_notes_get", + "elf_notes_patch", + "elf_notes_delete", + "elf_notes_publish", + "elf_notes_unpublish", + "elf_space_grants_list", + "elf_space_grant_upsert", + "elf_space_grant_revoke", + "elf_admin_traces_recent_list", + "elf_dreaming_review_queue", + "elf_recall_debug_panel", + "elf_work_journal_entry_create", + "elf_work_journal_entry_get", + "elf_work_journal_session_readback", + "elf_admin_trace_get", + "elf_admin_trajectory_get", + "elf_admin_trace_item_get", + "elf_admin_note_provenance_get", + "elf_admin_memory_history_get", + "elf_admin_trace_bundle_get", + "elf_admin_events_ingestion_profiles_list", + "elf_admin_events_ingestion_profiles_create", + "elf_admin_events_ingestion_profile_get", + "elf_admin_events_ingestion_profile_versions_list", + "elf_admin_events_ingestion_profile_default_get", + "elf_admin_events_ingestion_profile_default_set", + ]; + + for name in expected { + assert!(tools.contains_key(name), "Missing tool registration: {name}."); + } + + assert_eq!(tools.len(), expected.len(), "Unexpected tool count for MCP registration."); +} + +#[test] +fn notes_ingest_schema_includes_structured_entities_relations() { + let schema = super::notes_ingest_schema(); + let notes = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("notes ingest schema is missing properties.") + .get("notes") + .and_then(serde_json::Value::as_object) + .expect("notes schema is missing notes."); + let note_items = notes + .get("items") + .and_then(serde_json::Value::as_object) + .expect("notes schema is missing items."); + let note_properties = note_items + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("notes schema is missing note item properties."); + let structured = note_properties + .get("structured") + .and_then(serde_json::Value::as_object) + .expect("notes schema is missing structured."); + let structured_type = structured + .get("type") + .and_then(serde_json::Value::as_array) + .expect("structured.type is not an array."); + + assert!( + structured_type.contains(&serde_json::Value::String("object".to_string())) + && structured_type.contains(&serde_json::Value::String("null".to_string())) + ); + + let structured_properties = structured + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("structured schema is missing properties."); + + assert!(structured_properties.contains_key("entities")); + assert!(structured_properties.contains_key("relations")); + + let relation_object = structured_properties + .get("relations") + .and_then(serde_json::Value::as_object) + .and_then(|relations| relations.get("items")) + .and_then(serde_json::Value::as_object) + .and_then(|items| items.get("properties")) + .and_then(serde_json::Value::as_object) + .expect("relations schema is missing properties.") + .get("object") + .and_then(serde_json::Value::as_object) + .expect("relation schema is missing object."); + let one_of = relation_object + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("relation object is missing oneOf."); + + assert_eq!(one_of.len(), 2, "relation object should have entity/value oneOf variants."); + assert!(one_of.iter().any(|variant| { + variant.as_object().is_some_and(|branch| { + branch + .get("required") + .and_then(serde_json::Value::as_array) + .is_some_and(|required| required.iter().any(|value| value == "entity")) + }) + })); + assert!(one_of.iter().any(|variant| { + variant.as_object().is_some_and(|branch| { + branch + .get("required") + .and_then(serde_json::Value::as_array) + .is_some_and(|required| required.iter().any(|value| value == "value")) + }) + })); +} + +#[test] +fn admin_paths_use_admin_api_base() { + let context = McpContext { + tenant_id: "tenant-a".to_string(), + project_id: "project-a".to_string(), + agent_id: "agent-a".to_string(), + read_profile: "private_plus_project".to_string(), + }; + let mcp = ElfMcp::new( + "http://127.0.0.1:9000".to_string(), + "http://127.0.0.1:9001".to_string(), + ElfContextHeaders::new(&context), + McpAuthState::Off, + ); + + assert_eq!(mcp.api_base_for_path("/v2/admin/traces/recent"), "http://127.0.0.1:9001"); + assert_eq!(mcp.api_base_for_path("/v2/admin/notes/abcd/provenance"), "http://127.0.0.1:9001"); + assert_eq!(mcp.api_base_for_path("/v2/admin/notes/abcd/history"), "http://127.0.0.1:9001"); + assert_eq!(mcp.api_base_for_path("/v2/searches"), "http://127.0.0.1:9000"); + assert_eq!(mcp.api_base_for_path("/v2/recall-debug/panel"), "http://127.0.0.1:9000"); +} + +#[test] +fn recall_debug_tool_uses_public_agent_route() { + let tools = build_tools(); + let tool = tools.get("elf_recall_debug_panel").expect("Missing recall debug panel tool."); + + assert_eq!(tool.path, "/v2/recall-debug/panel"); + assert!(tool.description.contains("recall_trace")); +} + +#[test] +fn recall_debug_panel_schema_rejects_context_override_fields() { + let schema = super::recall_debug_panel_schema(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("recall debug panel schema is missing properties."); + + assert_eq!(schema.get("additionalProperties"), Some(&serde_json::Value::Bool(false))); + + for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { + assert!(!properties.contains_key(key), "{key} must not be a tool param."); + } + for key in ["graph_subject", "graph_predicate"] { + let one_of = properties + .get(key) + .and_then(serde_json::Value::as_object) + .and_then(|schema| schema.get("oneOf")) + .and_then(serde_json::Value::as_array) + .expect("selector schema is missing oneOf."); + + for branch in one_of.iter().filter_map(serde_json::Value::as_object) { + if branch.get("type").and_then(serde_json::Value::as_str) == Some("object") { + assert_eq!( + branch.get("additionalProperties"), + Some(&serde_json::Value::Bool(false)), + "{key} selector object branches must be closed." + ); + } + } + } +} + +#[test] +fn off_mode_allows_requests_without_auth_header() { + let headers = HeaderMap::new(); + + assert!(super::is_authorized(&headers, &McpAuthState::Off)); +} + +#[test] +fn static_keys_mode_requires_authorization_bearer_header() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Bearer token-a".parse().expect("valid header")); + + assert!(super::is_authorized( + &headers, + &McpAuthState::StaticKeys { bearer_token: "token-a".to_string() } + )); +} + +#[test] +fn static_keys_mode_rejects_non_bearer_schemes() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "bearer token-a".parse().expect("valid header")); + + assert!(!super::is_authorized( + &headers, + &McpAuthState::StaticKeys { bearer_token: "token-a".to_string() } + )); +} + +#[test] +fn docs_search_l0_schema_includes_filter_fields() { + let schema = super::docs_search_l0_schema(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("docs_search_l0 schema is missing properties."); + let required = ["query"]; + let expected = [ + "scope", + "status", + "doc_type", + "agent_id", + "thread_id", + "updated_after", + "updated_before", + "ts_gte", + "ts_lte", + "sparse_mode", + "domain", + "repo", + "explain", + ]; + + for field in required { + assert!( + schema + .get("required") + .and_then(serde_json::Value::as_array) + .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), + "Missing required field {field}." + ); + } + for field in expected { + assert!(properties.contains_key(field), "Missing schema field: {field}."); + } + + assert_eq!( + properties.get("status").and_then(serde_json::Value::as_object).and_then(|status| { + status.get("enum").and_then(serde_json::Value::as_array).map(|vals| vals.to_vec()) + }), + Some(vec![ + serde_json::Value::String("active".to_string()), + serde_json::Value::String("deleted".to_string()), + serde_json::Value::Null, + ]) + ); + assert_eq!( + properties.get("sparse_mode").and_then(serde_json::Value::as_object).and_then(|field| { + field.get("enum").and_then(serde_json::Value::as_array).map(|vals| vals.to_vec()) + }), + Some(vec![ + serde_json::Value::String("auto".to_string()), + serde_json::Value::String("on".to_string()), + serde_json::Value::String("off".to_string()), + serde_json::Value::Null, + ]) + ); +} + +#[test] +fn docs_put_schema_includes_required_fields_and_write_policy() { + let schema = super::docs_put_schema(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("docs_put schema is missing properties."); + let required = ["scope", "content", "source_ref"]; + let expected = ["scope", "doc_type", "title", "source_ref", "write_policy", "content"]; + + for field in required { + assert!( + schema + .get("required") + .and_then(serde_json::Value::as_array) + .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), + "Missing required field {field}." + ); + } + for field in expected { + assert!(properties.contains_key(field), "Missing schema field: {field}."); + } + + let write_policy = properties.get("write_policy").and_then(serde_json::Value::as_object); + let source_ref_properties = properties + .get("source_ref") + .and_then(|value| value.get("properties")) + .and_then(serde_json::Value::as_object) + .expect("docs_put source_ref schema is missing properties."); + + assert!( + write_policy.is_some_and(|field| { + field.get("type").and_then(serde_json::Value::as_array).is_some_and(|types| { + types.contains(&serde_json::Value::String("object".to_string())) + && types.contains(&serde_json::Value::String("null".to_string())) + }) + }), + "Missing write_policy object/null type in docs_put schema." + ); + + for field in ["source_kind", "canonical_uri", "captured_at", "trust_label", "excerpt_locator"] { + assert!(source_ref_properties.contains_key(field), "Missing source_ref field: {field}."); + } +} + +#[test] +fn work_journal_schemas_include_families_and_source_refs() { + let create_schema = super::work_journal_entry_create_schema(); + let create_properties = create_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("work_journal_entry_create schema is missing properties."); + let readback_schema = super::work_journal_session_readback_schema(); + let readback_properties = readback_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("work_journal_session_readback schema is missing properties."); + + for field in ["scope", "session_id", "family", "body", "source_refs"] { + assert!( + create_schema + .get("required") + .and_then(serde_json::Value::as_array) + .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), + "Missing Work Journal required field {field}." + ); + } + + assert!(create_properties.contains_key("write_policy")); + assert!(create_properties.contains_key("promotion_boundary")); + assert!(readback_properties.contains_key("session_id")); + assert!(readback_properties.contains_key("families")); +} + +#[test] +fn docs_excerpts_get_schema_includes_l0_level_and_optional_explain() { + let schema = super::docs_excerpts_get_schema(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("docs_excerpts_get schema is missing properties."); + let level_values = properties + .get("level") + .and_then(|level| level.get("enum")) + .and_then(|values| values.as_array()) + .expect("docs_excerpts_get level schema is missing enum."); + + assert!(level_values.contains(&serde_json::Value::String("L0".to_string()))); + assert!(properties.contains_key("explain")); +} + +#[test] +fn payload_level_schema_for_search_tools_is_l0_l1_l2() { + for schema in [ + super::searches_create_schema(), + super::searches_get_schema(), + super::searches_timeline_schema(), + super::searches_notes_schema(), + ] { + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("Search schema is missing properties."); + let payload_level = properties + .get("payload_level") + .and_then(serde_json::Value::as_object) + .expect("payload_level field is missing from search schema."); + let payload_level_values = payload_level + .get("enum") + .and_then(serde_json::Value::as_array) + .expect("payload_level enum is missing."); + + assert_eq!(payload_level_values.len(), 4, "Unexpected payload_level enum length."); + assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l0"))); + assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l1"))); + assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l2"))); + assert!(payload_level_values.iter().any(|value| value.is_null())); + } +} + +#[test] +fn searches_notes_tool_description_mentions_payload_level_shapes() { + let tools = build_tools(); + let tool = + tools.get("elf_searches_notes").expect("Missing elf_searches_notes tool definition."); + let description = tool.description.to_lowercase(); + + assert_eq!(tool.path, "/v2/searches/{search_id}/notes"); + assert!(description.contains("l0")); + assert!(description.contains("l1")); + assert!(description.contains("l2")); + assert!(description.contains("source_ref")); + assert!(description.contains("structured")); +} + +#[tokio::test] +async fn recall_debug_panel_rejects_context_override_params() { + let context = McpContext { + tenant_id: "tenant-a".to_string(), + project_id: "project-a".to_string(), + agent_id: "agent-a".to_string(), + read_profile: "private_plus_project".to_string(), + }; + let mcp = ElfMcp::new( + "http://127.0.0.1:1".to_string(), + "http://127.0.0.1:1".to_string(), + ElfContextHeaders::new(&context), + McpAuthState::Off, + ); + let params = Map::from_iter([( + "tenant_id".to_string(), + serde_json::Value::String("tenant-override".to_string()), + )]); + let result = mcp.elf_recall_debug_panel(params).await; + let err = result.expect_err("context override params must fail before forwarding."); + + assert!(format!("{err:?}").contains("tenant_id")); +} + +#[tokio::test] +async fn default_ingestion_profile_set_uses_put_admin_default_path() { + let (admin_base, received) = spawn_recording_admin_server().await; + let context = McpContext { + tenant_id: "tenant-a".to_string(), + project_id: "project-a".to_string(), + agent_id: "agent-a".to_string(), + read_profile: "private_plus_project".to_string(), + }; + let mcp = ElfMcp::new( + "http://127.0.0.1:9000".to_string(), + admin_base, + ElfContextHeaders::new(&context), + McpAuthState::Off, + ); + let params = Map::from_iter([ + ("profile_id".to_string(), serde_json::Value::String("profile-a".to_string())), + ("version".to_string(), serde_json::Value::Number(2.into())), + ]); + let result = mcp.elf_admin_events_ingestion_profile_default_set(params).await; + + assert!(result.is_ok(), "default setter should forward successfully: {result:?}"); + + let request = receive_recorded_request(received).await; + + assert_eq!(request.method, Method::PUT); + assert_eq!(request.path, "/v2/admin/events/ingestion-profiles/default"); + assert_eq!( + request.body.get("profile_id").and_then(serde_json::Value::as_str), + Some("profile-a") + ); + assert_eq!(request.body.get("version").and_then(serde_json::Value::as_i64), Some(2)); +} + +async fn spawn_recording_admin_server() -> (String, Receiver) { + let (tx, rx) = oneshot::channel(); + let app = Router::new() + .route("/v2/admin/events/ingestion-profiles/default", routing::any(record_request)) + .with_state(Arc::new(Mutex::new(Some(tx)))); + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(err) => panic!("Failed to bind MCP recording admin server: {err}."), + }; + let addr = match listener.local_addr() { + Ok(addr) => addr, + Err(err) => panic!("Failed to read MCP recording admin server address: {err}."), + }; + + tokio::spawn(async move { + if let Err(err) = axum::serve(listener, app).await { + panic!("MCP recording admin server failed: {err}."); + } + }); + + (format!("http://{addr}"), rx) +} + +async fn record_request( + State(recorder): State, + method: Method, + uri: Uri, + Json(body): Json, +) -> Json { + let mut sender = match recorder.lock() { + Ok(sender) => sender, + Err(err) => panic!("MCP recording admin server mutex was poisoned: {err}."), + }; + + if let Some(tx) = sender.take() { + let _ = tx.send(RecordedRequest { method, path: uri.path().to_string(), body }); + } + + Json(serde_json::json!({ "ok": true })) +} + +async fn receive_recorded_request(received: Receiver) -> RecordedRequest { + match time::timeout(Duration::from_secs(3), received).await { + Ok(Ok(request)) => request, + Ok(Err(err)) => panic!("MCP recording admin server closed before recording: {err}."), + Err(err) => panic!("Timed out waiting for MCP recording admin server: {err}."), + } +} diff --git a/apps/elf-worker/src/worker.rs b/apps/elf-worker/src/worker.rs index 53511239..1255bf58 100644 --- a/apps/elf-worker/src/worker.rs +++ b/apps/elf-worker/src/worker.rs @@ -1,5 +1,19 @@ //! Worker runtime and queue-processing helpers. +mod consolidation_jobs; +mod doc_indexing; +mod helpers; +mod note_indexing; +mod outbox_jobs; +mod runtime; +mod trace_jobs; +mod types; + +pub use self::{ + runtime::{process_once, run_worker}, + types::WorkerState, +}; + use std::{collections::HashMap, slice, string::ToString}; use qdrant_client::{ @@ -15,6 +29,8 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use uuid::Uuid; use crate::{Error, Result}; +use consolidation_jobs::handle_consolidation_job; +use doc_indexing::{handle_doc_delete, handle_doc_upsert}; use elf_chunking::{Chunk, ChunkingConfig, Tokenizer}; use elf_config::EmbeddingProviderConfig; use elf_domain::consolidation::{ @@ -34,1726 +50,28 @@ use elf_storage::{ qdrant::{BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME, QdrantStore}, queries, }; - -type ProjectDocRefFields = (String, Option, Option, Option); - -const POLL_INTERVAL_MS: i64 = 500; -const CLAIM_LEASE_SECONDS: i64 = 30; -const BASE_BACKOFF_MS: i64 = 500; -const MAX_BACKOFF_MS: i64 = 30_000; -const TRACE_CLEANUP_INTERVAL_SECONDS: i64 = 900; -const TRACE_OUTBOX_LEASE_SECONDS: i64 = 30; -const CONSOLIDATION_JOB_LEASE_SECONDS: i64 = 30; -const MAX_OUTBOX_ERROR_CHARS: usize = 1_024; - -/// Shared runtime state used by the worker loop. -pub struct WorkerState { - /// Postgres storage handle. - pub db: Db, - /// Note-index Qdrant collection handle. - pub qdrant: QdrantStore, - /// Document-index Qdrant collection handle. - pub docs_qdrant: QdrantStore, - /// Embedding provider configuration. - pub embedding: EmbeddingProviderConfig, - /// Chunking configuration for notes and docs. - pub chunking: ChunkingConfig, - /// Tokenizer used for chunking operations. - pub tokenizer: Tokenizer, -} - -#[derive(Debug, Deserialize)] -struct TracePayload { - trace: TraceRecord, - items: Vec, - #[serde(default)] - candidates: Vec, - #[serde(default)] - stages: Vec, -} - -#[derive(Debug, Deserialize)] -struct TraceRecord { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - expansion_mode: String, - expanded_queries: Vec, - allowed_scopes: Vec, - candidate_count: u32, - top_k: u32, - config_snapshot: Value, - trace_version: i32, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -#[derive(Debug, Deserialize)] -struct TraceItemRecord { - item_id: Uuid, - note_id: Uuid, - chunk_id: Option, - rank: u32, - final_score: f32, - explain: Value, -} - -#[derive(Debug, Deserialize)] -struct TraceCandidateRecord { - candidate_id: Uuid, - note_id: Uuid, - chunk_id: Uuid, - #[serde(default)] - chunk_index: i32, - #[serde(default)] - snippet: String, - #[serde(default)] - candidate_snapshot: Value, - retrieval_rank: u32, - rerank_score: f32, - note_scope: String, - note_importance: f32, - note_updated_at: OffsetDateTime, - #[serde(default)] - note_hit_count: i64, - note_last_hit_at: Option, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -#[derive(Debug, Deserialize)] -struct TraceTrajectoryStageRecord { - stage_id: Uuid, - stage_order: u32, - stage_name: String, - stage_payload: Value, - created_at: OffsetDateTime, - #[serde(default)] - items: Vec, -} - -#[derive(Debug, Deserialize)] -struct TraceTrajectoryStageItemRecord { - id: Uuid, - item_id: Option, - note_id: Option, - chunk_id: Option, - metrics: Value, -} - -struct TraceItemInsert { - item_id: Uuid, - note_id: Uuid, - chunk_id: Option, - rank: i32, - final_score: f32, - explain: Value, -} - -struct TraceCandidateInsert { - candidate_id: Uuid, - note_id: Uuid, - chunk_id: Uuid, - chunk_index: i32, - snippet: String, - candidate_snapshot: Value, - retrieval_rank: i32, - rerank_score: f32, - note_scope: String, - note_importance: f32, - note_updated_at: OffsetDateTime, - note_hit_count: i64, - note_last_hit_at: Option, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -struct TraceStageInsert { - stage_id: Uuid, - stage_order: i32, - stage_name: String, - stage_payload: Value, - created_at: OffsetDateTime, -} - -struct TraceStageItemInsert { - id: Uuid, - stage_id: Uuid, - item_id: Option, - note_id: Option, - chunk_id: Option, - metrics: Value, -} - -struct ChunkRecord { - chunk_id: Uuid, - chunk_index: i32, - start_offset: i32, - end_offset: i32, - text: String, -} - -#[derive(Debug, FromRow)] -struct NoteFieldRow { - field_id: Uuid, - text: String, -} - -#[derive(Debug, FromRow)] -struct DocChunkIndexRow { - doc_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - scope: String, - doc_type: String, - status: String, - created_at: OffsetDateTime, - updated_at: OffsetDateTime, - content_hash: String, - source_ref: Value, - chunk_id: Uuid, - chunk_index: i32, - start_offset: i32, - end_offset: i32, - chunk_text: String, - chunk_hash: String, -} - -/// Runs the worker polling loop for note, document, and trace outboxes. -pub async fn run_worker(state: WorkerState) -> Result<()> { - let mut last_trace_cleanup = OffsetDateTime::now_utc(); - - loop { - if let Err(err) = process_indexing_outbox_once(&state).await { - tracing::error!(error = %err, "Indexing outbox processing failed."); - } - if let Err(err) = process_doc_indexing_outbox_once(&state).await { - tracing::error!(error = %err, "Doc indexing outbox processing failed."); - } - if let Err(err) = process_trace_outbox_once(&state).await { - tracing::error!(error = %err, "Search trace outbox processing failed."); - } - if let Err(err) = process_consolidation_run_job_once(&state).await { - tracing::error!(error = %err, "Consolidation run job processing failed."); - } - - let now = OffsetDateTime::now_utc(); - - if now - last_trace_cleanup >= time::Duration::seconds(TRACE_CLEANUP_INTERVAL_SECONDS) { - if let Err(err) = purge_expired_trace_candidates(&state.db, now).await { - tracing::error!(error = %err, "Search trace candidate cleanup failed."); - } - if let Err(err) = purge_expired_traces(&state.db, now).await { - tracing::error!(error = %err, "Search trace cleanup failed."); - } else { - last_trace_cleanup = now; - } - if let Err(err) = purge_expired_cache(&state.db, now).await { - tracing::error!(error = %err, "LLM cache cleanup failed."); - } - if let Err(err) = purge_expired_search_sessions(&state.db, now).await { - tracing::error!(error = %err, "Search session cleanup failed."); - } - } - - tokio::time::sleep(to_std_duration(time::Duration::milliseconds(POLL_INTERVAL_MS))).await; - } -} - -/// Processes at most one due job from each worker-owned queue. -pub async fn process_once(state: &WorkerState) -> Result<()> { - process_indexing_outbox_once(state).await?; - process_doc_indexing_outbox_once(state).await?; - process_trace_outbox_once(state).await?; - process_consolidation_run_job_once(state).await?; - - Ok(()) -} - -fn is_not_found_error(err: &QdrantError) -> bool { - let message = err.to_string().to_lowercase(); - let point_not_found = - (message.contains("not found") || message.contains("404")) && message.contains("point"); - let no_point_found = message.contains("no point") && message.contains("found"); - - point_not_found || no_point_found -} - -fn note_is_active(note: &MemoryNote, now: OffsetDateTime) -> bool { - if !note.status.eq_ignore_ascii_case("active") { - return false; - } - - if let Some(expires_at) = note.expires_at - && expires_at <= now - { - return false; - } - - true -} - -fn build_chunk_records(note_id: Uuid, chunks: &[Chunk]) -> Result> { - let mut records = Vec::with_capacity(chunks.len()); - - for chunk in chunks { - let start_offset = to_i32(chunk.start_offset, "start_offset")?; - let end_offset = to_i32(chunk.end_offset, "end_offset")?; - - records.push(ChunkRecord { - chunk_id: chunk_id_for(note_id, chunk.chunk_index), - chunk_index: chunk.chunk_index, - start_offset, - end_offset, - text: chunk.text.clone(), - }); - } - - Ok(records) -} - -fn chunk_id_for(note_id: Uuid, chunk_index: i32) -> Uuid { - let name = format!("{note_id}:{chunk_index}"); - - Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) -} - -fn to_i32(value: usize, label: &str) -> Result { - i32::try_from(value).map_err(|_| { - Error::Validation(format!("Chunk {label} offset {value} exceeds supported range.")) - }) -} - -fn mean_pool(chunks: &[Vec]) -> Option> { - if chunks.is_empty() { - return None; - } - - let dim = chunks[0].len(); - let mut out = vec![0.0_f32; dim]; - - for vec in chunks { - for (idx, value) in vec.iter().enumerate() { - out[idx] += value; - } - } - for value in &mut out { - *value /= chunks.len() as f32; - } - - Some(out) -} - -fn format_timestamp(ts: OffsetDateTime) -> Result { - ts.format(&Rfc3339).map_err(|_| Error::Message("Failed to format timestamp.".to_string())) -} - -fn validate_vector_dim(vec: &[f32], expected_dim: u32) -> Result<()> { - if vec.len() != expected_dim as usize { - return Err(Error::Validation(format!( - "Embedding dimension {} does not match configured vector_dim {}.", - vec.len(), - expected_dim - ))); - } - - Ok(()) -} - -fn format_vector_text(vec: &[f32]) -> String { - let mut out = String::from("["); - - for (idx, value) in vec.iter().enumerate() { - if idx > 0 { - out.push(','); - } - - out.push_str(&value.to_string()); - } - - out.push(']'); - - out -} - -fn encode_json(value: &T, label: &str) -> Result -where - T: Serialize, -{ - serde_json::to_value(value) - .map_err(|err| Error::Message(format!("Failed to encode {label}: {err}."))) -} - -fn sanitize_outbox_error(text: &str) -> String { - let mut parts = Vec::new(); - let mut redact_next = false; - - for raw in text.split_whitespace() { - let mut word = raw.to_string(); - - if redact_next { - word = "[REDACTED]".to_string(); - redact_next = false; - } - if raw.eq_ignore_ascii_case("bearer") { - redact_next = true; - } - - let lowered = raw.to_ascii_lowercase(); - - for key in ["api_key", "apikey", "password", "secret", "token"] { - if lowered.contains(key) && (lowered.contains('=') || lowered.contains(':')) { - let sep = if raw.contains('=') { '=' } else { ':' }; - let prefix = match raw.split(sep).next() { - Some(prefix) => prefix, - None => raw, - }; - - word = format!("{prefix}{sep}[REDACTED]"); - - break; - } - } - - parts.push(word); - } - - let mut out = parts.join(" "); - - if out.chars().count() > MAX_OUTBOX_ERROR_CHARS { - out = out.chars().take(MAX_OUTBOX_ERROR_CHARS).collect(); - - out.push_str("..."); - } - - out -} - -fn backoff_for_attempt(attempt: i32) -> time::Duration { - let attempts = attempt.max(1) as u32; - let exp = attempts.saturating_sub(1).min(6); - let base = BASE_BACKOFF_MS.saturating_mul(1 << exp); - let capped = base.min(MAX_BACKOFF_MS); - - time::Duration::milliseconds(capped) -} - -fn to_std_duration(duration: time::Duration) -> std::time::Duration { - let millis = duration.whole_milliseconds(); - - if millis <= 0 { - return std::time::Duration::from_millis(0); - } - - std::time::Duration::from_millis(millis as u64) -} - -fn project_doc_ref_fields( - source_ref: &Value, - fallback_timestamp: OffsetDateTime, - doc_type: &str, -) -> Result { - let source_ref_field = |field_name: &str| -> Option { - source_ref - .get(field_name) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - }; - let doc_ts = match source_ref - .get("ts") - .and_then(Value::as_str) - .filter(|value| OffsetDateTime::parse(value, &Rfc3339).is_ok()) - .map(ToString::to_string) - .or_else(|| { - source_ref - .get("doc_ts") - .and_then(Value::as_str) - .filter(|value| OffsetDateTime::parse(value, &Rfc3339).is_ok()) - .map(ToString::to_string) - }) { - Some(value) => value, - None => format_timestamp(fallback_timestamp)?, - }; - let thread_id = if doc_type == "chat" { source_ref_field("thread_id") } else { None }; - let domain = if doc_type == "search" { source_ref_field("domain") } else { None }; - let repo = if doc_type == "dev" { source_ref_field("repo") } else { None }; - - Ok((doc_ts, thread_id, domain, repo)) -} - -fn proposal_row_from_contract( - job: &ConsolidationRunJob, - now: OffsetDateTime, - proposal: ConsolidationProposalContract, -) -> Result { - proposal.validate().map_err(consolidation_validation_error)?; - - Ok(ConsolidationProposal { - proposal_id: Uuid::new_v4(), - run_id: job.run_id, - tenant_id: job.tenant_id.clone(), - project_id: job.project_id.clone(), - agent_id: job.agent_id.clone(), - contract_schema: CONSOLIDATION_CONTRACT_SCHEMA_V1.to_string(), - proposal_kind: proposal.proposal_kind, - apply_intent: proposal.apply_intent.as_str().to_string(), - review_state: ConsolidationReviewState::Proposed.as_str().to_string(), - source_refs: encode_json(&proposal.source_refs, "consolidation source_refs")?, - source_snapshot: proposal.source_snapshot, - lineage: encode_json(&proposal.lineage, "consolidation lineage")?, - diff: encode_json(&proposal.diff, "consolidation diff")?, - confidence: proposal.confidence, - unsupported_claim_flags: encode_json( - &proposal.unsupported_claim_flags, - "consolidation unsupported_claim_flags", - )?, - contradiction_markers: encode_json( - &proposal.markers.contradictions, - "consolidation contradiction_markers", - )?, - staleness_markers: encode_json( - &proposal.markers.staleness, - "consolidation staleness_markers", - )?, - target_ref: proposal.target_ref, - proposed_payload: proposal.proposed_payload, - reviewer_agent_id: None, - review_comment: None, - reviewed_at: None, - created_at: now, - updated_at: now, - }) -} - -fn consolidation_validation_error(err: ConsolidationValidationError) -> Error { - Error::Validation(err.to_string()) -} - -async fn process_indexing_outbox_once(state: &WorkerState) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let job = outbox::claim_next_indexing_outbox_job(&state.db, now, CLAIM_LEASE_SECONDS).await?; - let Some(job) = job else { return Ok(()) }; - let result = match job.op.as_str() { - "UPSERT" => handle_upsert(state, &job).await, - "DELETE" => handle_delete(state, &job).await, - other => Err(Error::Validation(format!("Unsupported outbox op: {other}."))), - }; - - match result { - Ok(()) => { - outbox::mark_indexing_outbox_done(&state.db, job.outbox_id, OffsetDateTime::now_utc()) - .await?; - }, - Err(err) => { - tracing::error!( - error = %err, - outbox_id = %job.outbox_id, - note_id = %job.note_id, - "Outbox job failed." - ); - - mark_failed(&state.db, job.outbox_id, job.attempts, &err).await?; - }, - } - - Ok(()) -} - -async fn process_doc_indexing_outbox_once(state: &WorkerState) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let job = - doc_outbox::claim_next_doc_indexing_outbox_job(&state.db, now, CLAIM_LEASE_SECONDS).await?; - let Some(job) = job else { return Ok(()) }; - let result = match job.op.as_str() { - "UPSERT" => handle_doc_upsert(state, &job).await, - "DELETE" => handle_doc_delete(state, &job).await, - other => Err(Error::Validation(format!("Unsupported doc outbox op: {other}."))), - }; - - match result { - Ok(()) => { - doc_outbox::mark_doc_indexing_outbox_done( - &state.db, - job.outbox_id, - OffsetDateTime::now_utc(), - ) - .await?; - }, - Err(err) => { - tracing::error!( - error = %err, - outbox_id = %job.outbox_id, - doc_id = %job.doc_id, - chunk_id = %job.chunk_id, - "Doc outbox job failed." - ); - - mark_doc_failed(&state.db, job.outbox_id, job.attempts, &err).await?; - }, - } - - Ok(()) -} - -async fn process_trace_outbox_once(state: &WorkerState) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let job = - outbox::claim_next_trace_outbox_job(&state.db, now, TRACE_OUTBOX_LEASE_SECONDS).await?; - let Some(job) = job else { return Ok(()) }; - let result = handle_trace_job(&state.db, &job).await; - - match result { - Ok(()) => { - outbox::mark_trace_outbox_done(&state.db, job.outbox_id, OffsetDateTime::now_utc()) - .await?; - }, - Err(err) => { - tracing::error!( - error = %err, - outbox_id = %job.outbox_id, - trace_id = %job.trace_id, - "Search trace outbox job failed." - ); - - mark_trace_failed(&state.db, job.outbox_id, job.attempts, &err).await?; - }, - } - - Ok(()) -} - -async fn process_consolidation_run_job_once(state: &WorkerState) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let job = consolidation::claim_next_consolidation_run_job( - &state.db, - now, - CONSOLIDATION_JOB_LEASE_SECONDS, - ) - .await?; - let Some(job) = job else { return Ok(()) }; - let result = handle_consolidation_job(&state.db, &job).await; - - match result { - Ok(()) => {}, - Err(err) => { - tracing::error!( - error = %err, - job_id = %job.job_id, - run_id = %job.run_id, - "Consolidation run job failed." - ); - - mark_consolidation_failed(&state.db, job.job_id, job.attempts, &err).await?; - }, - } - - Ok(()) -} - -async fn handle_upsert(state: &WorkerState, job: &IndexingOutboxEntry) -> Result<()> { - let note = fetch_note(&state.db, job.note_id).await?; - let Some(note) = note else { - tracing::info!( - outbox_id = %job.outbox_id, - note_id = %job.note_id, - "Note missing for outbox job. Marking done." - ); - - return Ok(()); - }; - let now = OffsetDateTime::now_utc(); - - if !note_is_active(¬e, now) { - tracing::info!( - outbox_id = %job.outbox_id, - note_id = %job.note_id, - "Note inactive or expired. Skipping index." - ); - - return Ok(()); - } - - let fields = fetch_note_fields(&state.db, note.note_id).await?; - let chunks = elf_chunking::split_text(¬e.text, &state.chunking, &state.tokenizer); - - if chunks.is_empty() { - return Err(Error::Validation("Chunking produced no chunks.".to_string())); - } - - let records = build_chunk_records(note.note_id, &chunks)?; - let chunk_texts: Vec = records.iter().map(|record| record.text.clone()).collect(); - let field_texts: Vec = fields.iter().map(|field| field.text.clone()).collect(); - let mut embed_inputs = Vec::with_capacity(chunk_texts.len() + field_texts.len()); - - embed_inputs.extend(chunk_texts); - embed_inputs.extend(field_texts); - - let vectors = embedding::embed(&state.embedding, &embed_inputs) - .await - .map_err(|err| Error::Message(err.to_string()))?; - - if vectors.len() != records.len() + fields.len() { - return Err(Error::Validation(format!( - "Embedding provider returned {} vectors for {} items.", - vectors.len(), - records.len() + fields.len() - ))); - } - - let (chunk_vectors, field_vectors) = vectors.split_at(records.len()); - - for vector in chunk_vectors.iter().chain(field_vectors.iter()) { - validate_vector_dim(vector, state.qdrant.vector_dim)?; - } - - { - let mut tx = state.db.pool.begin().await?; - - queries::delete_note_chunks(&mut *tx, note.note_id).await?; - - for record in &records { - queries::insert_note_chunk( - &mut *tx, - record.chunk_id, - note.note_id, - record.chunk_index, - record.start_offset, - record.end_offset, - record.text.as_str(), - &job.embedding_version, - ) - .await?; - } - for (record, vector) in records.iter().zip(chunk_vectors.iter()) { - let vec_text = format_vector_text(vector); - - queries::insert_note_chunk_embedding( - &mut *tx, - record.chunk_id, - &job.embedding_version, - vector.len() as i32, - vec_text.as_str(), - ) - .await?; - } - - let pooled = mean_pool(chunk_vectors) - .ok_or_else(|| Error::Message("Cannot pool empty chunk vectors.".to_string()))?; - - validate_vector_dim(&pooled, state.qdrant.vector_dim)?; - insert_embedding_tx( - &mut *tx, - note.note_id, - &job.embedding_version, - pooled.len() as i32, - &pooled, - ) - .await?; - - for (field, vector) in fields.iter().zip(field_vectors.iter()) { - insert_note_field_embedding_tx( - &mut *tx, - field.field_id, - &job.embedding_version, - vector.len() as i32, - vector, - ) - .await?; - } - - tx.commit().await?; - } - - delete_qdrant_note_points(state, note.note_id).await?; - upsert_qdrant_chunks(state, ¬e, &job.embedding_version, &records, chunk_vectors).await?; - - Ok(()) -} - -async fn handle_delete(state: &WorkerState, job: &IndexingOutboxEntry) -> Result<()> { - delete_qdrant_note_points(state, job.note_id).await?; - - Ok(()) -} - -async fn fetch_doc_chunk_index_row(db: &Db, chunk_id: Uuid) -> Result> { - let row = sqlx::query_as::<_, DocChunkIndexRow>( - r#" -SELECT - d.doc_id, - d.tenant_id, - d.project_id, - d.agent_id, - d.scope, - d.doc_type, - d.status, - d.created_at, - d.updated_at, - d.content_hash, - COALESCE(d.source_ref, '{}'::jsonb) AS source_ref, - c.chunk_id, - c.chunk_index, - c.start_offset, - c.end_offset, - c.chunk_text, - c.chunk_hash -FROM doc_chunks c -JOIN doc_documents d ON d.doc_id = c.doc_id -WHERE c.chunk_id = $1 -LIMIT 1"#, - ) - .bind(chunk_id) - .fetch_optional(&db.pool) - .await?; - - Ok(row) -} - -async fn handle_doc_upsert(state: &WorkerState, job: &DocIndexingOutboxEntry) -> Result<()> { - let row = fetch_doc_chunk_index_row(&state.db, job.chunk_id).await?; - let Some(row) = row else { - tracing::info!( - outbox_id = %job.outbox_id, - doc_id = %job.doc_id, - chunk_id = %job.chunk_id, - "Doc chunk missing for outbox job. Marking done." - ); - - return Ok(()); - }; - - if !row.status.eq_ignore_ascii_case("active") { - tracing::info!( - outbox_id = %job.outbox_id, - doc_id = %row.doc_id, - chunk_id = %row.chunk_id, - "Doc inactive. Skipping index." - ); - - return Ok(()); - } - - let vectors = embedding::embed(&state.embedding, slice::from_ref(&row.chunk_text)) - .await - .map_err(|err| Error::Message(err.to_string()))?; - let vector = vectors - .first() - .ok_or_else(|| Error::Validation("Embedding provider returned no vectors.".to_string()))?; - - validate_vector_dim(vector, state.docs_qdrant.vector_dim)?; - - { - let vec_text = format_vector_text(vector); - let mut tx = state.db.pool.begin().await?; - - docs::insert_doc_chunk_embedding( - &mut *tx, - row.chunk_id, - &job.embedding_version, - vector.len() as i32, - vec_text.as_str(), - ) - .await?; - - tx.commit().await?; - } - - upsert_qdrant_doc_chunk(state, &row, &job.embedding_version, vector).await?; - - Ok(()) -} - -async fn handle_doc_delete(state: &WorkerState, job: &DocIndexingOutboxEntry) -> Result<()> { - let filter = Filter::must([Condition::matches("chunk_id", job.chunk_id.to_string())]); - let delete = - DeletePointsBuilder::new(state.docs_qdrant.collection.clone()).points(filter).wait(true); - - state.docs_qdrant.client.delete_points(delete).await?; - - Ok(()) -} - -async fn upsert_qdrant_doc_chunk( - state: &WorkerState, - row: &DocChunkIndexRow, - embedding_version: &str, - vec: &[f32], -) -> Result<()> { - let (doc_ts, thread_id, domain, repo) = - project_doc_ref_fields(&row.source_ref, row.created_at, row.doc_type.as_str())?; - let mut payload = Payload::new(); - - payload.insert("doc_id", row.doc_id.to_string()); - payload.insert("chunk_id", row.chunk_id.to_string()); - payload.insert("chunk_index", row.chunk_index as i64); - payload.insert("start_offset", row.start_offset as i64); - payload.insert("end_offset", row.end_offset as i64); - payload.insert("tenant_id", row.tenant_id.clone()); - payload.insert("project_id", row.project_id.clone()); - payload.insert("agent_id", row.agent_id.clone()); - payload.insert("scope", row.scope.clone()); - payload.insert("doc_type", row.doc_type.clone()); - payload.insert("status", row.status.clone()); - - let updated_at = format_timestamp(row.updated_at)?; - - payload.insert("updated_at", Value::String(updated_at)); - payload.insert("doc_ts", Value::String(doc_ts)); - - if let Some(value) = thread_id { - payload.insert("thread_id", Value::String(value)); - } - if let Some(value) = domain { - payload.insert("domain", Value::String(value)); - } - if let Some(value) = repo { - payload.insert("repo", Value::String(value)); - } - - payload.insert("embedding_version", embedding_version.to_string()); - payload.insert("content_hash", row.content_hash.clone()); - payload.insert("chunk_hash", row.chunk_hash.clone()); - - let mut vector_map = HashMap::new(); - - vector_map.insert(DENSE_VECTOR_NAME.to_string(), Vector::from(vec.to_vec())); - vector_map.insert( - BM25_VECTOR_NAME.to_string(), - Vector::from(Document::new(row.chunk_text.clone(), BM25_MODEL)), - ); - - let point = PointStruct::new(row.chunk_id.to_string(), vector_map, payload); - let upsert = - UpsertPointsBuilder::new(state.docs_qdrant.collection.clone(), vec![point]).wait(true); - - state.docs_qdrant.client.upsert_points(upsert).await?; - - Ok(()) -} - -async fn handle_trace_job(db: &Db, job: &TraceOutboxJob) -> Result<()> { - let payload: TracePayload = serde_json::from_value(job.payload.clone())?; - let TracePayload { trace, items, candidates, stages } = payload; - let trace_id = trace.trace_id; - let expanded_queries_json = encode_json(&trace.expanded_queries, "expanded_queries")?; - let allowed_scopes_json = encode_json(&trace.allowed_scopes, "allowed_scopes")?; - let mut tx = db.pool.begin().await?; - - insert_trace_tx(&mut *tx, trace_id, &trace, expanded_queries_json, allowed_scopes_json).await?; - insert_trace_items_tx(&mut *tx, trace_id, items).await?; - insert_trace_stages_tx(&mut tx, trace_id, stages).await?; - insert_trace_candidates_tx(&mut *tx, trace_id, candidates).await?; - - tx.commit().await?; - - Ok(()) -} - -async fn handle_consolidation_job(db: &Db, job: &ConsolidationRunJob) -> Result<()> { - let payload: ConsolidationJobPayload = serde_json::from_value(job.payload.clone())?; - - payload.validate().map_err(consolidation_validation_error)?; - - let existing = consolidation::get_consolidation_run( - &db.pool, - job.tenant_id.as_str(), - job.project_id.as_str(), - job.run_id, - ) - .await? - .ok_or_else(|| Error::Validation("Consolidation run does not exist.".to_string()))?; - let current_state = - ConsolidationRunState::parse(existing.status.as_str()).ok_or_else(|| { - Error::Validation("Stored consolidation run status is invalid.".to_string()) - })?; - let now = OffsetDateTime::now_utc(); - let mut tx = db.pool.begin().await?; - - match current_state { - ConsolidationRunState::Pending => { - current_state - .validate_transition(ConsolidationRunState::Running) - .map_err(consolidation_validation_error)?; - - let empty_error = Value::Object(Default::default()); - - consolidation::update_consolidation_run_state( - &mut *tx, - ConsolidationRunStateUpdate { - tenant_id: job.tenant_id.as_str(), - project_id: job.project_id.as_str(), - run_id: job.run_id, - status: ConsolidationRunState::Running.as_str(), - error: &empty_error, - now, - }, - ) - .await? - .ok_or_else(|| Error::Validation("Consolidation run disappeared.".to_string()))?; - }, - ConsolidationRunState::Running => {}, - ConsolidationRunState::Completed - | ConsolidationRunState::Failed - | ConsolidationRunState::Cancelled => { - consolidation::mark_consolidation_run_job_done(&mut *tx, job.job_id, now).await?; - - tx.commit().await?; - - return Ok(()); - }, - } - - for proposal in payload.proposals { - let row = proposal_row_from_contract(job, now, proposal)?; - - consolidation::insert_consolidation_proposal(&mut *tx, &row).await?; - } - - ConsolidationRunState::Running - .validate_transition(ConsolidationRunState::Completed) - .map_err(consolidation_validation_error)?; - - let empty_error = Value::Object(Default::default()); - - consolidation::update_consolidation_run_state( - &mut *tx, - ConsolidationRunStateUpdate { - tenant_id: job.tenant_id.as_str(), - project_id: job.project_id.as_str(), - run_id: job.run_id, - status: ConsolidationRunState::Completed.as_str(), - error: &empty_error, - now, - }, - ) - .await? - .ok_or_else(|| Error::Validation("Consolidation run disappeared.".to_string()))?; - consolidation::mark_consolidation_run_job_done(&mut *tx, job.job_id, now).await?; - - tx.commit().await?; - - Ok(()) -} - -async fn insert_trace_stages_tx( - executor: &mut PgConnection, - trace_id: Uuid, - stages: Vec, -) -> Result<()> { - if stages.is_empty() { - return Ok(()); - } - - let mut stage_inserts = Vec::with_capacity(stages.len()); - let mut item_inserts = Vec::new(); - - for stage in stages { - stage_inserts.push(TraceStageInsert { - stage_id: stage.stage_id, - stage_order: stage.stage_order as i32, - stage_name: stage.stage_name, - stage_payload: stage.stage_payload, - created_at: stage.created_at, - }); - - for item in stage.items { - item_inserts.push(TraceStageItemInsert { - id: item.id, - stage_id: stage.stage_id, - item_id: item.item_id, - note_id: item.note_id, - chunk_id: item.chunk_id, - metrics: item.metrics, - }); - } - } - - let mut stage_builder = QueryBuilder::new( - "\ - INSERT INTO search_trace_stages ( - stage_id, - trace_id, - stage_order, - stage_name, - stage_payload, - created_at - ) ", - ); - - stage_builder.push_values(stage_inserts, |mut b, stage| { - b.push_bind(stage.stage_id) - .push_bind(trace_id) - .push_bind(stage.stage_order) - .push_bind(stage.stage_name) - .push_bind(stage.stage_payload) - .push_bind(stage.created_at); - }); - stage_builder.push(" ON CONFLICT (stage_id) DO NOTHING"); - stage_builder.build().execute(&mut *executor).await?; - - if item_inserts.is_empty() { - return Ok(()); - } - - let mut item_builder = QueryBuilder::new( - "\ - INSERT INTO search_trace_stage_items ( - id, - stage_id, - item_id, - note_id, - chunk_id, - metrics - ) ", - ); - - item_builder.push_values(item_inserts, |mut b, item| { - b.push_bind(item.id) - .push_bind(item.stage_id) - .push_bind(item.item_id) - .push_bind(item.note_id) - .push_bind(item.chunk_id) - .push_bind(item.metrics); - }); - item_builder.push(" ON CONFLICT (id) DO NOTHING"); - item_builder.build().execute(executor).await?; - - Ok(()) -} - -async fn insert_trace_tx<'e, E>( - executor: E, - trace_id: Uuid, - trace: &TraceRecord, - expanded_queries_json: Value, - allowed_scopes_json: Value, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "INSERT INTO search_traces ( - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - expansion_mode, - expanded_queries, - allowed_scopes, - candidate_count, - top_k, - config_snapshot, - trace_version, - created_at, - expires_at -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15 -) -ON CONFLICT (trace_id) DO NOTHING", - ) - .bind(trace_id) - .bind(trace.tenant_id.as_str()) - .bind(trace.project_id.as_str()) - .bind(trace.agent_id.as_str()) - .bind(trace.read_profile.as_str()) - .bind(trace.query.as_str()) - .bind(trace.expansion_mode.as_str()) - .bind(expanded_queries_json) - .bind(allowed_scopes_json) - .bind(trace.candidate_count as i32) - .bind(trace.top_k as i32) - .bind(trace.config_snapshot.clone()) - .bind(trace.trace_version) - .bind(trace.created_at) - .bind(trace.expires_at) - .execute(executor) - .await?; - - Ok(()) -} - -async fn insert_trace_items_tx<'e, E>( - executor: E, - trace_id: Uuid, - items: Vec, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - if items.is_empty() { - return Ok(()); - } - - let mut inserts = Vec::with_capacity(items.len()); - - for item in items { - inserts.push(TraceItemInsert { - item_id: item.item_id, - note_id: item.note_id, - chunk_id: item.chunk_id, - rank: item.rank as i32, - final_score: item.final_score, - explain: item.explain, - }); - } - - let mut builder = QueryBuilder::new( - "\ -INSERT INTO search_trace_items ( - item_id, - trace_id, - note_id, - chunk_id, - rank, - final_score, - explain -) ", - ); - - builder.push_values(inserts, |mut b, item| { - b.push_bind(item.item_id) - .push_bind(trace_id) - .push_bind(item.note_id) - .push_bind(item.chunk_id) - .push_bind(item.rank) - .push_bind(item.final_score) - .push_bind(item.explain); - }); - builder.push(" ON CONFLICT (item_id) DO NOTHING"); - builder.build().execute(executor).await?; - - Ok(()) -} - -async fn insert_trace_candidates_tx<'e, E>( - executor: E, - trace_id: Uuid, - candidates: Vec, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - if candidates.is_empty() { - return Ok(()); - } - - let mut inserts = Vec::with_capacity(candidates.len()); - - for candidate in candidates { - inserts.push(TraceCandidateInsert { - candidate_id: candidate.candidate_id, - note_id: candidate.note_id, - chunk_id: candidate.chunk_id, - chunk_index: candidate.chunk_index, - snippet: candidate.snippet, - candidate_snapshot: candidate.candidate_snapshot, - retrieval_rank: candidate.retrieval_rank as i32, - rerank_score: candidate.rerank_score, - note_scope: candidate.note_scope, - note_importance: candidate.note_importance, - note_updated_at: candidate.note_updated_at, - note_hit_count: candidate.note_hit_count, - note_last_hit_at: candidate.note_last_hit_at, - created_at: candidate.created_at, - expires_at: candidate.expires_at, - }); - } - - let mut builder = QueryBuilder::new( - "\ -INSERT INTO search_trace_candidates ( - candidate_id, - trace_id, - note_id, - chunk_id, - chunk_index, - snippet, - candidate_snapshot, - retrieval_rank, - rerank_score, - note_scope, - note_importance, - note_updated_at, - note_hit_count, - note_last_hit_at, - created_at, - expires_at -) ", - ); - - builder.push_values(inserts, |mut b, candidate| { - b.push_bind(candidate.candidate_id) - .push_bind(trace_id) - .push_bind(candidate.note_id) - .push_bind(candidate.chunk_id) - .push_bind(candidate.chunk_index) - .push_bind(candidate.snippet) - .push_bind(candidate.candidate_snapshot) - .push_bind(candidate.retrieval_rank) - .push_bind(candidate.rerank_score) - .push_bind(candidate.note_scope) - .push_bind(candidate.note_importance) - .push_bind(candidate.note_updated_at) - .push_bind(candidate.note_hit_count) - .push_bind(candidate.note_last_hit_at) - .push_bind(candidate.created_at) - .push_bind(candidate.expires_at); - }); - builder.push(" ON CONFLICT (candidate_id) DO NOTHING"); - builder.build().execute(executor).await?; - - Ok(()) -} - -async fn purge_expired_trace_candidates(db: &Db, now: OffsetDateTime) -> Result<()> { - let result = sqlx::query("DELETE FROM search_trace_candidates WHERE expires_at <= $1") - .bind(now) - .execute(&db.pool) - .await?; - - if result.rows_affected() > 0 { - tracing::info!(count = result.rows_affected(), "Purged expired search trace candidates."); - } - - Ok(()) -} - -async fn purge_expired_traces(db: &Db, now: OffsetDateTime) -> Result<()> { - let result = sqlx::query("DELETE FROM search_traces WHERE expires_at <= $1") - .bind(now) - .execute(&db.pool) - .await?; - - if result.rows_affected() > 0 { - tracing::info!(count = result.rows_affected(), "Purged expired search traces."); - } - - Ok(()) -} - -async fn purge_expired_cache(db: &Db, now: OffsetDateTime) -> Result<()> { - let result = sqlx::query("DELETE FROM llm_cache WHERE expires_at <= $1") - .bind(now) - .execute(&db.pool) - .await?; - - if result.rows_affected() > 0 { - tracing::info!(count = result.rows_affected(), "Purged expired LLM cache entries."); - } - - Ok(()) -} - -async fn purge_expired_search_sessions(db: &Db, now: OffsetDateTime) -> Result<()> { - let result = sqlx::query("DELETE FROM search_sessions WHERE expires_at <= $1") - .bind(now) - .execute(&db.pool) - .await?; - - if result.rows_affected() > 0 { - tracing::info!(count = result.rows_affected(), "Purged expired search sessions."); - } - - Ok(()) -} - -async fn fetch_note(db: &Db, note_id: Uuid) -> Result> { - let note = sqlx::query_as::<_, MemoryNote>("SELECT * FROM memory_notes WHERE note_id = $1") - .bind(note_id) - .fetch_optional(&db.pool) - .await?; - - Ok(note) -} - -async fn fetch_note_fields(db: &Db, note_id: Uuid) -> Result> { - let rows = sqlx::query_as::<_, NoteFieldRow>( - "\ -SELECT field_id, text -FROM memory_note_fields -WHERE note_id = $1 -ORDER BY field_kind ASC, item_index ASC", - ) - .bind(note_id) - .fetch_all(&db.pool) - .await?; - - Ok(rows) -} - -async fn insert_embedding_tx<'e, E>( - executor: E, - note_id: Uuid, - embedding_version: &str, - embedding_dim: i32, - vec: &[f32], -) -> Result<()> -where - E: PgExecutor<'e>, -{ - let vec_text = format_vector_text(vec); - - sqlx::query( - "\ -INSERT INTO note_embeddings ( - note_id, - embedding_version, - embedding_dim, - vec -) -VALUES ($1, $2, $3, $4::text::vector) -ON CONFLICT (note_id, embedding_version) DO UPDATE -SET - embedding_dim = EXCLUDED.embedding_dim, - vec = EXCLUDED.vec, - created_at = now()", - ) - .bind(note_id) - .bind(embedding_version) - .bind(embedding_dim) - .bind(vec_text.as_str()) - .execute(executor) - .await?; - - Ok(()) -} - -async fn insert_note_field_embedding_tx<'e, E>( - executor: E, - field_id: Uuid, - embedding_version: &str, - embedding_dim: i32, - vec: &[f32], -) -> Result<()> -where - E: PgExecutor<'e>, -{ - let vec_text = format_vector_text(vec); - - sqlx::query( - "\ -INSERT INTO note_field_embeddings ( - field_id, - embedding_version, - embedding_dim, - vec -) -VALUES ($1, $2, $3, $4::text::vector) -ON CONFLICT (field_id, embedding_version) DO UPDATE -SET - embedding_dim = EXCLUDED.embedding_dim, - vec = EXCLUDED.vec, - created_at = now()", - ) - .bind(field_id) - .bind(embedding_version) - .bind(embedding_dim) - .bind(vec_text.as_str()) - .execute(executor) - .await?; - - Ok(()) -} - -async fn delete_qdrant_note_points(state: &WorkerState, note_id: Uuid) -> Result<()> { - let filter = Filter::must([Condition::matches("note_id", note_id.to_string())]); - let delete = - DeletePointsBuilder::new(state.qdrant.collection.clone()).points(filter).wait(true); - - match state.qdrant.client.delete_points(delete).await { - Ok(_) => {}, - Err(err) => - if is_not_found_error(&err) { - tracing::info!(note_id = %note_id, "Qdrant points missing during delete."); - } else { - return Err(err.into()); - }, - } - - Ok(()) -} - -async fn upsert_qdrant_chunks( - state: &WorkerState, - note: &MemoryNote, - embedding_version: &str, - records: &[ChunkRecord], - vectors: &[Vec], -) -> Result<()> { - let mut points = Vec::with_capacity(records.len()); - - for (record, vec) in records.iter().zip(vectors.iter()) { - let mut payload = Payload::new(); - - payload.insert("note_id", note.note_id.to_string()); - payload.insert("chunk_id", record.chunk_id.to_string()); - payload.insert("chunk_index", record.chunk_index as i64); - payload.insert("start_offset", record.start_offset as i64); - payload.insert("end_offset", record.end_offset as i64); - payload.insert("tenant_id", note.tenant_id.clone()); - payload.insert("project_id", note.project_id.clone()); - payload.insert("agent_id", note.agent_id.clone()); - payload.insert("scope", note.scope.clone()); - payload.insert("status", note.status.clone()); - payload.insert("type", note.r#type.clone()); - - match note.key.as_ref() { - Some(key) => payload.insert("key", key.clone()), - None => payload.insert("key", Value::Null), - } - - payload.insert("updated_at", Value::String(format_timestamp(note.updated_at)?)); - payload.insert( - "expires_at", - match note.expires_at { - Some(ts) => Value::String(format_timestamp(ts)?), - None => Value::Null, - }, - ); - payload.insert("importance", Value::from(note.importance as f64)); - payload.insert("confidence", Value::from(note.confidence as f64)); - payload.insert("embedding_version", embedding_version.to_string()); - - let mut vector_map = HashMap::new(); - - vector_map.insert(DENSE_VECTOR_NAME.to_string(), Vector::from(vec.to_vec())); - vector_map.insert( - BM25_VECTOR_NAME.to_string(), - Vector::from(Document::new(record.text.clone(), BM25_MODEL)), - ); - - let point = PointStruct::new(record.chunk_id.to_string(), vector_map, payload); - - points.push(point); - } - - let upsert = UpsertPointsBuilder::new(state.qdrant.collection.clone(), points).wait(true); - - state.qdrant.client.upsert_points(upsert).await?; - - Ok(()) -} - -async fn mark_failed(db: &Db, outbox_id: Uuid, attempts: i32, err: &Error) -> Result<()> { - let next_attempts = attempts.saturating_add(1); - let backoff = backoff_for_attempt(next_attempts); - let now = OffsetDateTime::now_utc(); - let available_at = now + backoff; - let error_text = sanitize_outbox_error(&err.to_string()); - - outbox::mark_indexing_outbox_failed( - db, - outbox_id, - next_attempts, - error_text.as_str(), - available_at, - now, - ) - .await?; - - Ok(()) -} - -async fn mark_doc_failed(db: &Db, outbox_id: Uuid, attempts: i32, err: &Error) -> Result<()> { - let next_attempts = attempts.saturating_add(1); - let backoff = backoff_for_attempt(next_attempts); - let now = OffsetDateTime::now_utc(); - let available_at = now + backoff; - let error_text = sanitize_outbox_error(&err.to_string()); - - doc_outbox::mark_doc_indexing_outbox_failed( - db, - outbox_id, - next_attempts, - error_text.as_str(), - available_at, - now, - ) - .await?; - - Ok(()) -} - -async fn mark_trace_failed(db: &Db, outbox_id: Uuid, attempts: i32, err: &Error) -> Result<()> { - let next_attempts = attempts.saturating_add(1); - let backoff = backoff_for_attempt(next_attempts); - let now = OffsetDateTime::now_utc(); - let available_at = now + backoff; - let error_text = sanitize_outbox_error(&err.to_string()); - - outbox::mark_trace_outbox_failed( - db, - outbox_id, - next_attempts, - error_text.as_str(), - available_at, - now, - ) - .await?; - - Ok(()) -} - -async fn mark_consolidation_failed( - db: &Db, - job_id: Uuid, - attempts: i32, - err: &Error, -) -> Result<()> { - let next_attempts = attempts.saturating_add(1); - let backoff = backoff_for_attempt(next_attempts); - let now = OffsetDateTime::now_utc(); - let available_at = now + backoff; - let error_text = sanitize_outbox_error(&err.to_string()); - - consolidation::mark_consolidation_run_job_failed( - db, - job_id, - next_attempts, - error_text.as_str(), - available_at, - now, - ) - .await?; - - Ok(()) -} +use helpers::{ + backoff_for_attempt, build_chunk_records, encode_json, format_timestamp, format_vector_text, + is_not_found_error, mean_pool, note_is_active, project_doc_ref_fields, sanitize_outbox_error, + to_std_duration, validate_vector_dim, +}; +use note_indexing::{handle_delete, handle_upsert}; +use outbox_jobs::{ + process_consolidation_run_job_once, process_doc_indexing_outbox_once, + process_indexing_outbox_once, process_trace_outbox_once, +}; +use trace_jobs::{ + handle_trace_job, purge_expired_cache, purge_expired_search_sessions, + purge_expired_trace_candidates, purge_expired_traces, +}; +use types::{ + BASE_BACKOFF_MS, CLAIM_LEASE_SECONDS, CONSOLIDATION_JOB_LEASE_SECONDS, ChunkRecord, + DocChunkIndexRow, MAX_BACKOFF_MS, MAX_OUTBOX_ERROR_CHARS, NoteFieldRow, POLL_INTERVAL_MS, + ProjectDocRefFields, TRACE_CLEANUP_INTERVAL_SECONDS, TRACE_OUTBOX_LEASE_SECONDS, + TraceCandidateInsert, TraceCandidateRecord, TraceItemInsert, TraceItemRecord, TracePayload, + TraceRecord, TraceStageInsert, TraceStageItemInsert, TraceTrajectoryStageRecord, +}; #[cfg(test)] -mod tests { - use serde_json; - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - - use crate::worker::{self}; - - #[test] - fn pooled_vector_is_mean_of_chunks() { - let chunks = vec![vec![1.0_f32, 3.0_f32], vec![3.0_f32, 5.0_f32]]; - let pooled = worker::mean_pool(&chunks).expect("Expected pooled vector."); - - assert_eq!(pooled, vec![2.0_f32, 4.0_f32]); - } - - #[test] - fn project_doc_ref_fields_falls_back_to_created_at_timestamp() { - let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) - .expect("Failed to parse fallback timestamp."); - let (doc_ts, thread_id, domain, repo) = worker::project_doc_ref_fields( - &serde_json::json!({"thread_id": ""}), - created_at, - "knowledge", - ) - .expect("Expected projection."); - - assert_eq!(doc_ts, created_at.format(&Rfc3339).expect("Failed to format fallback doc_ts.")); - assert!(thread_id.is_none()); - assert!(domain.is_none()); - assert!(repo.is_none()); - } - - #[test] - fn project_doc_ref_fields_prefers_source_ref_ts() { - let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) - .expect("Failed to parse fallback timestamp."); - let source_ref = serde_json::json!({ - "ts": "2025-01-01T01:02:03Z", - "doc_ts": "2020-01-01T00:00:00Z", - "thread_id": "thread-42", - "domain": "example.com", - "repo": "org/repo" - }); - let (doc_ts, thread_id, domain, repo) = - worker::project_doc_ref_fields(&source_ref, created_at, "chat") - .expect("Expected projection."); - - assert_eq!(doc_ts, "2025-01-01T01:02:03Z"); - assert_eq!(thread_id.as_deref(), Some("thread-42")); - assert!(domain.is_none()); - assert!(repo.is_none()); - } - - #[test] - fn project_doc_ref_fields_uses_legacy_doc_ts_when_ts_is_missing() { - let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) - .expect("Failed to parse fallback timestamp."); - let source_ref = serde_json::json!({ - "doc_ts": "2025-01-01T02:03:04Z", - "thread_id": "legacy-thread", - "domain": "legacy.example", - "repo": "legacy/repo" - }); - let (doc_ts, thread_id, domain, repo) = - worker::project_doc_ref_fields(&source_ref, created_at, "knowledge") - .expect("Expected projection."); - - assert_eq!(doc_ts, "2025-01-01T02:03:04Z"); - assert!(thread_id.is_none()); - assert!(domain.is_none()); - assert!(repo.is_none()); - } - - #[test] - fn project_doc_ref_fields_gates_optional_ref_fields_by_doc_type() { - let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) - .expect("Failed to parse fallback timestamp."); - let source_ref = serde_json::json!({ - "thread_id": "thread-42", - "domain": "example.com", - "repo": "org/repo", - }); - let (doc_ts_for_knowledge, thread_id_knowledge, domain_knowledge, repo_knowledge) = - worker::project_doc_ref_fields(&source_ref, created_at, "knowledge") - .expect("Expected projection."); - - assert_eq!( - doc_ts_for_knowledge, - created_at.format(&Rfc3339).expect("Failed to format fallback doc_ts.") - ); - assert!(thread_id_knowledge.is_none()); - assert!(domain_knowledge.is_none()); - assert!(repo_knowledge.is_none()); - - let chat_projection = worker::project_doc_ref_fields(&source_ref, created_at, "chat") - .expect("Expected projection."); - - assert_eq!(chat_projection.1.as_deref(), Some("thread-42")); - assert!(chat_projection.2.is_none()); - assert!(chat_projection.3.is_none()); - - let search_projection = worker::project_doc_ref_fields(&source_ref, created_at, "search") - .expect("Expected projection."); - - assert!(search_projection.1.is_none()); - assert_eq!(search_projection.2.as_deref(), Some("example.com")); - assert!(search_projection.3.is_none()); - - let dev_projection = worker::project_doc_ref_fields(&source_ref, created_at, "dev") - .expect("Expected projection."); - - assert!(dev_projection.1.is_none()); - assert!(dev_projection.2.is_none()); - assert_eq!(dev_projection.3.as_deref(), Some("org/repo")); - } -} +#[path = "worker/tests.rs"] +mod tests; diff --git a/apps/elf-worker/src/worker/consolidation_jobs.rs b/apps/elf-worker/src/worker/consolidation_jobs.rs new file mode 100644 index 00000000..f58c186b --- /dev/null +++ b/apps/elf-worker/src/worker/consolidation_jobs.rs @@ -0,0 +1,140 @@ +use crate::worker::{ + self, CONSOLIDATION_CONTRACT_SCHEMA_V1, ConsolidationJobPayload, ConsolidationProposal, + ConsolidationProposalContract, ConsolidationReviewState, ConsolidationRunJob, + ConsolidationRunState, ConsolidationRunStateUpdate, ConsolidationValidationError, Db, Error, + OffsetDateTime, Result, ToString, Uuid, Value, consolidation, +}; + +pub(super) fn proposal_row_from_contract( + job: &ConsolidationRunJob, + now: OffsetDateTime, + proposal: ConsolidationProposalContract, +) -> Result { + proposal.validate().map_err(consolidation_validation_error)?; + + Ok(ConsolidationProposal { + proposal_id: Uuid::new_v4(), + run_id: job.run_id, + tenant_id: job.tenant_id.clone(), + project_id: job.project_id.clone(), + agent_id: job.agent_id.clone(), + contract_schema: CONSOLIDATION_CONTRACT_SCHEMA_V1.to_string(), + proposal_kind: proposal.proposal_kind, + apply_intent: proposal.apply_intent.as_str().to_string(), + review_state: ConsolidationReviewState::Proposed.as_str().to_string(), + source_refs: worker::encode_json(&proposal.source_refs, "consolidation source_refs")?, + source_snapshot: proposal.source_snapshot, + lineage: worker::encode_json(&proposal.lineage, "consolidation lineage")?, + diff: worker::encode_json(&proposal.diff, "consolidation diff")?, + confidence: proposal.confidence, + unsupported_claim_flags: worker::encode_json( + &proposal.unsupported_claim_flags, + "consolidation unsupported_claim_flags", + )?, + contradiction_markers: worker::encode_json( + &proposal.markers.contradictions, + "consolidation contradiction_markers", + )?, + staleness_markers: worker::encode_json( + &proposal.markers.staleness, + "consolidation staleness_markers", + )?, + target_ref: proposal.target_ref, + proposed_payload: proposal.proposed_payload, + reviewer_agent_id: None, + review_comment: None, + reviewed_at: None, + created_at: now, + updated_at: now, + }) +} + +pub(super) fn consolidation_validation_error(err: ConsolidationValidationError) -> Error { + Error::Validation(err.to_string()) +} + +pub(super) async fn handle_consolidation_job(db: &Db, job: &ConsolidationRunJob) -> Result<()> { + let payload: ConsolidationJobPayload = serde_json::from_value(job.payload.clone())?; + + payload.validate().map_err(consolidation_validation_error)?; + + let existing = consolidation::get_consolidation_run( + &db.pool, + job.tenant_id.as_str(), + job.project_id.as_str(), + job.run_id, + ) + .await? + .ok_or_else(|| Error::Validation("Consolidation run does not exist.".to_string()))?; + let current_state = + ConsolidationRunState::parse(existing.status.as_str()).ok_or_else(|| { + Error::Validation("Stored consolidation run status is invalid.".to_string()) + })?; + let now = OffsetDateTime::now_utc(); + let mut tx = db.pool.begin().await?; + + match current_state { + ConsolidationRunState::Pending => { + current_state + .validate_transition(ConsolidationRunState::Running) + .map_err(consolidation_validation_error)?; + + let empty_error = Value::Object(Default::default()); + + consolidation::update_consolidation_run_state( + &mut *tx, + ConsolidationRunStateUpdate { + tenant_id: job.tenant_id.as_str(), + project_id: job.project_id.as_str(), + run_id: job.run_id, + status: ConsolidationRunState::Running.as_str(), + error: &empty_error, + now, + }, + ) + .await? + .ok_or_else(|| Error::Validation("Consolidation run disappeared.".to_string()))?; + }, + ConsolidationRunState::Running => {}, + ConsolidationRunState::Completed + | ConsolidationRunState::Failed + | ConsolidationRunState::Cancelled => { + consolidation::mark_consolidation_run_job_done(&mut *tx, job.job_id, now).await?; + + tx.commit().await?; + + return Ok(()); + }, + } + + for proposal in payload.proposals { + let row = proposal_row_from_contract(job, now, proposal)?; + + consolidation::insert_consolidation_proposal(&mut *tx, &row).await?; + } + + ConsolidationRunState::Running + .validate_transition(ConsolidationRunState::Completed) + .map_err(consolidation_validation_error)?; + + let empty_error = Value::Object(Default::default()); + + consolidation::update_consolidation_run_state( + &mut *tx, + ConsolidationRunStateUpdate { + tenant_id: job.tenant_id.as_str(), + project_id: job.project_id.as_str(), + run_id: job.run_id, + status: ConsolidationRunState::Completed.as_str(), + error: &empty_error, + now, + }, + ) + .await? + .ok_or_else(|| Error::Validation("Consolidation run disappeared.".to_string()))?; + consolidation::mark_consolidation_run_job_done(&mut *tx, job.job_id, now).await?; + + tx.commit().await?; + + Ok(()) +} diff --git a/apps/elf-worker/src/worker/doc_indexing.rs b/apps/elf-worker/src/worker/doc_indexing.rs new file mode 100644 index 00000000..1b1d8790 --- /dev/null +++ b/apps/elf-worker/src/worker/doc_indexing.rs @@ -0,0 +1,170 @@ +use crate::worker::{ + self, BM25_MODEL, BM25_VECTOR_NAME, Condition, DENSE_VECTOR_NAME, Db, DeletePointsBuilder, + DocChunkIndexRow, DocIndexingOutboxEntry, Document, Error, Filter, HashMap, Payload, + PointStruct, Result, ToString, UpsertPointsBuilder, Uuid, Value, Vector, WorkerState, docs, + embedding, slice, +}; + +pub(super) async fn fetch_doc_chunk_index_row( + db: &Db, + chunk_id: Uuid, +) -> Result> { + let row = sqlx::query_as::<_, DocChunkIndexRow>( + r#" +SELECT + d.doc_id, + d.tenant_id, + d.project_id, + d.agent_id, + d.scope, + d.doc_type, + d.status, + d.created_at, + d.updated_at, + d.content_hash, + COALESCE(d.source_ref, '{}'::jsonb) AS source_ref, + c.chunk_id, + c.chunk_index, + c.start_offset, + c.end_offset, + c.chunk_text, + c.chunk_hash +FROM doc_chunks c +JOIN doc_documents d ON d.doc_id = c.doc_id +WHERE c.chunk_id = $1 +LIMIT 1"#, + ) + .bind(chunk_id) + .fetch_optional(&db.pool) + .await?; + + Ok(row) +} + +pub(super) async fn handle_doc_upsert( + state: &WorkerState, + job: &DocIndexingOutboxEntry, +) -> Result<()> { + let row = fetch_doc_chunk_index_row(&state.db, job.chunk_id).await?; + let Some(row) = row else { + tracing::info!( + outbox_id = %job.outbox_id, + doc_id = %job.doc_id, + chunk_id = %job.chunk_id, + "Doc chunk missing for outbox job. Marking done." + ); + + return Ok(()); + }; + + if !row.status.eq_ignore_ascii_case("active") { + tracing::info!( + outbox_id = %job.outbox_id, + doc_id = %row.doc_id, + chunk_id = %row.chunk_id, + "Doc inactive. Skipping index." + ); + + return Ok(()); + } + + let vectors = embedding::embed(&state.embedding, slice::from_ref(&row.chunk_text)) + .await + .map_err(|err| Error::Message(err.to_string()))?; + let vector = vectors + .first() + .ok_or_else(|| Error::Validation("Embedding provider returned no vectors.".to_string()))?; + + worker::validate_vector_dim(vector, state.docs_qdrant.vector_dim)?; + + { + let vec_text = worker::format_vector_text(vector); + let mut tx = state.db.pool.begin().await?; + + docs::insert_doc_chunk_embedding( + &mut *tx, + row.chunk_id, + &job.embedding_version, + vector.len() as i32, + vec_text.as_str(), + ) + .await?; + + tx.commit().await?; + } + + upsert_qdrant_doc_chunk(state, &row, &job.embedding_version, vector).await?; + + Ok(()) +} + +pub(super) async fn handle_doc_delete( + state: &WorkerState, + job: &DocIndexingOutboxEntry, +) -> Result<()> { + let filter = Filter::must([Condition::matches("chunk_id", job.chunk_id.to_string())]); + let delete = + DeletePointsBuilder::new(state.docs_qdrant.collection.clone()).points(filter).wait(true); + + state.docs_qdrant.client.delete_points(delete).await?; + + Ok(()) +} + +pub(super) async fn upsert_qdrant_doc_chunk( + state: &WorkerState, + row: &DocChunkIndexRow, + embedding_version: &str, + vec: &[f32], +) -> Result<()> { + let (doc_ts, thread_id, domain, repo) = + worker::project_doc_ref_fields(&row.source_ref, row.created_at, row.doc_type.as_str())?; + let mut payload = Payload::new(); + + payload.insert("doc_id", row.doc_id.to_string()); + payload.insert("chunk_id", row.chunk_id.to_string()); + payload.insert("chunk_index", row.chunk_index as i64); + payload.insert("start_offset", row.start_offset as i64); + payload.insert("end_offset", row.end_offset as i64); + payload.insert("tenant_id", row.tenant_id.clone()); + payload.insert("project_id", row.project_id.clone()); + payload.insert("agent_id", row.agent_id.clone()); + payload.insert("scope", row.scope.clone()); + payload.insert("doc_type", row.doc_type.clone()); + payload.insert("status", row.status.clone()); + + let updated_at = worker::format_timestamp(row.updated_at)?; + + payload.insert("updated_at", Value::String(updated_at)); + payload.insert("doc_ts", Value::String(doc_ts)); + + if let Some(value) = thread_id { + payload.insert("thread_id", Value::String(value)); + } + if let Some(value) = domain { + payload.insert("domain", Value::String(value)); + } + if let Some(value) = repo { + payload.insert("repo", Value::String(value)); + } + + payload.insert("embedding_version", embedding_version.to_string()); + payload.insert("content_hash", row.content_hash.clone()); + payload.insert("chunk_hash", row.chunk_hash.clone()); + + let mut vector_map = HashMap::new(); + + vector_map.insert(DENSE_VECTOR_NAME.to_string(), Vector::from(vec.to_vec())); + vector_map.insert( + BM25_VECTOR_NAME.to_string(), + Vector::from(Document::new(row.chunk_text.clone(), BM25_MODEL)), + ); + + let point = PointStruct::new(row.chunk_id.to_string(), vector_map, payload); + let upsert = + UpsertPointsBuilder::new(state.docs_qdrant.collection.clone(), vec![point]).wait(true); + + state.docs_qdrant.client.upsert_points(upsert).await?; + + Ok(()) +} diff --git a/apps/elf-worker/src/worker/helpers.rs b/apps/elf-worker/src/worker/helpers.rs new file mode 100644 index 00000000..1b8f9f84 --- /dev/null +++ b/apps/elf-worker/src/worker/helpers.rs @@ -0,0 +1,217 @@ +use crate::worker::{ + BASE_BACKOFF_MS, Chunk, ChunkRecord, Error, MAX_BACKOFF_MS, MAX_OUTBOX_ERROR_CHARS, MemoryNote, + OffsetDateTime, ProjectDocRefFields, QdrantError, Result, Rfc3339, Serialize, ToString, Uuid, + Value, +}; + +pub(super) fn is_not_found_error(err: &QdrantError) -> bool { + let message = err.to_string().to_lowercase(); + let point_not_found = + (message.contains("not found") || message.contains("404")) && message.contains("point"); + let no_point_found = message.contains("no point") && message.contains("found"); + + point_not_found || no_point_found +} + +pub(super) fn note_is_active(note: &MemoryNote, now: OffsetDateTime) -> bool { + if !note.status.eq_ignore_ascii_case("active") { + return false; + } + + if let Some(expires_at) = note.expires_at + && expires_at <= now + { + return false; + } + + true +} + +pub(super) fn build_chunk_records(note_id: Uuid, chunks: &[Chunk]) -> Result> { + let mut records = Vec::with_capacity(chunks.len()); + + for chunk in chunks { + let start_offset = to_i32(chunk.start_offset, "start_offset")?; + let end_offset = to_i32(chunk.end_offset, "end_offset")?; + + records.push(ChunkRecord { + chunk_id: chunk_id_for(note_id, chunk.chunk_index), + chunk_index: chunk.chunk_index, + start_offset, + end_offset, + text: chunk.text.clone(), + }); + } + + Ok(records) +} + +pub(super) fn chunk_id_for(note_id: Uuid, chunk_index: i32) -> Uuid { + let name = format!("{note_id}:{chunk_index}"); + + Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) +} + +pub(super) fn to_i32(value: usize, label: &str) -> Result { + i32::try_from(value).map_err(|_| { + Error::Validation(format!("Chunk {label} offset {value} exceeds supported range.")) + }) +} + +pub(super) fn mean_pool(chunks: &[Vec]) -> Option> { + if chunks.is_empty() { + return None; + } + + let dim = chunks[0].len(); + let mut out = vec![0.0_f32; dim]; + + for vec in chunks { + for (idx, value) in vec.iter().enumerate() { + out[idx] += value; + } + } + for value in &mut out { + *value /= chunks.len() as f32; + } + + Some(out) +} + +pub(super) fn format_timestamp(ts: OffsetDateTime) -> Result { + ts.format(&Rfc3339).map_err(|_| Error::Message("Failed to format timestamp.".to_string())) +} + +pub(super) fn validate_vector_dim(vec: &[f32], expected_dim: u32) -> Result<()> { + if vec.len() != expected_dim as usize { + return Err(Error::Validation(format!( + "Embedding dimension {} does not match configured vector_dim {}.", + vec.len(), + expected_dim + ))); + } + + Ok(()) +} + +pub(super) fn format_vector_text(vec: &[f32]) -> String { + let mut out = String::from("["); + + for (idx, value) in vec.iter().enumerate() { + if idx > 0 { + out.push(','); + } + + out.push_str(&value.to_string()); + } + + out.push(']'); + + out +} + +pub(super) fn encode_json(value: &T, label: &str) -> Result +where + T: Serialize, +{ + serde_json::to_value(value) + .map_err(|err| Error::Message(format!("Failed to encode {label}: {err}."))) +} + +pub(super) fn sanitize_outbox_error(text: &str) -> String { + let mut parts = Vec::new(); + let mut redact_next = false; + + for raw in text.split_whitespace() { + let mut word = raw.to_string(); + + if redact_next { + word = "[REDACTED]".to_string(); + redact_next = false; + } + if raw.eq_ignore_ascii_case("bearer") { + redact_next = true; + } + + let lowered = raw.to_ascii_lowercase(); + + for key in ["api_key", "apikey", "password", "secret", "token"] { + if lowered.contains(key) && (lowered.contains('=') || lowered.contains(':')) { + let sep = if raw.contains('=') { '=' } else { ':' }; + let prefix = match raw.split(sep).next() { + Some(prefix) => prefix, + None => raw, + }; + + word = format!("{prefix}{sep}[REDACTED]"); + + break; + } + } + + parts.push(word); + } + + let mut out = parts.join(" "); + + if out.chars().count() > MAX_OUTBOX_ERROR_CHARS { + out = out.chars().take(MAX_OUTBOX_ERROR_CHARS).collect(); + + out.push_str("..."); + } + + out +} + +pub(super) fn backoff_for_attempt(attempt: i32) -> time::Duration { + let attempts = attempt.max(1) as u32; + let exp = attempts.saturating_sub(1).min(6); + let base = BASE_BACKOFF_MS.saturating_mul(1 << exp); + let capped = base.min(MAX_BACKOFF_MS); + + time::Duration::milliseconds(capped) +} + +pub(super) fn to_std_duration(duration: time::Duration) -> std::time::Duration { + let millis = duration.whole_milliseconds(); + + if millis <= 0 { + return std::time::Duration::from_millis(0); + } + + std::time::Duration::from_millis(millis as u64) +} + +pub(super) fn project_doc_ref_fields( + source_ref: &Value, + fallback_timestamp: OffsetDateTime, + doc_type: &str, +) -> Result { + let source_ref_field = |field_name: &str| -> Option { + source_ref + .get(field_name) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }; + let doc_ts = match source_ref + .get("ts") + .and_then(Value::as_str) + .filter(|value| OffsetDateTime::parse(value, &Rfc3339).is_ok()) + .map(ToString::to_string) + .or_else(|| { + source_ref + .get("doc_ts") + .and_then(Value::as_str) + .filter(|value| OffsetDateTime::parse(value, &Rfc3339).is_ok()) + .map(ToString::to_string) + }) { + Some(value) => value, + None => format_timestamp(fallback_timestamp)?, + }; + let thread_id = if doc_type == "chat" { source_ref_field("thread_id") } else { None }; + let domain = if doc_type == "search" { source_ref_field("domain") } else { None }; + let repo = if doc_type == "dev" { source_ref_field("repo") } else { None }; + + Ok((doc_ts, thread_id, domain, repo)) +} diff --git a/apps/elf-worker/src/worker/note_indexing.rs b/apps/elf-worker/src/worker/note_indexing.rs new file mode 100644 index 00000000..43de33c3 --- /dev/null +++ b/apps/elf-worker/src/worker/note_indexing.rs @@ -0,0 +1,310 @@ +use crate::worker::{ + self, BM25_MODEL, BM25_VECTOR_NAME, ChunkRecord, Condition, DENSE_VECTOR_NAME, Db, + DeletePointsBuilder, Document, Error, Filter, HashMap, IndexingOutboxEntry, MemoryNote, + NoteFieldRow, OffsetDateTime, Payload, PgExecutor, PointStruct, Result, ToString, + UpsertPointsBuilder, Uuid, Value, Vector, WorkerState, embedding, queries, +}; + +pub(super) async fn handle_upsert(state: &WorkerState, job: &IndexingOutboxEntry) -> Result<()> { + let note = fetch_note(&state.db, job.note_id).await?; + let Some(note) = note else { + tracing::info!( + outbox_id = %job.outbox_id, + note_id = %job.note_id, + "Note missing for outbox job. Marking done." + ); + + return Ok(()); + }; + let now = OffsetDateTime::now_utc(); + + if !worker::note_is_active(¬e, now) { + tracing::info!( + outbox_id = %job.outbox_id, + note_id = %job.note_id, + "Note inactive or expired. Skipping index." + ); + + return Ok(()); + } + + let fields = fetch_note_fields(&state.db, note.note_id).await?; + let chunks = elf_chunking::split_text(¬e.text, &state.chunking, &state.tokenizer); + + if chunks.is_empty() { + return Err(Error::Validation("Chunking produced no chunks.".to_string())); + } + + let records = worker::build_chunk_records(note.note_id, &chunks)?; + let chunk_texts: Vec = records.iter().map(|record| record.text.clone()).collect(); + let field_texts: Vec = fields.iter().map(|field| field.text.clone()).collect(); + let mut embed_inputs = Vec::with_capacity(chunk_texts.len() + field_texts.len()); + + embed_inputs.extend(chunk_texts); + embed_inputs.extend(field_texts); + + let vectors = embedding::embed(&state.embedding, &embed_inputs) + .await + .map_err(|err| Error::Message(err.to_string()))?; + + if vectors.len() != records.len() + fields.len() { + return Err(Error::Validation(format!( + "Embedding provider returned {} vectors for {} items.", + vectors.len(), + records.len() + fields.len() + ))); + } + + let (chunk_vectors, field_vectors) = vectors.split_at(records.len()); + + for vector in chunk_vectors.iter().chain(field_vectors.iter()) { + worker::validate_vector_dim(vector, state.qdrant.vector_dim)?; + } + + { + let mut tx = state.db.pool.begin().await?; + + queries::delete_note_chunks(&mut *tx, note.note_id).await?; + + for record in &records { + queries::insert_note_chunk( + &mut *tx, + record.chunk_id, + note.note_id, + record.chunk_index, + record.start_offset, + record.end_offset, + record.text.as_str(), + &job.embedding_version, + ) + .await?; + } + for (record, vector) in records.iter().zip(chunk_vectors.iter()) { + let vec_text = worker::format_vector_text(vector); + + queries::insert_note_chunk_embedding( + &mut *tx, + record.chunk_id, + &job.embedding_version, + vector.len() as i32, + vec_text.as_str(), + ) + .await?; + } + + let pooled = worker::mean_pool(chunk_vectors) + .ok_or_else(|| Error::Message("Cannot pool empty chunk vectors.".to_string()))?; + + worker::validate_vector_dim(&pooled, state.qdrant.vector_dim)?; + + insert_embedding_tx( + &mut *tx, + note.note_id, + &job.embedding_version, + pooled.len() as i32, + &pooled, + ) + .await?; + + for (field, vector) in fields.iter().zip(field_vectors.iter()) { + insert_note_field_embedding_tx( + &mut *tx, + field.field_id, + &job.embedding_version, + vector.len() as i32, + vector, + ) + .await?; + } + + tx.commit().await?; + } + + delete_qdrant_note_points(state, note.note_id).await?; + upsert_qdrant_chunks(state, ¬e, &job.embedding_version, &records, chunk_vectors).await?; + + Ok(()) +} + +pub(super) async fn handle_delete(state: &WorkerState, job: &IndexingOutboxEntry) -> Result<()> { + delete_qdrant_note_points(state, job.note_id).await?; + + Ok(()) +} + +pub(super) async fn fetch_note(db: &Db, note_id: Uuid) -> Result> { + let note = sqlx::query_as::<_, MemoryNote>("SELECT * FROM memory_notes WHERE note_id = $1") + .bind(note_id) + .fetch_optional(&db.pool) + .await?; + + Ok(note) +} + +pub(super) async fn fetch_note_fields(db: &Db, note_id: Uuid) -> Result> { + let rows = sqlx::query_as::<_, NoteFieldRow>( + "\ +SELECT field_id, text +FROM memory_note_fields +WHERE note_id = $1 +ORDER BY field_kind ASC, item_index ASC", + ) + .bind(note_id) + .fetch_all(&db.pool) + .await?; + + Ok(rows) +} + +pub(super) async fn insert_embedding_tx<'e, E>( + executor: E, + note_id: Uuid, + embedding_version: &str, + embedding_dim: i32, + vec: &[f32], +) -> Result<()> +where + E: PgExecutor<'e>, +{ + let vec_text = worker::format_vector_text(vec); + + sqlx::query( + "\ +INSERT INTO note_embeddings ( + note_id, + embedding_version, + embedding_dim, + vec +) +VALUES ($1, $2, $3, $4::text::vector) +ON CONFLICT (note_id, embedding_version) DO UPDATE +SET + embedding_dim = EXCLUDED.embedding_dim, + vec = EXCLUDED.vec, + created_at = now()", + ) + .bind(note_id) + .bind(embedding_version) + .bind(embedding_dim) + .bind(vec_text.as_str()) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn insert_note_field_embedding_tx<'e, E>( + executor: E, + field_id: Uuid, + embedding_version: &str, + embedding_dim: i32, + vec: &[f32], +) -> Result<()> +where + E: PgExecutor<'e>, +{ + let vec_text = worker::format_vector_text(vec); + + sqlx::query( + "\ +INSERT INTO note_field_embeddings ( + field_id, + embedding_version, + embedding_dim, + vec +) +VALUES ($1, $2, $3, $4::text::vector) +ON CONFLICT (field_id, embedding_version) DO UPDATE +SET + embedding_dim = EXCLUDED.embedding_dim, + vec = EXCLUDED.vec, + created_at = now()", + ) + .bind(field_id) + .bind(embedding_version) + .bind(embedding_dim) + .bind(vec_text.as_str()) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn delete_qdrant_note_points(state: &WorkerState, note_id: Uuid) -> Result<()> { + let filter = Filter::must([Condition::matches("note_id", note_id.to_string())]); + let delete = + DeletePointsBuilder::new(state.qdrant.collection.clone()).points(filter).wait(true); + + match state.qdrant.client.delete_points(delete).await { + Ok(_) => {}, + Err(err) => + if worker::is_not_found_error(&err) { + tracing::info!(note_id = %note_id, "Qdrant points missing during delete."); + } else { + return Err(err.into()); + }, + } + + Ok(()) +} + +pub(super) async fn upsert_qdrant_chunks( + state: &WorkerState, + note: &MemoryNote, + embedding_version: &str, + records: &[ChunkRecord], + vectors: &[Vec], +) -> Result<()> { + let mut points = Vec::with_capacity(records.len()); + + for (record, vec) in records.iter().zip(vectors.iter()) { + let mut payload = Payload::new(); + + payload.insert("note_id", note.note_id.to_string()); + payload.insert("chunk_id", record.chunk_id.to_string()); + payload.insert("chunk_index", record.chunk_index as i64); + payload.insert("start_offset", record.start_offset as i64); + payload.insert("end_offset", record.end_offset as i64); + payload.insert("tenant_id", note.tenant_id.clone()); + payload.insert("project_id", note.project_id.clone()); + payload.insert("agent_id", note.agent_id.clone()); + payload.insert("scope", note.scope.clone()); + payload.insert("status", note.status.clone()); + payload.insert("type", note.r#type.clone()); + + match note.key.as_ref() { + Some(key) => payload.insert("key", key.clone()), + None => payload.insert("key", Value::Null), + } + + payload.insert("updated_at", Value::String(worker::format_timestamp(note.updated_at)?)); + payload.insert( + "expires_at", + match note.expires_at { + Some(ts) => Value::String(worker::format_timestamp(ts)?), + None => Value::Null, + }, + ); + payload.insert("importance", Value::from(note.importance as f64)); + payload.insert("confidence", Value::from(note.confidence as f64)); + payload.insert("embedding_version", embedding_version.to_string()); + + let mut vector_map = HashMap::new(); + + vector_map.insert(DENSE_VECTOR_NAME.to_string(), Vector::from(vec.to_vec())); + vector_map.insert( + BM25_VECTOR_NAME.to_string(), + Vector::from(Document::new(record.text.clone(), BM25_MODEL)), + ); + + let point = PointStruct::new(record.chunk_id.to_string(), vector_map, payload); + + points.push(point); + } + + let upsert = UpsertPointsBuilder::new(state.qdrant.collection.clone(), points).wait(true); + + state.qdrant.client.upsert_points(upsert).await?; + + Ok(()) +} diff --git a/apps/elf-worker/src/worker/outbox_jobs.rs b/apps/elf-worker/src/worker/outbox_jobs.rs new file mode 100644 index 00000000..1ba66e47 --- /dev/null +++ b/apps/elf-worker/src/worker/outbox_jobs.rs @@ -0,0 +1,225 @@ +use crate::worker::{ + self, CLAIM_LEASE_SECONDS, CONSOLIDATION_JOB_LEASE_SECONDS, Db, Error, OffsetDateTime, Result, + TRACE_OUTBOX_LEASE_SECONDS, ToString, Uuid, WorkerState, consolidation, doc_outbox, outbox, +}; + +pub(super) async fn process_indexing_outbox_once(state: &WorkerState) -> Result<()> { + let now = OffsetDateTime::now_utc(); + let job = outbox::claim_next_indexing_outbox_job(&state.db, now, CLAIM_LEASE_SECONDS).await?; + let Some(job) = job else { return Ok(()) }; + let result = match job.op.as_str() { + "UPSERT" => worker::handle_upsert(state, &job).await, + "DELETE" => worker::handle_delete(state, &job).await, + other => Err(Error::Validation(format!("Unsupported outbox op: {other}."))), + }; + + match result { + Ok(()) => { + outbox::mark_indexing_outbox_done(&state.db, job.outbox_id, OffsetDateTime::now_utc()) + .await?; + }, + Err(err) => { + tracing::error!( + error = %err, + outbox_id = %job.outbox_id, + note_id = %job.note_id, + "Outbox job failed." + ); + + mark_failed(&state.db, job.outbox_id, job.attempts, &err).await?; + }, + } + + Ok(()) +} + +pub(super) async fn process_doc_indexing_outbox_once(state: &WorkerState) -> Result<()> { + let now = OffsetDateTime::now_utc(); + let job = + doc_outbox::claim_next_doc_indexing_outbox_job(&state.db, now, CLAIM_LEASE_SECONDS).await?; + let Some(job) = job else { return Ok(()) }; + let result = match job.op.as_str() { + "UPSERT" => worker::handle_doc_upsert(state, &job).await, + "DELETE" => worker::handle_doc_delete(state, &job).await, + other => Err(Error::Validation(format!("Unsupported doc outbox op: {other}."))), + }; + + match result { + Ok(()) => { + doc_outbox::mark_doc_indexing_outbox_done( + &state.db, + job.outbox_id, + OffsetDateTime::now_utc(), + ) + .await?; + }, + Err(err) => { + tracing::error!( + error = %err, + outbox_id = %job.outbox_id, + doc_id = %job.doc_id, + chunk_id = %job.chunk_id, + "Doc outbox job failed." + ); + + mark_doc_failed(&state.db, job.outbox_id, job.attempts, &err).await?; + }, + } + + Ok(()) +} + +pub(super) async fn process_trace_outbox_once(state: &WorkerState) -> Result<()> { + let now = OffsetDateTime::now_utc(); + let job = + outbox::claim_next_trace_outbox_job(&state.db, now, TRACE_OUTBOX_LEASE_SECONDS).await?; + let Some(job) = job else { return Ok(()) }; + let result = worker::handle_trace_job(&state.db, &job).await; + + match result { + Ok(()) => { + outbox::mark_trace_outbox_done(&state.db, job.outbox_id, OffsetDateTime::now_utc()) + .await?; + }, + Err(err) => { + tracing::error!( + error = %err, + outbox_id = %job.outbox_id, + trace_id = %job.trace_id, + "Search trace outbox job failed." + ); + + mark_trace_failed(&state.db, job.outbox_id, job.attempts, &err).await?; + }, + } + + Ok(()) +} + +pub(super) async fn process_consolidation_run_job_once(state: &WorkerState) -> Result<()> { + let now = OffsetDateTime::now_utc(); + let job = consolidation::claim_next_consolidation_run_job( + &state.db, + now, + CONSOLIDATION_JOB_LEASE_SECONDS, + ) + .await?; + let Some(job) = job else { return Ok(()) }; + let result = worker::handle_consolidation_job(&state.db, &job).await; + + match result { + Ok(()) => {}, + Err(err) => { + tracing::error!( + error = %err, + job_id = %job.job_id, + run_id = %job.run_id, + "Consolidation run job failed." + ); + + mark_consolidation_failed(&state.db, job.job_id, job.attempts, &err).await?; + }, + } + + Ok(()) +} + +pub(super) async fn mark_failed( + db: &Db, + outbox_id: Uuid, + attempts: i32, + err: &Error, +) -> Result<()> { + let next_attempts = attempts.saturating_add(1); + let backoff = worker::backoff_for_attempt(next_attempts); + let now = OffsetDateTime::now_utc(); + let available_at = now + backoff; + let error_text = worker::sanitize_outbox_error(&err.to_string()); + + outbox::mark_indexing_outbox_failed( + db, + outbox_id, + next_attempts, + error_text.as_str(), + available_at, + now, + ) + .await?; + + Ok(()) +} + +pub(super) async fn mark_doc_failed( + db: &Db, + outbox_id: Uuid, + attempts: i32, + err: &Error, +) -> Result<()> { + let next_attempts = attempts.saturating_add(1); + let backoff = worker::backoff_for_attempt(next_attempts); + let now = OffsetDateTime::now_utc(); + let available_at = now + backoff; + let error_text = worker::sanitize_outbox_error(&err.to_string()); + + doc_outbox::mark_doc_indexing_outbox_failed( + db, + outbox_id, + next_attempts, + error_text.as_str(), + available_at, + now, + ) + .await?; + + Ok(()) +} + +pub(super) async fn mark_trace_failed( + db: &Db, + outbox_id: Uuid, + attempts: i32, + err: &Error, +) -> Result<()> { + let next_attempts = attempts.saturating_add(1); + let backoff = worker::backoff_for_attempt(next_attempts); + let now = OffsetDateTime::now_utc(); + let available_at = now + backoff; + let error_text = worker::sanitize_outbox_error(&err.to_string()); + + outbox::mark_trace_outbox_failed( + db, + outbox_id, + next_attempts, + error_text.as_str(), + available_at, + now, + ) + .await?; + + Ok(()) +} + +pub(super) async fn mark_consolidation_failed( + db: &Db, + job_id: Uuid, + attempts: i32, + err: &Error, +) -> Result<()> { + let next_attempts = attempts.saturating_add(1); + let backoff = worker::backoff_for_attempt(next_attempts); + let now = OffsetDateTime::now_utc(); + let available_at = now + backoff; + let error_text = worker::sanitize_outbox_error(&err.to_string()); + + consolidation::mark_consolidation_run_job_failed( + db, + job_id, + next_attempts, + error_text.as_str(), + available_at, + now, + ) + .await?; + + Ok(()) +} diff --git a/apps/elf-worker/src/worker/runtime.rs b/apps/elf-worker/src/worker/runtime.rs new file mode 100644 index 00000000..bebce97c --- /dev/null +++ b/apps/elf-worker/src/worker/runtime.rs @@ -0,0 +1,56 @@ +use time::Duration; + +use crate::worker::{ + self, OffsetDateTime, POLL_INTERVAL_MS, Result, TRACE_CLEANUP_INTERVAL_SECONDS, WorkerState, +}; + +/// Runs the worker polling loop for note, document, and trace outboxes. +pub async fn run_worker(state: WorkerState) -> Result<()> { + let mut last_trace_cleanup = OffsetDateTime::now_utc(); + + loop { + if let Err(err) = worker::process_indexing_outbox_once(&state).await { + tracing::error!(error = %err, "Indexing outbox processing failed."); + } + if let Err(err) = worker::process_doc_indexing_outbox_once(&state).await { + tracing::error!(error = %err, "Doc indexing outbox processing failed."); + } + if let Err(err) = worker::process_trace_outbox_once(&state).await { + tracing::error!(error = %err, "Search trace outbox processing failed."); + } + if let Err(err) = worker::process_consolidation_run_job_once(&state).await { + tracing::error!(error = %err, "Consolidation run job processing failed."); + } + + let now = OffsetDateTime::now_utc(); + + if now - last_trace_cleanup >= Duration::seconds(TRACE_CLEANUP_INTERVAL_SECONDS) { + if let Err(err) = worker::purge_expired_trace_candidates(&state.db, now).await { + tracing::error!(error = %err, "Search trace candidate cleanup failed."); + } + if let Err(err) = worker::purge_expired_traces(&state.db, now).await { + tracing::error!(error = %err, "Search trace cleanup failed."); + } else { + last_trace_cleanup = now; + } + if let Err(err) = worker::purge_expired_cache(&state.db, now).await { + tracing::error!(error = %err, "LLM cache cleanup failed."); + } + if let Err(err) = worker::purge_expired_search_sessions(&state.db, now).await { + tracing::error!(error = %err, "Search session cleanup failed."); + } + } + + tokio::time::sleep(worker::to_std_duration(Duration::milliseconds(POLL_INTERVAL_MS))).await; + } +} + +/// Processes at most one due job from each worker-owned queue. +pub async fn process_once(state: &WorkerState) -> Result<()> { + worker::process_indexing_outbox_once(state).await?; + worker::process_doc_indexing_outbox_once(state).await?; + worker::process_trace_outbox_once(state).await?; + worker::process_consolidation_run_job_once(state).await?; + + Ok(()) +} diff --git a/apps/elf-worker/src/worker/tests.rs b/apps/elf-worker/src/worker/tests.rs new file mode 100644 index 00000000..35b9e17c --- /dev/null +++ b/apps/elf-worker/src/worker/tests.rs @@ -0,0 +1,113 @@ +use serde_json; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::worker::{self}; + +#[test] +fn pooled_vector_is_mean_of_chunks() { + let chunks = vec![vec![1.0_f32, 3.0_f32], vec![3.0_f32, 5.0_f32]]; + let pooled = worker::mean_pool(&chunks).expect("Expected pooled vector."); + + assert_eq!(pooled, vec![2.0_f32, 4.0_f32]); +} + +#[test] +fn project_doc_ref_fields_falls_back_to_created_at_timestamp() { + let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) + .expect("Failed to parse fallback timestamp."); + let (doc_ts, thread_id, domain, repo) = worker::project_doc_ref_fields( + &serde_json::json!({"thread_id": ""}), + created_at, + "knowledge", + ) + .expect("Expected projection."); + + assert_eq!(doc_ts, created_at.format(&Rfc3339).expect("Failed to format fallback doc_ts.")); + assert!(thread_id.is_none()); + assert!(domain.is_none()); + assert!(repo.is_none()); +} + +#[test] +fn project_doc_ref_fields_prefers_source_ref_ts() { + let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) + .expect("Failed to parse fallback timestamp."); + let source_ref = serde_json::json!({ + "ts": "2025-01-01T01:02:03Z", + "doc_ts": "2020-01-01T00:00:00Z", + "thread_id": "thread-42", + "domain": "example.com", + "repo": "org/repo" + }); + let (doc_ts, thread_id, domain, repo) = + worker::project_doc_ref_fields(&source_ref, created_at, "chat") + .expect("Expected projection."); + + assert_eq!(doc_ts, "2025-01-01T01:02:03Z"); + assert_eq!(thread_id.as_deref(), Some("thread-42")); + assert!(domain.is_none()); + assert!(repo.is_none()); +} + +#[test] +fn project_doc_ref_fields_uses_legacy_doc_ts_when_ts_is_missing() { + let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) + .expect("Failed to parse fallback timestamp."); + let source_ref = serde_json::json!({ + "doc_ts": "2025-01-01T02:03:04Z", + "thread_id": "legacy-thread", + "domain": "legacy.example", + "repo": "legacy/repo" + }); + let (doc_ts, thread_id, domain, repo) = + worker::project_doc_ref_fields(&source_ref, created_at, "knowledge") + .expect("Expected projection."); + + assert_eq!(doc_ts, "2025-01-01T02:03:04Z"); + assert!(thread_id.is_none()); + assert!(domain.is_none()); + assert!(repo.is_none()); +} + +#[test] +fn project_doc_ref_fields_gates_optional_ref_fields_by_doc_type() { + let created_at = OffsetDateTime::parse("2025-01-01T00:00:00Z", &Rfc3339) + .expect("Failed to parse fallback timestamp."); + let source_ref = serde_json::json!({ + "thread_id": "thread-42", + "domain": "example.com", + "repo": "org/repo", + }); + let (doc_ts_for_knowledge, thread_id_knowledge, domain_knowledge, repo_knowledge) = + worker::project_doc_ref_fields(&source_ref, created_at, "knowledge") + .expect("Expected projection."); + + assert_eq!( + doc_ts_for_knowledge, + created_at.format(&Rfc3339).expect("Failed to format fallback doc_ts.") + ); + assert!(thread_id_knowledge.is_none()); + assert!(domain_knowledge.is_none()); + assert!(repo_knowledge.is_none()); + + let chat_projection = worker::project_doc_ref_fields(&source_ref, created_at, "chat") + .expect("Expected projection."); + + assert_eq!(chat_projection.1.as_deref(), Some("thread-42")); + assert!(chat_projection.2.is_none()); + assert!(chat_projection.3.is_none()); + + let search_projection = worker::project_doc_ref_fields(&source_ref, created_at, "search") + .expect("Expected projection."); + + assert!(search_projection.1.is_none()); + assert_eq!(search_projection.2.as_deref(), Some("example.com")); + assert!(search_projection.3.is_none()); + + let dev_projection = worker::project_doc_ref_fields(&source_ref, created_at, "dev") + .expect("Expected projection."); + + assert!(dev_projection.1.is_none()); + assert!(dev_projection.2.is_none()); + assert_eq!(dev_projection.3.as_deref(), Some("org/repo")); +} diff --git a/apps/elf-worker/src/worker/trace_jobs.rs b/apps/elf-worker/src/worker/trace_jobs.rs new file mode 100644 index 00000000..3131e90e --- /dev/null +++ b/apps/elf-worker/src/worker/trace_jobs.rs @@ -0,0 +1,362 @@ +use crate::worker::{ + self, Db, OffsetDateTime, PgConnection, PgExecutor, QueryBuilder, Result, TraceCandidateInsert, + TraceCandidateRecord, TraceItemInsert, TraceItemRecord, TraceOutboxJob, TracePayload, + TraceRecord, TraceStageInsert, TraceStageItemInsert, TraceTrajectoryStageRecord, Uuid, Value, +}; + +pub(super) async fn handle_trace_job(db: &Db, job: &TraceOutboxJob) -> Result<()> { + let payload: TracePayload = serde_json::from_value(job.payload.clone())?; + let TracePayload { trace, items, candidates, stages } = payload; + let trace_id = trace.trace_id; + let expanded_queries_json = worker::encode_json(&trace.expanded_queries, "expanded_queries")?; + let allowed_scopes_json = worker::encode_json(&trace.allowed_scopes, "allowed_scopes")?; + let mut tx = db.pool.begin().await?; + + insert_trace_tx(&mut *tx, trace_id, &trace, expanded_queries_json, allowed_scopes_json).await?; + insert_trace_items_tx(&mut *tx, trace_id, items).await?; + insert_trace_stages_tx(&mut tx, trace_id, stages).await?; + insert_trace_candidates_tx(&mut *tx, trace_id, candidates).await?; + + tx.commit().await?; + + Ok(()) +} + +pub(super) async fn insert_trace_stages_tx( + executor: &mut PgConnection, + trace_id: Uuid, + stages: Vec, +) -> Result<()> { + if stages.is_empty() { + return Ok(()); + } + + let mut stage_inserts = Vec::with_capacity(stages.len()); + let mut item_inserts = Vec::new(); + + for stage in stages { + stage_inserts.push(TraceStageInsert { + stage_id: stage.stage_id, + stage_order: stage.stage_order as i32, + stage_name: stage.stage_name, + stage_payload: stage.stage_payload, + created_at: stage.created_at, + }); + + for item in stage.items { + item_inserts.push(TraceStageItemInsert { + id: item.id, + stage_id: stage.stage_id, + item_id: item.item_id, + note_id: item.note_id, + chunk_id: item.chunk_id, + metrics: item.metrics, + }); + } + } + + let mut stage_builder = QueryBuilder::new( + "\ + INSERT INTO search_trace_stages ( + stage_id, + trace_id, + stage_order, + stage_name, + stage_payload, + created_at + ) ", + ); + + stage_builder.push_values(stage_inserts, |mut b, stage| { + b.push_bind(stage.stage_id) + .push_bind(trace_id) + .push_bind(stage.stage_order) + .push_bind(stage.stage_name) + .push_bind(stage.stage_payload) + .push_bind(stage.created_at); + }); + stage_builder.push(" ON CONFLICT (stage_id) DO NOTHING"); + stage_builder.build().execute(&mut *executor).await?; + + if item_inserts.is_empty() { + return Ok(()); + } + + let mut item_builder = QueryBuilder::new( + "\ + INSERT INTO search_trace_stage_items ( + id, + stage_id, + item_id, + note_id, + chunk_id, + metrics + ) ", + ); + + item_builder.push_values(item_inserts, |mut b, item| { + b.push_bind(item.id) + .push_bind(item.stage_id) + .push_bind(item.item_id) + .push_bind(item.note_id) + .push_bind(item.chunk_id) + .push_bind(item.metrics); + }); + item_builder.push(" ON CONFLICT (id) DO NOTHING"); + item_builder.build().execute(executor).await?; + + Ok(()) +} + +pub(super) async fn insert_trace_tx<'e, E>( + executor: E, + trace_id: Uuid, + trace: &TraceRecord, + expanded_queries_json: Value, + allowed_scopes_json: Value, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "INSERT INTO search_traces ( + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + expansion_mode, + expanded_queries, + allowed_scopes, + candidate_count, + top_k, + config_snapshot, + trace_version, + created_at, + expires_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15 +) +ON CONFLICT (trace_id) DO NOTHING", + ) + .bind(trace_id) + .bind(trace.tenant_id.as_str()) + .bind(trace.project_id.as_str()) + .bind(trace.agent_id.as_str()) + .bind(trace.read_profile.as_str()) + .bind(trace.query.as_str()) + .bind(trace.expansion_mode.as_str()) + .bind(expanded_queries_json) + .bind(allowed_scopes_json) + .bind(trace.candidate_count as i32) + .bind(trace.top_k as i32) + .bind(trace.config_snapshot.clone()) + .bind(trace.trace_version) + .bind(trace.created_at) + .bind(trace.expires_at) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn insert_trace_items_tx<'e, E>( + executor: E, + trace_id: Uuid, + items: Vec, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + if items.is_empty() { + return Ok(()); + } + + let mut inserts = Vec::with_capacity(items.len()); + + for item in items { + inserts.push(TraceItemInsert { + item_id: item.item_id, + note_id: item.note_id, + chunk_id: item.chunk_id, + rank: item.rank as i32, + final_score: item.final_score, + explain: item.explain, + }); + } + + let mut builder = QueryBuilder::new( + "\ +INSERT INTO search_trace_items ( + item_id, + trace_id, + note_id, + chunk_id, + rank, + final_score, + explain +) ", + ); + + builder.push_values(inserts, |mut b, item| { + b.push_bind(item.item_id) + .push_bind(trace_id) + .push_bind(item.note_id) + .push_bind(item.chunk_id) + .push_bind(item.rank) + .push_bind(item.final_score) + .push_bind(item.explain); + }); + builder.push(" ON CONFLICT (item_id) DO NOTHING"); + builder.build().execute(executor).await?; + + Ok(()) +} + +pub(super) async fn insert_trace_candidates_tx<'e, E>( + executor: E, + trace_id: Uuid, + candidates: Vec, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + if candidates.is_empty() { + return Ok(()); + } + + let mut inserts = Vec::with_capacity(candidates.len()); + + for candidate in candidates { + inserts.push(TraceCandidateInsert { + candidate_id: candidate.candidate_id, + note_id: candidate.note_id, + chunk_id: candidate.chunk_id, + chunk_index: candidate.chunk_index, + snippet: candidate.snippet, + candidate_snapshot: candidate.candidate_snapshot, + retrieval_rank: candidate.retrieval_rank as i32, + rerank_score: candidate.rerank_score, + note_scope: candidate.note_scope, + note_importance: candidate.note_importance, + note_updated_at: candidate.note_updated_at, + note_hit_count: candidate.note_hit_count, + note_last_hit_at: candidate.note_last_hit_at, + created_at: candidate.created_at, + expires_at: candidate.expires_at, + }); + } + + let mut builder = QueryBuilder::new( + "\ +INSERT INTO search_trace_candidates ( + candidate_id, + trace_id, + note_id, + chunk_id, + chunk_index, + snippet, + candidate_snapshot, + retrieval_rank, + rerank_score, + note_scope, + note_importance, + note_updated_at, + note_hit_count, + note_last_hit_at, + created_at, + expires_at +) ", + ); + + builder.push_values(inserts, |mut b, candidate| { + b.push_bind(candidate.candidate_id) + .push_bind(trace_id) + .push_bind(candidate.note_id) + .push_bind(candidate.chunk_id) + .push_bind(candidate.chunk_index) + .push_bind(candidate.snippet) + .push_bind(candidate.candidate_snapshot) + .push_bind(candidate.retrieval_rank) + .push_bind(candidate.rerank_score) + .push_bind(candidate.note_scope) + .push_bind(candidate.note_importance) + .push_bind(candidate.note_updated_at) + .push_bind(candidate.note_hit_count) + .push_bind(candidate.note_last_hit_at) + .push_bind(candidate.created_at) + .push_bind(candidate.expires_at); + }); + builder.push(" ON CONFLICT (candidate_id) DO NOTHING"); + builder.build().execute(executor).await?; + + Ok(()) +} + +pub(super) async fn purge_expired_trace_candidates(db: &Db, now: OffsetDateTime) -> Result<()> { + let result = sqlx::query("DELETE FROM search_trace_candidates WHERE expires_at <= $1") + .bind(now) + .execute(&db.pool) + .await?; + + if result.rows_affected() > 0 { + tracing::info!(count = result.rows_affected(), "Purged expired search trace candidates."); + } + + Ok(()) +} + +pub(super) async fn purge_expired_traces(db: &Db, now: OffsetDateTime) -> Result<()> { + let result = sqlx::query("DELETE FROM search_traces WHERE expires_at <= $1") + .bind(now) + .execute(&db.pool) + .await?; + + if result.rows_affected() > 0 { + tracing::info!(count = result.rows_affected(), "Purged expired search traces."); + } + + Ok(()) +} + +pub(super) async fn purge_expired_cache(db: &Db, now: OffsetDateTime) -> Result<()> { + let result = sqlx::query("DELETE FROM llm_cache WHERE expires_at <= $1") + .bind(now) + .execute(&db.pool) + .await?; + + if result.rows_affected() > 0 { + tracing::info!(count = result.rows_affected(), "Purged expired LLM cache entries."); + } + + Ok(()) +} + +pub(super) async fn purge_expired_search_sessions(db: &Db, now: OffsetDateTime) -> Result<()> { + let result = sqlx::query("DELETE FROM search_sessions WHERE expires_at <= $1") + .bind(now) + .execute(&db.pool) + .await?; + + if result.rows_affected() > 0 { + tracing::info!(count = result.rows_affected(), "Purged expired search sessions."); + } + + Ok(()) +} diff --git a/apps/elf-worker/src/worker/types.rs b/apps/elf-worker/src/worker/types.rs new file mode 100644 index 00000000..bf55135b --- /dev/null +++ b/apps/elf-worker/src/worker/types.rs @@ -0,0 +1,192 @@ +use crate::worker::{ + ChunkingConfig, Db, Deserialize, EmbeddingProviderConfig, FromRow, OffsetDateTime, QdrantStore, + Tokenizer, Uuid, Value, +}; + +pub(super) type ProjectDocRefFields = (String, Option, Option, Option); + +pub(super) const POLL_INTERVAL_MS: i64 = 500; +pub(super) const CLAIM_LEASE_SECONDS: i64 = 30; +pub(super) const BASE_BACKOFF_MS: i64 = 500; +pub(super) const MAX_BACKOFF_MS: i64 = 30_000; +pub(super) const TRACE_CLEANUP_INTERVAL_SECONDS: i64 = 900; +pub(super) const TRACE_OUTBOX_LEASE_SECONDS: i64 = 30; +pub(super) const CONSOLIDATION_JOB_LEASE_SECONDS: i64 = 30; +pub(super) const MAX_OUTBOX_ERROR_CHARS: usize = 1_024; + +/// Shared runtime state used by the worker loop. +pub struct WorkerState { + /// Postgres storage handle. + pub db: Db, + /// Note-index Qdrant collection handle. + pub qdrant: QdrantStore, + /// Document-index Qdrant collection handle. + pub docs_qdrant: QdrantStore, + /// Embedding provider configuration. + pub embedding: EmbeddingProviderConfig, + /// Chunking configuration for notes and docs. + pub chunking: ChunkingConfig, + /// Tokenizer used for chunking operations. + pub tokenizer: Tokenizer, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TracePayload { + pub(super) trace: TraceRecord, + pub(super) items: Vec, + #[serde(default)] + pub(super) candidates: Vec, + #[serde(default)] + pub(super) stages: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TraceRecord { + pub(super) trace_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) query: String, + pub(super) expansion_mode: String, + pub(super) expanded_queries: Vec, + pub(super) allowed_scopes: Vec, + pub(super) candidate_count: u32, + pub(super) top_k: u32, + pub(super) config_snapshot: Value, + pub(super) trace_version: i32, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TraceItemRecord { + pub(super) item_id: Uuid, + pub(super) note_id: Uuid, + pub(super) chunk_id: Option, + pub(super) rank: u32, + pub(super) final_score: f32, + pub(super) explain: Value, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TraceCandidateRecord { + pub(super) candidate_id: Uuid, + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + #[serde(default)] + pub(super) chunk_index: i32, + #[serde(default)] + pub(super) snippet: String, + #[serde(default)] + pub(super) candidate_snapshot: Value, + pub(super) retrieval_rank: u32, + pub(super) rerank_score: f32, + pub(super) note_scope: String, + pub(super) note_importance: f32, + pub(super) note_updated_at: OffsetDateTime, + #[serde(default)] + pub(super) note_hit_count: i64, + pub(super) note_last_hit_at: Option, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TraceTrajectoryStageRecord { + pub(super) stage_id: Uuid, + pub(super) stage_order: u32, + pub(super) stage_name: String, + pub(super) stage_payload: Value, + pub(super) created_at: OffsetDateTime, + #[serde(default)] + pub(super) items: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TraceTrajectoryStageItemRecord { + pub(super) id: Uuid, + pub(super) item_id: Option, + pub(super) note_id: Option, + pub(super) chunk_id: Option, + pub(super) metrics: Value, +} + +pub(super) struct TraceItemInsert { + pub(super) item_id: Uuid, + pub(super) note_id: Uuid, + pub(super) chunk_id: Option, + pub(super) rank: i32, + pub(super) final_score: f32, + pub(super) explain: Value, +} + +pub(super) struct TraceCandidateInsert { + pub(super) candidate_id: Uuid, + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + pub(super) chunk_index: i32, + pub(super) snippet: String, + pub(super) candidate_snapshot: Value, + pub(super) retrieval_rank: i32, + pub(super) rerank_score: f32, + pub(super) note_scope: String, + pub(super) note_importance: f32, + pub(super) note_updated_at: OffsetDateTime, + pub(super) note_hit_count: i64, + pub(super) note_last_hit_at: Option, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +pub(super) struct TraceStageInsert { + pub(super) stage_id: Uuid, + pub(super) stage_order: i32, + pub(super) stage_name: String, + pub(super) stage_payload: Value, + pub(super) created_at: OffsetDateTime, +} + +pub(super) struct TraceStageItemInsert { + pub(super) id: Uuid, + pub(super) stage_id: Uuid, + pub(super) item_id: Option, + pub(super) note_id: Option, + pub(super) chunk_id: Option, + pub(super) metrics: Value, +} + +pub(super) struct ChunkRecord { + pub(super) chunk_id: Uuid, + pub(super) chunk_index: i32, + pub(super) start_offset: i32, + pub(super) end_offset: i32, + pub(super) text: String, +} + +#[derive(Debug, FromRow)] +pub(super) struct NoteFieldRow { + pub(super) field_id: Uuid, + pub(super) text: String, +} + +#[derive(Debug, FromRow)] +pub(super) struct DocChunkIndexRow { + pub(super) doc_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) scope: String, + pub(super) doc_type: String, + pub(super) status: String, + pub(super) created_at: OffsetDateTime, + pub(super) updated_at: OffsetDateTime, + pub(super) content_hash: String, + pub(super) source_ref: Value, + pub(super) chunk_id: Uuid, + pub(super) chunk_index: i32, + pub(super) start_offset: i32, + pub(super) end_offset: i32, + pub(super) chunk_text: String, + pub(super) chunk_hash: String, +} diff --git a/packages/elf-config/src/lib.rs b/packages/elf-config/src/lib.rs index bf865c3a..8c0a881f 100644 --- a/packages/elf-config/src/lib.rs +++ b/packages/elf-config/src/lib.rs @@ -1,10 +1,13 @@ //! ELF configuration loading and validation. mod error; +mod loader; mod types; +mod validation; pub use self::{ error::{Error, Result}, + loader::load, types::{ Chunking, Config, Context, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, McpContext, Memory, MemoryPolicy, MemoryPolicyRule, Postgres, ProviderConfig, Providers, @@ -15,767 +18,5 @@ pub use self::{ SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, SecurityAuthKey, SecurityAuthRole, Service, Storage, TtlDays, }, + validation::validate, }; - -use std::{collections::HashSet, fs, path::Path}; - -/// Loads, deserializes, and validates an ELF TOML configuration file. -pub fn load(path: &Path) -> Result { - let raw = fs::read_to_string(path) - .map_err(|err| Error::ReadConfig { path: path.to_path_buf(), source: err })?; - let cfg: Config = toml::from_str(&raw) - .map_err(|err| Error::ParseConfig { path: path.to_path_buf(), source: err })?; - - validate(&cfg)?; - - Ok(cfg) -} - -/// Validates a deserialized ELF configuration against repository runtime rules. -pub fn validate(cfg: &Config) -> Result<()> { - validate_security(cfg)?; - validate_service(cfg)?; - validate_storage(cfg)?; - validate_providers(cfg)?; - validate_memory(cfg)?; - validate_search(cfg)?; - validate_ranking(cfg)?; - validate_chunking(cfg)?; - validate_context(cfg)?; - validate_mcp(cfg)?; - validate_search_graph_context(cfg)?; - - Ok(()) -} - -fn validate_storage(cfg: &Config) -> Result<()> { - if cfg.storage.postgres.dsn.trim().is_empty() { - return Err(Error::Validation { - message: "storage.postgres.dsn must be non-empty.".to_string(), - }); - } - if cfg.storage.qdrant.url.trim().is_empty() { - return Err(Error::Validation { - message: "storage.qdrant.url must be non-empty.".to_string(), - }); - } - if cfg.storage.qdrant.collection.trim().is_empty() { - return Err(Error::Validation { - message: "storage.qdrant.collection must be non-empty.".to_string(), - }); - } - if cfg.storage.qdrant.docs_collection.trim().is_empty() { - return Err(Error::Validation { - message: "storage.qdrant.docs_collection must be non-empty.".to_string(), - }); - } - if cfg.storage.qdrant.vector_dim == 0 { - return Err(Error::Validation { - message: "storage.qdrant.vector_dim must be greater than zero.".to_string(), - }); - } - - Ok(()) -} - -fn validate_memory(cfg: &Config) -> Result<()> { - let mut seen_rules = HashSet::new(); - - for (idx, rule) in cfg.memory.policy.rules.iter().enumerate() { - let path = format!("memory.policy.rules[{idx}]"); - - if let Some(note_type) = rule.note_type.as_ref() { - if note_type.trim().is_empty() { - return Err(Error::Validation { - message: format!("{path}.note_type cannot be blank or whitespace-only."), - }); - } - if !matches!( - note_type.as_str(), - "preference" | "constraint" | "decision" | "profile" | "fact" | "plan" - ) { - return Err(Error::Validation { - message: format!( - "{path}.note_type must be one of preference, constraint, decision, profile, fact, or plan." - ), - }); - } - } - if let Some(scope) = rule.scope.as_ref() { - if scope.trim().is_empty() { - return Err(Error::Validation { - message: format!("{path}.scope cannot be blank or whitespace-only."), - }); - } - if !cfg.scopes.allowed.iter().any(|allowed_scope| allowed_scope == scope) { - return Err(Error::Validation { - message: format!("{path}.scope must be one of allowed scopes."), - }); - } - } - if let Some(min_confidence) = rule.min_confidence { - if !min_confidence.is_finite() { - return Err(Error::Validation { - message: format!("{path}.min_confidence must be a finite number."), - }); - } - if !(0.0..=1.0).contains(&min_confidence) { - return Err(Error::Validation { - message: format!("{path}.min_confidence must be between 0.0 and 1.0."), - }); - } - } - if let Some(min_importance) = rule.min_importance { - if !min_importance.is_finite() { - return Err(Error::Validation { - message: format!("{path}.min_importance must be a finite number."), - }); - } - if !(0.0..=1.0).contains(&min_importance) { - return Err(Error::Validation { - message: format!("{path}.min_importance must be between 0.0 and 1.0."), - }); - } - } - - let rule_key = (rule.note_type.clone(), rule.scope.clone()); - - if !seen_rules.insert(rule_key) { - return Err(Error::Validation { - message: format!("{path} has a duplicate note_type and scope pair."), - }); - } - } - - Ok(()) -} - -fn validate_security(cfg: &Config) -> Result<()> { - if !cfg.security.reject_non_english { - return Err(Error::Validation { - message: "security.reject_non_english must be true.".to_string(), - }); - } - - let auth_mode = cfg.security.auth_mode.trim(); - - if !matches!(auth_mode, "off" | "static_keys") { - return Err(Error::Validation { - message: "security.auth_mode must be one of off or static_keys.".to_string(), - }); - } - if auth_mode == "off" { - if !cfg.security.auth_keys.is_empty() { - return Err(Error::Validation { - message: "security.auth_keys must be empty when security.auth_mode is off." - .to_string(), - }); - } - - return Ok(()); - } - if cfg.security.auth_keys.is_empty() { - return Err(Error::Validation { - message: "security.auth_keys must be non-empty when security.auth_mode is static_keys." - .to_string(), - }); - } - - let mut token_ids = HashSet::new(); - let mut tokens = HashSet::new(); - - for (idx, key) in cfg.security.auth_keys.iter().enumerate() { - let path = format!("security.auth_keys[{idx}]"); - - if key.token_id.trim().is_empty() { - return Err(Error::Validation { - message: format!("{path}.token_id must be non-empty."), - }); - } - if key.token.trim().is_empty() { - return Err(Error::Validation { message: format!("{path}.token must be non-empty.") }); - } - if key.tenant_id.trim().is_empty() { - return Err(Error::Validation { - message: format!("{path}.tenant_id must be non-empty."), - }); - } - if key.project_id.trim().is_empty() { - return Err(Error::Validation { - message: format!("{path}.project_id must be non-empty."), - }); - } - if key.read_profile.trim().is_empty() { - return Err(Error::Validation { - message: format!("{path}.read_profile must be non-empty."), - }); - } - if !matches!( - key.read_profile.as_str(), - "private_only" | "private_plus_project" | "all_scopes" - ) { - return Err(Error::Validation { - message: format!( - "{path}.read_profile must be one of private_only, private_plus_project, or all_scopes." - ), - }); - } - - if let Some(agent_id) = key.agent_id.as_ref() - && agent_id.trim().is_empty() - { - return Err(Error::Validation { - message: format!("{path}.agent_id must be non-empty when provided."), - }); - } - - if key.agent_id.as_ref().map(|agent_id| agent_id.trim().is_empty()).unwrap_or(true) { - return Err(Error::Validation { - message: format!( - "{path}.agent_id is required when security.auth_mode is static_keys." - ), - }); - } - if !token_ids.insert(key.token_id.as_str()) { - return Err(Error::Validation { - message: format!("{path}.token_id must be unique across security.auth_keys."), - }); - } - if !tokens.insert(key.token.as_str()) { - return Err(Error::Validation { - message: format!("{path}.token must be unique across security.auth_keys."), - }); - } - } - - Ok(()) -} - -fn validate_service(cfg: &Config) -> Result<()> { - if cfg.service.mcp_bind.trim().is_empty() { - return Err(Error::Validation { - message: "service.mcp_bind must be non-empty.".to_string(), - }); - } - - Ok(()) -} - -fn validate_providers(cfg: &Config) -> Result<()> { - if cfg.providers.embedding.dimensions == 0 { - return Err(Error::Validation { - message: "providers.embedding.dimensions must be greater than zero.".to_string(), - }); - } - if cfg.providers.embedding.dimensions != cfg.storage.qdrant.vector_dim { - return Err(Error::Validation { - message: "providers.embedding.dimensions must match storage.qdrant.vector_dim." - .to_string(), - }); - } - - for (label, key) in [ - ("embedding", &cfg.providers.embedding.api_key), - ("rerank", &cfg.providers.rerank.api_key), - ("llm_extractor", &cfg.providers.llm_extractor.api_key), - ] { - if key.trim().is_empty() { - return Err(Error::Validation { - message: format!("Provider {label} api_key must be non-empty."), - }); - } - } - - Ok(()) -} - -fn validate_search(cfg: &Config) -> Result<()> { - validate_search_expansion(cfg)?; - validate_search_dynamic(cfg)?; - validate_search_cache(cfg)?; - validate_search_explain(cfg)?; - validate_search_explain_write_mode(cfg)?; - validate_search_recursive(cfg)?; - - Ok(()) -} - -fn validate_search_expansion(cfg: &Config) -> Result<()> { - let expansion_mode = cfg.search.expansion.mode.as_str(); - - if !matches!(expansion_mode, "off" | "always" | "dynamic") { - return Err(Error::Validation { - message: "search.expansion.mode must be one of off, always, or dynamic.".to_string(), - }); - } - if cfg.search.expansion.max_queries == 0 { - return Err(Error::Validation { - message: "search.expansion.max_queries must be greater than zero.".to_string(), - }); - } - - Ok(()) -} - -fn validate_search_dynamic(cfg: &Config) -> Result<()> { - if cfg.search.dynamic.min_candidates == 0 { - return Err(Error::Validation { - message: "search.dynamic.min_candidates must be greater than zero.".to_string(), - }); - } - if cfg.search.dynamic.min_top_score < 0.0 { - return Err(Error::Validation { - message: "search.dynamic.min_top_score must be zero or greater.".to_string(), - }); - } - - Ok(()) -} - -fn validate_search_cache(cfg: &Config) -> Result<()> { - if cfg.search.cache.expansion_ttl_days <= 0 { - return Err(Error::Validation { - message: "search.cache.expansion_ttl_days must be greater than zero.".to_string(), - }); - } - if cfg.search.cache.rerank_ttl_days <= 0 { - return Err(Error::Validation { - message: "search.cache.rerank_ttl_days must be greater than zero.".to_string(), - }); - } - - if let Some(max) = cfg.search.cache.max_payload_bytes - && max == 0 - { - return Err(Error::Validation { - message: "search.cache.max_payload_bytes must be greater than zero.".to_string(), - }); - } - - Ok(()) -} - -fn validate_search_explain(cfg: &Config) -> Result<()> { - if cfg.search.explain.retention_days <= 0 { - return Err(Error::Validation { - message: "search.explain.retention_days must be greater than zero.".to_string(), - }); - } - if cfg.search.explain.candidate_retention_days <= 0 { - return Err(Error::Validation { - message: "search.explain.candidate_retention_days must be greater than zero." - .to_string(), - }); - } - if cfg.search.explain.candidate_retention_days > cfg.search.explain.retention_days { - return Err(Error::Validation { - message: - "search.explain.candidate_retention_days must be less than or equal to search.explain.retention_days." - .to_string(), - }); - } - - Ok(()) -} - -fn validate_search_explain_write_mode(cfg: &Config) -> Result<()> { - match cfg.search.explain.write_mode.trim().to_ascii_lowercase().as_str() { - "outbox" | "inline" => Ok(()), - other => Err(Error::Validation { - message: format!( - "search.explain.write_mode must be one of: outbox, inline. Got {other}." - ), - }), - } -} - -fn validate_search_recursive(cfg: &Config) -> Result<()> { - if !cfg.search.recursive.enabled { - return Ok(()); - } - if cfg.search.recursive.max_depth == 0 { - return Err(Error::Validation { - message: "search.recursive.max_depth must be greater than zero.".to_string(), - }); - } - if cfg.search.recursive.max_depth > 8 { - return Err(Error::Validation { - message: "search.recursive.max_depth must be 8 or less.".to_string(), - }); - } - if cfg.search.recursive.max_children_per_node == 0 { - return Err(Error::Validation { - message: "search.recursive.max_children_per_node must be greater than zero." - .to_string(), - }); - } - if cfg.search.recursive.max_children_per_node > 64 { - return Err(Error::Validation { - message: "search.recursive.max_children_per_node must be 64 or less.".to_string(), - }); - } - if cfg.search.recursive.max_nodes_per_scope == 0 { - return Err(Error::Validation { - message: "search.recursive.max_nodes_per_scope must be greater than zero.".to_string(), - }); - } - if cfg.search.recursive.max_nodes_per_scope > 250 { - return Err(Error::Validation { - message: "search.recursive.max_nodes_per_scope must be 250 or less.".to_string(), - }); - } - if cfg.search.recursive.max_total_nodes == 0 { - return Err(Error::Validation { - message: "search.recursive.max_total_nodes must be greater than zero.".to_string(), - }); - } - if cfg.search.recursive.max_total_nodes > 2_000 { - return Err(Error::Validation { - message: "search.recursive.max_total_nodes must be 2_000 or less.".to_string(), - }); - } - if cfg.search.recursive.max_total_nodes < cfg.search.recursive.max_nodes_per_scope { - return Err(Error::Validation { - message: - "search.recursive.max_total_nodes must be at least search.recursive.max_nodes_per_scope." - .to_string(), - }); - } - - Ok(()) -} - -fn validate_search_graph_context(cfg: &Config) -> Result<()> { - if !cfg.search.graph_context.enabled { - return Ok(()); - } - - let ctx = &cfg.search.graph_context; - - if ctx.max_facts_per_item == 0 { - return Err(Error::Validation { - message: "search.graph_context.max_facts_per_item must be greater than zero." - .to_string(), - }); - } - if ctx.max_facts_per_item > 1_000 { - return Err(Error::Validation { - message: "search.graph_context.max_facts_per_item must be 1,000 or less.".to_string(), - }); - } - if ctx.max_evidence_notes_per_fact == 0 { - return Err(Error::Validation { - message: "search.graph_context.max_evidence_notes_per_fact must be greater than zero." - .to_string(), - }); - } - if ctx.max_evidence_notes_per_fact > 1_000 { - return Err(Error::Validation { - message: "search.graph_context.max_evidence_notes_per_fact must be 1,000 or less." - .to_string(), - }); - } - - Ok(()) -} - -fn validate_ranking(cfg: &Config) -> Result<()> { - validate_ranking_core(cfg)?; - validate_ranking_blend(cfg)?; - validate_ranking_diversity(cfg)?; - validate_ranking_retrieval_sources(cfg)?; - validate_ranking_deterministic(cfg)?; - - Ok(()) -} - -fn validate_ranking_core(cfg: &Config) -> Result<()> { - if cfg.ranking.tie_breaker_weight < 0.0 { - return Err(Error::Validation { - message: "ranking.tie_breaker_weight must be zero or greater.".to_string(), - }); - } - if !cfg.ranking.tie_breaker_weight.is_finite() { - return Err(Error::Validation { - message: "ranking.tie_breaker_weight must be a finite number.".to_string(), - }); - } - if cfg.ranking.recency_tau_days < 0.0 { - return Err(Error::Validation { - message: "ranking.recency_tau_days must be zero or greater.".to_string(), - }); - } - if !cfg.ranking.recency_tau_days.is_finite() { - return Err(Error::Validation { - message: "ranking.recency_tau_days must be a finite number.".to_string(), - }); - } - - Ok(()) -} - -fn validate_ranking_blend(cfg: &Config) -> Result<()> { - if !cfg.ranking.blend.enabled { - return Ok(()); - } - if cfg.ranking.blend.segments.is_empty() { - return Err(Error::Validation { - message: "ranking.blend.segments must be non-empty when enabled.".to_string(), - }); - } - - for segment in &cfg.ranking.blend.segments { - if !segment.retrieval_weight.is_finite() { - return Err(Error::Validation { - message: "ranking.blend.segments.retrieval_weight must be a finite number." - .to_string(), - }); - } - if !(0.0..=1.0).contains(&segment.retrieval_weight) { - return Err(Error::Validation { - message: "ranking.blend.segments.retrieval_weight must be in the range 0.0-1.0." - .to_string(), - }); - } - if segment.max_retrieval_rank == 0 { - return Err(Error::Validation { - message: "ranking.blend.segments.max_retrieval_rank must be greater than zero." - .to_string(), - }); - } - } - - Ok(()) -} - -fn validate_ranking_diversity(cfg: &Config) -> Result<()> { - let diversity = &cfg.ranking.diversity; - - if !diversity.sim_threshold.is_finite() { - return Err(Error::Validation { - message: "ranking.diversity.sim_threshold must be a finite number.".to_string(), - }); - } - if !(0.0..=1.0).contains(&diversity.sim_threshold) { - return Err(Error::Validation { - message: "ranking.diversity.sim_threshold must be in the range 0.0-1.0.".to_string(), - }); - } - if !diversity.mmr_lambda.is_finite() { - return Err(Error::Validation { - message: "ranking.diversity.mmr_lambda must be a finite number.".to_string(), - }); - } - if !(0.0..=1.0).contains(&diversity.mmr_lambda) { - return Err(Error::Validation { - message: "ranking.diversity.mmr_lambda must be in the range 0.0-1.0.".to_string(), - }); - } - - Ok(()) -} - -fn validate_ranking_retrieval_sources(cfg: &Config) -> Result<()> { - let retrieval_sources = &cfg.ranking.retrieval_sources; - - for (path, value) in [ - ("ranking.retrieval_sources.fusion_weight", retrieval_sources.fusion_weight), - ( - "ranking.retrieval_sources.structured_field_weight", - retrieval_sources.structured_field_weight, - ), - ] { - if !value.is_finite() { - return Err(Error::Validation { message: format!("{path} must be a finite number.") }); - } - if value < 0.0 { - return Err(Error::Validation { message: format!("{path} must be zero or greater.") }); - } - } - - if retrieval_sources.fusion_weight <= 0.0 && retrieval_sources.structured_field_weight <= 0.0 { - return Err(Error::Validation { - message: "At least one retrieval source weight must be greater than zero.".to_string(), - }); - } - - Ok(()) -} - -fn validate_ranking_deterministic(cfg: &Config) -> Result<()> { - let det = &cfg.ranking.deterministic; - let det_lex = &det.lexical; - let det_hits = &det.hits; - let det_decay = &det.decay; - - for (path, weight) in [ - ("ranking.deterministic.lexical", det_lex.weight), - ("ranking.deterministic.hits", det_hits.weight), - ("ranking.deterministic.decay", det_decay.weight), - ] { - if weight < 0.0 { - return Err(Error::Validation { - message: format!("{path}.weight must be zero or greater."), - }); - } - if !weight.is_finite() { - return Err(Error::Validation { - message: format!("{path}.weight must be a finite number."), - }); - } - } - - if det.enabled && det_lex.enabled { - if !det_lex.min_ratio.is_finite() { - return Err(Error::Validation { - message: "ranking.deterministic.lexical.min_ratio must be a finite number." - .to_string(), - }); - } - if !(0.0..=1.0).contains(&det_lex.min_ratio) { - return Err(Error::Validation { - message: "ranking.deterministic.lexical.min_ratio must be in the range 0.0-1.0." - .to_string(), - }); - } - if det_lex.max_query_terms == 0 { - return Err(Error::Validation { - message: "ranking.deterministic.lexical.max_query_terms must be greater than zero." - .to_string(), - }); - } - if det_lex.max_text_terms == 0 { - return Err(Error::Validation { - message: "ranking.deterministic.lexical.max_text_terms must be greater than zero." - .to_string(), - }); - } - } - if det.enabled && det_hits.enabled { - if !det_hits.half_saturation.is_finite() { - return Err(Error::Validation { - message: "ranking.deterministic.hits.half_saturation must be a finite number." - .to_string(), - }); - } - if det_hits.half_saturation <= 0.0 { - return Err(Error::Validation { - message: "ranking.deterministic.hits.half_saturation must be greater than zero." - .to_string(), - }); - } - if !det_hits.last_hit_tau_days.is_finite() { - return Err(Error::Validation { - message: "ranking.deterministic.hits.last_hit_tau_days must be a finite number." - .to_string(), - }); - } - if det_hits.last_hit_tau_days < 0.0 { - return Err(Error::Validation { - message: "ranking.deterministic.hits.last_hit_tau_days must be zero or greater." - .to_string(), - }); - } - } - if det.enabled && det_decay.enabled { - if !det_decay.tau_days.is_finite() { - return Err(Error::Validation { - message: "ranking.deterministic.decay.tau_days must be a finite number." - .to_string(), - }); - } - if det_decay.tau_days <= 0.0 { - return Err(Error::Validation { - message: "ranking.deterministic.decay.tau_days must be greater than zero." - .to_string(), - }); - } - } - - Ok(()) -} - -fn validate_chunking(cfg: &Config) -> Result<()> { - if !cfg.chunking.enabled { - return Err(Error::Validation { message: "chunking.enabled must be true.".to_string() }); - } - if cfg.chunking.tokenizer_repo.trim().is_empty() { - return Err(Error::Validation { - message: "chunking.tokenizer_repo must be a non-empty string.".to_string(), - }); - } - if cfg.chunking.max_tokens == 0 { - return Err(Error::Validation { - message: "chunking.max_tokens must be greater than zero.".to_string(), - }); - } - if cfg.chunking.overlap_tokens >= cfg.chunking.max_tokens { - return Err(Error::Validation { - message: "chunking.overlap_tokens must be less than chunking.max_tokens.".to_string(), - }); - } - - Ok(()) -} - -fn validate_context(cfg: &Config) -> Result<()> { - if let Some(context) = cfg.context.as_ref() - && let Some(weight) = context.scope_boost_weight - { - if !weight.is_finite() { - return Err(Error::Validation { - message: "context.scope_boost_weight must be a finite number.".to_string(), - }); - } - if weight < 0.0 { - return Err(Error::Validation { - message: "context.scope_boost_weight must be zero or greater.".to_string(), - }); - } - if weight > 1.0 { - return Err(Error::Validation { - message: "context.scope_boost_weight must be 1.0 or less.".to_string(), - }); - } - if weight > 0.0 - && context - .scope_descriptions - .as_ref() - .map(|descriptions| descriptions.is_empty()) - .unwrap_or(true) - { - return Err(Error::Validation { - message: "context.scope_descriptions must be non-empty when context.scope_boost_weight is greater than zero." - .to_string(), - }); - } - } - - Ok(()) -} - -fn validate_mcp(cfg: &Config) -> Result<()> { - let Some(mcp) = cfg.mcp.as_ref() else { return Ok(()) }; - - for (label, value) in [ - ("mcp.tenant_id", &mcp.tenant_id), - ("mcp.project_id", &mcp.project_id), - ("mcp.agent_id", &mcp.agent_id), - ("mcp.read_profile", &mcp.read_profile), - ] { - if value.trim().is_empty() { - return Err(Error::Validation { message: format!("{label} must be non-empty.") }); - } - } - - if !matches!(mcp.read_profile.as_str(), "private_only" | "private_plus_project" | "all_scopes") - { - return Err(Error::Validation { - message: - "mcp.read_profile must be one of private_only, private_plus_project, or all_scopes." - .to_string(), - }); - } - - Ok(()) -} diff --git a/packages/elf-config/src/loader.rs b/packages/elf-config/src/loader.rs new file mode 100644 index 00000000..8f690f11 --- /dev/null +++ b/packages/elf-config/src/loader.rs @@ -0,0 +1,15 @@ +use std::{fs, path::Path}; + +use crate::{Config, Error, Result, validation}; + +/// Loads, deserializes, and validates an ELF TOML configuration file. +pub fn load(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .map_err(|err| Error::ReadConfig { path: path.to_path_buf(), source: err })?; + let cfg: Config = toml::from_str(&raw) + .map_err(|err| Error::ParseConfig { path: path.to_path_buf(), source: err })?; + + validation::validate(&cfg)?; + + Ok(cfg) +} diff --git a/packages/elf-config/src/types.rs b/packages/elf-config/src/types.rs index ff7144e0..e8d30777 100644 --- a/packages/elf-config/src/types.rs +++ b/packages/elf-config/src/types.rs @@ -1,7 +1,37 @@ -use std::collections::HashMap; +mod chunking; +mod context; +mod lifecycle; +mod memory; +mod providers; +mod ranking; +mod scopes; +mod search; +mod security; +mod service; +mod storage; + +pub use self::{ + chunking::Chunking, + context::{Context, McpContext}, + lifecycle::{Lifecycle, TtlDays}, + memory::{Memory, MemoryPolicy, MemoryPolicyRule}, + providers::{EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig, Providers}, + ranking::{ + Ranking, RankingBlend, RankingBlendSegment, RankingDeterministic, + RankingDeterministicDecay, RankingDeterministicHits, RankingDeterministicLexical, + RankingDiversity, RankingRetrievalSources, + }, + scopes::{ReadProfiles, ScopePrecedence, ScopeWriteAllowed, Scopes}, + search::{ + Search, SearchCache, SearchDynamic, SearchExpansion, SearchExplain, SearchGraphContext, + SearchPrefilter, SearchRecursive, + }, + security::{Security, SecurityAuthKey, SecurityAuthRole}, + service::Service, + storage::{Postgres, Qdrant, Storage}, +}; use serde::Deserialize; -use serde_json::{Map, Value}; /// Complete ELF runtime configuration loaded from `elf.toml`. #[derive(Debug, Deserialize)] @@ -31,538 +61,3 @@ pub struct Config { /// Optional MCP forwarding context used by `elf-mcp`. pub mcp: Option, } - -/// Optional metadata used to improve retrieval disambiguation across projects and scopes. -#[derive(Debug, Deserialize)] -pub struct Context { - /// Optional. Map keys are either ":" or "". - pub project_descriptions: Option>, - /// Optional. Map keys are scope labels, e.g. "project_shared". - pub scope_descriptions: Option>, - /// Optional. Additive boost applied to final scores when a query's tokens match a scope - /// description. - pub scope_boost_weight: Option, -} - -/// Static forwarding context attached by `elf-mcp` to proxied requests. -#[derive(Clone, Debug, Deserialize)] -pub struct McpContext { - /// Tenant identifier attached to proxied MCP requests. - pub tenant_id: String, - /// Project identifier attached to proxied MCP requests. - pub project_id: String, - /// Agent identifier attached to proxied MCP requests. - pub agent_id: String, - /// Read profile attached to proxied MCP requests. - pub read_profile: String, -} - -/// Bind addresses and logging settings for ELF services. -#[derive(Debug, Deserialize)] -pub struct Service { - /// Bind address for the public HTTP API. - pub http_bind: String, - /// Bind address for the MCP server entrypoint. - pub mcp_bind: String, - /// Bind address for the admin HTTP API. - pub admin_bind: String, - /// Default service log level. - pub log_level: String, -} - -/// Storage backend configuration for persisted note and document data. -#[derive(Debug, Deserialize)] -pub struct Storage { - /// Postgres source-of-truth settings. - pub postgres: Postgres, - /// Qdrant derived-index settings. - pub qdrant: Qdrant, -} - -/// Postgres connection settings. -#[derive(Debug, Deserialize)] -pub struct Postgres { - /// Postgres DSN used by ELF services. - pub dsn: String, - /// Maximum number of pooled Postgres connections. - pub pool_max_conns: u32, -} - -/// Qdrant collection settings for note and document vectors. -#[derive(Debug, Deserialize)] -pub struct Qdrant { - /// Qdrant base URL used by clients in this workspace. - pub url: String, - /// Primary notes collection name. - pub collection: String, - /// Document-chunk collection name. - pub docs_collection: String, - /// Vector dimension expected by both note and document collections. - pub vector_dim: u32, -} - -/// Provider configuration bundle for all external model calls. -#[derive(Debug, Deserialize)] -pub struct Providers { - /// Embedding provider used for vector generation. - pub embedding: EmbeddingProviderConfig, - /// Rerank provider used for late-stage scoring. - pub rerank: ProviderConfig, - /// LLM provider used by extraction flows such as `add_event`. - pub llm_extractor: LlmProviderConfig, -} - -/// Embedding-provider settings. -#[derive(Debug, Deserialize)] -pub struct EmbeddingProviderConfig { - /// Provider implementation identifier. - pub provider_id: String, - /// Base URL for embedding API requests. - pub api_base: String, - /// Non-empty API key for embedding requests. - pub api_key: String, - /// Request path appended to `api_base`. - pub path: String, - /// Embedding model identifier. - pub model: String, - /// Expected embedding vector dimension. - pub dimensions: u32, - /// Request timeout in milliseconds. - pub timeout_ms: u64, - /// Extra HTTP headers sent with embedding requests. - pub default_headers: Map, -} - -/// Generic provider settings shared by non-embedding APIs such as rerank. -#[derive(Debug, Deserialize)] -pub struct ProviderConfig { - /// Provider implementation identifier. - pub provider_id: String, - /// Base URL for provider API requests. - pub api_base: String, - /// Non-empty API key for provider requests. - pub api_key: String, - /// Request path appended to `api_base`. - pub path: String, - /// Provider model identifier. - pub model: String, - /// Request timeout in milliseconds. - pub timeout_ms: u64, - /// Extra HTTP headers sent with provider requests. - pub default_headers: Map, -} - -/// LLM extractor provider settings. -#[derive(Debug, Deserialize)] -pub struct LlmProviderConfig { - /// Provider implementation identifier. - pub provider_id: String, - /// Base URL for extraction API requests. - pub api_base: String, - /// Non-empty API key for extraction requests. - pub api_key: String, - /// Request path appended to `api_base`. - pub path: String, - /// LLM model identifier. - pub model: String, - /// Sampling temperature for extraction requests. - pub temperature: f32, - /// Request timeout in milliseconds. - pub timeout_ms: u64, - /// Extra HTTP headers sent with extraction requests. - pub default_headers: Map, -} - -/// Scope labels and access policy used by memory operations. -#[derive(Debug, Deserialize)] -pub struct Scopes { - /// All scope labels allowed by this deployment. - pub allowed: Vec, - /// Scope sets referenced by named read profiles. - pub read_profiles: ReadProfiles, - /// Relative precedence used when multiple scopes are eligible. - pub precedence: ScopePrecedence, - /// Scope-level write permissions. - pub write_allowed: ScopeWriteAllowed, -} - -/// Scope lists used by named read profiles. -#[derive(Debug, Deserialize)] -pub struct ReadProfiles { - /// Scope set for `private_only`. - pub private_only: Vec, - /// Scope set for `private_plus_project`. - pub private_plus_project: Vec, - /// Scope set for `all_scopes`. - pub all_scopes: Vec, -} - -/// Integer precedence used to break ties between scope classes. -#[derive(Debug, Deserialize)] -pub struct ScopePrecedence { - /// Precedence assigned to `agent_private`. - pub agent_private: i32, - /// Precedence assigned to `project_shared`. - pub project_shared: i32, - /// Precedence assigned to `org_shared`. - pub org_shared: i32, -} - -/// Scope-level write toggles. -#[derive(Debug, Deserialize)] -pub struct ScopeWriteAllowed { - /// Whether writes to `agent_private` are allowed. - pub agent_private: bool, - /// Whether writes to `project_shared` are allowed. - pub project_shared: bool, - /// Whether writes to `org_shared` are allowed. - pub org_shared: bool, -} - -/// Write-path limits and policy controls for note ingestion. -#[derive(Debug, Deserialize)] -pub struct Memory { - /// Maximum number of notes accepted per `add_event` request. - pub max_notes_per_add_event: u32, - /// Maximum character length for an individual note. - pub max_note_chars: u32, - /// Similarity threshold for duplicate detection. - pub dup_sim_threshold: f32, - /// Similarity threshold for update-vs-insert decisions. - pub update_sim_threshold: f32, - /// Candidate pool size used before final top-k selection. - pub candidate_k: u32, - /// Final top-k size for note retrieval. - pub top_k: u32, - /// Optional downgrade rules applied after base memory decisions. - pub policy: MemoryPolicy, -} - -/// Collection of memory-policy downgrade rules. -#[derive(Debug, Deserialize)] -pub struct MemoryPolicy { - /// Ordered policy rules evaluated against note type, scope, and scores. - pub rules: Vec, -} - -/// A single memory-policy rule matched by note metadata and confidence/importance thresholds. -#[derive(Debug, Default, Deserialize)] -pub struct MemoryPolicyRule { - /// Optional note type selector. - pub note_type: Option, - /// Optional scope selector. - pub scope: Option, - /// Optional minimum confidence required for the rule to match. - pub min_confidence: Option, - /// Optional minimum importance required for the rule to match. - pub min_importance: Option, -} - -/// Sentence-aware token chunking settings. -#[derive(Debug, Deserialize)] -pub struct Chunking { - /// Whether chunking support is enabled. - pub enabled: bool, - /// Maximum tokens allowed in one chunk. - pub max_tokens: u32, - /// Number of tail tokens overlapped into the next chunk. - pub overlap_tokens: u32, - /// Hugging Face tokenizer repo used for token counting. - pub tokenizer_repo: String, -} - -/// Query-time search settings. -#[derive(Debug, Deserialize)] -pub struct Search { - /// Query expansion behavior. - pub expansion: SearchExpansion, - /// Dynamic-expansion trigger thresholds. - pub dynamic: SearchDynamic, - /// Prefilter candidate cap. - pub prefilter: SearchPrefilter, - /// Search cache settings. - pub cache: SearchCache, - /// Explainability retention settings. - pub explain: SearchExplain, - /// Recursive retrieval traversal settings. - pub recursive: SearchRecursive, - /// Graph-context enrichment settings. - pub graph_context: SearchGraphContext, -} - -/// Query expansion settings. -#[derive(Debug, Deserialize)] -pub struct SearchExpansion { - /// Expansion mode such as `off`, `always`, or `dynamic`. - pub mode: String, - /// Maximum number of expansion queries emitted. - pub max_queries: u32, - /// Whether the original query is retained alongside expansions. - pub include_original: bool, -} - -/// Thresholds that determine when dynamic expansion is activated. -#[derive(Debug, Deserialize)] -pub struct SearchDynamic { - /// Minimum initial candidate count before dynamic expansion is skipped. - pub min_candidates: u32, - /// Minimum top score before dynamic expansion is skipped. - pub min_top_score: f32, -} - -/// Candidate prefilter settings. -#[derive(Debug, Deserialize)] -pub struct SearchPrefilter { - /// Maximum number of candidates kept before later stages. - pub max_candidates: u32, -} - -/// Cache settings for expansion and rerank outputs. -#[derive(Debug, Deserialize)] -pub struct SearchCache { - /// Whether search caching is enabled. - pub enabled: bool, - /// TTL in days for cached expansion outputs. - pub expansion_ttl_days: i64, - /// TTL in days for cached rerank outputs. - pub rerank_ttl_days: i64, - /// Optional upper bound on cached payload size in bytes. - pub max_payload_bytes: Option, -} - -/// Search explainability retention and write-path settings. -#[derive(Debug, Deserialize)] -pub struct SearchExplain { - /// Retention window for explain rows in days. - pub retention_days: i64, - /// Whether candidate snapshots are captured. - pub capture_candidates: bool, - /// Retention window for candidate snapshots in days. - pub candidate_retention_days: i64, - /// Explainability write mode. - pub write_mode: String, -} - -/// Recursive retrieval traversal limits. -#[derive(Debug, Deserialize)] -pub struct SearchRecursive { - /// Whether recursive retrieval is enabled. - pub enabled: bool, - /// Maximum recursion depth. - pub max_depth: u32, - /// Maximum children expanded per node. - pub max_children_per_node: u32, - /// Maximum nodes retained per scope. - pub max_nodes_per_scope: u32, - /// Maximum nodes retained across the whole traversal. - pub max_total_nodes: u32, -} - -/// Graph-context enrichment limits applied to search responses. -#[derive(Debug, Deserialize)] -pub struct SearchGraphContext { - /// Whether graph-context enrichment is enabled. - pub enabled: bool, - /// Maximum facts attached to one response item. - pub max_facts_per_item: u32, - /// Maximum evidence notes attached to one fact. - pub max_evidence_notes_per_fact: u32, -} - -/// Ranking settings for retrieval and rerank fusion. -#[derive(Debug, Deserialize)] -pub struct Ranking { - /// Recency decay window in days. - pub recency_tau_days: f32, - /// Small deterministic tie-breaker weight. - pub tie_breaker_weight: f32, - /// Retrieval/rerank blending configuration. - pub blend: RankingBlend, - /// Optional deterministic scoring overlays. - pub deterministic: RankingDeterministic, - /// Diversity settings applied during selection. - pub diversity: RankingDiversity, - /// Source weighting and priority between fusion and structured fields. - pub retrieval_sources: RankingRetrievalSources, -} - -/// Deterministic ranking overlays applied on top of model scores. -#[derive(Debug, Deserialize)] -pub struct RankingDeterministic { - /// Whether deterministic overlays are enabled. - pub enabled: bool, - /// Lexical-overlap term settings. - pub lexical: RankingDeterministicLexical, - /// Historical-hit term settings. - pub hits: RankingDeterministicHits, - /// Decay term settings. - pub decay: RankingDeterministicDecay, -} - -/// Lexical-overlap deterministic term. -#[derive(Debug, Deserialize)] -pub struct RankingDeterministicLexical { - /// Whether the lexical term is enabled. - pub enabled: bool, - /// Weight assigned to the lexical term. - pub weight: f32, - /// Minimum overlap ratio required before the term applies. - pub min_ratio: f32, - /// Maximum number of query terms examined. - pub max_query_terms: u32, - /// Maximum number of text terms examined. - pub max_text_terms: u32, -} - -/// Historical-hit deterministic term. -#[derive(Debug, Deserialize)] -pub struct RankingDeterministicHits { - /// Whether the hits term is enabled. - pub enabled: bool, - /// Weight assigned to the hits term. - pub weight: f32, - /// Half-saturation parameter for hit-count scaling. - pub half_saturation: f32, - /// Decay window in days for the last-hit component. - pub last_hit_tau_days: f32, -} - -/// Decay-based deterministic term. -#[derive(Debug, Deserialize)] -pub struct RankingDeterministicDecay { - /// Whether the decay term is enabled. - pub enabled: bool, - /// Weight assigned to the decay term. - pub weight: f32, - /// Decay window in days. - pub tau_days: f32, -} - -/// Retrieval/rerank blending configuration. -#[derive(Debug, Deserialize)] -pub struct RankingBlend { - /// Whether blend mode is enabled. - pub enabled: bool, - /// Normalization strategy applied to rerank scores. - pub rerank_normalization: String, - /// Normalization strategy applied to retrieval scores. - pub retrieval_normalization: String, - /// Retrieval-rank segments that assign retrieval weights. - pub segments: Vec, -} - -/// One retrieval-rank segment used by blend mode. -#[derive(Debug, Deserialize)] -pub struct RankingBlendSegment { - /// Inclusive maximum retrieval rank for this segment. - pub max_retrieval_rank: u32, - /// Retrieval weight applied within this segment. - pub retrieval_weight: f32, -} - -/// Diversity controls used when selecting final results. -#[derive(Debug, Deserialize)] -pub struct RankingDiversity { - /// Whether diversity filtering is enabled. - pub enabled: bool, - /// Similarity threshold above which candidates may be skipped. - pub sim_threshold: f32, - /// Lambda used by MMR-style balancing. - pub mmr_lambda: f32, - /// Maximum number of skipped candidates before backfilling. - pub max_skips: u32, -} - -/// Source weighting and priority between fusion and structured-field retrieval. -#[derive(Debug, Deserialize)] -pub struct RankingRetrievalSources { - /// Weight applied to fused retrieval results. - pub fusion_weight: f32, - /// Weight applied to structured-field matches. - pub structured_field_weight: f32, - /// Priority assigned to fused retrieval results. - pub fusion_priority: u32, - /// Priority assigned to structured-field matches. - pub structured_field_priority: u32, -} - -/// Lifecycle retention and purge settings. -#[derive(Debug, Deserialize)] -pub struct Lifecycle { - /// Note-type-specific TTL settings. - pub ttl_days: TtlDays, - /// Days to retain deleted notes before purge. - pub purge_deleted_after_days: i64, - /// Days to retain deprecated notes before purge. - pub purge_deprecated_after_days: i64, -} - -/// TTL values in days for each note type. -#[derive(Debug, Deserialize)] -pub struct TtlDays { - /// TTL for `plan` notes. - pub plan: i64, - /// TTL for `fact` notes. - pub fact: i64, - /// TTL for `preference` notes. - pub preference: i64, - /// TTL for `constraint` notes. - pub constraint: i64, - /// TTL for `decision` notes. - pub decision: i64, - /// TTL for `profile` notes. - pub profile: i64, -} - -/// Request security, evidence, and auth settings. -#[derive(Debug, Deserialize)] -pub struct Security { - /// Whether services must bind only to loopback interfaces. - pub bind_localhost_only: bool, - /// Whether non-English input is rejected at the API boundary. - pub reject_non_english: bool, - /// Whether secret-like text is redacted before write. - pub redact_secrets_on_write: bool, - /// Minimum number of quotes required for evidence binding. - pub evidence_min_quotes: u32, - /// Maximum number of quotes allowed for evidence binding. - pub evidence_max_quotes: u32, - /// Maximum characters allowed in one evidence quote. - pub evidence_max_quote_chars: u32, - /// Authentication mode such as `off` or `static_keys`. - pub auth_mode: String, - /// Static bearer-token entries used when `auth_mode` is `static_keys`. - pub auth_keys: Vec, -} - -/// A single static bearer-token entry. -#[derive(Debug, Deserialize)] -pub struct SecurityAuthKey { - /// Stable token identifier used for auditing. - pub token_id: String, - /// Bearer token value matched from incoming requests. - pub token: String, - /// Tenant identifier granted by this token. - pub tenant_id: String, - /// Project identifier granted by this token. - pub project_id: String, - - /// Optional agent identifier restriction. - pub agent_id: Option, - /// Read profile granted by this token. - pub read_profile: String, - /// Role assigned to this token. - pub role: SecurityAuthRole, -} - -/// Role values accepted by static auth keys. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SecurityAuthRole { - /// Standard user token. - User, - /// Admin token with elevated write privileges. - Admin, - /// Super-admin token for global admin operations. - SuperAdmin, -} diff --git a/packages/elf-config/src/types/chunking.rs b/packages/elf-config/src/types/chunking.rs new file mode 100644 index 00000000..229c00d7 --- /dev/null +++ b/packages/elf-config/src/types/chunking.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; + +/// Sentence-aware token chunking settings. +#[derive(Debug, Deserialize)] +pub struct Chunking { + /// Whether chunking support is enabled. + pub enabled: bool, + /// Maximum tokens allowed in one chunk. + pub max_tokens: u32, + /// Number of tail tokens overlapped into the next chunk. + pub overlap_tokens: u32, + /// Hugging Face tokenizer repo used for token counting. + pub tokenizer_repo: String, +} diff --git a/packages/elf-config/src/types/context.rs b/packages/elf-config/src/types/context.rs new file mode 100644 index 00000000..27416108 --- /dev/null +++ b/packages/elf-config/src/types/context.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +/// Optional metadata used to improve retrieval disambiguation across projects and scopes. +#[derive(Debug, Deserialize)] +pub struct Context { + /// Optional. Map keys are either ":" or "". + pub project_descriptions: Option>, + /// Optional. Map keys are scope labels, e.g. "project_shared". + pub scope_descriptions: Option>, + /// Optional. Additive boost applied to final scores when a query's tokens match a scope + /// description. + pub scope_boost_weight: Option, +} + +/// Static forwarding context attached by `elf-mcp` to proxied requests. +#[derive(Clone, Debug, Deserialize)] +pub struct McpContext { + /// Tenant identifier attached to proxied MCP requests. + pub tenant_id: String, + /// Project identifier attached to proxied MCP requests. + pub project_id: String, + /// Agent identifier attached to proxied MCP requests. + pub agent_id: String, + /// Read profile attached to proxied MCP requests. + pub read_profile: String, +} diff --git a/packages/elf-config/src/types/lifecycle.rs b/packages/elf-config/src/types/lifecycle.rs new file mode 100644 index 00000000..02d99323 --- /dev/null +++ b/packages/elf-config/src/types/lifecycle.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +/// Lifecycle retention and purge settings. +#[derive(Debug, Deserialize)] +pub struct Lifecycle { + /// Note-type-specific TTL settings. + pub ttl_days: TtlDays, + /// Days to retain deleted notes before purge. + pub purge_deleted_after_days: i64, + /// Days to retain deprecated notes before purge. + pub purge_deprecated_after_days: i64, +} + +/// TTL values in days for each note type. +#[derive(Debug, Deserialize)] +pub struct TtlDays { + /// TTL for `plan` notes. + pub plan: i64, + /// TTL for `fact` notes. + pub fact: i64, + /// TTL for `preference` notes. + pub preference: i64, + /// TTL for `constraint` notes. + pub constraint: i64, + /// TTL for `decision` notes. + pub decision: i64, + /// TTL for `profile` notes. + pub profile: i64, +} diff --git a/packages/elf-config/src/types/memory.rs b/packages/elf-config/src/types/memory.rs new file mode 100644 index 00000000..9935af2d --- /dev/null +++ b/packages/elf-config/src/types/memory.rs @@ -0,0 +1,40 @@ +use serde::Deserialize; + +/// Write-path limits and policy controls for note ingestion. +#[derive(Debug, Deserialize)] +pub struct Memory { + /// Maximum number of notes accepted per `add_event` request. + pub max_notes_per_add_event: u32, + /// Maximum character length for an individual note. + pub max_note_chars: u32, + /// Similarity threshold for duplicate detection. + pub dup_sim_threshold: f32, + /// Similarity threshold for update-vs-insert decisions. + pub update_sim_threshold: f32, + /// Candidate pool size used before final top-k selection. + pub candidate_k: u32, + /// Final top-k size for note retrieval. + pub top_k: u32, + /// Optional downgrade rules applied after base memory decisions. + pub policy: MemoryPolicy, +} + +/// Collection of memory-policy downgrade rules. +#[derive(Debug, Deserialize)] +pub struct MemoryPolicy { + /// Ordered policy rules evaluated against note type, scope, and scores. + pub rules: Vec, +} + +/// A single memory-policy rule matched by note metadata and confidence/importance thresholds. +#[derive(Debug, Default, Deserialize)] +pub struct MemoryPolicyRule { + /// Optional note type selector. + pub note_type: Option, + /// Optional scope selector. + pub scope: Option, + /// Optional minimum confidence required for the rule to match. + pub min_confidence: Option, + /// Optional minimum importance required for the rule to match. + pub min_importance: Option, +} diff --git a/packages/elf-config/src/types/providers.rs b/packages/elf-config/src/types/providers.rs new file mode 100644 index 00000000..4b0d9c93 --- /dev/null +++ b/packages/elf-config/src/types/providers.rs @@ -0,0 +1,74 @@ +use serde::Deserialize; +use serde_json::{Map, Value}; + +/// Provider configuration bundle for all external model calls. +#[derive(Debug, Deserialize)] +pub struct Providers { + /// Embedding provider used for vector generation. + pub embedding: EmbeddingProviderConfig, + /// Rerank provider used for late-stage scoring. + pub rerank: ProviderConfig, + /// LLM provider used by extraction flows such as `add_event`. + pub llm_extractor: LlmProviderConfig, +} + +/// Embedding-provider settings. +#[derive(Debug, Deserialize)] +pub struct EmbeddingProviderConfig { + /// Provider implementation identifier. + pub provider_id: String, + /// Base URL for embedding API requests. + pub api_base: String, + /// Non-empty API key for embedding requests. + pub api_key: String, + /// Request path appended to `api_base`. + pub path: String, + /// Embedding model identifier. + pub model: String, + /// Expected embedding vector dimension. + pub dimensions: u32, + /// Request timeout in milliseconds. + pub timeout_ms: u64, + /// Extra HTTP headers sent with embedding requests. + pub default_headers: Map, +} + +/// Generic provider settings shared by non-embedding APIs such as rerank. +#[derive(Debug, Deserialize)] +pub struct ProviderConfig { + /// Provider implementation identifier. + pub provider_id: String, + /// Base URL for provider API requests. + pub api_base: String, + /// Non-empty API key for provider requests. + pub api_key: String, + /// Request path appended to `api_base`. + pub path: String, + /// Provider model identifier. + pub model: String, + /// Request timeout in milliseconds. + pub timeout_ms: u64, + /// Extra HTTP headers sent with provider requests. + pub default_headers: Map, +} + +/// LLM extractor provider settings. +#[derive(Debug, Deserialize)] +pub struct LlmProviderConfig { + /// Provider implementation identifier. + pub provider_id: String, + /// Base URL for extraction API requests. + pub api_base: String, + /// Non-empty API key for extraction requests. + pub api_key: String, + /// Request path appended to `api_base`. + pub path: String, + /// LLM model identifier. + pub model: String, + /// Sampling temperature for extraction requests. + pub temperature: f32, + /// Request timeout in milliseconds. + pub timeout_ms: u64, + /// Extra HTTP headers sent with extraction requests. + pub default_headers: Map, +} diff --git a/packages/elf-config/src/types/ranking.rs b/packages/elf-config/src/types/ranking.rs new file mode 100644 index 00000000..b6707c1f --- /dev/null +++ b/packages/elf-config/src/types/ranking.rs @@ -0,0 +1,118 @@ +use serde::Deserialize; + +/// Ranking settings for retrieval and rerank fusion. +#[derive(Debug, Deserialize)] +pub struct Ranking { + /// Recency decay window in days. + pub recency_tau_days: f32, + /// Small deterministic tie-breaker weight. + pub tie_breaker_weight: f32, + /// Retrieval/rerank blending configuration. + pub blend: RankingBlend, + /// Optional deterministic scoring overlays. + pub deterministic: RankingDeterministic, + /// Diversity settings applied during selection. + pub diversity: RankingDiversity, + /// Source weighting and priority between fusion and structured fields. + pub retrieval_sources: RankingRetrievalSources, +} + +/// Deterministic ranking overlays applied on top of model scores. +#[derive(Debug, Deserialize)] +pub struct RankingDeterministic { + /// Whether deterministic overlays are enabled. + pub enabled: bool, + /// Lexical-overlap term settings. + pub lexical: RankingDeterministicLexical, + /// Historical-hit term settings. + pub hits: RankingDeterministicHits, + /// Decay term settings. + pub decay: RankingDeterministicDecay, +} + +/// Lexical-overlap deterministic term. +#[derive(Debug, Deserialize)] +pub struct RankingDeterministicLexical { + /// Whether the lexical term is enabled. + pub enabled: bool, + /// Weight assigned to the lexical term. + pub weight: f32, + /// Minimum overlap ratio required before the term applies. + pub min_ratio: f32, + /// Maximum number of query terms examined. + pub max_query_terms: u32, + /// Maximum number of text terms examined. + pub max_text_terms: u32, +} + +/// Historical-hit deterministic term. +#[derive(Debug, Deserialize)] +pub struct RankingDeterministicHits { + /// Whether the hits term is enabled. + pub enabled: bool, + /// Weight assigned to the hits term. + pub weight: f32, + /// Half-saturation parameter for hit-count scaling. + pub half_saturation: f32, + /// Decay window in days for the last-hit component. + pub last_hit_tau_days: f32, +} + +/// Decay-based deterministic term. +#[derive(Debug, Deserialize)] +pub struct RankingDeterministicDecay { + /// Whether the decay term is enabled. + pub enabled: bool, + /// Weight assigned to the decay term. + pub weight: f32, + /// Decay window in days. + pub tau_days: f32, +} + +/// Retrieval/rerank blending configuration. +#[derive(Debug, Deserialize)] +pub struct RankingBlend { + /// Whether blend mode is enabled. + pub enabled: bool, + /// Normalization strategy applied to rerank scores. + pub rerank_normalization: String, + /// Normalization strategy applied to retrieval scores. + pub retrieval_normalization: String, + /// Retrieval-rank segments that assign retrieval weights. + pub segments: Vec, +} + +/// One retrieval-rank segment used by blend mode. +#[derive(Debug, Deserialize)] +pub struct RankingBlendSegment { + /// Inclusive maximum retrieval rank for this segment. + pub max_retrieval_rank: u32, + /// Retrieval weight applied within this segment. + pub retrieval_weight: f32, +} + +/// Diversity controls used when selecting final results. +#[derive(Debug, Deserialize)] +pub struct RankingDiversity { + /// Whether diversity filtering is enabled. + pub enabled: bool, + /// Similarity threshold above which candidates may be skipped. + pub sim_threshold: f32, + /// Lambda used by MMR-style balancing. + pub mmr_lambda: f32, + /// Maximum number of skipped candidates before backfilling. + pub max_skips: u32, +} + +/// Source weighting and priority between fusion and structured-field retrieval. +#[derive(Debug, Deserialize)] +pub struct RankingRetrievalSources { + /// Weight applied to fused retrieval results. + pub fusion_weight: f32, + /// Weight applied to structured-field matches. + pub structured_field_weight: f32, + /// Priority assigned to fused retrieval results. + pub fusion_priority: u32, + /// Priority assigned to structured-field matches. + pub structured_field_priority: u32, +} diff --git a/packages/elf-config/src/types/scopes.rs b/packages/elf-config/src/types/scopes.rs new file mode 100644 index 00000000..37c736b3 --- /dev/null +++ b/packages/elf-config/src/types/scopes.rs @@ -0,0 +1,47 @@ +use serde::Deserialize; + +/// Scope labels and access policy used by memory operations. +#[derive(Debug, Deserialize)] +pub struct Scopes { + /// All scope labels allowed by this deployment. + pub allowed: Vec, + /// Scope sets referenced by named read profiles. + pub read_profiles: ReadProfiles, + /// Relative precedence used when multiple scopes are eligible. + pub precedence: ScopePrecedence, + /// Scope-level write permissions. + pub write_allowed: ScopeWriteAllowed, +} + +/// Scope lists used by named read profiles. +#[derive(Debug, Deserialize)] +pub struct ReadProfiles { + /// Scope set for `private_only`. + pub private_only: Vec, + /// Scope set for `private_plus_project`. + pub private_plus_project: Vec, + /// Scope set for `all_scopes`. + pub all_scopes: Vec, +} + +/// Integer precedence used to break ties between scope classes. +#[derive(Debug, Deserialize)] +pub struct ScopePrecedence { + /// Precedence assigned to `agent_private`. + pub agent_private: i32, + /// Precedence assigned to `project_shared`. + pub project_shared: i32, + /// Precedence assigned to `org_shared`. + pub org_shared: i32, +} + +/// Scope-level write toggles. +#[derive(Debug, Deserialize)] +pub struct ScopeWriteAllowed { + /// Whether writes to `agent_private` are allowed. + pub agent_private: bool, + /// Whether writes to `project_shared` are allowed. + pub project_shared: bool, + /// Whether writes to `org_shared` are allowed. + pub org_shared: bool, +} diff --git a/packages/elf-config/src/types/search.rs b/packages/elf-config/src/types/search.rs new file mode 100644 index 00000000..2e806bc3 --- /dev/null +++ b/packages/elf-config/src/types/search.rs @@ -0,0 +1,99 @@ +use serde::Deserialize; + +/// Query-time search settings. +#[derive(Debug, Deserialize)] +pub struct Search { + /// Query expansion behavior. + pub expansion: SearchExpansion, + /// Dynamic-expansion trigger thresholds. + pub dynamic: SearchDynamic, + /// Prefilter candidate cap. + pub prefilter: SearchPrefilter, + /// Search cache settings. + pub cache: SearchCache, + /// Explainability retention settings. + pub explain: SearchExplain, + /// Recursive retrieval traversal settings. + pub recursive: SearchRecursive, + /// Graph-context enrichment settings. + pub graph_context: SearchGraphContext, +} + +/// Query expansion settings. +#[derive(Debug, Deserialize)] +pub struct SearchExpansion { + /// Expansion mode such as `off`, `always`, or `dynamic`. + pub mode: String, + /// Maximum number of expansion queries emitted. + pub max_queries: u32, + /// Whether the original query is retained alongside expansions. + pub include_original: bool, +} + +/// Thresholds that determine when dynamic expansion is activated. +#[derive(Debug, Deserialize)] +pub struct SearchDynamic { + /// Minimum initial candidate count before dynamic expansion is skipped. + pub min_candidates: u32, + /// Minimum top score before dynamic expansion is skipped. + pub min_top_score: f32, +} + +/// Candidate prefilter settings. +#[derive(Debug, Deserialize)] +pub struct SearchPrefilter { + /// Maximum number of candidates kept before later stages. + pub max_candidates: u32, +} + +/// Cache settings for expansion and rerank outputs. +#[derive(Debug, Deserialize)] +pub struct SearchCache { + /// Whether search caching is enabled. + pub enabled: bool, + /// TTL in days for cached expansion outputs. + pub expansion_ttl_days: i64, + /// TTL in days for cached rerank outputs. + pub rerank_ttl_days: i64, + /// Optional upper bound on cached payload size in bytes. + pub max_payload_bytes: Option, +} + +/// Search explainability retention and write-path settings. +#[derive(Debug, Deserialize)] +pub struct SearchExplain { + /// Retention window for explain rows in days. + pub retention_days: i64, + /// Whether candidate snapshots are captured. + pub capture_candidates: bool, + /// Retention window for candidate snapshots in days. + pub candidate_retention_days: i64, + /// Explainability write mode. + pub write_mode: String, +} + +/// Recursive retrieval traversal limits. +#[derive(Debug, Deserialize)] +pub struct SearchRecursive { + /// Whether recursive retrieval is enabled. + pub enabled: bool, + /// Maximum recursion depth. + pub max_depth: u32, + /// Maximum children expanded per node. + pub max_children_per_node: u32, + /// Maximum nodes retained per scope. + pub max_nodes_per_scope: u32, + /// Maximum nodes retained across the whole traversal. + pub max_total_nodes: u32, +} + +/// Graph-context enrichment limits applied to search responses. +#[derive(Debug, Deserialize)] +pub struct SearchGraphContext { + /// Whether graph-context enrichment is enabled. + pub enabled: bool, + /// Maximum facts attached to one response item. + pub max_facts_per_item: u32, + /// Maximum evidence notes attached to one fact. + pub max_evidence_notes_per_fact: u32, +} diff --git a/packages/elf-config/src/types/security.rs b/packages/elf-config/src/types/security.rs new file mode 100644 index 00000000..8cb6c230 --- /dev/null +++ b/packages/elf-config/src/types/security.rs @@ -0,0 +1,54 @@ +use serde::Deserialize; + +/// Request security, evidence, and auth settings. +#[derive(Debug, Deserialize)] +pub struct Security { + /// Whether services must bind only to loopback interfaces. + pub bind_localhost_only: bool, + /// Whether non-English input is rejected at the API boundary. + pub reject_non_english: bool, + /// Whether secret-like text is redacted before write. + pub redact_secrets_on_write: bool, + /// Minimum number of quotes required for evidence binding. + pub evidence_min_quotes: u32, + /// Maximum number of quotes allowed for evidence binding. + pub evidence_max_quotes: u32, + /// Maximum characters allowed in one evidence quote. + pub evidence_max_quote_chars: u32, + /// Authentication mode such as `off` or `static_keys`. + pub auth_mode: String, + /// Static bearer-token entries used when `auth_mode` is `static_keys`. + pub auth_keys: Vec, +} + +/// A single static bearer-token entry. +#[derive(Debug, Deserialize)] +pub struct SecurityAuthKey { + /// Stable token identifier used for auditing. + pub token_id: String, + /// Bearer token value matched from incoming requests. + pub token: String, + /// Tenant identifier granted by this token. + pub tenant_id: String, + /// Project identifier granted by this token. + pub project_id: String, + + /// Optional agent identifier restriction. + pub agent_id: Option, + /// Read profile granted by this token. + pub read_profile: String, + /// Role assigned to this token. + pub role: SecurityAuthRole, +} + +/// Role values accepted by static auth keys. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SecurityAuthRole { + /// Standard user token. + User, + /// Admin token with elevated write privileges. + Admin, + /// Super-admin token for global admin operations. + SuperAdmin, +} diff --git a/packages/elf-config/src/types/service.rs b/packages/elf-config/src/types/service.rs new file mode 100644 index 00000000..80fc7b90 --- /dev/null +++ b/packages/elf-config/src/types/service.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; + +/// Bind addresses and logging settings for ELF services. +#[derive(Debug, Deserialize)] +pub struct Service { + /// Bind address for the public HTTP API. + pub http_bind: String, + /// Bind address for the MCP server entrypoint. + pub mcp_bind: String, + /// Bind address for the admin HTTP API. + pub admin_bind: String, + /// Default service log level. + pub log_level: String, +} diff --git a/packages/elf-config/src/types/storage.rs b/packages/elf-config/src/types/storage.rs new file mode 100644 index 00000000..ac05d8cb --- /dev/null +++ b/packages/elf-config/src/types/storage.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; + +/// Storage backend configuration for persisted note and document data. +#[derive(Debug, Deserialize)] +pub struct Storage { + /// Postgres source-of-truth settings. + pub postgres: Postgres, + /// Qdrant derived-index settings. + pub qdrant: Qdrant, +} + +/// Postgres connection settings. +#[derive(Debug, Deserialize)] +pub struct Postgres { + /// Postgres DSN used by ELF services. + pub dsn: String, + /// Maximum number of pooled Postgres connections. + pub pool_max_conns: u32, +} + +/// Qdrant collection settings for note and document vectors. +#[derive(Debug, Deserialize)] +pub struct Qdrant { + /// Qdrant base URL used by clients in this workspace. + pub url: String, + /// Primary notes collection name. + pub collection: String, + /// Document-chunk collection name. + pub docs_collection: String, + /// Vector dimension expected by both note and document collections. + pub vector_dim: u32, +} diff --git a/packages/elf-config/src/validation.rs b/packages/elf-config/src/validation.rs new file mode 100644 index 00000000..8751cb6d --- /dev/null +++ b/packages/elf-config/src/validation.rs @@ -0,0 +1,29 @@ +mod chunking; +mod context; +mod mcp; +mod memory; +mod providers; +mod ranking; +mod search; +mod security; +mod service; +mod storage; + +use crate::{Config, Result}; + +/// Validates a deserialized ELF configuration against repository runtime rules. +pub fn validate(cfg: &Config) -> Result<()> { + security::validate(cfg)?; + service::validate(cfg)?; + storage::validate(cfg)?; + providers::validate(cfg)?; + memory::validate(cfg)?; + search::validate(cfg)?; + ranking::validate(cfg)?; + chunking::validate(cfg)?; + context::validate(cfg)?; + mcp::validate(cfg)?; + search::validate_graph_context(cfg)?; + + Ok(()) +} diff --git a/packages/elf-config/src/validation/chunking.rs b/packages/elf-config/src/validation/chunking.rs new file mode 100644 index 00000000..89d10153 --- /dev/null +++ b/packages/elf-config/src/validation/chunking.rs @@ -0,0 +1,24 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + if !cfg.chunking.enabled { + return Err(Error::Validation { message: "chunking.enabled must be true.".to_string() }); + } + if cfg.chunking.tokenizer_repo.trim().is_empty() { + return Err(Error::Validation { + message: "chunking.tokenizer_repo must be a non-empty string.".to_string(), + }); + } + if cfg.chunking.max_tokens == 0 { + return Err(Error::Validation { + message: "chunking.max_tokens must be greater than zero.".to_string(), + }); + } + if cfg.chunking.overlap_tokens >= cfg.chunking.max_tokens { + return Err(Error::Validation { + message: "chunking.overlap_tokens must be less than chunking.max_tokens.".to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/context.rs b/packages/elf-config/src/validation/context.rs new file mode 100644 index 00000000..9f6d19d2 --- /dev/null +++ b/packages/elf-config/src/validation/context.rs @@ -0,0 +1,37 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + if let Some(context) = cfg.context.as_ref() + && let Some(weight) = context.scope_boost_weight + { + if !weight.is_finite() { + return Err(Error::Validation { + message: "context.scope_boost_weight must be a finite number.".to_string(), + }); + } + if weight < 0.0 { + return Err(Error::Validation { + message: "context.scope_boost_weight must be zero or greater.".to_string(), + }); + } + if weight > 1.0 { + return Err(Error::Validation { + message: "context.scope_boost_weight must be 1.0 or less.".to_string(), + }); + } + if weight > 0.0 + && context + .scope_descriptions + .as_ref() + .map(|descriptions| descriptions.is_empty()) + .unwrap_or(true) + { + return Err(Error::Validation { + message: "context.scope_descriptions must be non-empty when context.scope_boost_weight is greater than zero." + .to_string(), + }); + } + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/mcp.rs b/packages/elf-config/src/validation/mcp.rs new file mode 100644 index 00000000..ef4a5d0e --- /dev/null +++ b/packages/elf-config/src/validation/mcp.rs @@ -0,0 +1,27 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + let Some(mcp) = cfg.mcp.as_ref() else { return Ok(()) }; + + for (label, value) in [ + ("mcp.tenant_id", &mcp.tenant_id), + ("mcp.project_id", &mcp.project_id), + ("mcp.agent_id", &mcp.agent_id), + ("mcp.read_profile", &mcp.read_profile), + ] { + if value.trim().is_empty() { + return Err(Error::Validation { message: format!("{label} must be non-empty.") }); + } + } + + if !matches!(mcp.read_profile.as_str(), "private_only" | "private_plus_project" | "all_scopes") + { + return Err(Error::Validation { + message: + "mcp.read_profile must be one of private_only, private_plus_project, or all_scopes." + .to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/memory.rs b/packages/elf-config/src/validation/memory.rs new file mode 100644 index 00000000..beedb343 --- /dev/null +++ b/packages/elf-config/src/validation/memory.rs @@ -0,0 +1,75 @@ +use std::collections::HashSet; + +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + let mut seen_rules = HashSet::new(); + + for (idx, rule) in cfg.memory.policy.rules.iter().enumerate() { + let path = format!("memory.policy.rules[{idx}]"); + + if let Some(note_type) = rule.note_type.as_ref() { + if note_type.trim().is_empty() { + return Err(Error::Validation { + message: format!("{path}.note_type cannot be blank or whitespace-only."), + }); + } + if !matches!( + note_type.as_str(), + "preference" | "constraint" | "decision" | "profile" | "fact" | "plan" + ) { + return Err(Error::Validation { + message: format!( + "{path}.note_type must be one of preference, constraint, decision, profile, fact, or plan." + ), + }); + } + } + if let Some(scope) = rule.scope.as_ref() { + if scope.trim().is_empty() { + return Err(Error::Validation { + message: format!("{path}.scope cannot be blank or whitespace-only."), + }); + } + if !cfg.scopes.allowed.iter().any(|allowed_scope| allowed_scope == scope) { + return Err(Error::Validation { + message: format!("{path}.scope must be one of allowed scopes."), + }); + } + } + if let Some(min_confidence) = rule.min_confidence { + if !min_confidence.is_finite() { + return Err(Error::Validation { + message: format!("{path}.min_confidence must be a finite number."), + }); + } + if !(0.0..=1.0).contains(&min_confidence) { + return Err(Error::Validation { + message: format!("{path}.min_confidence must be between 0.0 and 1.0."), + }); + } + } + if let Some(min_importance) = rule.min_importance { + if !min_importance.is_finite() { + return Err(Error::Validation { + message: format!("{path}.min_importance must be a finite number."), + }); + } + if !(0.0..=1.0).contains(&min_importance) { + return Err(Error::Validation { + message: format!("{path}.min_importance must be between 0.0 and 1.0."), + }); + } + } + + let rule_key = (rule.note_type.clone(), rule.scope.clone()); + + if !seen_rules.insert(rule_key) { + return Err(Error::Validation { + message: format!("{path} has a duplicate note_type and scope pair."), + }); + } + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/providers.rs b/packages/elf-config/src/validation/providers.rs new file mode 100644 index 00000000..4691934d --- /dev/null +++ b/packages/elf-config/src/validation/providers.rs @@ -0,0 +1,29 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + if cfg.providers.embedding.dimensions == 0 { + return Err(Error::Validation { + message: "providers.embedding.dimensions must be greater than zero.".to_string(), + }); + } + if cfg.providers.embedding.dimensions != cfg.storage.qdrant.vector_dim { + return Err(Error::Validation { + message: "providers.embedding.dimensions must match storage.qdrant.vector_dim." + .to_string(), + }); + } + + for (label, key) in [ + ("embedding", &cfg.providers.embedding.api_key), + ("rerank", &cfg.providers.rerank.api_key), + ("llm_extractor", &cfg.providers.llm_extractor.api_key), + ] { + if key.trim().is_empty() { + return Err(Error::Validation { + message: format!("Provider {label} api_key must be non-empty."), + }); + } + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/ranking.rs b/packages/elf-config/src/validation/ranking.rs new file mode 100644 index 00000000..7da5e1b4 --- /dev/null +++ b/packages/elf-config/src/validation/ranking.rs @@ -0,0 +1,217 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + validate_core(cfg)?; + validate_blend(cfg)?; + validate_diversity(cfg)?; + validate_retrieval_sources(cfg)?; + validate_deterministic(cfg)?; + + Ok(()) +} + +fn validate_core(cfg: &Config) -> Result<()> { + if cfg.ranking.tie_breaker_weight < 0.0 { + return Err(Error::Validation { + message: "ranking.tie_breaker_weight must be zero or greater.".to_string(), + }); + } + if !cfg.ranking.tie_breaker_weight.is_finite() { + return Err(Error::Validation { + message: "ranking.tie_breaker_weight must be a finite number.".to_string(), + }); + } + if cfg.ranking.recency_tau_days < 0.0 { + return Err(Error::Validation { + message: "ranking.recency_tau_days must be zero or greater.".to_string(), + }); + } + if !cfg.ranking.recency_tau_days.is_finite() { + return Err(Error::Validation { + message: "ranking.recency_tau_days must be a finite number.".to_string(), + }); + } + + Ok(()) +} + +fn validate_blend(cfg: &Config) -> Result<()> { + if !cfg.ranking.blend.enabled { + return Ok(()); + } + if cfg.ranking.blend.segments.is_empty() { + return Err(Error::Validation { + message: "ranking.blend.segments must be non-empty when enabled.".to_string(), + }); + } + + for segment in &cfg.ranking.blend.segments { + if !segment.retrieval_weight.is_finite() { + return Err(Error::Validation { + message: "ranking.blend.segments.retrieval_weight must be a finite number." + .to_string(), + }); + } + if !(0.0..=1.0).contains(&segment.retrieval_weight) { + return Err(Error::Validation { + message: "ranking.blend.segments.retrieval_weight must be in the range 0.0-1.0." + .to_string(), + }); + } + if segment.max_retrieval_rank == 0 { + return Err(Error::Validation { + message: "ranking.blend.segments.max_retrieval_rank must be greater than zero." + .to_string(), + }); + } + } + + Ok(()) +} + +fn validate_diversity(cfg: &Config) -> Result<()> { + let diversity = &cfg.ranking.diversity; + + if !diversity.sim_threshold.is_finite() { + return Err(Error::Validation { + message: "ranking.diversity.sim_threshold must be a finite number.".to_string(), + }); + } + if !(0.0..=1.0).contains(&diversity.sim_threshold) { + return Err(Error::Validation { + message: "ranking.diversity.sim_threshold must be in the range 0.0-1.0.".to_string(), + }); + } + if !diversity.mmr_lambda.is_finite() { + return Err(Error::Validation { + message: "ranking.diversity.mmr_lambda must be a finite number.".to_string(), + }); + } + if !(0.0..=1.0).contains(&diversity.mmr_lambda) { + return Err(Error::Validation { + message: "ranking.diversity.mmr_lambda must be in the range 0.0-1.0.".to_string(), + }); + } + + Ok(()) +} + +fn validate_retrieval_sources(cfg: &Config) -> Result<()> { + let retrieval_sources = &cfg.ranking.retrieval_sources; + + for (path, value) in [ + ("ranking.retrieval_sources.fusion_weight", retrieval_sources.fusion_weight), + ( + "ranking.retrieval_sources.structured_field_weight", + retrieval_sources.structured_field_weight, + ), + ] { + if !value.is_finite() { + return Err(Error::Validation { message: format!("{path} must be a finite number.") }); + } + if value < 0.0 { + return Err(Error::Validation { message: format!("{path} must be zero or greater.") }); + } + } + + if retrieval_sources.fusion_weight <= 0.0 && retrieval_sources.structured_field_weight <= 0.0 { + return Err(Error::Validation { + message: "At least one retrieval source weight must be greater than zero.".to_string(), + }); + } + + Ok(()) +} + +fn validate_deterministic(cfg: &Config) -> Result<()> { + let det = &cfg.ranking.deterministic; + let det_lex = &det.lexical; + let det_hits = &det.hits; + let det_decay = &det.decay; + + for (path, weight) in [ + ("ranking.deterministic.lexical", det_lex.weight), + ("ranking.deterministic.hits", det_hits.weight), + ("ranking.deterministic.decay", det_decay.weight), + ] { + if weight < 0.0 { + return Err(Error::Validation { + message: format!("{path}.weight must be zero or greater."), + }); + } + if !weight.is_finite() { + return Err(Error::Validation { + message: format!("{path}.weight must be a finite number."), + }); + } + } + + if det.enabled && det_lex.enabled { + if !det_lex.min_ratio.is_finite() { + return Err(Error::Validation { + message: "ranking.deterministic.lexical.min_ratio must be a finite number." + .to_string(), + }); + } + if !(0.0..=1.0).contains(&det_lex.min_ratio) { + return Err(Error::Validation { + message: "ranking.deterministic.lexical.min_ratio must be in the range 0.0-1.0." + .to_string(), + }); + } + if det_lex.max_query_terms == 0 { + return Err(Error::Validation { + message: "ranking.deterministic.lexical.max_query_terms must be greater than zero." + .to_string(), + }); + } + if det_lex.max_text_terms == 0 { + return Err(Error::Validation { + message: "ranking.deterministic.lexical.max_text_terms must be greater than zero." + .to_string(), + }); + } + } + if det.enabled && det_hits.enabled { + if !det_hits.half_saturation.is_finite() { + return Err(Error::Validation { + message: "ranking.deterministic.hits.half_saturation must be a finite number." + .to_string(), + }); + } + if det_hits.half_saturation <= 0.0 { + return Err(Error::Validation { + message: "ranking.deterministic.hits.half_saturation must be greater than zero." + .to_string(), + }); + } + if !det_hits.last_hit_tau_days.is_finite() { + return Err(Error::Validation { + message: "ranking.deterministic.hits.last_hit_tau_days must be a finite number." + .to_string(), + }); + } + if det_hits.last_hit_tau_days < 0.0 { + return Err(Error::Validation { + message: "ranking.deterministic.hits.last_hit_tau_days must be zero or greater." + .to_string(), + }); + } + } + if det.enabled && det_decay.enabled { + if !det_decay.tau_days.is_finite() { + return Err(Error::Validation { + message: "ranking.deterministic.decay.tau_days must be a finite number." + .to_string(), + }); + } + if det_decay.tau_days <= 0.0 { + return Err(Error::Validation { + message: "ranking.deterministic.decay.tau_days must be greater than zero." + .to_string(), + }); + } + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/search.rs b/packages/elf-config/src/validation/search.rs new file mode 100644 index 00000000..338775db --- /dev/null +++ b/packages/elf-config/src/validation/search.rs @@ -0,0 +1,191 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + validate_expansion(cfg)?; + validate_dynamic(cfg)?; + validate_cache(cfg)?; + validate_explain(cfg)?; + validate_explain_write_mode(cfg)?; + validate_recursive(cfg)?; + + Ok(()) +} + +pub(super) fn validate_graph_context(cfg: &Config) -> Result<()> { + if !cfg.search.graph_context.enabled { + return Ok(()); + } + + let ctx = &cfg.search.graph_context; + + if ctx.max_facts_per_item == 0 { + return Err(Error::Validation { + message: "search.graph_context.max_facts_per_item must be greater than zero." + .to_string(), + }); + } + if ctx.max_facts_per_item > 1_000 { + return Err(Error::Validation { + message: "search.graph_context.max_facts_per_item must be 1,000 or less.".to_string(), + }); + } + if ctx.max_evidence_notes_per_fact == 0 { + return Err(Error::Validation { + message: "search.graph_context.max_evidence_notes_per_fact must be greater than zero." + .to_string(), + }); + } + if ctx.max_evidence_notes_per_fact > 1_000 { + return Err(Error::Validation { + message: "search.graph_context.max_evidence_notes_per_fact must be 1,000 or less." + .to_string(), + }); + } + + Ok(()) +} + +fn validate_expansion(cfg: &Config) -> Result<()> { + let expansion_mode = cfg.search.expansion.mode.as_str(); + + if !matches!(expansion_mode, "off" | "always" | "dynamic") { + return Err(Error::Validation { + message: "search.expansion.mode must be one of off, always, or dynamic.".to_string(), + }); + } + if cfg.search.expansion.max_queries == 0 { + return Err(Error::Validation { + message: "search.expansion.max_queries must be greater than zero.".to_string(), + }); + } + + Ok(()) +} + +fn validate_dynamic(cfg: &Config) -> Result<()> { + if cfg.search.dynamic.min_candidates == 0 { + return Err(Error::Validation { + message: "search.dynamic.min_candidates must be greater than zero.".to_string(), + }); + } + if cfg.search.dynamic.min_top_score < 0.0 { + return Err(Error::Validation { + message: "search.dynamic.min_top_score must be zero or greater.".to_string(), + }); + } + + Ok(()) +} + +fn validate_cache(cfg: &Config) -> Result<()> { + if cfg.search.cache.expansion_ttl_days <= 0 { + return Err(Error::Validation { + message: "search.cache.expansion_ttl_days must be greater than zero.".to_string(), + }); + } + if cfg.search.cache.rerank_ttl_days <= 0 { + return Err(Error::Validation { + message: "search.cache.rerank_ttl_days must be greater than zero.".to_string(), + }); + } + + if let Some(max) = cfg.search.cache.max_payload_bytes + && max == 0 + { + return Err(Error::Validation { + message: "search.cache.max_payload_bytes must be greater than zero.".to_string(), + }); + } + + Ok(()) +} + +fn validate_explain(cfg: &Config) -> Result<()> { + if cfg.search.explain.retention_days <= 0 { + return Err(Error::Validation { + message: "search.explain.retention_days must be greater than zero.".to_string(), + }); + } + if cfg.search.explain.candidate_retention_days <= 0 { + return Err(Error::Validation { + message: "search.explain.candidate_retention_days must be greater than zero." + .to_string(), + }); + } + if cfg.search.explain.candidate_retention_days > cfg.search.explain.retention_days { + return Err(Error::Validation { + message: + "search.explain.candidate_retention_days must be less than or equal to search.explain.retention_days." + .to_string(), + }); + } + + Ok(()) +} + +fn validate_explain_write_mode(cfg: &Config) -> Result<()> { + match cfg.search.explain.write_mode.trim().to_ascii_lowercase().as_str() { + "outbox" | "inline" => Ok(()), + other => Err(Error::Validation { + message: format!( + "search.explain.write_mode must be one of: outbox, inline. Got {other}." + ), + }), + } +} + +fn validate_recursive(cfg: &Config) -> Result<()> { + if !cfg.search.recursive.enabled { + return Ok(()); + } + if cfg.search.recursive.max_depth == 0 { + return Err(Error::Validation { + message: "search.recursive.max_depth must be greater than zero.".to_string(), + }); + } + if cfg.search.recursive.max_depth > 8 { + return Err(Error::Validation { + message: "search.recursive.max_depth must be 8 or less.".to_string(), + }); + } + if cfg.search.recursive.max_children_per_node == 0 { + return Err(Error::Validation { + message: "search.recursive.max_children_per_node must be greater than zero." + .to_string(), + }); + } + if cfg.search.recursive.max_children_per_node > 64 { + return Err(Error::Validation { + message: "search.recursive.max_children_per_node must be 64 or less.".to_string(), + }); + } + if cfg.search.recursive.max_nodes_per_scope == 0 { + return Err(Error::Validation { + message: "search.recursive.max_nodes_per_scope must be greater than zero.".to_string(), + }); + } + if cfg.search.recursive.max_nodes_per_scope > 250 { + return Err(Error::Validation { + message: "search.recursive.max_nodes_per_scope must be 250 or less.".to_string(), + }); + } + if cfg.search.recursive.max_total_nodes == 0 { + return Err(Error::Validation { + message: "search.recursive.max_total_nodes must be greater than zero.".to_string(), + }); + } + if cfg.search.recursive.max_total_nodes > 2_000 { + return Err(Error::Validation { + message: "search.recursive.max_total_nodes must be 2_000 or less.".to_string(), + }); + } + if cfg.search.recursive.max_total_nodes < cfg.search.recursive.max_nodes_per_scope { + return Err(Error::Validation { + message: + "search.recursive.max_total_nodes must be at least search.recursive.max_nodes_per_scope." + .to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/security.rs b/packages/elf-config/src/validation/security.rs new file mode 100644 index 00000000..f8f54106 --- /dev/null +++ b/packages/elf-config/src/validation/security.rs @@ -0,0 +1,104 @@ +use std::collections::HashSet; + +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + if !cfg.security.reject_non_english { + return Err(Error::Validation { + message: "security.reject_non_english must be true.".to_string(), + }); + } + + let auth_mode = cfg.security.auth_mode.trim(); + + if !matches!(auth_mode, "off" | "static_keys") { + return Err(Error::Validation { + message: "security.auth_mode must be one of off or static_keys.".to_string(), + }); + } + if auth_mode == "off" { + if !cfg.security.auth_keys.is_empty() { + return Err(Error::Validation { + message: "security.auth_keys must be empty when security.auth_mode is off." + .to_string(), + }); + } + + return Ok(()); + } + if cfg.security.auth_keys.is_empty() { + return Err(Error::Validation { + message: "security.auth_keys must be non-empty when security.auth_mode is static_keys." + .to_string(), + }); + } + + let mut token_ids = HashSet::new(); + let mut tokens = HashSet::new(); + + for (idx, key) in cfg.security.auth_keys.iter().enumerate() { + let path = format!("security.auth_keys[{idx}]"); + + if key.token_id.trim().is_empty() { + return Err(Error::Validation { + message: format!("{path}.token_id must be non-empty."), + }); + } + if key.token.trim().is_empty() { + return Err(Error::Validation { message: format!("{path}.token must be non-empty.") }); + } + if key.tenant_id.trim().is_empty() { + return Err(Error::Validation { + message: format!("{path}.tenant_id must be non-empty."), + }); + } + if key.project_id.trim().is_empty() { + return Err(Error::Validation { + message: format!("{path}.project_id must be non-empty."), + }); + } + if key.read_profile.trim().is_empty() { + return Err(Error::Validation { + message: format!("{path}.read_profile must be non-empty."), + }); + } + if !matches!( + key.read_profile.as_str(), + "private_only" | "private_plus_project" | "all_scopes" + ) { + return Err(Error::Validation { + message: format!( + "{path}.read_profile must be one of private_only, private_plus_project, or all_scopes." + ), + }); + } + + if let Some(agent_id) = key.agent_id.as_ref() + && agent_id.trim().is_empty() + { + return Err(Error::Validation { + message: format!("{path}.agent_id must be non-empty when provided."), + }); + } + + if key.agent_id.as_ref().map(|agent_id| agent_id.trim().is_empty()).unwrap_or(true) { + return Err(Error::Validation { + message: format!( + "{path}.agent_id is required when security.auth_mode is static_keys." + ), + }); + } + if !token_ids.insert(key.token_id.as_str()) { + return Err(Error::Validation { + message: format!("{path}.token_id must be unique across security.auth_keys."), + }); + } + if !tokens.insert(key.token.as_str()) { + return Err(Error::Validation { + message: format!("{path}.token must be unique across security.auth_keys."), + }); + } + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/service.rs b/packages/elf-config/src/validation/service.rs new file mode 100644 index 00000000..a4615a24 --- /dev/null +++ b/packages/elf-config/src/validation/service.rs @@ -0,0 +1,11 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + if cfg.service.mcp_bind.trim().is_empty() { + return Err(Error::Validation { + message: "service.mcp_bind must be non-empty.".to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-config/src/validation/storage.rs b/packages/elf-config/src/validation/storage.rs new file mode 100644 index 00000000..3e831bf0 --- /dev/null +++ b/packages/elf-config/src/validation/storage.rs @@ -0,0 +1,31 @@ +use crate::{Config, Error, Result}; + +pub(super) fn validate(cfg: &Config) -> Result<()> { + if cfg.storage.postgres.dsn.trim().is_empty() { + return Err(Error::Validation { + message: "storage.postgres.dsn must be non-empty.".to_string(), + }); + } + if cfg.storage.qdrant.url.trim().is_empty() { + return Err(Error::Validation { + message: "storage.qdrant.url must be non-empty.".to_string(), + }); + } + if cfg.storage.qdrant.collection.trim().is_empty() { + return Err(Error::Validation { + message: "storage.qdrant.collection must be non-empty.".to_string(), + }); + } + if cfg.storage.qdrant.docs_collection.trim().is_empty() { + return Err(Error::Validation { + message: "storage.qdrant.docs_collection must be non-empty.".to_string(), + }); + } + if cfg.storage.qdrant.vector_dim == 0 { + return Err(Error::Validation { + message: "storage.qdrant.vector_dim must be greater than zero.".to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-domain/src/consolidation.rs b/packages/elf-domain/src/consolidation.rs index e9af2075..a4f0bfd7 100644 --- a/packages/elf-domain/src/consolidation.rs +++ b/packages/elf-domain/src/consolidation.rs @@ -1,8 +1,29 @@ //! Consolidation proposal contract validation. -use std::{ - error::Error, - fmt::{Display, Formatter}, +mod error; +mod lifecycle; +mod markers; +mod proposal; +mod sources; + +pub use self::{ + error::ConsolidationValidationError, + lifecycle::{ + ConsolidationApplyIntent, ConsolidationReviewAction, ConsolidationReviewState, + ConsolidationRunState, + }, + markers::{ + ConsolidationMarker, ConsolidationMarkerSeverity, ConsolidationMarkers, + ConsolidationUnsupportedClaimFlag, + }, + proposal::{ + ConsolidationJobPayload, ConsolidationLineage, ConsolidationProposalContract, + ConsolidationProposalDiff, + }, + sources::{ + ConsolidationInputRef, ConsolidationSourceKind, ConsolidationSourceSnapshot, + validate_source_refs, + }, }; use serde::{Deserialize, Serialize}; @@ -23,569 +44,6 @@ const FORBIDDEN_DIFF_KEYS: [&str; 7] = [ "overwrite_source", ]; -/// Error returned by consolidation contract validation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ConsolidationValidationError { - /// A required source reference list was empty. - MissingSourceRefs, - /// A source snapshot did not include any immutable freshness guard. - MissingSourceSnapshot, - /// A JSON field was not the required object shape. - InvalidJsonObject { - /// Name of the invalid field. - field: &'static str, - }, - /// A required text field was empty. - EmptyText { - /// Name of the invalid field. - field: &'static str, - }, - /// A confidence value was outside the inclusive range 0.0 to 1.0. - InvalidConfidence, - /// The proposal diff included a source mutation key. - DestructiveDiff, - /// A proposal review transition is not allowed by the lifecycle. - InvalidReviewTransition { - /// Current review state. - from: ConsolidationReviewState, - /// Requested review state. - to: ConsolidationReviewState, - }, - /// A run state transition is not allowed by the job lifecycle. - InvalidRunTransition { - /// Current run state. - from: ConsolidationRunState, - /// Requested run state. - to: ConsolidationRunState, - }, - /// A stored state string is not part of the contract. - UnknownState { - /// Name of the invalid field. - field: &'static str, - }, - /// The queued contract schema did not match the consolidation v1 contract. - InvalidContractSchema, -} -impl Display for ConsolidationValidationError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::MissingSourceRefs => write!(f, "source_refs must not be empty"), - Self::MissingSourceSnapshot => - write!(f, "source snapshot must include at least one freshness guard"), - Self::InvalidJsonObject { field } => write!(f, "{field} must be a JSON object"), - Self::EmptyText { field } => write!(f, "{field} must not be empty"), - Self::InvalidConfidence => write!(f, "confidence must be in the range 0.0..=1.0"), - Self::DestructiveDiff => write!(f, "proposal diff must not mutate source memory"), - Self::InvalidReviewTransition { from, to } => - write!(f, "invalid proposal review transition from {from:?} to {to:?}"), - Self::InvalidRunTransition { from, to } => - write!(f, "invalid consolidation run transition from {from:?} to {to:?}"), - Self::UnknownState { field } => write!(f, "{field} is not a known state"), - Self::InvalidContractSchema => - write!(f, "contract_schema must be elf.consolidation/v1"), - } - } -} -impl Error for ConsolidationValidationError {} - -/// Source artifact kind accepted by consolidation input references. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConsolidationSourceKind { - /// Memory note evidence. - Note, - /// Event ingestion source. - Event, - /// Search trace source. - Trace, - /// Search trace item source. - TraceItem, - /// Document extension source. - Doc, - /// Document chunk source. - DocChunk, -} -impl ConsolidationSourceKind { - /// Returns the canonical storage string. - pub fn as_str(self) -> &'static str { - match self { - Self::Note => "note", - Self::Event => "event", - Self::Trace => "trace", - Self::TraceItem => "trace_item", - Self::Doc => "doc", - Self::DocChunk => "doc_chunk", - } - } -} - -/// Immutable source snapshot guard captured before a proposal is stored. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationSourceSnapshot { - /// Source lifecycle status observed by the consolidation run. - pub status: Option, - /// Source last-update timestamp observed by the consolidation run. - pub updated_at: Option, - /// Source content or payload hash, when available. - pub content_hash: Option, - /// Source embedding version, when relevant. - pub embedding_version: Option, - /// Trace schema or trace version, when the source is a trace. - pub trace_version: Option, - #[serde(default)] - /// Opaque source reference copied from the authoritative source. - pub source_ref: Value, - #[serde(default)] - /// Additional snapshot metadata used for replay or review. - pub metadata: Value, -} -impl ConsolidationSourceSnapshot { - /// Validates snapshot shape and immutable freshness guards. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - validate_json_object("source_ref", &self.source_ref)?; - validate_json_object("metadata", &self.metadata)?; - - let has_hash = self.content_hash.as_ref().is_some_and(|hash| !hash.trim().is_empty()); - let has_embedding = - self.embedding_version.as_ref().is_some_and(|version| !version.trim().is_empty()); - let has_status = self.status.as_ref().is_some_and(|status| !status.trim().is_empty()); - let has_source_ref = non_empty_object(&self.source_ref); - let has_metadata = non_empty_object(&self.metadata); - let has_guard = self.updated_at.is_some() - || self.trace_version.is_some() - || has_hash - || has_embedding - || has_status - || has_source_ref - || has_metadata; - - if has_guard { Ok(()) } else { Err(ConsolidationValidationError::MissingSourceSnapshot) } - } -} - -/// Stable pointer to one immutable consolidation input. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationInputRef { - /// Kind of source artifact being referenced. - pub kind: ConsolidationSourceKind, - /// Identifier of the source artifact. - pub id: Uuid, - /// Snapshot metadata captured before proposal generation. - pub snapshot: ConsolidationSourceSnapshot, -} -impl ConsolidationInputRef { - /// Validates the input reference and its snapshot guard. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - self.snapshot.validate() - } -} - -/// Confidence or honesty marker severity. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConsolidationMarkerSeverity { - /// Low-severity marker. - Low, - /// Medium-severity marker. - Medium, - /// High-severity marker. - High, -} - -/// One contradiction or staleness marker attached to a proposal. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationMarker { - /// Marker severity. - pub severity: ConsolidationMarkerSeverity, - /// Human-readable marker text. - pub message: String, - /// Optional source that triggered the marker. - pub source: Option, -} -impl ConsolidationMarker { - /// Validates marker content and optional source evidence. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - if self.message.trim().is_empty() { - return Err(ConsolidationValidationError::EmptyText { field: "marker.message" }); - } - - if let Some(source) = &self.source { - source.validate()?; - } - - Ok(()) - } -} - -/// Contradiction and staleness markers attached to a proposal. -#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationMarkers { - #[serde(default)] - /// Contradiction markers that a reviewer must inspect. - pub contradictions: Vec, - #[serde(default)] - /// Staleness markers that a reviewer must inspect. - pub staleness: Vec, -} -impl ConsolidationMarkers { - /// Validates all marker payloads. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - for marker in self.contradictions.iter().chain(self.staleness.iter()) { - marker.validate()?; - } - - Ok(()) - } -} - -/// Unsupported-claim marker attached to a proposal for reviewer inspection. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationUnsupportedClaimFlag { - /// Stable claim identifier when the source fixture or worker supplies one. - pub claim_id: Option, - /// Human-readable unsupported-claim description. - pub message: String, - /// Optional source that demonstrates why the claim is unsupported. - pub source: Option, -} -impl ConsolidationUnsupportedClaimFlag { - /// Validates unsupported-claim marker content and optional source evidence. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - if self.message.trim().is_empty() { - return Err(ConsolidationValidationError::EmptyText { - field: "unsupported_claim_flags.message", - }); - } - - if let Some(claim_id) = &self.claim_id - && claim_id.trim().is_empty() - { - return Err(ConsolidationValidationError::EmptyText { - field: "unsupported_claim_flags.claim_id", - }); - } - if let Some(source) = &self.source { - source.validate()?; - } - - Ok(()) - } -} - -/// Derived-output apply intent for a reviewable proposal. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConsolidationApplyIntent { - /// Create a new derived memory note after review. - CreateDerivedNote, - /// Update an existing derived memory note after review. - UpdateDerivedNote, - /// Create a derived knowledge page after review. - CreateDerivedKnowledgePage, - /// Update a derived knowledge page after review. - UpdateDerivedKnowledgePage, - /// Create or refresh a derived graph view after review. - CreateDerivedGraphView, - /// Store the proposal for review without applying a downstream derived artifact. - NoOp, -} -impl ConsolidationApplyIntent { - /// Returns the canonical storage string. - pub fn as_str(self) -> &'static str { - match self { - Self::CreateDerivedNote => "create_derived_note", - Self::UpdateDerivedNote => "update_derived_note", - Self::CreateDerivedKnowledgePage => "create_derived_knowledge_page", - Self::UpdateDerivedKnowledgePage => "update_derived_knowledge_page", - Self::CreateDerivedGraphView => "create_derived_graph_view", - Self::NoOp => "no_op", - } - } -} - -/// Reviewer action requested for a consolidation proposal. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConsolidationReviewAction { - /// Approve a proposal for later application. - Approve, - /// Apply an approved proposal to a derived target. - Apply, - /// Discard a proposal as rejected. - Discard, - /// Defer a proposal by archiving it for later audit. - Defer, -} -impl ConsolidationReviewAction { - /// Returns the canonical storage string. - pub fn as_str(self) -> &'static str { - match self { - Self::Approve => "approve", - Self::Apply => "apply", - Self::Discard => "discard", - Self::Defer => "defer", - } - } -} - -/// Review lifecycle for a consolidation proposal. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConsolidationReviewState { - /// Proposal is awaiting review. - Proposed, - /// Proposal has been approved for downstream derived-output application. - Approved, - /// Proposal was rejected by a reviewer. - Rejected, - /// Proposal was approved and marked applied to the derived target. - Applied, - /// Proposal is retained but no longer active for review. - Archived, -} -impl ConsolidationReviewState { - /// Returns the canonical storage string. - pub fn as_str(self) -> &'static str { - match self { - Self::Proposed => "proposed", - Self::Approved => "approved", - Self::Rejected => "rejected", - Self::Applied => "applied", - Self::Archived => "archived", - } - } - - /// Parses a canonical storage string. - pub fn parse(raw: &str) -> Option { - match raw { - "proposed" => Some(Self::Proposed), - "approved" => Some(Self::Approved), - "rejected" => Some(Self::Rejected), - "applied" => Some(Self::Applied), - "archived" => Some(Self::Archived), - _ => None, - } - } - - /// Validates a review lifecycle transition. - pub fn validate_transition(self, to: Self) -> Result<(), ConsolidationValidationError> { - let allowed = match self { - Self::Proposed => matches!(to, Self::Approved | Self::Rejected | Self::Archived), - Self::Approved => matches!(to, Self::Applied | Self::Rejected | Self::Archived), - Self::Rejected | Self::Applied | Self::Archived => false, - }; - - if allowed { - Ok(()) - } else { - Err(ConsolidationValidationError::InvalidReviewTransition { from: self, to }) - } - } -} - -/// Consolidation job lifecycle. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConsolidationRunState { - /// Job has been registered but has not started. - Pending, - /// Job is actively generating fixture or future provider-backed proposals. - Running, - /// Job completed proposal generation. - Completed, - /// Job failed before completion. - Failed, - /// Job was cancelled by an operator. - Cancelled, -} -impl ConsolidationRunState { - /// Returns the canonical storage string. - pub fn as_str(self) -> &'static str { - match self { - Self::Pending => "pending", - Self::Running => "running", - Self::Completed => "completed", - Self::Failed => "failed", - Self::Cancelled => "cancelled", - } - } - - /// Parses a canonical storage string. - pub fn parse(raw: &str) -> Option { - match raw { - "pending" => Some(Self::Pending), - "running" => Some(Self::Running), - "completed" => Some(Self::Completed), - "failed" => Some(Self::Failed), - "cancelled" => Some(Self::Cancelled), - _ => None, - } - } - - /// Validates a job lifecycle transition. - pub fn validate_transition(self, to: Self) -> Result<(), ConsolidationValidationError> { - let allowed = match self { - Self::Pending => matches!(to, Self::Running | Self::Cancelled), - Self::Running => matches!(to, Self::Completed | Self::Failed | Self::Cancelled), - Self::Completed | Self::Failed | Self::Cancelled => false, - }; - - if allowed { - Ok(()) - } else { - Err(ConsolidationValidationError::InvalidRunTransition { from: self, to }) - } - } -} - -/// Reviewable diff between prior derived output and proposed derived output. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationProposalDiff { - /// Human-readable diff summary. - pub summary: String, - #[serde(default)] - /// Previous derived output snapshot, or an empty object for creates. - pub before: Value, - #[serde(default)] - /// Proposed derived output snapshot. - pub after: Value, -} -impl ConsolidationProposalDiff { - /// Validates diff shape and rejects source-mutation payloads. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - if self.summary.trim().is_empty() { - return Err(ConsolidationValidationError::EmptyText { field: "diff.summary" }); - } - - validate_json_object("diff.before", &self.before)?; - validate_json_object("diff.after", &self.after)?; - - if contains_forbidden_diff_key(&self.before) || contains_forbidden_diff_key(&self.after) { - return Err(ConsolidationValidationError::DestructiveDiff); - } - - Ok(()) - } -} - -/// Source lineage for one consolidation proposal. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationLineage { - /// Source references directly supporting the proposal. - pub source_refs: Vec, - /// Parent consolidation run, when this proposal is derived from an earlier run. - pub parent_run_id: Option, - #[serde(default)] - /// Parent proposals used as lineage inputs. - pub parent_proposal_ids: Vec, -} -impl ConsolidationLineage { - /// Validates source lineage references. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - validate_source_refs(&self.source_refs) - } -} - -/// Full reviewable consolidation proposal contract. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationProposalContract { - /// Proposal kind, such as `derived_note` or `knowledge_page`. - pub proposal_kind: String, - /// Derived-output apply intent. - pub apply_intent: ConsolidationApplyIntent, - /// Source references directly supporting the proposal. - pub source_refs: Vec, - #[serde(default)] - /// Aggregate source snapshot metadata for reviewer inspection. - pub source_snapshot: Value, - /// Proposal lineage. - pub lineage: ConsolidationLineage, - /// Model or fixture confidence in the proposal. - pub confidence: f32, - #[serde(default)] - /// Unsupported claims that the reviewer must inspect before accepting a proposal. - pub unsupported_claim_flags: Vec, - /// Review markers for contradiction and staleness checks. - pub markers: ConsolidationMarkers, - /// Reviewable derived-output diff. - pub diff: ConsolidationProposalDiff, - #[serde(default)] - /// Derived target reference, when the target already exists. - pub target_ref: Value, - #[serde(default)] - /// Proposed derived output payload. - pub proposed_payload: Value, -} -impl ConsolidationProposalContract { - /// Validates a proposal contract before persistence. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - if self.proposal_kind.trim().is_empty() { - return Err(ConsolidationValidationError::EmptyText { field: "proposal_kind" }); - } - - validate_source_refs(&self.source_refs)?; - validate_json_object("source_snapshot", &self.source_snapshot)?; - - self.lineage.validate()?; - - if !self.confidence.is_finite() || !(0.0..=1.0).contains(&self.confidence) { - return Err(ConsolidationValidationError::InvalidConfidence); - } - - self.markers.validate()?; - - for flag in &self.unsupported_claim_flags { - flag.validate()?; - } - - self.diff.validate()?; - - validate_json_object("target_ref", &self.target_ref)?; - validate_json_object("proposed_payload", &self.proposed_payload)?; - - Ok(()) - } -} - -/// Worker payload for materializing one consolidation run. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct ConsolidationJobPayload { - /// Versioned consolidation contract schema. - pub contract_schema: String, - #[serde(default)] - /// Proposals to persist for review. - pub proposals: Vec, -} -impl ConsolidationJobPayload { - /// Validates the queued worker payload and all proposal contracts. - pub fn validate(&self) -> Result<(), ConsolidationValidationError> { - if self.contract_schema != CONSOLIDATION_CONTRACT_SCHEMA_V1 { - return Err(ConsolidationValidationError::InvalidContractSchema); - } - - for proposal in &self.proposals { - proposal.validate()?; - } - - Ok(()) - } -} - -/// Validates a source reference list. -pub fn validate_source_refs( - source_refs: &[ConsolidationInputRef], -) -> Result<(), ConsolidationValidationError> { - if source_refs.is_empty() { - return Err(ConsolidationValidationError::MissingSourceRefs); - } - - for source_ref in source_refs { - source_ref.validate()?; - } - - Ok(()) -} - fn validate_json_object( field: &'static str, value: &Value, diff --git a/packages/elf-domain/src/consolidation/error.rs b/packages/elf-domain/src/consolidation/error.rs new file mode 100644 index 00000000..a8f234b2 --- /dev/null +++ b/packages/elf-domain/src/consolidation/error.rs @@ -0,0 +1,68 @@ +/// Error returned by consolidation contract validation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConsolidationValidationError { + /// A required source reference list was empty. + MissingSourceRefs, + /// A source snapshot did not include any immutable freshness guard. + MissingSourceSnapshot, + /// A JSON field was not the required object shape. + InvalidJsonObject { + /// Name of the invalid field. + field: &'static str, + }, + /// A required text field was empty. + EmptyText { + /// Name of the invalid field. + field: &'static str, + }, + /// A confidence value was outside the inclusive range 0.0 to 1.0. + InvalidConfidence, + /// The proposal diff included a source mutation key. + DestructiveDiff, + /// A proposal review transition is not allowed by the lifecycle. + InvalidReviewTransition { + /// Current review state. + from: super::lifecycle::ConsolidationReviewState, + /// Requested review state. + to: super::lifecycle::ConsolidationReviewState, + }, + /// A run state transition is not allowed by the job lifecycle. + InvalidRunTransition { + /// Current run state. + from: super::lifecycle::ConsolidationRunState, + /// Requested run state. + to: super::lifecycle::ConsolidationRunState, + }, + /// A stored state string is not part of the contract. + UnknownState { + /// Name of the invalid field. + field: &'static str, + }, + /// The queued contract schema did not match the consolidation v1 contract. + InvalidContractSchema, +} +impl std::fmt::Display for ConsolidationValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingSourceRefs => write!(f, "source_refs must not be empty"), + Self::MissingSourceSnapshot => { + write!(f, "source snapshot must include at least one freshness guard") + }, + Self::InvalidJsonObject { field } => write!(f, "{field} must be a JSON object"), + Self::EmptyText { field } => write!(f, "{field} must not be empty"), + Self::InvalidConfidence => write!(f, "confidence must be in the range 0.0..=1.0"), + Self::DestructiveDiff => write!(f, "proposal diff must not mutate source memory"), + Self::InvalidReviewTransition { from, to } => { + write!(f, "invalid proposal review transition from {from:?} to {to:?}") + }, + Self::InvalidRunTransition { from, to } => { + write!(f, "invalid consolidation run transition from {from:?} to {to:?}") + }, + Self::UnknownState { field } => write!(f, "{field} is not a known state"), + Self::InvalidContractSchema => { + write!(f, "contract_schema must be elf.consolidation/v1") + }, + } + } +} +impl std::error::Error for ConsolidationValidationError {} diff --git a/packages/elf-domain/src/consolidation/lifecycle.rs b/packages/elf-domain/src/consolidation/lifecycle.rs new file mode 100644 index 00000000..2552247a --- /dev/null +++ b/packages/elf-domain/src/consolidation/lifecycle.rs @@ -0,0 +1,167 @@ +use crate::consolidation::{ConsolidationValidationError, Deserialize, Serialize}; + +/// Derived-output apply intent for a reviewable proposal. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsolidationApplyIntent { + /// Create a new derived memory note after review. + CreateDerivedNote, + /// Update an existing derived memory note after review. + UpdateDerivedNote, + /// Create a derived knowledge page after review. + CreateDerivedKnowledgePage, + /// Update a derived knowledge page after review. + UpdateDerivedKnowledgePage, + /// Create or refresh a derived graph view after review. + CreateDerivedGraphView, + /// Store the proposal for review without applying a downstream derived artifact. + NoOp, +} +impl ConsolidationApplyIntent { + /// Returns the canonical storage string. + pub fn as_str(self) -> &'static str { + match self { + Self::CreateDerivedNote => "create_derived_note", + Self::UpdateDerivedNote => "update_derived_note", + Self::CreateDerivedKnowledgePage => "create_derived_knowledge_page", + Self::UpdateDerivedKnowledgePage => "update_derived_knowledge_page", + Self::CreateDerivedGraphView => "create_derived_graph_view", + Self::NoOp => "no_op", + } + } +} + +/// Reviewer action requested for a consolidation proposal. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsolidationReviewAction { + /// Approve a proposal for later application. + Approve, + /// Apply an approved proposal to a derived target. + Apply, + /// Discard a proposal as rejected. + Discard, + /// Defer a proposal by archiving it for later audit. + Defer, +} +impl ConsolidationReviewAction { + /// Returns the canonical storage string. + pub fn as_str(self) -> &'static str { + match self { + Self::Approve => "approve", + Self::Apply => "apply", + Self::Discard => "discard", + Self::Defer => "defer", + } + } +} + +/// Review lifecycle for a consolidation proposal. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsolidationReviewState { + /// Proposal is awaiting review. + Proposed, + /// Proposal has been approved for downstream derived-output application. + Approved, + /// Proposal was rejected by a reviewer. + Rejected, + /// Proposal was approved and marked applied to the derived target. + Applied, + /// Proposal is retained but no longer active for review. + Archived, +} +impl ConsolidationReviewState { + /// Returns the canonical storage string. + pub fn as_str(self) -> &'static str { + match self { + Self::Proposed => "proposed", + Self::Approved => "approved", + Self::Rejected => "rejected", + Self::Applied => "applied", + Self::Archived => "archived", + } + } + + /// Parses a canonical storage string. + pub fn parse(raw: &str) -> Option { + match raw { + "proposed" => Some(Self::Proposed), + "approved" => Some(Self::Approved), + "rejected" => Some(Self::Rejected), + "applied" => Some(Self::Applied), + "archived" => Some(Self::Archived), + _ => None, + } + } + + /// Validates a review lifecycle transition. + pub fn validate_transition(self, to: Self) -> Result<(), ConsolidationValidationError> { + let allowed = match self { + Self::Proposed => matches!(to, Self::Approved | Self::Rejected | Self::Archived), + Self::Approved => matches!(to, Self::Applied | Self::Rejected | Self::Archived), + Self::Rejected | Self::Applied | Self::Archived => false, + }; + + if allowed { + Ok(()) + } else { + Err(ConsolidationValidationError::InvalidReviewTransition { from: self, to }) + } + } +} + +/// Consolidation job lifecycle. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsolidationRunState { + /// Job has been registered but has not started. + Pending, + /// Job is actively generating fixture or future provider-backed proposals. + Running, + /// Job completed proposal generation. + Completed, + /// Job failed before completion. + Failed, + /// Job was cancelled by an operator. + Cancelled, +} +impl ConsolidationRunState { + /// Returns the canonical storage string. + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Running => "running", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } + + /// Parses a canonical storage string. + pub fn parse(raw: &str) -> Option { + match raw { + "pending" => Some(Self::Pending), + "running" => Some(Self::Running), + "completed" => Some(Self::Completed), + "failed" => Some(Self::Failed), + "cancelled" => Some(Self::Cancelled), + _ => None, + } + } + + /// Validates a job lifecycle transition. + pub fn validate_transition(self, to: Self) -> Result<(), ConsolidationValidationError> { + let allowed = match self { + Self::Pending => matches!(to, Self::Running | Self::Cancelled), + Self::Running => matches!(to, Self::Completed | Self::Failed | Self::Cancelled), + Self::Completed | Self::Failed | Self::Cancelled => false, + }; + + if allowed { + Ok(()) + } else { + Err(ConsolidationValidationError::InvalidRunTransition { from: self, to }) + } + } +} diff --git a/packages/elf-domain/src/consolidation/markers.rs b/packages/elf-domain/src/consolidation/markers.rs new file mode 100644 index 00000000..7833cb14 --- /dev/null +++ b/packages/elf-domain/src/consolidation/markers.rs @@ -0,0 +1,95 @@ +use crate::consolidation::{ + ConsolidationInputRef, ConsolidationValidationError, Deserialize, Serialize, +}; + +/// Confidence or honesty marker severity. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsolidationMarkerSeverity { + /// Low-severity marker. + Low, + /// Medium-severity marker. + Medium, + /// High-severity marker. + High, +} + +/// One contradiction or staleness marker attached to a proposal. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationMarker { + /// Marker severity. + pub severity: ConsolidationMarkerSeverity, + /// Human-readable marker text. + pub message: String, + /// Optional source that triggered the marker. + pub source: Option, +} +impl ConsolidationMarker { + /// Validates marker content and optional source evidence. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + if self.message.trim().is_empty() { + return Err(ConsolidationValidationError::EmptyText { field: "marker.message" }); + } + + if let Some(source) = &self.source { + source.validate()?; + } + + Ok(()) + } +} + +/// Contradiction and staleness markers attached to a proposal. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationMarkers { + #[serde(default)] + /// Contradiction markers that a reviewer must inspect. + pub contradictions: Vec, + #[serde(default)] + /// Staleness markers that a reviewer must inspect. + pub staleness: Vec, +} +impl ConsolidationMarkers { + /// Validates all marker payloads. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + for marker in self.contradictions.iter().chain(self.staleness.iter()) { + marker.validate()?; + } + + Ok(()) + } +} + +/// Unsupported-claim marker attached to a proposal for reviewer inspection. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationUnsupportedClaimFlag { + /// Stable claim identifier when the source fixture or worker supplies one. + pub claim_id: Option, + /// Human-readable unsupported-claim description. + pub message: String, + /// Optional source that demonstrates why the claim is unsupported. + pub source: Option, +} +impl ConsolidationUnsupportedClaimFlag { + /// Validates unsupported-claim marker content and optional source evidence. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + if self.message.trim().is_empty() { + return Err(ConsolidationValidationError::EmptyText { + field: "unsupported_claim_flags.message", + }); + } + + if let Some(claim_id) = &self.claim_id + && claim_id.trim().is_empty() + { + return Err(ConsolidationValidationError::EmptyText { + field: "unsupported_claim_flags.claim_id", + }); + } + if let Some(source) = &self.source { + source.validate()?; + } + + Ok(()) + } +} diff --git a/packages/elf-domain/src/consolidation/proposal.rs b/packages/elf-domain/src/consolidation/proposal.rs new file mode 100644 index 00000000..000ea66e --- /dev/null +++ b/packages/elf-domain/src/consolidation/proposal.rs @@ -0,0 +1,140 @@ +use crate::consolidation::{ + self, CONSOLIDATION_CONTRACT_SCHEMA_V1, ConsolidationApplyIntent, ConsolidationInputRef, + ConsolidationMarkers, ConsolidationUnsupportedClaimFlag, ConsolidationValidationError, + Deserialize, Serialize, Uuid, Value, +}; + +/// Reviewable diff between prior derived output and proposed derived output. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationProposalDiff { + /// Human-readable diff summary. + pub summary: String, + #[serde(default)] + /// Previous derived output snapshot, or an empty object for creates. + pub before: Value, + #[serde(default)] + /// Proposed derived output snapshot. + pub after: Value, +} +impl ConsolidationProposalDiff { + /// Validates diff shape and rejects source-mutation payloads. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + if self.summary.trim().is_empty() { + return Err(ConsolidationValidationError::EmptyText { field: "diff.summary" }); + } + + consolidation::validate_json_object("diff.before", &self.before)?; + consolidation::validate_json_object("diff.after", &self.after)?; + + if consolidation::contains_forbidden_diff_key(&self.before) + || consolidation::contains_forbidden_diff_key(&self.after) + { + return Err(ConsolidationValidationError::DestructiveDiff); + } + + Ok(()) + } +} + +/// Source lineage for one consolidation proposal. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationLineage { + /// Source references directly supporting the proposal. + pub source_refs: Vec, + /// Parent consolidation run, when this proposal is derived from an earlier run. + pub parent_run_id: Option, + #[serde(default)] + /// Parent proposals used as lineage inputs. + pub parent_proposal_ids: Vec, +} +impl ConsolidationLineage { + /// Validates source lineage references. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + consolidation::validate_source_refs(&self.source_refs) + } +} + +/// Full reviewable consolidation proposal contract. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationProposalContract { + /// Proposal kind, such as `derived_note` or `knowledge_page`. + pub proposal_kind: String, + /// Derived-output apply intent. + pub apply_intent: ConsolidationApplyIntent, + /// Source references directly supporting the proposal. + pub source_refs: Vec, + #[serde(default)] + /// Aggregate source snapshot metadata for reviewer inspection. + pub source_snapshot: Value, + /// Proposal lineage. + pub lineage: ConsolidationLineage, + /// Model or fixture confidence in the proposal. + pub confidence: f32, + #[serde(default)] + /// Unsupported claims that the reviewer must inspect before accepting a proposal. + pub unsupported_claim_flags: Vec, + /// Review markers for contradiction and staleness checks. + pub markers: ConsolidationMarkers, + /// Reviewable derived-output diff. + pub diff: ConsolidationProposalDiff, + #[serde(default)] + /// Derived target reference, when the target already exists. + pub target_ref: Value, + #[serde(default)] + /// Proposed derived output payload. + pub proposed_payload: Value, +} +impl ConsolidationProposalContract { + /// Validates a proposal contract before persistence. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + if self.proposal_kind.trim().is_empty() { + return Err(ConsolidationValidationError::EmptyText { field: "proposal_kind" }); + } + + consolidation::validate_source_refs(&self.source_refs)?; + consolidation::validate_json_object("source_snapshot", &self.source_snapshot)?; + + self.lineage.validate()?; + + if !self.confidence.is_finite() || !(0.0..=1.0).contains(&self.confidence) { + return Err(ConsolidationValidationError::InvalidConfidence); + } + + self.markers.validate()?; + + for flag in &self.unsupported_claim_flags { + flag.validate()?; + } + + self.diff.validate()?; + + consolidation::validate_json_object("target_ref", &self.target_ref)?; + consolidation::validate_json_object("proposed_payload", &self.proposed_payload)?; + + Ok(()) + } +} + +/// Worker payload for materializing one consolidation run. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationJobPayload { + /// Versioned consolidation contract schema. + pub contract_schema: String, + #[serde(default)] + /// Proposals to persist for review. + pub proposals: Vec, +} +impl ConsolidationJobPayload { + /// Validates the queued worker payload and all proposal contracts. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + if self.contract_schema != CONSOLIDATION_CONTRACT_SCHEMA_V1 { + return Err(ConsolidationValidationError::InvalidContractSchema); + } + + for proposal in &self.proposals { + proposal.validate()?; + } + + Ok(()) + } +} diff --git a/packages/elf-domain/src/consolidation/sources.rs b/packages/elf-domain/src/consolidation/sources.rs new file mode 100644 index 00000000..24ebea5e --- /dev/null +++ b/packages/elf-domain/src/consolidation/sources.rs @@ -0,0 +1,110 @@ +use crate::consolidation::{ + self, ConsolidationValidationError, Deserialize, OffsetDateTime, Serialize, Uuid, Value, +}; + +/// Source artifact kind accepted by consolidation input references. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsolidationSourceKind { + /// Memory note evidence. + Note, + /// Event ingestion source. + Event, + /// Search trace source. + Trace, + /// Search trace item source. + TraceItem, + /// Document extension source. + Doc, + /// Document chunk source. + DocChunk, +} +impl ConsolidationSourceKind { + /// Returns the canonical storage string. + pub fn as_str(self) -> &'static str { + match self { + Self::Note => "note", + Self::Event => "event", + Self::Trace => "trace", + Self::TraceItem => "trace_item", + Self::Doc => "doc", + Self::DocChunk => "doc_chunk", + } + } +} + +/// Immutable source snapshot guard captured before a proposal is stored. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationSourceSnapshot { + /// Source lifecycle status observed by the consolidation run. + pub status: Option, + /// Source last-update timestamp observed by the consolidation run. + pub updated_at: Option, + /// Source content or payload hash, when available. + pub content_hash: Option, + /// Source embedding version, when relevant. + pub embedding_version: Option, + /// Trace schema or trace version, when the source is a trace. + pub trace_version: Option, + #[serde(default)] + /// Opaque source reference copied from the authoritative source. + pub source_ref: Value, + #[serde(default)] + /// Additional snapshot metadata used for replay or review. + pub metadata: Value, +} +impl ConsolidationSourceSnapshot { + /// Validates snapshot shape and immutable freshness guards. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + consolidation::validate_json_object("source_ref", &self.source_ref)?; + consolidation::validate_json_object("metadata", &self.metadata)?; + + let has_hash = self.content_hash.as_ref().is_some_and(|hash| !hash.trim().is_empty()); + let has_embedding = + self.embedding_version.as_ref().is_some_and(|version| !version.trim().is_empty()); + let has_status = self.status.as_ref().is_some_and(|status| !status.trim().is_empty()); + let has_source_ref = consolidation::non_empty_object(&self.source_ref); + let has_metadata = consolidation::non_empty_object(&self.metadata); + let has_guard = self.updated_at.is_some() + || self.trace_version.is_some() + || has_hash + || has_embedding + || has_status + || has_source_ref + || has_metadata; + + if has_guard { Ok(()) } else { Err(ConsolidationValidationError::MissingSourceSnapshot) } + } +} + +/// Stable pointer to one immutable consolidation input. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ConsolidationInputRef { + /// Kind of source artifact being referenced. + pub kind: ConsolidationSourceKind, + /// Identifier of the source artifact. + pub id: Uuid, + /// Snapshot metadata captured before proposal generation. + pub snapshot: ConsolidationSourceSnapshot, +} +impl ConsolidationInputRef { + /// Validates the input reference and its snapshot guard. + pub fn validate(&self) -> Result<(), ConsolidationValidationError> { + self.snapshot.validate() + } +} + +/// Validates a source reference list. +pub fn validate_source_refs( + source_refs: &[ConsolidationInputRef], +) -> Result<(), ConsolidationValidationError> { + if source_refs.is_empty() { + return Err(ConsolidationValidationError::MissingSourceRefs); + } + + for source_ref in source_refs { + source_ref.validate()?; + } + + Ok(()) +} diff --git a/packages/elf-domain/src/memory_policy.rs b/packages/elf-domain/src/memory_policy.rs index cafe3aef..8ff4d883 100644 --- a/packages/elf-domain/src/memory_policy.rs +++ b/packages/elf-domain/src/memory_policy.rs @@ -122,413 +122,5 @@ fn should_downgrade( } #[cfg(test)] -mod tests { - use crate::memory_policy::{self, MemoryPolicyDecision, MemoryPolicyEvaluation}; - use elf_config::{ - Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, - MemoryPolicy, MemoryPolicyRule, Postgres, ProviderConfig, Providers, Qdrant, Ranking, - RankingBlend, RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, - RankingDeterministicHits, RankingDeterministicLexical, RankingDiversity, - RankingRetrievalSources, ReadProfiles, ScopePrecedence, ScopeWriteAllowed, Scopes, Search, - SearchCache, SearchDynamic, SearchExpansion, SearchExplain, SearchGraphContext, - SearchPrefilter, SearchRecursive, Security, Service, Storage, TtlDays, - }; - - fn test_config(policy: MemoryPolicy) -> Config { - let mut cfg = test_default_config(); - - cfg.memory.policy = policy; - - cfg - } - - fn test_default_config() -> Config { - Config { - service: test_service_config(), - storage: test_storage_config(), - providers: test_providers_config(), - scopes: test_scopes_config(), - memory: test_memory_config(), - search: test_search_config(), - ranking: test_ranking_config(), - lifecycle: test_lifecycle_config(), - security: test_security_config(), - chunking: test_chunking_config(), - context: None, - mcp: None, - } - } - - fn test_service_config() -> Service { - Service { - http_bind: "127.0.0.1:8080".to_string(), - mcp_bind: "127.0.0.1:8082".to_string(), - admin_bind: "127.0.0.1:8081".to_string(), - log_level: "info".to_string(), - } - } - - fn test_storage_config() -> Storage { - Storage { - postgres: Postgres { - dsn: "postgres://user:pass@localhost/db".to_string(), - pool_max_conns: 1, - }, - qdrant: Qdrant { - url: "http://localhost".to_string(), - collection: "mem_notes_v2".to_string(), - docs_collection: "doc_chunks_v1".to_string(), - vector_dim: 4_096, - }, - } - } - - fn test_providers_config() -> Providers { - Providers { - embedding: test_embedding_provider_config(), - rerank: test_rerank_provider_config(), - llm_extractor: test_llm_extractor_provider_config(), - } - } - - fn test_embedding_provider_config() -> EmbeddingProviderConfig { - EmbeddingProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - dimensions: 3, - timeout_ms: 1_000, - default_headers: Default::default(), - } - } - - fn test_rerank_provider_config() -> ProviderConfig { - ProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - timeout_ms: 1_000, - default_headers: Default::default(), - } - } - - fn test_llm_extractor_provider_config() -> LlmProviderConfig { - LlmProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - temperature: 0.1, - timeout_ms: 1_000, - default_headers: Default::default(), - } - } - - fn test_scopes_config() -> Scopes { - Scopes { - allowed: vec!["agent_private".to_string()], - read_profiles: test_read_profiles_config(), - precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, - write_allowed: ScopeWriteAllowed { - agent_private: true, - project_shared: true, - org_shared: true, - }, - } - } - - fn test_read_profiles_config() -> ReadProfiles { - ReadProfiles { - private_only: vec!["agent_private".to_string()], - private_plus_project: vec!["agent_private".to_string()], - all_scopes: vec!["agent_private".to_string()], - } - } - - fn test_memory_config() -> Memory { - Memory { - max_notes_per_add_event: 3, - max_note_chars: 240, - dup_sim_threshold: 0.92, - update_sim_threshold: 0.85, - candidate_k: 60, - top_k: 12, - policy: MemoryPolicy { - rules: vec![ - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.9), - min_importance: Some(0.1), - }, - MemoryPolicyRule { - note_type: Some("preference".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.75), - min_importance: None, - }, - MemoryPolicyRule { - note_type: Some("preference".to_string()), - scope: None, - min_confidence: Some(0.6), - min_importance: None, - }, - MemoryPolicyRule { - note_type: None, - scope: None, - min_confidence: None, - min_importance: None, - }, - ], - }, - } - } - - fn test_search_config() -> Search { - Search { - expansion: SearchExpansion { - mode: "off".to_string(), - max_queries: 4, - include_original: true, - }, - dynamic: SearchDynamic { min_candidates: 10, min_top_score: 0.12 }, - prefilter: SearchPrefilter { max_candidates: 0 }, - cache: SearchCache { - enabled: true, - expansion_ttl_days: 7, - rerank_ttl_days: 7, - max_payload_bytes: Some(262_144), - }, - explain: SearchExplain { - retention_days: 7, - capture_candidates: false, - candidate_retention_days: 2, - write_mode: "outbox".to_string(), - }, - recursive: SearchRecursive { - enabled: false, - max_depth: 2, - max_children_per_node: 4, - max_nodes_per_scope: 32, - max_total_nodes: 256, - }, - graph_context: SearchGraphContext { - enabled: false, - max_facts_per_item: 16, - max_evidence_notes_per_fact: 16, - }, - } - } - - fn test_ranking_config() -> Ranking { - Ranking { - recency_tau_days: 60.0, - tie_breaker_weight: 0.1, - deterministic: test_ranking_deterministic_config(), - blend: RankingBlend { - enabled: true, - rerank_normalization: "rank".to_string(), - retrieval_normalization: "rank".to_string(), - segments: vec![ - RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, - RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, - RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, - ], - }, - diversity: RankingDiversity { - enabled: true, - sim_threshold: 0.88, - mmr_lambda: 0.7, - max_skips: 64, - }, - retrieval_sources: RankingRetrievalSources { - fusion_weight: 1.0, - structured_field_weight: 1.0, - fusion_priority: 1, - structured_field_priority: 0, - }, - } - } - - fn test_ranking_deterministic_config() -> RankingDeterministic { - RankingDeterministic { - enabled: false, - lexical: RankingDeterministicLexical { - enabled: false, - weight: 0.05, - min_ratio: 0.3, - max_query_terms: 16, - max_text_terms: 1_024, - }, - hits: RankingDeterministicHits { - enabled: false, - weight: 0.05, - half_saturation: 8.0, - last_hit_tau_days: 14.0, - }, - decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, - } - } - - fn test_lifecycle_config() -> Lifecycle { - Lifecycle { - ttl_days: TtlDays { - plan: 14, - fact: 180, - preference: 0, - constraint: 0, - decision: 0, - profile: 0, - }, - purge_deleted_after_days: 30, - purge_deprecated_after_days: 180, - } - } - - fn test_security_config() -> Security { - Security { - bind_localhost_only: true, - reject_non_english: true, - redact_secrets_on_write: true, - evidence_min_quotes: 1, - evidence_max_quotes: 2, - evidence_max_quote_chars: 320, - auth_mode: "off".to_string(), - auth_keys: vec![], - } - } - - fn test_chunking_config() -> Chunking { - Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: "REPLACE_ME".to_string(), - } - } - #[test] - fn policy_precedence_prefers_note_type_and_scope_over_note_type_only() { - let cfg = test_config(MemoryPolicy { - rules: vec![ - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: None, - min_confidence: Some(0.05), - min_importance: None, - }, - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.95), - min_importance: None, - }, - MemoryPolicyRule { - note_type: None, - scope: Some("agent_private".to_string()), - min_confidence: Some(0.40), - min_importance: None, - }, - ], - }); - let MemoryPolicyEvaluation { decision, matched_rule } = - memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.5, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(decision, MemoryPolicyDecision::Ignore); - - let rule = matched_rule.expect("expected policy match"); - - assert_eq!(rule.note_type.as_deref(), Some("fact")); - assert_eq!(rule.scope.as_deref(), Some("agent_private")); - assert_eq!(rule.min_confidence, Some(0.95)); - assert_eq!(rule.min_importance, None); - } - - #[test] - fn evaluate_downgrades_base_remember_update_only() { - let cfg = test_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.9), - min_importance: Some(0.5), - }], - }); - let remember = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.95, - 0.4, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(remember.decision, MemoryPolicyDecision::Ignore); - - let update = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - f64::NAN, - f64::NAN, - MemoryPolicyDecision::Update, - ); - - assert_eq!(update.decision, MemoryPolicyDecision::Ignore); - - let ignore = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.1, - 0.1, - MemoryPolicyDecision::Ignore, - ); - - assert_eq!(ignore.decision, MemoryPolicyDecision::Ignore); - - let reject = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.1, - 0.1, - MemoryPolicyDecision::Reject, - ); - - assert_eq!(reject.decision, MemoryPolicyDecision::Reject); - } - - #[test] - fn evaluate_without_matching_threshold_leaves_base_unchanged() { - let cfg = test_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: None, - min_importance: None, - }], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.0, - 0.0, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Remember); - } -} +#[path = "memory_policy/tests.rs"] +mod tests; diff --git a/packages/elf-domain/src/memory_policy/tests.rs b/packages/elf-domain/src/memory_policy/tests.rs new file mode 100644 index 00000000..989d175a --- /dev/null +++ b/packages/elf-domain/src/memory_policy/tests.rs @@ -0,0 +1,408 @@ +use crate::memory_policy::{self, MemoryPolicyDecision, MemoryPolicyEvaluation}; +use elf_config::{ + Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, + MemoryPolicyRule, Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, + RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, + RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, + ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, + SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, + Service, Storage, TtlDays, +}; + +fn test_config(policy: MemoryPolicy) -> Config { + let mut cfg = test_default_config(); + + cfg.memory.policy = policy; + + cfg +} + +fn test_default_config() -> Config { + Config { + service: test_service_config(), + storage: test_storage_config(), + providers: test_providers_config(), + scopes: test_scopes_config(), + memory: test_memory_config(), + search: test_search_config(), + ranking: test_ranking_config(), + lifecycle: test_lifecycle_config(), + security: test_security_config(), + chunking: test_chunking_config(), + context: None, + mcp: None, + } +} + +fn test_service_config() -> Service { + Service { + http_bind: "127.0.0.1:8080".to_string(), + mcp_bind: "127.0.0.1:8082".to_string(), + admin_bind: "127.0.0.1:8081".to_string(), + log_level: "info".to_string(), + } +} + +fn test_storage_config() -> Storage { + Storage { + postgres: Postgres { + dsn: "postgres://user:pass@localhost/db".to_string(), + pool_max_conns: 1, + }, + qdrant: Qdrant { + url: "http://localhost".to_string(), + collection: "mem_notes_v2".to_string(), + docs_collection: "doc_chunks_v1".to_string(), + vector_dim: 4_096, + }, + } +} + +fn test_providers_config() -> Providers { + Providers { + embedding: test_embedding_provider_config(), + rerank: test_rerank_provider_config(), + llm_extractor: test_llm_extractor_provider_config(), + } +} + +fn test_embedding_provider_config() -> EmbeddingProviderConfig { + EmbeddingProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + dimensions: 3, + timeout_ms: 1_000, + default_headers: Default::default(), + } +} + +fn test_rerank_provider_config() -> ProviderConfig { + ProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + timeout_ms: 1_000, + default_headers: Default::default(), + } +} + +fn test_llm_extractor_provider_config() -> LlmProviderConfig { + LlmProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + temperature: 0.1, + timeout_ms: 1_000, + default_headers: Default::default(), + } +} + +fn test_scopes_config() -> Scopes { + Scopes { + allowed: vec!["agent_private".to_string()], + read_profiles: test_read_profiles_config(), + precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, + write_allowed: ScopeWriteAllowed { + agent_private: true, + project_shared: true, + org_shared: true, + }, + } +} + +fn test_read_profiles_config() -> ReadProfiles { + ReadProfiles { + private_only: vec!["agent_private".to_string()], + private_plus_project: vec!["agent_private".to_string()], + all_scopes: vec!["agent_private".to_string()], + } +} + +fn test_memory_config() -> Memory { + Memory { + max_notes_per_add_event: 3, + max_note_chars: 240, + dup_sim_threshold: 0.92, + update_sim_threshold: 0.85, + candidate_k: 60, + top_k: 12, + policy: MemoryPolicy { + rules: vec![ + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.9), + min_importance: Some(0.1), + }, + MemoryPolicyRule { + note_type: Some("preference".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.75), + min_importance: None, + }, + MemoryPolicyRule { + note_type: Some("preference".to_string()), + scope: None, + min_confidence: Some(0.6), + min_importance: None, + }, + MemoryPolicyRule { + note_type: None, + scope: None, + min_confidence: None, + min_importance: None, + }, + ], + }, + } +} + +fn test_search_config() -> Search { + Search { + expansion: SearchExpansion { + mode: "off".to_string(), + max_queries: 4, + include_original: true, + }, + dynamic: SearchDynamic { min_candidates: 10, min_top_score: 0.12 }, + prefilter: SearchPrefilter { max_candidates: 0 }, + cache: SearchCache { + enabled: true, + expansion_ttl_days: 7, + rerank_ttl_days: 7, + max_payload_bytes: Some(262_144), + }, + explain: SearchExplain { + retention_days: 7, + capture_candidates: false, + candidate_retention_days: 2, + write_mode: "outbox".to_string(), + }, + recursive: SearchRecursive { + enabled: false, + max_depth: 2, + max_children_per_node: 4, + max_nodes_per_scope: 32, + max_total_nodes: 256, + }, + graph_context: SearchGraphContext { + enabled: false, + max_facts_per_item: 16, + max_evidence_notes_per_fact: 16, + }, + } +} + +fn test_ranking_config() -> Ranking { + Ranking { + recency_tau_days: 60.0, + tie_breaker_weight: 0.1, + deterministic: test_ranking_deterministic_config(), + blend: RankingBlend { + enabled: true, + rerank_normalization: "rank".to_string(), + retrieval_normalization: "rank".to_string(), + segments: vec![ + RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, + RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, + RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, + ], + }, + diversity: RankingDiversity { + enabled: true, + sim_threshold: 0.88, + mmr_lambda: 0.7, + max_skips: 64, + }, + retrieval_sources: RankingRetrievalSources { + fusion_weight: 1.0, + structured_field_weight: 1.0, + fusion_priority: 1, + structured_field_priority: 0, + }, + } +} + +fn test_ranking_deterministic_config() -> RankingDeterministic { + RankingDeterministic { + enabled: false, + lexical: RankingDeterministicLexical { + enabled: false, + weight: 0.05, + min_ratio: 0.3, + max_query_terms: 16, + max_text_terms: 1_024, + }, + hits: RankingDeterministicHits { + enabled: false, + weight: 0.05, + half_saturation: 8.0, + last_hit_tau_days: 14.0, + }, + decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, + } +} + +fn test_lifecycle_config() -> Lifecycle { + Lifecycle { + ttl_days: TtlDays { + plan: 14, + fact: 180, + preference: 0, + constraint: 0, + decision: 0, + profile: 0, + }, + purge_deleted_after_days: 30, + purge_deprecated_after_days: 180, + } +} + +fn test_security_config() -> Security { + Security { + bind_localhost_only: true, + reject_non_english: true, + redact_secrets_on_write: true, + evidence_min_quotes: 1, + evidence_max_quotes: 2, + evidence_max_quote_chars: 320, + auth_mode: "off".to_string(), + auth_keys: vec![], + } +} + +fn test_chunking_config() -> Chunking { + Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "REPLACE_ME".to_string(), + } +} + +#[test] +fn policy_precedence_prefers_note_type_and_scope_over_note_type_only() { + let cfg = test_config(MemoryPolicy { + rules: vec![ + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: None, + min_confidence: Some(0.05), + min_importance: None, + }, + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.95), + min_importance: None, + }, + MemoryPolicyRule { + note_type: None, + scope: Some("agent_private".to_string()), + min_confidence: Some(0.40), + min_importance: None, + }, + ], + }); + let MemoryPolicyEvaluation { decision, matched_rule } = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.5, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(decision, MemoryPolicyDecision::Ignore); + + let rule = matched_rule.expect("expected policy match"); + + assert_eq!(rule.note_type.as_deref(), Some("fact")); + assert_eq!(rule.scope.as_deref(), Some("agent_private")); + assert_eq!(rule.min_confidence, Some(0.95)); + assert_eq!(rule.min_importance, None); +} + +#[test] +fn evaluate_downgrades_base_remember_update_only() { + let cfg = test_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.9), + min_importance: Some(0.5), + }], + }); + let remember = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.95, + 0.4, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(remember.decision, MemoryPolicyDecision::Ignore); + + let update = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + f64::NAN, + f64::NAN, + MemoryPolicyDecision::Update, + ); + + assert_eq!(update.decision, MemoryPolicyDecision::Ignore); + + let ignore = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.1, + 0.1, + MemoryPolicyDecision::Ignore, + ); + + assert_eq!(ignore.decision, MemoryPolicyDecision::Ignore); + + let reject = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.1, + 0.1, + MemoryPolicyDecision::Reject, + ); + + assert_eq!(reject.decision, MemoryPolicyDecision::Reject); +} + +#[test] +fn evaluate_without_matching_threshold_leaves_base_unchanged() { + let cfg = test_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: None, + min_importance: None, + }], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.0, + 0.0, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Remember); +} diff --git a/packages/elf-domain/src/writegate.rs b/packages/elf-domain/src/writegate.rs index 3d66dcc4..3a7bf82e 100644 --- a/packages/elf-domain/src/writegate.rs +++ b/packages/elf-domain/src/writegate.rs @@ -1,591 +1,24 @@ //! Writegate validation and redaction helpers. +mod policy; +mod secrets; +mod types; +mod validation; + +pub use self::{ + policy::apply_write_policy, + secrets::contains_secrets, + types::{ + NoteInput, RejectCode, WritePolicy, WritePolicyAudit, WritePolicyError, WritePolicyResult, + WriteRedaction, WriteRedactionResult, WriteSpan, + }, + validation::writegate, +}; + use regex::Regex; use serde::{Deserialize, Serialize}; use crate::english_gate; use elf_config::Config; -/// Reasons a note can be rejected by the write gate. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RejectCode { - /// The note text failed the English gate. - RejectNonEnglish, - /// The note text exceeded the configured length limit. - RejectTooLong, - /// The note text appears to contain secret material. - RejectSecret, - /// The note type is not one of the allowed values. - RejectInvalidType, - /// The note scope is not allowed or not writable. - RejectScopeDenied, - /// The note text is empty after trimming. - RejectEmpty, -} - -/// One write-policy redaction operation. -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum WriteRedaction { - /// Replaces the target span with a literal string. - Replace { - /// Span to replace before persistence. - span: WriteSpan, - /// Literal replacement text to insert for the span. - replacement: String, - }, - /// Removes the target span entirely. - Remove { - /// Span to remove before persistence. - span: WriteSpan, - }, -} - -/// Errors returned while validating write-policy spans. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum WritePolicyError { - /// A span was out of bounds or not aligned to char boundaries. - InvalidSpan, - /// Two exclusions/redactions overlapped. - OverlappingOps, -} - -#[derive(Clone, Debug)] -enum WriteOpKind { - Exclude, - Redact(String), -} - -/// Half-open byte span within input text. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub struct WriteSpan { - /// Inclusive start byte offset. - pub start: usize, - /// Exclusive end byte offset. - pub end: usize, -} - -/// Optional write-policy transform applied before note ingestion. -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub struct WritePolicy { - /// Spans that should be removed before persistence. - #[serde(default)] - pub exclusions: Vec, - /// Redactions that should be applied before persistence. - #[serde(default)] - pub redactions: Vec, -} - -/// Result of applying a write policy to one note body. -#[derive(Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -pub struct WritePolicyResult { - /// Transformed note text after exclusions and redactions. - pub transformed: String, - /// Audit data describing which operations were applied. - pub audit: WritePolicyAudit, -} - -/// Audit payload emitted when a write policy is applied. -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub struct WritePolicyAudit { - /// Exclusion spans that were applied. - pub exclusions: Vec, - /// Redactions that were applied. - pub redactions: Vec, -} - -/// One redaction entry in write-policy audit output. -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub struct WriteRedactionResult { - /// Span that was removed or replaced. - pub span: WriteSpan, - /// Replacement text that was applied. - pub replacement: String, -} - -/// Normalized note input passed through `writegate`. -pub struct NoteInput { - /// Requested note type. - pub note_type: String, - /// Requested write scope. - pub scope: String, - /// Note text after request decoding. - pub text: String, -} - -#[derive(Clone, Debug)] -struct WriteOp { - span: WriteSpan, - kind: WriteOpKind, -} - -/// Applies an optional write policy to note text and returns the transformed output. -pub fn apply_write_policy( - text: &str, - policy: Option<&WritePolicy>, -) -> Result { - let policy = match policy { - Some(policy) => policy, - None => { - return Ok(WritePolicyResult { - transformed: text.to_string(), - audit: WritePolicyAudit::default(), - }); - }, - }; - let mut exclusions = policy.exclusions.clone(); - let mut redactions = policy.redactions.clone(); - - if exclusions.is_empty() && redactions.is_empty() { - return Ok(WritePolicyResult { - transformed: text.to_string(), - audit: WritePolicyAudit::default(), - }); - } - - exclusions.sort_by_key(|span| (span.start, span.end)); - redactions.sort_by_key(|r| match r { - WriteRedaction::Replace { span, .. } => (span.start, span.end), - WriteRedaction::Remove { span } => (span.start, span.end), - }); - - let mut ops = Vec::with_capacity(exclusions.len() + redactions.len()); - let mut audit = WritePolicyAudit::default(); - - for span in &exclusions { - validate_span(text, span)?; - - ops.push(WriteOp { span: *span, kind: WriteOpKind::Exclude }); - audit.exclusions.push(*span); - } - for redaction in &redactions { - match redaction { - WriteRedaction::Remove { span } => { - validate_span(text, span)?; - - ops.push(WriteOp { span: *span, kind: WriteOpKind::Redact(String::new()) }); - audit - .redactions - .push(WriteRedactionResult { span: *span, replacement: String::new() }); - }, - - WriteRedaction::Replace { span, replacement } => { - validate_span(text, span)?; - - ops.push(WriteOp { span: *span, kind: WriteOpKind::Redact(replacement.clone()) }); - audit - .redactions - .push(WriteRedactionResult { span: *span, replacement: replacement.clone() }); - }, - } - } - - ops.sort_by_key(|op| (op.span.start, op.span.end)); - - validate_non_overlapping_ops(&ops)?; - - let mut transformed = text.to_string(); - - for op in ops.iter().rev() { - match &op.kind { - WriteOpKind::Exclude => transformed.replace_range(op.span.start..op.span.end, ""), - WriteOpKind::Redact(replacement) => - transformed.replace_range(op.span.start..op.span.end, replacement.as_str()), - } - } - - Ok(WritePolicyResult { transformed, audit }) -} - -/// Validates note content and metadata against ELF write-gate rules. -pub fn writegate(note: &NoteInput, cfg: &Config) -> Result<(), RejectCode> { - if note.text.trim().is_empty() { - return Err(RejectCode::RejectEmpty); - } - if !english_gate::is_english_natural_language(note.text.as_str()) { - return Err(RejectCode::RejectNonEnglish); - } - if note.text.chars().count() as u32 > cfg.memory.max_note_chars { - return Err(RejectCode::RejectTooLong); - } - if !is_allowed_type(¬e.note_type) { - return Err(RejectCode::RejectInvalidType); - } - if !cfg.scopes.allowed.iter().any(|scope| scope == ¬e.scope) { - return Err(RejectCode::RejectScopeDenied); - } - if !scope_write_allowed(cfg, ¬e.scope) { - return Err(RejectCode::RejectScopeDenied); - } - if contains_secrets(¬e.text) { - return Err(RejectCode::RejectSecret); - } - - Ok(()) -} - -/// Returns whether the input appears to contain secret material. -pub fn contains_secrets(text: &str) -> bool { - let patterns = [ - r"(?i)-----BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY-----", - r"(?i)ssh-rsa", - r"(?i)sk-[a-z0-9]{20,}", - r"(?i)api[_-]?key\s*[:=]\s*\S+", - r"(?i)password\s*[:=]\s*\S+", - r"(?i)secret\s*[:=]\s*\S+", - r"(?i)token\s*[:=]\s*\S+", - r"(?i)seed phrase", - ]; - - for pattern in patterns { - if Regex::new(pattern).map(|re| re.is_match(text)).unwrap_or(false) { - return true; - } - } - - false -} - -fn validate_span(text: &str, span: &WriteSpan) -> Result<(), WritePolicyError> { - if span.end < span.start { - return Err(WritePolicyError::InvalidSpan); - } - if span.end > text.len() { - return Err(WritePolicyError::InvalidSpan); - } - if !text.is_char_boundary(span.start) || !text.is_char_boundary(span.end) { - return Err(WritePolicyError::InvalidSpan); - } - - Ok(()) -} - -fn validate_non_overlapping_ops(ops: &[WriteOp]) -> Result<(), WritePolicyError> { - let mut last_end = 0_usize; - - for op in ops { - if op.span.start < last_end { - return Err(WritePolicyError::OverlappingOps); - } - - last_end = op.span.end; - } - - Ok(()) -} - -fn scope_write_allowed(cfg: &Config, scope: &str) -> bool { - match scope { - "agent_private" => cfg.scopes.write_allowed.agent_private, - "project_shared" => cfg.scopes.write_allowed.project_shared, - "org_shared" => cfg.scopes.write_allowed.org_shared, - _ => false, - } -} - -fn is_allowed_type(note_type: &str) -> bool { - matches!(note_type, "preference" | "constraint" | "decision" | "profile" | "fact" | "plan") -} - -#[cfg(test)] -mod tests { - use crate::writegate::{ - self, NoteInput, RejectCode, WritePolicy, WritePolicyResult, WriteRedaction, - WriteRedactionResult, - }; - use elf_config::{ - Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, - MemoryPolicy, Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, - RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, - RankingDeterministicHits, RankingDeterministicLexical, RankingDiversity, - RankingRetrievalSources, ReadProfiles, ScopePrecedence, ScopeWriteAllowed, Scopes, Search, - SearchCache, SearchDynamic, SearchExpansion, SearchExplain, SearchGraphContext, - SearchPrefilter, SearchRecursive, Security, Service, Storage, TtlDays, - }; - - fn test_ranking() -> Ranking { - Ranking { - recency_tau_days: 60.0, - tie_breaker_weight: 0.1, - deterministic: RankingDeterministic { - enabled: false, - lexical: RankingDeterministicLexical { - enabled: false, - weight: 0.05, - min_ratio: 0.3, - max_query_terms: 16, - max_text_terms: 1_024, - }, - hits: RankingDeterministicHits { - enabled: false, - weight: 0.05, - half_saturation: 8.0, - last_hit_tau_days: 14.0, - }, - decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, - }, - blend: RankingBlend { - enabled: true, - rerank_normalization: "rank".to_string(), - retrieval_normalization: "rank".to_string(), - segments: vec![ - RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, - RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, - RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, - ], - }, - diversity: RankingDiversity { - enabled: true, - sim_threshold: 0.88, - mmr_lambda: 0.7, - max_skips: 64, - }, - retrieval_sources: RankingRetrievalSources { - fusion_weight: 1.0, - structured_field_weight: 1.0, - fusion_priority: 1, - structured_field_priority: 0, - }, - } - } - - fn config() -> Config { - Config { - service: Service { - http_bind: "127.0.0.1:8080".to_string(), - mcp_bind: "127.0.0.1:8082".to_string(), - admin_bind: "127.0.0.1:8081".to_string(), - log_level: "info".to_string(), - }, - storage: Storage { - postgres: Postgres { - dsn: "postgres://user:pass@localhost/db".to_string(), - pool_max_conns: 1, - }, - qdrant: Qdrant { - url: "http://localhost".to_string(), - collection: "mem_notes_v2".to_string(), - docs_collection: "doc_chunks_v1".to_string(), - vector_dim: 4_096, - }, - }, - providers: Providers { - embedding: dummy_embedding_provider(), - rerank: dummy_provider(), - llm_extractor: dummy_llm_provider(), - }, - scopes: Scopes { - allowed: vec!["agent_private".to_string()], - read_profiles: ReadProfiles { - private_only: vec!["agent_private".to_string()], - private_plus_project: vec!["agent_private".to_string()], - all_scopes: vec!["agent_private".to_string()], - }, - precedence: ScopePrecedence { - agent_private: 30, - project_shared: 20, - org_shared: 10, - }, - write_allowed: ScopeWriteAllowed { - agent_private: true, - project_shared: true, - org_shared: true, - }, - }, - memory: Memory { - max_notes_per_add_event: 3, - max_note_chars: 10, - dup_sim_threshold: 0.9, - update_sim_threshold: 0.8, - candidate_k: 10, - top_k: 5, - policy: MemoryPolicy { rules: vec![] }, - }, - search: Search { - expansion: SearchExpansion { - mode: "off".to_string(), - max_queries: 4, - include_original: true, - }, - dynamic: SearchDynamic { min_candidates: 10, min_top_score: 0.12 }, - prefilter: SearchPrefilter { max_candidates: 0 }, - cache: SearchCache { - enabled: true, - expansion_ttl_days: 7, - rerank_ttl_days: 7, - max_payload_bytes: Some(262_144), - }, - explain: SearchExplain { - retention_days: 7, - capture_candidates: false, - candidate_retention_days: 2, - write_mode: "outbox".to_string(), - }, - recursive: SearchRecursive { - enabled: false, - max_depth: 2, - max_children_per_node: 4, - max_nodes_per_scope: 32, - max_total_nodes: 256, - }, - graph_context: SearchGraphContext { - enabled: false, - max_facts_per_item: 16, - max_evidence_notes_per_fact: 16, - }, - }, - ranking: test_ranking(), - lifecycle: Lifecycle { - ttl_days: TtlDays { - plan: 1, - fact: 2, - preference: 0, - constraint: 0, - decision: 0, - profile: 0, - }, - purge_deleted_after_days: 30, - purge_deprecated_after_days: 180, - }, - security: Security { - bind_localhost_only: true, - reject_non_english: true, - redact_secrets_on_write: true, - evidence_min_quotes: 1, - evidence_max_quotes: 2, - evidence_max_quote_chars: 320, - auth_mode: "off".to_string(), - auth_keys: vec![], - }, - chunking: Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: "REPLACE_ME".to_string(), - }, - context: None, - mcp: None, - } - } - - fn dummy_embedding_provider() -> EmbeddingProviderConfig { - EmbeddingProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - dimensions: 3, - timeout_ms: 1_000, - default_headers: serde_json::Map::new(), - } - } - - fn dummy_provider() -> ProviderConfig { - ProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - timeout_ms: 1_000, - default_headers: serde_json::Map::new(), - } - } - - fn dummy_llm_provider() -> LlmProviderConfig { - LlmProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - temperature: 0.1, - timeout_ms: 1_000, - default_headers: serde_json::Map::new(), - } - } - - #[test] - fn rejects_long_text() { - let cfg = config(); - let note = NoteInput { - note_type: "fact".to_string(), - scope: "agent_private".to_string(), - text: "12345678901".to_string(), - }; - - assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectTooLong)); - } - - #[test] - fn rejects_invalid_type() { - let cfg = config(); - let note = NoteInput { - note_type: "other".to_string(), - scope: "agent_private".to_string(), - text: "hello".to_string(), - }; - - assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectInvalidType)); - } - - #[test] - fn detects_secret_patterns() { - assert!(writegate::contains_secrets("password: hunter2")); - } - - #[test] - fn applies_empty_policy_as_noop() { - let policy = WritePolicy::default(); - - assert_eq!( - writegate::apply_write_policy("keep this", Some(&policy)), - Ok(WritePolicyResult { - transformed: "keep this".to_string(), - ..WritePolicyResult::default() - }) - ); - } - - #[test] - fn applies_exclusion_span() { - let policy = WritePolicy { - exclusions: vec![crate::writegate::WriteSpan { start: 4, end: 9 }], - redactions: vec![], - }; - let actual = writegate::apply_write_policy("hello world", Some(&policy)) - .expect("policy apply should succeed"); - - assert_eq!(actual.transformed, "hellld"); - assert_eq!(actual.audit.exclusions, vec![crate::writegate::WriteSpan { start: 4, end: 9 }]); - assert!(actual.audit.redactions.is_empty()); - } - - #[test] - fn applies_simple_replacement_redaction() { - let policy = WritePolicy { - exclusions: vec![], - redactions: vec![WriteRedaction::Replace { - span: crate::writegate::WriteSpan { start: 4, end: 5 }, - replacement: "***".to_string(), - }], - }; - let actual = writegate::apply_write_policy("secret", Some(&policy)) - .expect("policy apply should succeed"); - - assert_eq!(actual.transformed, "secr***t"); - assert_eq!( - actual.audit.redactions, - vec![WriteRedactionResult { - span: crate::writegate::WriteSpan { start: 4, end: 5 }, - replacement: "***".to_string(), - }] - ); - assert!(actual.audit.exclusions.is_empty()); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-domain/src/writegate/policy.rs b/packages/elf-domain/src/writegate/policy.rs new file mode 100644 index 00000000..7fbd646b --- /dev/null +++ b/packages/elf-domain/src/writegate/policy.rs @@ -0,0 +1,122 @@ +use crate::writegate::{ + WritePolicy, WritePolicyAudit, WritePolicyError, WritePolicyResult, WriteRedaction, + WriteRedactionResult, WriteSpan, +}; + +#[derive(Clone, Debug)] +enum WriteOpKind { + Exclude, + Redact(String), +} + +#[derive(Clone, Debug)] +struct WriteOp { + span: WriteSpan, + kind: WriteOpKind, +} + +/// Applies an optional write policy to note text and returns the transformed output. +pub fn apply_write_policy( + text: &str, + policy: Option<&WritePolicy>, +) -> Result { + let policy = match policy { + Some(policy) => policy, + None => { + return Ok(WritePolicyResult { + transformed: text.to_string(), + audit: WritePolicyAudit::default(), + }); + }, + }; + let mut exclusions = policy.exclusions.clone(); + let mut redactions = policy.redactions.clone(); + + if exclusions.is_empty() && redactions.is_empty() { + return Ok(WritePolicyResult { + transformed: text.to_string(), + audit: WritePolicyAudit::default(), + }); + } + + exclusions.sort_by_key(|span| (span.start, span.end)); + redactions.sort_by_key(|r| match r { + WriteRedaction::Replace { span, .. } => (span.start, span.end), + WriteRedaction::Remove { span } => (span.start, span.end), + }); + + let mut ops = Vec::with_capacity(exclusions.len() + redactions.len()); + let mut audit = WritePolicyAudit::default(); + + for span in &exclusions { + validate_span(text, span)?; + + ops.push(WriteOp { span: *span, kind: WriteOpKind::Exclude }); + audit.exclusions.push(*span); + } + for redaction in &redactions { + match redaction { + WriteRedaction::Remove { span } => { + validate_span(text, span)?; + + ops.push(WriteOp { span: *span, kind: WriteOpKind::Redact(String::new()) }); + audit + .redactions + .push(WriteRedactionResult { span: *span, replacement: String::new() }); + }, + + WriteRedaction::Replace { span, replacement } => { + validate_span(text, span)?; + + ops.push(WriteOp { span: *span, kind: WriteOpKind::Redact(replacement.clone()) }); + audit + .redactions + .push(WriteRedactionResult { span: *span, replacement: replacement.clone() }); + }, + } + } + + ops.sort_by_key(|op| (op.span.start, op.span.end)); + + validate_non_overlapping_ops(&ops)?; + + let mut transformed = text.to_string(); + + for op in ops.iter().rev() { + match &op.kind { + WriteOpKind::Exclude => transformed.replace_range(op.span.start..op.span.end, ""), + WriteOpKind::Redact(replacement) => + transformed.replace_range(op.span.start..op.span.end, replacement.as_str()), + } + } + + Ok(WritePolicyResult { transformed, audit }) +} + +fn validate_span(text: &str, span: &WriteSpan) -> Result<(), WritePolicyError> { + if span.end < span.start { + return Err(WritePolicyError::InvalidSpan); + } + if span.end > text.len() { + return Err(WritePolicyError::InvalidSpan); + } + if !text.is_char_boundary(span.start) || !text.is_char_boundary(span.end) { + return Err(WritePolicyError::InvalidSpan); + } + + Ok(()) +} + +fn validate_non_overlapping_ops(ops: &[WriteOp]) -> Result<(), WritePolicyError> { + let mut last_end = 0_usize; + + for op in ops { + if op.span.start < last_end { + return Err(WritePolicyError::OverlappingOps); + } + + last_end = op.span.end; + } + + Ok(()) +} diff --git a/packages/elf-domain/src/writegate/secrets.rs b/packages/elf-domain/src/writegate/secrets.rs new file mode 100644 index 00000000..dd0c8a3a --- /dev/null +++ b/packages/elf-domain/src/writegate/secrets.rs @@ -0,0 +1,23 @@ +use crate::writegate::Regex; + +/// Returns whether the input appears to contain secret material. +pub fn contains_secrets(text: &str) -> bool { + let patterns = [ + r"(?i)-----BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY-----", + r"(?i)ssh-rsa", + r"(?i)sk-[a-z0-9]{20,}", + r"(?i)api[_-]?key\s*[:=]\s*\S+", + r"(?i)password\s*[:=]\s*\S+", + r"(?i)secret\s*[:=]\s*\S+", + r"(?i)token\s*[:=]\s*\S+", + r"(?i)seed phrase", + ]; + + for pattern in patterns { + if Regex::new(pattern).map(|re| re.is_match(text)).unwrap_or(false) { + return true; + } + } + + false +} diff --git a/packages/elf-domain/src/writegate/tests.rs b/packages/elf-domain/src/writegate/tests.rs new file mode 100644 index 00000000..81612d84 --- /dev/null +++ b/packages/elf-domain/src/writegate/tests.rs @@ -0,0 +1,293 @@ +use serde_json::Map; + +use crate::writegate::{ + self, NoteInput, RejectCode, WritePolicy, WritePolicyResult, WriteRedaction, + WriteRedactionResult, +}; +use elf_config::{ + Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, + Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, RankingBlendSegment, + RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, + RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, + ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, + SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, + Service, Storage, TtlDays, +}; + +fn test_ranking() -> Ranking { + Ranking { + recency_tau_days: 60.0, + tie_breaker_weight: 0.1, + deterministic: RankingDeterministic { + enabled: false, + lexical: RankingDeterministicLexical { + enabled: false, + weight: 0.05, + min_ratio: 0.3, + max_query_terms: 16, + max_text_terms: 1_024, + }, + hits: RankingDeterministicHits { + enabled: false, + weight: 0.05, + half_saturation: 8.0, + last_hit_tau_days: 14.0, + }, + decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, + }, + blend: RankingBlend { + enabled: true, + rerank_normalization: "rank".to_string(), + retrieval_normalization: "rank".to_string(), + segments: vec![ + RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, + RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, + RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, + ], + }, + diversity: RankingDiversity { + enabled: true, + sim_threshold: 0.88, + mmr_lambda: 0.7, + max_skips: 64, + }, + retrieval_sources: RankingRetrievalSources { + fusion_weight: 1.0, + structured_field_weight: 1.0, + fusion_priority: 1, + structured_field_priority: 0, + }, + } +} + +fn config() -> Config { + Config { + service: Service { + http_bind: "127.0.0.1:8080".to_string(), + mcp_bind: "127.0.0.1:8082".to_string(), + admin_bind: "127.0.0.1:8081".to_string(), + log_level: "info".to_string(), + }, + storage: Storage { + postgres: Postgres { + dsn: "postgres://user:pass@localhost/db".to_string(), + pool_max_conns: 1, + }, + qdrant: Qdrant { + url: "http://localhost".to_string(), + collection: "mem_notes_v2".to_string(), + docs_collection: "doc_chunks_v1".to_string(), + vector_dim: 4_096, + }, + }, + providers: Providers { + embedding: dummy_embedding_provider(), + rerank: dummy_provider(), + llm_extractor: dummy_llm_provider(), + }, + scopes: Scopes { + allowed: vec!["agent_private".to_string()], + read_profiles: ReadProfiles { + private_only: vec!["agent_private".to_string()], + private_plus_project: vec!["agent_private".to_string()], + all_scopes: vec!["agent_private".to_string()], + }, + precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, + write_allowed: ScopeWriteAllowed { + agent_private: true, + project_shared: true, + org_shared: true, + }, + }, + memory: Memory { + max_notes_per_add_event: 3, + max_note_chars: 10, + dup_sim_threshold: 0.9, + update_sim_threshold: 0.8, + candidate_k: 10, + top_k: 5, + policy: MemoryPolicy { rules: vec![] }, + }, + search: Search { + expansion: SearchExpansion { + mode: "off".to_string(), + max_queries: 4, + include_original: true, + }, + dynamic: SearchDynamic { min_candidates: 10, min_top_score: 0.12 }, + prefilter: SearchPrefilter { max_candidates: 0 }, + cache: SearchCache { + enabled: true, + expansion_ttl_days: 7, + rerank_ttl_days: 7, + max_payload_bytes: Some(262_144), + }, + explain: SearchExplain { + retention_days: 7, + capture_candidates: false, + candidate_retention_days: 2, + write_mode: "outbox".to_string(), + }, + recursive: SearchRecursive { + enabled: false, + max_depth: 2, + max_children_per_node: 4, + max_nodes_per_scope: 32, + max_total_nodes: 256, + }, + graph_context: SearchGraphContext { + enabled: false, + max_facts_per_item: 16, + max_evidence_notes_per_fact: 16, + }, + }, + ranking: test_ranking(), + lifecycle: Lifecycle { + ttl_days: TtlDays { + plan: 1, + fact: 2, + preference: 0, + constraint: 0, + decision: 0, + profile: 0, + }, + purge_deleted_after_days: 30, + purge_deprecated_after_days: 180, + }, + security: Security { + bind_localhost_only: true, + reject_non_english: true, + redact_secrets_on_write: true, + evidence_min_quotes: 1, + evidence_max_quotes: 2, + evidence_max_quote_chars: 320, + auth_mode: "off".to_string(), + auth_keys: vec![], + }, + chunking: Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "REPLACE_ME".to_string(), + }, + context: None, + mcp: None, + } +} + +fn dummy_embedding_provider() -> EmbeddingProviderConfig { + EmbeddingProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + dimensions: 3, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +fn dummy_provider() -> ProviderConfig { + ProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +fn dummy_llm_provider() -> LlmProviderConfig { + LlmProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + temperature: 0.1, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +#[test] +fn rejects_long_text() { + let cfg = config(); + let note = NoteInput { + note_type: "fact".to_string(), + scope: "agent_private".to_string(), + text: "12345678901".to_string(), + }; + + assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectTooLong)); +} + +#[test] +fn rejects_invalid_type() { + let cfg = config(); + let note = NoteInput { + note_type: "other".to_string(), + scope: "agent_private".to_string(), + text: "hello".to_string(), + }; + + assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectInvalidType)); +} + +#[test] +fn detects_secret_patterns() { + assert!(writegate::contains_secrets("password: hunter2")); +} + +#[test] +fn applies_empty_policy_as_noop() { + let policy = WritePolicy::default(); + + assert_eq!( + writegate::apply_write_policy("keep this", Some(&policy)), + Ok(WritePolicyResult { + transformed: "keep this".to_string(), + ..WritePolicyResult::default() + }) + ); +} + +#[test] +fn applies_exclusion_span() { + let policy = WritePolicy { + exclusions: vec![crate::writegate::WriteSpan { start: 4, end: 9 }], + redactions: vec![], + }; + let actual = writegate::apply_write_policy("hello world", Some(&policy)) + .expect("policy apply should succeed"); + + assert_eq!(actual.transformed, "hellld"); + assert_eq!(actual.audit.exclusions, vec![crate::writegate::WriteSpan { start: 4, end: 9 }]); + assert!(actual.audit.redactions.is_empty()); +} + +#[test] +fn applies_simple_replacement_redaction() { + let policy = WritePolicy { + exclusions: vec![], + redactions: vec![WriteRedaction::Replace { + span: crate::writegate::WriteSpan { start: 4, end: 5 }, + replacement: "***".to_string(), + }], + }; + let actual = writegate::apply_write_policy("secret", Some(&policy)) + .expect("policy apply should succeed"); + + assert_eq!(actual.transformed, "secr***t"); + assert_eq!( + actual.audit.redactions, + vec![WriteRedactionResult { + span: crate::writegate::WriteSpan { start: 4, end: 5 }, + replacement: "***".to_string(), + }] + ); + assert!(actual.audit.exclusions.is_empty()); +} diff --git a/packages/elf-domain/src/writegate/types.rs b/packages/elf-domain/src/writegate/types.rs new file mode 100644 index 00000000..74a96b26 --- /dev/null +++ b/packages/elf-domain/src/writegate/types.rs @@ -0,0 +1,106 @@ +use crate::writegate::{Deserialize, Serialize}; + +/// Reasons a note can be rejected by the write gate. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RejectCode { + /// The note text failed the English gate. + RejectNonEnglish, + /// The note text exceeded the configured length limit. + RejectTooLong, + /// The note text appears to contain secret material. + RejectSecret, + /// The note type is not one of the allowed values. + RejectInvalidType, + /// The note scope is not allowed or not writable. + RejectScopeDenied, + /// The note text is empty after trimming. + RejectEmpty, +} + +/// One write-policy redaction operation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WriteRedaction { + /// Replaces the target span with a literal string. + Replace { + /// Span to replace before persistence. + span: WriteSpan, + /// Literal replacement text to insert for the span. + replacement: String, + }, + /// Removes the target span entirely. + Remove { + /// Span to remove before persistence. + span: WriteSpan, + }, +} + +/// Errors returned while validating write-policy spans. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WritePolicyError { + /// A span was out of bounds or not aligned to char boundaries. + InvalidSpan, + /// Two exclusions/redactions overlapped. + OverlappingOps, +} + +/// Half-open byte span within input text. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct WriteSpan { + /// Inclusive start byte offset. + pub start: usize, + /// Exclusive end byte offset. + pub end: usize, +} + +/// Optional write-policy transform applied before note ingestion. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct WritePolicy { + /// Spans that should be removed before persistence. + #[serde(default)] + pub exclusions: Vec, + /// Redactions that should be applied before persistence. + #[serde(default)] + pub redactions: Vec, +} + +/// Result of applying a write policy to one note body. +#[derive(Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct WritePolicyResult { + /// Transformed note text after exclusions and redactions. + pub transformed: String, + /// Audit data describing which operations were applied. + pub audit: WritePolicyAudit, +} + +/// Audit payload emitted when a write policy is applied. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct WritePolicyAudit { + /// Exclusion spans that were applied. + pub exclusions: Vec, + /// Redactions that were applied. + pub redactions: Vec, +} + +/// One redaction entry in write-policy audit output. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct WriteRedactionResult { + /// Span that was removed or replaced. + pub span: WriteSpan, + /// Replacement text that was applied. + pub replacement: String, +} + +/// Normalized note input passed through `writegate`. +pub struct NoteInput { + /// Requested note type. + pub note_type: String, + /// Requested write scope. + pub scope: String, + /// Note text after request decoding. + pub text: String, +} diff --git a/packages/elf-domain/src/writegate/validation.rs b/packages/elf-domain/src/writegate/validation.rs new file mode 100644 index 00000000..33c79afc --- /dev/null +++ b/packages/elf-domain/src/writegate/validation.rs @@ -0,0 +1,41 @@ +use crate::writegate::{Config, NoteInput, RejectCode, english_gate}; + +/// Validates note content and metadata against ELF write-gate rules. +pub fn writegate(note: &NoteInput, cfg: &Config) -> Result<(), RejectCode> { + if note.text.trim().is_empty() { + return Err(RejectCode::RejectEmpty); + } + if !english_gate::is_english_natural_language(note.text.as_str()) { + return Err(RejectCode::RejectNonEnglish); + } + if note.text.chars().count() as u32 > cfg.memory.max_note_chars { + return Err(RejectCode::RejectTooLong); + } + if !is_allowed_type(¬e.note_type) { + return Err(RejectCode::RejectInvalidType); + } + if !cfg.scopes.allowed.iter().any(|scope| scope == ¬e.scope) { + return Err(RejectCode::RejectScopeDenied); + } + if !scope_write_allowed(cfg, ¬e.scope) { + return Err(RejectCode::RejectScopeDenied); + } + if crate::writegate::contains_secrets(¬e.text) { + return Err(RejectCode::RejectSecret); + } + + Ok(()) +} + +fn scope_write_allowed(cfg: &Config, scope: &str) -> bool { + match scope { + "agent_private" => cfg.scopes.write_allowed.agent_private, + "project_shared" => cfg.scopes.write_allowed.project_shared, + "org_shared" => cfg.scopes.write_allowed.org_shared, + _ => false, + } +} + +fn is_allowed_type(note_type: &str) -> bool { + matches!(note_type, "preference" | "constraint" | "decision" | "profile" | "fact" | "plan") +} diff --git a/packages/elf-service/src/add_event.rs b/packages/elf-service/src/add_event.rs index a6eb0b80..0a4e86c5 100644 --- a/packages/elf-service/src/add_event.rs +++ b/packages/elf-service/src/add_event.rs @@ -1,1453 +1,14 @@ //! Event ingestion APIs. -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{PgConnection, Postgres, Transaction}; -use time::{Duration, OffsetDateTime}; -use uuid::Uuid; - -use crate::{ - ElfService, Error, InsertVersionArgs, NoteOp, REJECT_EVIDENCE_MISMATCH, - REJECT_WRITE_POLICY_MISMATCH, ResolveUpdateArgs, Result, UpdateDecision, - access::{self, ORG_PROJECT_ID}, - graph_ingestion, - ingest_audit::{self, IngestAuditArgs}, - ingestion_profiles::{self, IngestionProfileRef, IngestionProfileSelector}, - structured_fields::{self, StructuredFields}, -}; -use elf_config::Config; -use elf_domain::{ - english_gate, evidence, - memory_policy::{self, MemoryPolicyDecision}, - ttl, - writegate::{self, NoteInput, WritePolicy, WritePolicyAudit, WritePolicyError}, -}; -use elf_storage::models::MemoryNote; - -type ProcessedEventOutput = (Vec, Vec, Option>); -type AddEventPersistOutput = (AddEventResult, Option); - -const REJECT_STRUCTURED_INVALID: &str = "REJECT_STRUCTURED_INVALID"; -const IGNORE_DUPLICATE: &str = "IGNORE_DUPLICATE"; -const IGNORE_POLICY_THRESHOLD: &str = "IGNORE_POLICY_THRESHOLD"; - -/// One chat or event message passed to the event extractor. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct EventMessage { - /// Speaker or message role. - pub role: String, - /// Message body content. - pub content: String, - /// Optional source timestamp string. - pub ts: Option, - /// Optional message identifier from the upstream source. - pub msg_id: Option, - /// Optional write policy applied before extraction. - pub write_policy: Option, -} - -/// Request payload for event-driven note extraction. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddEventRequest { - /// Tenant that owns the request. - pub tenant_id: String, - /// Project that owns the request. - pub project_id: String, - /// Agent that emitted the event batch. - pub agent_id: String, - /// Optional explicit scope override for extracted notes. - pub scope: Option, - /// When true, performs validation and extraction without persisting notes. - pub dry_run: Option, - /// Optional ingestion profile selector. - pub ingestion_profile: Option, - /// Source messages to extract notes from. - pub messages: Vec, -} - -/// Per-note outcome for an `add_event` request. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddEventResult { - /// Note identifier when one was created or updated. - pub note_id: Option, - /// Persistence operation chosen for the extracted note. - pub op: NoteOp, - /// Memory-policy decision applied to the extracted note. - pub policy_decision: MemoryPolicyDecision, - /// Machine-readable rejection or ignore code, if any. - pub reason_code: Option, - /// Human-readable rejection or ignore message, if any. - pub reason: Option, - /// Field path associated with a validation failure, if any. - pub field_path: Option, - /// Per-message write-policy audits when write policies were applied. - pub write_policy_audits: Option>, -} - -/// Response payload for event-driven note extraction. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddEventResponse { - /// Raw structured extractor output after normalization. - pub extracted: Value, - /// One result per extracted note. - pub results: Vec, - /// Resolved ingestion profile used for the request. - pub ingestion_profile: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ExtractorOutput { - pub notes: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ExtractedNote { - pub r#type: Option, - pub key: Option, - pub text: Option, - pub structured: Option, - pub importance: Option, - pub confidence: Option, - pub ttl_days: Option, - pub scope_suggestion: Option, - pub evidence: Option>, - pub reason: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct EvidenceQuote { - pub message_index: usize, - pub quote: String, -} - -struct NoteProcessingData { - note_type: String, - text: String, - structured: Option, - importance: f32, - confidence: f32, - reason: Option, - ttl_days: Option, - scope: String, - evidence: Vec, - structured_present: bool, - graph_present: bool, -} -impl NoteProcessingData { - fn from_request_and_note(req: &AddEventRequest, note: &ExtractedNote) -> Self { - let note_type = note.r#type.clone().unwrap_or_default(); - let text = note.text.clone().unwrap_or_default(); - let structured = note.structured.clone(); - let structured_present = - structured.as_ref().is_some_and(|value| !value.is_effectively_empty()); - let graph_present = structured.as_ref().is_some_and(StructuredFields::has_graph_fields); - - Self { - note_type, - text, - structured, - importance: note.importance.unwrap_or(0.0), - confidence: note.confidence.unwrap_or(0.0), - reason: note.reason.clone(), - ttl_days: note.ttl_days, - scope: req.scope.clone().or(note.scope_suggestion.clone()).unwrap_or_default(), - evidence: note.evidence.clone().unwrap_or_default(), - structured_present, - graph_present, - } - } -} - -struct PersistExtractedNoteArgs<'a> { - req: &'a AddEventRequest, - project_id: &'a str, - structured: Option<&'a StructuredFields>, - key: Option<&'a str>, - reason: Option<&'a String>, - note_type: &'a str, - text: &'a str, - scope: &'a str, - importance: f32, - confidence: f32, - expires_at: Option, - source_ref: Value, - now: OffsetDateTime, - embed_version: &'a str, -} - -struct AddEventContext<'a> { - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - scope: &'a str, - now: OffsetDateTime, -} - -impl ElfService { - /// Extracts notes from an event transcript and optionally persists the accepted results. - pub async fn add_event(&self, req: AddEventRequest) -> Result { - validate_add_event_request(&req)?; - - let resolved_profile = ingestion_profiles::resolve_add_event_profile( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.ingestion_profile.as_ref(), - ) - .await?; - let (messages, message_policy_applied, write_policy_audits) = - apply_write_policies_to_messages(req.messages.as_slice())?; - let message_texts: Vec = - messages.iter().map(|message| message.content.clone()).collect(); - let messages_json = - serde_json::to_string(&messages).map_err(|_| Error::InvalidRequest { - message: "Failed to serialize messages for extractor.".to_string(), - })?; - let extractor_messages = resolved_profile.build_extractor_messages( - &messages_json, - self.cfg.memory.max_notes_per_add_event, - self.cfg.memory.max_note_chars, - )?; - let llm_cfg = resolved_profile.resolved_llm_config(&self.cfg.providers.llm_extractor); - let extracted_raw = self.providers.extractor.extract(&llm_cfg, &extractor_messages).await?; - let max_notes = self.cfg.memory.max_notes_per_add_event as usize; - let mut extracted: ExtractorOutput = serde_json::from_value(extracted_raw.clone()) - .map_err(|_| Error::InvalidRequest { - message: "Extractor output is missing notes array.".to_string(), - })?; - - if extracted.notes.len() > max_notes { - extracted.notes.truncate(max_notes); - } - - let extracted_json = serde_json::to_value(&extracted).map_err(|_| { - Error::InvalidRequest { message: "Failed to serialize extracted notes.".to_string() } - })?; - let base_now = OffsetDateTime::now_utc(); - let embed_version = crate::embedding_version(&self.cfg); - let dry_run = req.dry_run.unwrap_or(false); - let mut results = Vec::with_capacity(extracted.notes.len()); - - for (note_idx, note) in extracted.notes.into_iter().enumerate() { - let now = base_now + Duration::microseconds(note_idx as i64); - - results.push( - self.process_extracted_note( - &req, - &resolved_profile.profile_ref, - &message_texts, - &message_policy_applied, - write_policy_audits.as_ref(), - note, - now, - embed_version.as_str(), - dry_run, - ) - .await?, - ); - } - - Ok(AddEventResponse { - extracted: extracted_json, - results, - ingestion_profile: Some(resolved_profile.profile_ref), - }) - } - - #[allow(clippy::too_many_arguments)] - async fn process_extracted_note( - &self, - req: &AddEventRequest, - ingestion_profile: &IngestionProfileRef, - message_texts: &[String], - message_policy_applied: &[bool], - write_policy_audits: Option<&Vec>, - note: ExtractedNote, - now: OffsetDateTime, - embed_version: &str, - dry_run: bool, - ) -> Result { - let note_data = NoteProcessingData::from_request_and_note(req, ¬e); - let effective_project_id = if note_data.scope.trim() == "org_shared" { - ORG_PROJECT_ID - } else { - req.project_id.as_str() - }; - let ctx = AddEventContext { - tenant_id: req.tenant_id.as_str(), - project_id: effective_project_id, - agent_id: req.agent_id.as_str(), - scope: note_data.scope.as_str(), - now, - }; - let mut tx = self.db.pool.begin().await?; - - if let Some(result) = self - .record_extracted_note_rejections( - &mut tx, - &ctx, - ingestion_profile, - ¬e, - ¬e_data, - message_texts, - message_policy_applied, - write_policy_audits, - ) - .await? - { - tx.commit().await?; - - return Ok(result); - } - - let result = self - .apply_extracted_note_decision( - req, - ingestion_profile, - &mut tx, - &ctx, - ¬e, - ¬e_data, - note_data.note_type.as_str(), - effective_project_id, - now, - embed_version, - dry_run, - write_policy_audits, - ) - .await?; - - tx.commit().await?; - - Ok(result) - } - - #[allow(clippy::too_many_arguments)] - async fn apply_extracted_note_decision( - &self, - req: &AddEventRequest, - ingestion_profile: &IngestionProfileRef, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddEventContext<'_>, - note: &ExtractedNote, - note_data: &NoteProcessingData, - note_type: &str, - project_id: &str, - now: OffsetDateTime, - embed_version: &str, - dry_run: bool, - write_policy_audits: Option<&Vec>, - ) -> Result { - let decision = self.resolve_extracted_note_update(note, req, note_data, tx, now).await?; - let metadata = decision.metadata(); - let base_decision = base_decision_for_update( - &decision, - note_data.structured_present, - note_data.graph_present, - ); - let (policy_decision, decision_policy_rule, min_confidence, min_importance) = - resolve_policy_for_update(&self.cfg, note_data, base_decision); - let ignore_reason_code = - ignore_reason_code_for_policy(base_decision, policy_decision, metadata.matched_dup); - let should_apply = matches!( - policy_decision, - MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update - ); - let mut result = build_result_from_decision( - &decision, - policy_decision, - note_data.reason.clone(), - note_data.structured_present || note_data.graph_present, - ); - - apply_policy_ignore_adjustments( - &mut result, - &decision, - policy_decision, - ignore_reason_code, - ); - - let mut note_version_id = None; - - if should_apply && !dry_run { - let persist_args = PersistExtractedNoteArgs { - req, - project_id, - structured: note_data.structured.as_ref(), - key: note.key.as_deref(), - reason: note.reason.as_ref(), - note_type, - text: note_data.text.as_str(), - scope: note_data.scope.as_str(), - importance: note_data.importance, - confidence: note_data.confidence, - expires_at: ttl::compute_expires_at( - note_data.ttl_days, - note_data.note_type.as_str(), - &self.cfg, - now, - ), - source_ref: serde_json::json!({ - "evidence": note_data.evidence.clone(), - "reason": note_data.reason.clone().unwrap_or_default(), - "ingestion_profile": serde_json::json!({ - "id": ingestion_profile.id, - "version": ingestion_profile.version, - }), - }), - now, - embed_version, - }; - let persisted = self - .persist_extracted_note_decision(tx, persist_args, decision, policy_decision) - .await?; - - result = persisted.0; - note_version_id = persisted.1; - } - - result.write_policy_audits = write_policy_audits.cloned(); - - record_ingest_decision( - tx, - &self.cfg, - ctx, - note, - note_data.note_type.as_str(), - result.note_id, - note_version_id, - base_decision, - policy_decision, - result.op, - result.reason_code.as_deref(), - decision_policy_rule.as_deref(), - metadata.similarity_best, - metadata.key_match, - metadata.matched_dup, - min_confidence, - min_importance, - Some(ingestion_profile.id.as_str()), - Some(ingestion_profile.version), - note_data.structured_present, - note_data.graph_present, - write_policy_audits.cloned(), - ) - .await?; - - Ok(result) - } - - #[allow(clippy::too_many_arguments)] - async fn record_extracted_note_rejections( - &self, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddEventContext<'_>, - ingestion_profile: &IngestionProfileRef, - note: &ExtractedNote, - note_data: &NoteProcessingData, - message_texts: &[String], - message_policy_applied: &[bool], - write_policy_audits: Option<&Vec>, - ) -> Result> { - if let Some(result) = reject_extracted_note_if_evidence_invalid( - &self.cfg, - note.reason.as_ref(), - ¬e_data.evidence, - message_texts, - message_policy_applied, - ) { - let mut result = result; - - result.write_policy_audits = write_policy_audits.cloned(); - - record_ingest_decision( - tx, - &self.cfg, - ctx, - note, - note_data.note_type.as_str(), - None, - None, - MemoryPolicyDecision::Reject, - MemoryPolicyDecision::Reject, - NoteOp::Rejected, - result.reason_code.as_deref(), - None, - None, - false, - false, - None, - None, - Some(ingestion_profile.id.as_str()), - Some(ingestion_profile.version), - note_data.structured_present, - note_data.graph_present, - write_policy_audits.cloned(), - ) - .await?; - - return Ok(Some(result)); - } else if let Some(result) = reject_extracted_note_if_structured_invalid( - note_data.structured.as_ref(), - note_data.text.as_str(), - ¬e_data.evidence, - note.reason.as_ref(), - ) { - let mut result = result; - - result.write_policy_audits = write_policy_audits.cloned(); - - record_ingest_decision( - tx, - &self.cfg, - ctx, - note, - note_data.note_type.as_str(), - None, - None, - MemoryPolicyDecision::Reject, - MemoryPolicyDecision::Reject, - NoteOp::Rejected, - Some(REJECT_STRUCTURED_INVALID), - None, - None, - false, - false, - None, - None, - Some(ingestion_profile.id.as_str()), - Some(ingestion_profile.version), - note_data.structured_present, - note_data.graph_present, - write_policy_audits.cloned(), - ) - .await?; - - return Ok(Some(result)); - } else if let Some(result) = reject_extracted_note_if_writegate_rejects( - &self.cfg, - note.reason.as_ref(), - note_data.note_type.as_str(), - note_data.scope.as_str(), - note_data.text.as_str(), - ) { - let mut result = result; - - result.write_policy_audits = write_policy_audits.cloned(); - - record_ingest_decision( - tx, - &self.cfg, - ctx, - note, - note_data.note_type.as_str(), - None, - None, - MemoryPolicyDecision::Reject, - MemoryPolicyDecision::Reject, - NoteOp::Rejected, - result.reason_code.as_deref(), - None, - None, - false, - false, - None, - None, - Some(ingestion_profile.id.as_str()), - Some(ingestion_profile.version), - note_data.structured_present, - note_data.graph_present, - write_policy_audits.cloned(), - ) - .await?; - - return Ok(Some(result)); - } - - Ok(None) - } - - async fn resolve_extracted_note_update( - &self, - note: &ExtractedNote, - req: &AddEventRequest, - note_data: &NoteProcessingData, - tx: &mut PgConnection, - now: OffsetDateTime, - ) -> Result { - crate::resolve_update( - tx, - ResolveUpdateArgs { - cfg: &self.cfg, - providers: &self.providers, - tenant_id: req.tenant_id.as_str(), - project_id: if note_data.scope.trim() == "org_shared" { - ORG_PROJECT_ID - } else { - req.project_id.as_str() - }, - agent_id: req.agent_id.as_str(), - scope: note_data.scope.as_str(), - note_type: note_data.note_type.as_str(), - key: note.key.as_deref(), - text: note_data.text.as_str(), - now, - }, - ) - .await - } - - async fn persist_extracted_note_decision( - &self, - tx: &mut Transaction<'_, Postgres>, - args: PersistExtractedNoteArgs<'_>, - decision: UpdateDecision, - policy_decision: MemoryPolicyDecision, - ) -> Result { - match (decision, args) { - (UpdateDecision::Add { note_id, .. }, args) => - self.persist_extracted_note_add(tx, args, note_id, policy_decision).await, - (UpdateDecision::Update { note_id, .. }, args) => - self.persist_extracted_note_update(tx, args, note_id, policy_decision).await, - (UpdateDecision::None { note_id, .. }, args) => - self.persist_extracted_note_none(tx, args, note_id, policy_decision).await, - } - } - - async fn persist_extracted_note_add( - &self, - tx: &mut Transaction<'_, Postgres>, - args: PersistExtractedNoteArgs<'_>, - note_id: Uuid, - policy_decision: MemoryPolicyDecision, - ) -> Result { - access::ensure_active_project_scope_grant( - &mut **tx, - args.req.tenant_id.as_str(), - args.project_id, - args.scope, - args.req.agent_id.as_str(), - ) - .await?; - - let memory_note = MemoryNote { - note_id, - tenant_id: args.req.tenant_id.clone(), - project_id: args.project_id.to_string(), - agent_id: args.req.agent_id.clone(), - scope: args.scope.to_string(), - r#type: args.note_type.to_string(), - key: args.key.map(ToString::to_string), - text: args.text.to_string(), - importance: args.importance, - confidence: args.confidence, - status: "active".to_string(), - created_at: args.now, - updated_at: args.now, - expires_at: args.expires_at, - embedding_version: args.embed_version.to_string(), - source_ref: args.source_ref, - hit_count: 0, - last_hit_at: None, - }; - - insert_memory_note_tx(tx, &memory_note).await?; - - let note_version_id = crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id: memory_note.note_id, - op: "ADD", - prev_snapshot: None, - new_snapshot: Some(crate::note_snapshot(&memory_note)), - reason: "add_event", - actor: args.req.agent_id.as_str(), - ts: args.now, - }, - ) - .await?; - - crate::enqueue_outbox_tx( - &mut **tx, - memory_note.note_id, - "UPSERT", - args.embed_version, - args.now, - ) - .await?; - - upsert_structured_fields_tx(tx, args.structured, memory_note.note_id, args.now).await?; - - if let Some(structured) = args.structured - && structured.has_graph_fields() - { - graph_ingestion::persist_graph_fields_tx( - tx, - args.req.tenant_id.as_str(), - args.project_id, - args.req.agent_id.as_str(), - args.scope, - memory_note.note_id, - structured, - args.now, - ) - .await?; - } - - Ok(( - AddEventResult { - note_id: Some(note_id), - op: NoteOp::Add, - policy_decision, - reason_code: None, - reason: args.reason.cloned(), - field_path: None, - write_policy_audits: None, - }, - Some(note_version_id), - )) - } - - async fn persist_extracted_note_update( - &self, - tx: &mut Transaction<'_, Postgres>, - args: PersistExtractedNoteArgs<'_>, - note_id: Uuid, - policy_decision: MemoryPolicyDecision, - ) -> Result { - let mut existing: MemoryNote = sqlx::query_as::<_, MemoryNote>( - "SELECT * FROM memory_notes WHERE note_id = $1 FOR UPDATE", - ) - .bind(note_id) - .fetch_one(&mut **tx) - .await?; - - access::ensure_active_project_scope_grant( - &mut **tx, - existing.tenant_id.as_str(), - existing.project_id.as_str(), - existing.scope.as_str(), - existing.agent_id.as_str(), - ) - .await?; - - let prev_snapshot = crate::note_snapshot(&existing); - - existing.text = args.text.to_string(); - existing.importance = args.importance; - existing.confidence = args.confidence; - existing.updated_at = args.now; - existing.expires_at = args.expires_at; - existing.source_ref = args.source_ref; - - update_memory_note_tx(tx, &existing).await?; - - let note_version_id = crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id: existing.note_id, - op: "UPDATE", - prev_snapshot: Some(prev_snapshot), - new_snapshot: Some(crate::note_snapshot(&existing)), - reason: "add_event", - actor: args.req.agent_id.as_str(), - ts: args.now, - }, - ) - .await?; - - crate::enqueue_outbox_tx( - &mut **tx, - existing.note_id, - "UPSERT", - existing.embedding_version.as_str(), - args.now, - ) - .await?; - - upsert_structured_fields_tx(tx, args.structured, existing.note_id, args.now).await?; - - if let Some(structured) = args.structured - && structured.has_graph_fields() - { - graph_ingestion::persist_graph_fields_tx( - tx, - args.req.tenant_id.as_str(), - existing.project_id.as_str(), - args.req.agent_id.as_str(), - args.scope, - existing.note_id, - structured, - args.now, - ) - .await?; - } - - Ok(( - AddEventResult { - note_id: Some(note_id), - op: NoteOp::Update, - policy_decision, - reason_code: None, - reason: args.reason.cloned(), - field_path: None, - write_policy_audits: None, - }, - Some(note_version_id), - )) - } - - async fn persist_extracted_note_none( - &self, - tx: &mut Transaction<'_, Postgres>, - args: PersistExtractedNoteArgs<'_>, - note_id: Uuid, - policy_decision: MemoryPolicyDecision, - ) -> Result { - let mut did_update = false; - - if let Some(structured) = args.structured - && !structured.is_effectively_empty() - { - structured_fields::upsert_structured_fields_tx(tx, note_id, structured, args.now) - .await?; - crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", args.embed_version, args.now) - .await?; - - did_update = true; - } - if let Some(structured) = args.structured - && structured.has_graph_fields() - { - graph_ingestion::persist_graph_fields_tx( - tx, - args.req.tenant_id.as_str(), - args.project_id, - args.req.agent_id.as_str(), - args.scope, - note_id, - structured, - args.now, - ) - .await?; - - did_update = true; - } - - if did_update { - let note_row: MemoryNote = - sqlx::query_as("SELECT * FROM memory_notes WHERE note_id = $1") - .bind(note_id) - .fetch_one(&mut **tx) - .await?; - let snapshot = crate::note_snapshot(¬e_row); - let note_version_id = crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id, - op: "UPDATE", - prev_snapshot: Some(snapshot.clone()), - new_snapshot: Some(snapshot), - reason: "add_event_structured", - actor: args.req.agent_id.as_str(), - ts: args.now, - }, - ) - .await?; - - if matches!(args.scope, "project_shared" | "org_shared") { - access::ensure_active_project_scope_grant( - &mut **tx, - args.req.tenant_id.as_str(), - args.project_id, - args.scope, - args.req.agent_id.as_str(), - ) - .await?; - } - - return Ok(( - AddEventResult { - note_id: Some(note_id), - op: NoteOp::Update, - policy_decision, - reason_code: None, - reason: args.reason.cloned(), - field_path: None, - write_policy_audits: None, - }, - Some(note_version_id), - )); - } - - Ok(( - AddEventResult { - note_id: Some(note_id), - op: NoteOp::None, - policy_decision, - reason_code: None, - reason: args.reason.cloned(), - field_path: None, - write_policy_audits: None, - }, - None, - )) - } -} - -fn resolve_policy_for_update( - cfg: &Config, - note_data: &NoteProcessingData, - base_decision: MemoryPolicyDecision, -) -> (MemoryPolicyDecision, Option, Option, Option) { - if matches!(base_decision, MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update) { - let policy_eval = memory_policy::evaluate_memory_policy( - cfg, - note_data.note_type.as_str(), - note_data.scope.as_str(), - note_data.confidence as f64, - note_data.importance as f64, - base_decision, - ); - let decision_policy_rule = policy_eval - .matched_rule - .and_then(|rule| policy_rule_id(rule.note_type.as_deref(), rule.scope.as_deref())); - let min_confidence = policy_eval.matched_rule.and_then(|rule| rule.min_confidence); - let min_importance = policy_eval.matched_rule.and_then(|rule| rule.min_importance); - - (policy_eval.decision, decision_policy_rule, min_confidence, min_importance) - } else { - (MemoryPolicyDecision::Ignore, None, None, None) - } -} - -fn ignore_reason_code_for_policy( - base_decision: MemoryPolicyDecision, - policy_decision: MemoryPolicyDecision, - matched_duplicate: bool, -) -> Option<&'static str> { - if !matches!(policy_decision, MemoryPolicyDecision::Ignore) { - return None; - } - - match base_decision { - MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update => - Some(IGNORE_POLICY_THRESHOLD), - MemoryPolicyDecision::Ignore if matched_duplicate => Some(IGNORE_DUPLICATE), - _ => None, - } -} - -fn build_result_from_decision( - decision: &UpdateDecision, - policy_decision: MemoryPolicyDecision, - reason: Option, - structured_present: bool, -) -> AddEventResult { - match decision { - UpdateDecision::Add { note_id, .. } => AddEventResult { - note_id: Some(*note_id), - op: NoteOp::Add, - policy_decision, - reason_code: None, - reason, - field_path: None, - write_policy_audits: None, - }, - UpdateDecision::Update { note_id, .. } => AddEventResult { - note_id: Some(*note_id), - op: NoteOp::Update, - policy_decision, - reason_code: None, - reason, - field_path: None, - write_policy_audits: None, - }, - UpdateDecision::None { note_id, .. } => AddEventResult { - note_id: Some(*note_id), - op: if structured_present { NoteOp::Update } else { NoteOp::None }, - policy_decision, - reason_code: None, - reason, - field_path: None, - write_policy_audits: None, - }, - } -} - -fn apply_policy_ignore_adjustments( - result: &mut AddEventResult, - decision: &UpdateDecision, - policy_decision: MemoryPolicyDecision, - ignore_reason_code: Option<&str>, -) { - if !matches!(policy_decision, MemoryPolicyDecision::Ignore) { - return; - } - - if let UpdateDecision::Add { .. } = decision { - result.note_id = None; - } - - result.op = NoteOp::None; - result.reason_code = ignore_reason_code.map(str::to_string); -} - -fn validate_add_event_request(req: &AddEventRequest) -> Result<()> { - if req.messages.is_empty() { - return Err(Error::InvalidRequest { message: "Messages list is empty.".to_string() }); - } - if req.tenant_id.trim().is_empty() - || req.project_id.trim().is_empty() - || req.agent_id.trim().is_empty() - { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - if let Some(scope) = req.scope.as_ref() - && scope.trim().is_empty() - { - return Err(Error::InvalidRequest { - message: "scope must not be empty when provided.".to_string(), - }); - } - if let Some(profile) = req.ingestion_profile.as_ref() { - if profile.id.trim().is_empty() { - return Err(Error::InvalidRequest { - message: "ingestion_profile.id must not be empty.".to_string(), - }); - } - - if let Some(version) = profile.version - && version <= 0 - { - return Err(Error::InvalidRequest { - message: "ingestion_profile.version must be greater than zero.".to_string(), - }); - } - } - - for (idx, msg) in req.messages.iter().enumerate() { - if !english_gate::is_english_natural_language(msg.content.as_str()) { - return Err(Error::NonEnglishInput { field: format!("$.messages[{idx}].content") }); - } - } - - Ok(()) -} - -fn apply_write_policies_to_messages(messages: &[EventMessage]) -> Result { - let mut message_policy_applied = Vec::with_capacity(messages.len()); - let mut write_policy_audits = Vec::new(); - let mut transformed_messages = Vec::with_capacity(messages.len()); - - for message in messages { - let (transformed_message, audit) = apply_write_policy_to_message(message)?; - - message_policy_applied.push(audit.is_some()); - - if let Some(audit) = audit { - write_policy_audits.push(audit); - } - - transformed_messages.push(transformed_message); - } - - Ok(( - transformed_messages, - message_policy_applied, - if write_policy_audits.is_empty() { None } else { Some(write_policy_audits) }, - )) -} - -fn apply_write_policy_to_message( - message: &EventMessage, -) -> Result<(EventMessage, Option)> { - let result = - writegate::apply_write_policy(message.content.as_str(), message.write_policy.as_ref()) - .map_err(|err| { - let message = match err { - WritePolicyError::InvalidSpan => "Invalid write_policy span provided.", - WritePolicyError::OverlappingOps => "Overlapping write_policy spans provided.", - }; - - Error::InvalidRequest { message: message.to_string() } - })?; - let has_policy = message.write_policy.is_some(); - let mut transformed = message.clone(); - - transformed.content = result.transformed; - - Ok((transformed, if has_policy { Some(result.audit) } else { None })) -} - -fn reject_extracted_note_if_evidence_invalid( - cfg: &Config, - reason: Option<&String>, - evidence: &[EvidenceQuote], - message_texts: &[String], - message_policy_applied: &[bool], -) -> Option { - if evidence.is_empty() - || evidence.len() < cfg.security.evidence_min_quotes as usize - || evidence.len() > cfg.security.evidence_max_quotes as usize - { - return Some(AddEventResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()), - reason: reason.cloned(), - field_path: None, - write_policy_audits: None, - }); - } - - for quote in evidence { - if quote.quote.len() > cfg.security.evidence_max_quote_chars as usize { - return Some(AddEventResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()), - reason: reason.cloned(), - field_path: None, - write_policy_audits: None, - }); - } - if !evidence::evidence_matches(message_texts, quote.message_index, quote.quote.as_str()) { - let reason_code = - message_policy_applied.get(quote.message_index).is_some_and(|applied| *applied); - - return Some(AddEventResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(if reason_code { - REJECT_WRITE_POLICY_MISMATCH.to_string() - } else { - REJECT_EVIDENCE_MISMATCH.to_string() - }), - reason: reason.cloned(), - field_path: None, - write_policy_audits: None, - }); - } - } - - None -} - -fn reject_extracted_note_if_structured_invalid( - structured: Option<&StructuredFields>, - text: &str, - evidence: &[EvidenceQuote], - reason: Option<&String>, -) -> Option { - let structured = structured?; - - if structured.is_effectively_empty() { - return None; - } - - let event_evidence: Vec<(usize, String)> = - evidence.iter().map(|q| (q.message_index, q.quote.clone())).collect(); - - if let Err(err) = structured_fields::validate_structured_fields( - structured, - text, - &serde_json::json!({}), - Some(event_evidence.as_slice()), - ) { - tracing::info!(error = %err, "Rejecting extracted note due to invalid structured fields."); - - let field_path = extract_structured_rejection_field_path(&err); - - return Some(AddEventResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(REJECT_STRUCTURED_INVALID.to_string()), - reason: reason.cloned(), - field_path, - write_policy_audits: None, - }); - } - - None -} - -fn reject_extracted_note_if_writegate_rejects( - cfg: &Config, - reason: Option<&String>, - note_type: &str, - scope: &str, - text: &str, -) -> Option { - let gate_input = NoteInput { - note_type: note_type.to_string(), - scope: scope.to_string(), - text: text.to_string(), - }; - - if let Err(code) = writegate::writegate(&gate_input, cfg) { - return Some(AddEventResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(crate::writegate_reason_code(code).to_string()), - reason: reason.cloned(), - field_path: None, - write_policy_audits: None, - }); - } - - None -} - -fn extract_structured_rejection_field_path(err: &Error) -> Option { - match err { - Error::NonEnglishInput { field } => Some(field.clone()), - Error::InvalidRequest { message } if message.starts_with("structured.") => - message.split_whitespace().next().map(ToString::to_string), - _ => None, - } -} - -fn base_decision_for_update( - decision: &UpdateDecision, - structured_present: bool, - graph_present: bool, -) -> MemoryPolicyDecision { - match decision { - UpdateDecision::Update { .. } => MemoryPolicyDecision::Update, - UpdateDecision::Add { .. } => MemoryPolicyDecision::Remember, - UpdateDecision::None { .. } => - if structured_present || graph_present { - MemoryPolicyDecision::Update - } else { - MemoryPolicyDecision::Ignore - }, - } -} - -fn policy_rule_id(note_type: Option<&str>, scope: Option<&str>) -> Option { - match (note_type, scope) { - (Some(note_type), Some(scope)) => Some(format!("note_type={note_type},scope={scope}")), - (Some(note_type), None) => Some(format!("note_type={note_type}")), - (None, Some(scope)) => Some(format!("scope={scope}")), - (None, None) => None, - } -} - -#[allow(clippy::too_many_arguments)] -async fn record_ingest_decision( - tx: &mut Transaction<'_, Postgres>, - cfg: &Config, - ctx: &AddEventContext<'_>, - note: &ExtractedNote, - note_type: &str, - note_id: Option, - note_version_id: Option, - base_decision: MemoryPolicyDecision, - policy_decision: MemoryPolicyDecision, - note_op: NoteOp, - reason_code: Option<&str>, - policy_rule: Option<&str>, - similarity_best: Option, - key_match: bool, - matched_dup: bool, - min_confidence: Option, - min_importance: Option, - ingestion_profile_id: Option<&str>, - ingestion_profile_version: Option, - structured_present: bool, - graph_present: bool, - write_policy_audits: Option>, -) -> Result<()> { - let args = IngestAuditArgs { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: ctx.scope, - pipeline: "add_event", - note_type, - note_key: note.key.as_deref(), - note_id, - note_version_id, - base_decision, - policy_decision, - note_op, - reason_code, - similarity_best, - key_match, - matched_dup, - dup_sim_threshold: cfg.memory.dup_sim_threshold, - update_sim_threshold: cfg.memory.update_sim_threshold, - confidence: note.confidence.unwrap_or(0.0), - importance: note.importance.unwrap_or(0.0), - structured_present, - graph_present, - policy_rule, - min_confidence, - min_importance, - ingestion_profile_id, - ingestion_profile_version, - write_policy_audits, - ts: ctx.now, - }; - - ingest_audit::insert_ingest_decision(tx, args).await -} - -async fn update_memory_note_tx( - tx: &mut Transaction<'_, Postgres>, - memory_note: &MemoryNote, -) -> Result<()> { - sqlx::query( - "\ -UPDATE memory_notes -SET - text = $1, - importance = $2, - confidence = $3, - updated_at = $4, - expires_at = $5, - source_ref = $6 -WHERE note_id = $7", - ) - .bind(memory_note.text.as_str()) - .bind(memory_note.importance) - .bind(memory_note.confidence) - .bind(memory_note.updated_at) - .bind(memory_note.expires_at) - .bind(&memory_note.source_ref) - .bind(memory_note.note_id) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn insert_memory_note_tx( - tx: &mut Transaction<'_, Postgres>, - memory_note: &MemoryNote, -) -> Result<()> { - sqlx::query( - "\ -INSERT INTO memory_notes ( - note_id, - tenant_id, - project_id, - agent_id, - scope, - type, - key, - text, - importance, - confidence, - status, - created_at, - updated_at, - expires_at, - embedding_version, - source_ref, - hit_count, - last_hit_at -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15, - $16, - $17, - $18 -)", - ) - .bind(memory_note.note_id) - .bind(memory_note.tenant_id.as_str()) - .bind(memory_note.project_id.as_str()) - .bind(memory_note.agent_id.as_str()) - .bind(memory_note.scope.as_str()) - .bind(memory_note.r#type.as_str()) - .bind(memory_note.key.as_deref()) - .bind(memory_note.text.as_str()) - .bind(memory_note.importance) - .bind(memory_note.confidence) - .bind(memory_note.status.as_str()) - .bind(memory_note.created_at) - .bind(memory_note.updated_at) - .bind(memory_note.expires_at) - .bind(memory_note.embedding_version.as_str()) - .bind(&memory_note.source_ref) - .bind(memory_note.hit_count) - .bind(memory_note.last_hit_at) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn upsert_structured_fields_tx( - tx: &mut Transaction<'_, Postgres>, - structured: Option<&StructuredFields>, - note_id: Uuid, - now: OffsetDateTime, -) -> Result<()> { - if let Some(structured) = structured - && !structured.is_effectively_empty() - { - structured_fields::upsert_structured_fields_tx(tx, note_id, structured, now).await?; - } - - Ok(()) -} - -#[cfg(test)] -mod english_gate_tests { - use crate::{ - Error, - add_event::{self, AddEventRequest, EventMessage}, - }; - - #[test] - fn rejects_long_non_english_message_content() { - let req = AddEventRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: None, - dry_run: None, - ingestion_profile: None, - messages: vec![EventMessage { - role: "user".to_string(), - content: "Bonjour, je veux m'assurer que ce texte est suffisamment long et riche en lettres pour declencher la detection de langue. Merci beaucoup." - .to_string(), - ts: None, - msg_id: None, - write_policy: None, - }], - }; - let err = add_event::validate_add_event_request(&req) - .expect_err("Expected English gate rejection."); - - assert!(matches!( - err, - Error::NonEnglishInput { field } if field == "$.messages[0].content" - )); - } -} +mod audit; +mod materialize; +mod persistence; +mod policy; +mod rejection; +mod service; +mod types; +mod validation; + +pub use types::{AddEventRequest, AddEventResponse, AddEventResult, EventMessage}; + +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/add_event/audit.rs b/packages/elf-service/src/add_event/audit.rs new file mode 100644 index 00000000..d07dac12 --- /dev/null +++ b/packages/elf-service/src/add_event/audit.rs @@ -0,0 +1,70 @@ +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use crate::{ + NoteOp, Result, + add_event::types::{AddEventContext, ExtractedNote}, + ingest_audit::{self, IngestAuditArgs}, +}; +use elf_config::Config; +use elf_domain::{memory_policy::MemoryPolicyDecision, writegate::WritePolicyAudit}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn record_ingest_decision( + tx: &mut Transaction<'_, Postgres>, + cfg: &Config, + ctx: &AddEventContext<'_>, + note: &ExtractedNote, + note_type: &str, + note_id: Option, + note_version_id: Option, + base_decision: MemoryPolicyDecision, + policy_decision: MemoryPolicyDecision, + note_op: NoteOp, + reason_code: Option<&str>, + policy_rule: Option<&str>, + similarity_best: Option, + key_match: bool, + matched_dup: bool, + min_confidence: Option, + min_importance: Option, + ingestion_profile_id: Option<&str>, + ingestion_profile_version: Option, + structured_present: bool, + graph_present: bool, + write_policy_audits: Option>, +) -> Result<()> { + let args = IngestAuditArgs { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: ctx.scope, + pipeline: "add_event", + note_type, + note_key: note.key.as_deref(), + note_id, + note_version_id, + base_decision, + policy_decision, + note_op, + reason_code, + similarity_best, + key_match, + matched_dup, + dup_sim_threshold: cfg.memory.dup_sim_threshold, + update_sim_threshold: cfg.memory.update_sim_threshold, + confidence: note.confidence.unwrap_or(0.0), + importance: note.importance.unwrap_or(0.0), + structured_present, + graph_present, + policy_rule, + min_confidence, + min_importance, + ingestion_profile_id, + ingestion_profile_version, + write_policy_audits, + ts: ctx.now, + }; + + ingest_audit::insert_ingest_decision(tx, args).await +} diff --git a/packages/elf-service/src/add_event/materialize.rs b/packages/elf-service/src/add_event/materialize.rs new file mode 100644 index 00000000..b80df7c6 --- /dev/null +++ b/packages/elf-service/src/add_event/materialize.rs @@ -0,0 +1,303 @@ +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use crate::{ + InsertVersionArgs, NoteOp, Result, UpdateDecision, access, + add_event::{ + persistence::{self}, + types::{AddEventPersistOutput, AddEventResult, PersistExtractedNoteArgs}, + }, + graph_ingestion, structured_fields, +}; +use elf_domain::memory_policy::MemoryPolicyDecision; +use elf_storage::models::MemoryNote; + +pub(super) async fn persist_extracted_note_decision( + tx: &mut Transaction<'_, Postgres>, + args: PersistExtractedNoteArgs<'_>, + decision: UpdateDecision, + policy_decision: MemoryPolicyDecision, +) -> Result { + match (decision, args) { + (UpdateDecision::Add { note_id, .. }, args) => + persist_extracted_note_add(tx, args, note_id, policy_decision).await, + (UpdateDecision::Update { note_id, .. }, args) => + persist_extracted_note_update(tx, args, note_id, policy_decision).await, + (UpdateDecision::None { note_id, .. }, args) => + persist_extracted_note_none(tx, args, note_id, policy_decision).await, + } +} + +async fn persist_extracted_note_add( + tx: &mut Transaction<'_, Postgres>, + args: PersistExtractedNoteArgs<'_>, + note_id: Uuid, + policy_decision: MemoryPolicyDecision, +) -> Result { + access::ensure_active_project_scope_grant( + &mut **tx, + args.req.tenant_id.as_str(), + args.project_id, + args.scope, + args.req.agent_id.as_str(), + ) + .await?; + + let memory_note = MemoryNote { + note_id, + tenant_id: args.req.tenant_id.clone(), + project_id: args.project_id.to_string(), + agent_id: args.req.agent_id.clone(), + scope: args.scope.to_string(), + r#type: args.note_type.to_string(), + key: args.key.map(ToString::to_string), + text: args.text.to_string(), + importance: args.importance, + confidence: args.confidence, + status: "active".to_string(), + created_at: args.now, + updated_at: args.now, + expires_at: args.expires_at, + embedding_version: args.embed_version.to_string(), + source_ref: args.source_ref, + hit_count: 0, + last_hit_at: None, + }; + + persistence::insert_memory_note_tx(tx, &memory_note).await?; + + let note_version_id = crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id: memory_note.note_id, + op: "ADD", + prev_snapshot: None, + new_snapshot: Some(crate::note_snapshot(&memory_note)), + reason: "add_event", + actor: args.req.agent_id.as_str(), + ts: args.now, + }, + ) + .await?; + + crate::enqueue_outbox_tx( + &mut **tx, + memory_note.note_id, + "UPSERT", + args.embed_version, + args.now, + ) + .await?; + persistence::upsert_structured_fields_tx(tx, args.structured, memory_note.note_id, args.now) + .await?; + + if let Some(structured) = args.structured + && structured.has_graph_fields() + { + graph_ingestion::persist_graph_fields_tx( + tx, + args.req.tenant_id.as_str(), + args.project_id, + args.req.agent_id.as_str(), + args.scope, + memory_note.note_id, + structured, + args.now, + ) + .await?; + } + + Ok(( + AddEventResult { + note_id: Some(note_id), + op: NoteOp::Add, + policy_decision, + reason_code: None, + reason: args.reason.cloned(), + field_path: None, + write_policy_audits: None, + }, + Some(note_version_id), + )) +} + +async fn persist_extracted_note_update( + tx: &mut Transaction<'_, Postgres>, + args: PersistExtractedNoteArgs<'_>, + note_id: Uuid, + policy_decision: MemoryPolicyDecision, +) -> Result { + let mut existing: MemoryNote = + sqlx::query_as::<_, MemoryNote>("SELECT * FROM memory_notes WHERE note_id = $1 FOR UPDATE") + .bind(note_id) + .fetch_one(&mut **tx) + .await?; + + access::ensure_active_project_scope_grant( + &mut **tx, + existing.tenant_id.as_str(), + existing.project_id.as_str(), + existing.scope.as_str(), + existing.agent_id.as_str(), + ) + .await?; + + let prev_snapshot = crate::note_snapshot(&existing); + + existing.text = args.text.to_string(); + existing.importance = args.importance; + existing.confidence = args.confidence; + existing.updated_at = args.now; + existing.expires_at = args.expires_at; + existing.source_ref = args.source_ref; + + persistence::update_memory_note_tx(tx, &existing).await?; + + let note_version_id = crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id: existing.note_id, + op: "UPDATE", + prev_snapshot: Some(prev_snapshot), + new_snapshot: Some(crate::note_snapshot(&existing)), + reason: "add_event", + actor: args.req.agent_id.as_str(), + ts: args.now, + }, + ) + .await?; + + crate::enqueue_outbox_tx( + &mut **tx, + existing.note_id, + "UPSERT", + existing.embedding_version.as_str(), + args.now, + ) + .await?; + persistence::upsert_structured_fields_tx(tx, args.structured, existing.note_id, args.now) + .await?; + + if let Some(structured) = args.structured + && structured.has_graph_fields() + { + graph_ingestion::persist_graph_fields_tx( + tx, + args.req.tenant_id.as_str(), + existing.project_id.as_str(), + args.req.agent_id.as_str(), + args.scope, + existing.note_id, + structured, + args.now, + ) + .await?; + } + + Ok(( + AddEventResult { + note_id: Some(note_id), + op: NoteOp::Update, + policy_decision, + reason_code: None, + reason: args.reason.cloned(), + field_path: None, + write_policy_audits: None, + }, + Some(note_version_id), + )) +} + +async fn persist_extracted_note_none( + tx: &mut Transaction<'_, Postgres>, + args: PersistExtractedNoteArgs<'_>, + note_id: Uuid, + policy_decision: MemoryPolicyDecision, +) -> Result { + let mut did_update = false; + + if let Some(structured) = args.structured + && !structured.is_effectively_empty() + { + structured_fields::upsert_structured_fields_tx(tx, note_id, structured, args.now).await?; + crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", args.embed_version, args.now) + .await?; + + did_update = true; + } + if let Some(structured) = args.structured + && structured.has_graph_fields() + { + graph_ingestion::persist_graph_fields_tx( + tx, + args.req.tenant_id.as_str(), + args.project_id, + args.req.agent_id.as_str(), + args.scope, + note_id, + structured, + args.now, + ) + .await?; + + did_update = true; + } + + if did_update { + let note_row: MemoryNote = sqlx::query_as("SELECT * FROM memory_notes WHERE note_id = $1") + .bind(note_id) + .fetch_one(&mut **tx) + .await?; + let snapshot = crate::note_snapshot(¬e_row); + let note_version_id = crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id, + op: "UPDATE", + prev_snapshot: Some(snapshot.clone()), + new_snapshot: Some(snapshot), + reason: "add_event_structured", + actor: args.req.agent_id.as_str(), + ts: args.now, + }, + ) + .await?; + + if matches!(args.scope, "project_shared" | "org_shared") { + access::ensure_active_project_scope_grant( + &mut **tx, + args.req.tenant_id.as_str(), + args.project_id, + args.scope, + args.req.agent_id.as_str(), + ) + .await?; + } + + return Ok(( + AddEventResult { + note_id: Some(note_id), + op: NoteOp::Update, + policy_decision, + reason_code: None, + reason: args.reason.cloned(), + field_path: None, + write_policy_audits: None, + }, + Some(note_version_id), + )); + } + + Ok(( + AddEventResult { + note_id: Some(note_id), + op: NoteOp::None, + policy_decision, + reason_code: None, + reason: args.reason.cloned(), + field_path: None, + write_policy_audits: None, + }, + None, + )) +} diff --git a/packages/elf-service/src/add_event/persistence.rs b/packages/elf-service/src/add_event/persistence.rs new file mode 100644 index 00000000..10b32d2e --- /dev/null +++ b/packages/elf-service/src/add_event/persistence.rs @@ -0,0 +1,124 @@ +use sqlx::{Postgres, Transaction}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Result, + structured_fields::{self, StructuredFields}, +}; +use elf_storage::models::MemoryNote; + +pub(super) async fn update_memory_note_tx( + tx: &mut Transaction<'_, Postgres>, + memory_note: &MemoryNote, +) -> Result<()> { + sqlx::query( + "\ +UPDATE memory_notes +SET + text = $1, + importance = $2, + confidence = $3, + updated_at = $4, + expires_at = $5, + source_ref = $6 +WHERE note_id = $7", + ) + .bind(memory_note.text.as_str()) + .bind(memory_note.importance) + .bind(memory_note.confidence) + .bind(memory_note.updated_at) + .bind(memory_note.expires_at) + .bind(&memory_note.source_ref) + .bind(memory_note.note_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(super) async fn insert_memory_note_tx( + tx: &mut Transaction<'_, Postgres>, + memory_note: &MemoryNote, +) -> Result<()> { + sqlx::query( + "\ +INSERT INTO memory_notes ( + note_id, + tenant_id, + project_id, + agent_id, + scope, + type, + key, + text, + importance, + confidence, + status, + created_at, + updated_at, + expires_at, + embedding_version, + source_ref, + hit_count, + last_hit_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18 +)", + ) + .bind(memory_note.note_id) + .bind(memory_note.tenant_id.as_str()) + .bind(memory_note.project_id.as_str()) + .bind(memory_note.agent_id.as_str()) + .bind(memory_note.scope.as_str()) + .bind(memory_note.r#type.as_str()) + .bind(memory_note.key.as_deref()) + .bind(memory_note.text.as_str()) + .bind(memory_note.importance) + .bind(memory_note.confidence) + .bind(memory_note.status.as_str()) + .bind(memory_note.created_at) + .bind(memory_note.updated_at) + .bind(memory_note.expires_at) + .bind(memory_note.embedding_version.as_str()) + .bind(&memory_note.source_ref) + .bind(memory_note.hit_count) + .bind(memory_note.last_hit_at) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(super) async fn upsert_structured_fields_tx( + tx: &mut Transaction<'_, Postgres>, + structured: Option<&StructuredFields>, + note_id: Uuid, + now: OffsetDateTime, +) -> Result<()> { + if let Some(structured) = structured + && !structured.is_effectively_empty() + { + structured_fields::upsert_structured_fields_tx(tx, note_id, structured, now).await?; + } + + Ok(()) +} diff --git a/packages/elf-service/src/add_event/policy.rs b/packages/elf-service/src/add_event/policy.rs new file mode 100644 index 00000000..e5f1cd11 --- /dev/null +++ b/packages/elf-service/src/add_event/policy.rs @@ -0,0 +1,133 @@ +use crate::{ + NoteOp, UpdateDecision, + add_event::types::{AddEventResult, NoteProcessingData}, +}; +use elf_config::Config; +use elf_domain::memory_policy::{self, MemoryPolicyDecision}; + +const IGNORE_DUPLICATE: &str = "IGNORE_DUPLICATE"; +const IGNORE_POLICY_THRESHOLD: &str = "IGNORE_POLICY_THRESHOLD"; + +pub(super) fn resolve_policy_for_update( + cfg: &Config, + note_data: &NoteProcessingData, + base_decision: MemoryPolicyDecision, +) -> (MemoryPolicyDecision, Option, Option, Option) { + if matches!(base_decision, MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update) { + let policy_eval = memory_policy::evaluate_memory_policy( + cfg, + note_data.note_type.as_str(), + note_data.scope.as_str(), + note_data.confidence as f64, + note_data.importance as f64, + base_decision, + ); + let decision_policy_rule = policy_eval + .matched_rule + .and_then(|rule| policy_rule_id(rule.note_type.as_deref(), rule.scope.as_deref())); + let min_confidence = policy_eval.matched_rule.and_then(|rule| rule.min_confidence); + let min_importance = policy_eval.matched_rule.and_then(|rule| rule.min_importance); + + (policy_eval.decision, decision_policy_rule, min_confidence, min_importance) + } else { + (MemoryPolicyDecision::Ignore, None, None, None) + } +} + +pub(super) fn ignore_reason_code_for_policy( + base_decision: MemoryPolicyDecision, + policy_decision: MemoryPolicyDecision, + matched_duplicate: bool, +) -> Option<&'static str> { + if !matches!(policy_decision, MemoryPolicyDecision::Ignore) { + return None; + } + + match base_decision { + MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update => + Some(IGNORE_POLICY_THRESHOLD), + MemoryPolicyDecision::Ignore if matched_duplicate => Some(IGNORE_DUPLICATE), + _ => None, + } +} + +pub(super) fn build_result_from_decision( + decision: &UpdateDecision, + policy_decision: MemoryPolicyDecision, + reason: Option, + structured_present: bool, +) -> AddEventResult { + match decision { + UpdateDecision::Add { note_id, .. } => AddEventResult { + note_id: Some(*note_id), + op: NoteOp::Add, + policy_decision, + reason_code: None, + reason, + field_path: None, + write_policy_audits: None, + }, + UpdateDecision::Update { note_id, .. } => AddEventResult { + note_id: Some(*note_id), + op: NoteOp::Update, + policy_decision, + reason_code: None, + reason, + field_path: None, + write_policy_audits: None, + }, + UpdateDecision::None { note_id, .. } => AddEventResult { + note_id: Some(*note_id), + op: if structured_present { NoteOp::Update } else { NoteOp::None }, + policy_decision, + reason_code: None, + reason, + field_path: None, + write_policy_audits: None, + }, + } +} + +pub(super) fn apply_policy_ignore_adjustments( + result: &mut AddEventResult, + decision: &UpdateDecision, + policy_decision: MemoryPolicyDecision, + ignore_reason_code: Option<&str>, +) { + if !matches!(policy_decision, MemoryPolicyDecision::Ignore) { + return; + } + + if let UpdateDecision::Add { .. } = decision { + result.note_id = None; + } + + result.op = NoteOp::None; + result.reason_code = ignore_reason_code.map(str::to_string); +} + +pub(super) fn base_decision_for_update( + decision: &UpdateDecision, + structured_present: bool, + graph_present: bool, +) -> MemoryPolicyDecision { + match decision { + UpdateDecision::Update { .. } => MemoryPolicyDecision::Update, + UpdateDecision::Add { .. } => MemoryPolicyDecision::Remember, + UpdateDecision::None { .. } => + if structured_present || graph_present { + MemoryPolicyDecision::Update + } else { + MemoryPolicyDecision::Ignore + }, + } +} + +fn policy_rule_id(note_type: Option<&str>, scope: Option<&str>) -> Option { + match (note_type, scope) { + (Some(note_type), Some(scope)) => Some(format!("note_type={note_type},scope={scope}")), + (Some(note_type), None) => Some(format!("note_type={note_type}")), + (None, Some(scope)) => Some(format!("scope={scope}")), + (None, None) => None, + } +} diff --git a/packages/elf-service/src/add_event/rejection.rs b/packages/elf-service/src/add_event/rejection.rs new file mode 100644 index 00000000..3c284349 --- /dev/null +++ b/packages/elf-service/src/add_event/rejection.rs @@ -0,0 +1,143 @@ +use sqlx::{Postgres, Transaction}; + +use crate::{ + NoteOp, Result, + add_event::{ + audit, + types::{AddEventContext, AddEventResult, ExtractedNote, NoteProcessingData}, + validation::{self, REJECT_STRUCTURED_INVALID}, + }, + ingestion_profiles::IngestionProfileRef, +}; +use elf_config::Config; +use elf_domain::{memory_policy::MemoryPolicyDecision, writegate::WritePolicyAudit}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn record_extracted_note_rejections( + tx: &mut Transaction<'_, Postgres>, + cfg: &Config, + ctx: &AddEventContext<'_>, + ingestion_profile: &IngestionProfileRef, + note: &ExtractedNote, + note_data: &NoteProcessingData, + message_texts: &[String], + message_policy_applied: &[bool], + write_policy_audits: Option<&Vec>, +) -> Result> { + if let Some(result) = validation::reject_extracted_note_if_evidence_invalid( + cfg, + note.reason.as_ref(), + ¬e_data.evidence, + message_texts, + message_policy_applied, + ) { + let mut result = result; + + result.write_policy_audits = write_policy_audits.cloned(); + + audit::record_ingest_decision( + tx, + cfg, + ctx, + note, + note_data.note_type.as_str(), + None, + None, + MemoryPolicyDecision::Reject, + MemoryPolicyDecision::Reject, + NoteOp::Rejected, + result.reason_code.as_deref(), + None, + None, + false, + false, + None, + None, + Some(ingestion_profile.id.as_str()), + Some(ingestion_profile.version), + note_data.structured_present, + note_data.graph_present, + write_policy_audits.cloned(), + ) + .await?; + + return Ok(Some(result)); + } else if let Some(result) = validation::reject_extracted_note_if_structured_invalid( + note_data.structured.as_ref(), + note_data.text.as_str(), + ¬e_data.evidence, + note.reason.as_ref(), + ) { + let mut result = result; + + result.write_policy_audits = write_policy_audits.cloned(); + + audit::record_ingest_decision( + tx, + cfg, + ctx, + note, + note_data.note_type.as_str(), + None, + None, + MemoryPolicyDecision::Reject, + MemoryPolicyDecision::Reject, + NoteOp::Rejected, + Some(REJECT_STRUCTURED_INVALID), + None, + None, + false, + false, + None, + None, + Some(ingestion_profile.id.as_str()), + Some(ingestion_profile.version), + note_data.structured_present, + note_data.graph_present, + write_policy_audits.cloned(), + ) + .await?; + + return Ok(Some(result)); + } else if let Some(result) = validation::reject_extracted_note_if_writegate_rejects( + cfg, + note.reason.as_ref(), + note_data.note_type.as_str(), + note_data.scope.as_str(), + note_data.text.as_str(), + ) { + let mut result = result; + + result.write_policy_audits = write_policy_audits.cloned(); + + audit::record_ingest_decision( + tx, + cfg, + ctx, + note, + note_data.note_type.as_str(), + None, + None, + MemoryPolicyDecision::Reject, + MemoryPolicyDecision::Reject, + NoteOp::Rejected, + result.reason_code.as_deref(), + None, + None, + false, + false, + None, + None, + Some(ingestion_profile.id.as_str()), + Some(ingestion_profile.version), + note_data.structured_present, + note_data.graph_present, + write_policy_audits.cloned(), + ) + .await?; + + return Ok(Some(result)); + } + + Ok(None) +} diff --git a/packages/elf-service/src/add_event/service.rs b/packages/elf-service/src/add_event/service.rs new file mode 100644 index 00000000..f78df0dc --- /dev/null +++ b/packages/elf-service/src/add_event/service.rs @@ -0,0 +1,312 @@ +use serde_json; +use sqlx::{PgConnection, Postgres, Transaction}; +use time::{Duration, OffsetDateTime}; + +use crate::{ + ElfService, Error, ResolveUpdateArgs, Result, UpdateDecision, + access::ORG_PROJECT_ID, + add_event::{ + audit, materialize, + policy::{self}, + rejection, + types::{ + AddEventContext, AddEventRequest, AddEventResponse, AddEventResult, ExtractedNote, + ExtractorOutput, NoteProcessingData, PersistExtractedNoteArgs, + }, + validation::{self}, + }, + ingestion_profiles::{self, IngestionProfileRef}, +}; +use elf_domain::{memory_policy::MemoryPolicyDecision, ttl, writegate::WritePolicyAudit}; + +impl ElfService { + /// Extracts notes from an event transcript and optionally persists the accepted results. + pub async fn add_event(&self, req: AddEventRequest) -> Result { + validation::validate_add_event_request(&req)?; + + let resolved_profile = ingestion_profiles::resolve_add_event_profile( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.ingestion_profile.as_ref(), + ) + .await?; + let (messages, message_policy_applied, write_policy_audits) = + validation::apply_write_policies_to_messages(req.messages.as_slice())?; + let message_texts: Vec = + messages.iter().map(|message| message.content.clone()).collect(); + let messages_json = + serde_json::to_string(&messages).map_err(|_| Error::InvalidRequest { + message: "Failed to serialize messages for extractor.".to_string(), + })?; + let extractor_messages = resolved_profile.build_extractor_messages( + &messages_json, + self.cfg.memory.max_notes_per_add_event, + self.cfg.memory.max_note_chars, + )?; + let llm_cfg = resolved_profile.resolved_llm_config(&self.cfg.providers.llm_extractor); + let extracted_raw = self.providers.extractor.extract(&llm_cfg, &extractor_messages).await?; + let max_notes = self.cfg.memory.max_notes_per_add_event as usize; + let mut extracted: ExtractorOutput = serde_json::from_value(extracted_raw.clone()) + .map_err(|_| Error::InvalidRequest { + message: "Extractor output is missing notes array.".to_string(), + })?; + + if extracted.notes.len() > max_notes { + extracted.notes.truncate(max_notes); + } + + let extracted_json = serde_json::to_value(&extracted).map_err(|_| { + Error::InvalidRequest { message: "Failed to serialize extracted notes.".to_string() } + })?; + let base_now = OffsetDateTime::now_utc(); + let embed_version = crate::embedding_version(&self.cfg); + let dry_run = req.dry_run.unwrap_or(false); + let mut results = Vec::with_capacity(extracted.notes.len()); + + for (note_idx, note) in extracted.notes.into_iter().enumerate() { + let now = base_now + Duration::microseconds(note_idx as i64); + + results.push( + self.process_extracted_note( + &req, + &resolved_profile.profile_ref, + &message_texts, + &message_policy_applied, + write_policy_audits.as_ref(), + note, + now, + embed_version.as_str(), + dry_run, + ) + .await?, + ); + } + + Ok(AddEventResponse { + extracted: extracted_json, + results, + ingestion_profile: Some(resolved_profile.profile_ref), + }) + } + + #[allow(clippy::too_many_arguments)] + async fn process_extracted_note( + &self, + req: &AddEventRequest, + ingestion_profile: &IngestionProfileRef, + message_texts: &[String], + message_policy_applied: &[bool], + write_policy_audits: Option<&Vec>, + note: ExtractedNote, + now: OffsetDateTime, + embed_version: &str, + dry_run: bool, + ) -> Result { + let note_data = NoteProcessingData::from_request_and_note(req, ¬e); + let effective_project_id = if note_data.scope.trim() == "org_shared" { + ORG_PROJECT_ID + } else { + req.project_id.as_str() + }; + let ctx = AddEventContext { + tenant_id: req.tenant_id.as_str(), + project_id: effective_project_id, + agent_id: req.agent_id.as_str(), + scope: note_data.scope.as_str(), + now, + }; + let mut tx = self.db.pool.begin().await?; + + if let Some(result) = rejection::record_extracted_note_rejections( + &mut tx, + &self.cfg, + &ctx, + ingestion_profile, + ¬e, + ¬e_data, + message_texts, + message_policy_applied, + write_policy_audits, + ) + .await? + { + tx.commit().await?; + + return Ok(result); + } + + let result = self + .apply_extracted_note_decision( + req, + ingestion_profile, + &mut tx, + &ctx, + ¬e, + ¬e_data, + note_data.note_type.as_str(), + effective_project_id, + now, + embed_version, + dry_run, + write_policy_audits, + ) + .await?; + + tx.commit().await?; + + Ok(result) + } + + #[allow(clippy::too_many_arguments)] + async fn apply_extracted_note_decision( + &self, + req: &AddEventRequest, + ingestion_profile: &IngestionProfileRef, + tx: &mut Transaction<'_, Postgres>, + ctx: &AddEventContext<'_>, + note: &ExtractedNote, + note_data: &NoteProcessingData, + note_type: &str, + project_id: &str, + now: OffsetDateTime, + embed_version: &str, + dry_run: bool, + write_policy_audits: Option<&Vec>, + ) -> Result { + let decision = self.resolve_extracted_note_update(note, req, note_data, tx, now).await?; + let metadata = decision.metadata(); + let base_decision = policy::base_decision_for_update( + &decision, + note_data.structured_present, + note_data.graph_present, + ); + let (policy_decision, decision_policy_rule, min_confidence, min_importance) = + policy::resolve_policy_for_update(&self.cfg, note_data, base_decision); + let ignore_reason_code = policy::ignore_reason_code_for_policy( + base_decision, + policy_decision, + metadata.matched_dup, + ); + let should_apply = matches!( + policy_decision, + MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update + ); + let mut result = policy::build_result_from_decision( + &decision, + policy_decision, + note_data.reason.clone(), + note_data.structured_present || note_data.graph_present, + ); + + policy::apply_policy_ignore_adjustments( + &mut result, + &decision, + policy_decision, + ignore_reason_code, + ); + + let mut note_version_id = None; + + if should_apply && !dry_run { + let persist_args = PersistExtractedNoteArgs { + req, + project_id, + structured: note_data.structured.as_ref(), + key: note.key.as_deref(), + reason: note.reason.as_ref(), + note_type, + text: note_data.text.as_str(), + scope: note_data.scope.as_str(), + importance: note_data.importance, + confidence: note_data.confidence, + expires_at: ttl::compute_expires_at( + note_data.ttl_days, + note_data.note_type.as_str(), + &self.cfg, + now, + ), + source_ref: serde_json::json!({ + "evidence": note_data.evidence.clone(), + "reason": note_data.reason.clone().unwrap_or_default(), + "ingestion_profile": serde_json::json!({ + "id": ingestion_profile.id, + "version": ingestion_profile.version, + }), + }), + now, + embed_version, + }; + let persisted = materialize::persist_extracted_note_decision( + tx, + persist_args, + decision, + policy_decision, + ) + .await?; + + result = persisted.0; + note_version_id = persisted.1; + } + + result.write_policy_audits = write_policy_audits.cloned(); + + audit::record_ingest_decision( + tx, + &self.cfg, + ctx, + note, + note_data.note_type.as_str(), + result.note_id, + note_version_id, + base_decision, + policy_decision, + result.op, + result.reason_code.as_deref(), + decision_policy_rule.as_deref(), + metadata.similarity_best, + metadata.key_match, + metadata.matched_dup, + min_confidence, + min_importance, + Some(ingestion_profile.id.as_str()), + Some(ingestion_profile.version), + note_data.structured_present, + note_data.graph_present, + write_policy_audits.cloned(), + ) + .await?; + + Ok(result) + } + + async fn resolve_extracted_note_update( + &self, + note: &ExtractedNote, + req: &AddEventRequest, + note_data: &NoteProcessingData, + tx: &mut PgConnection, + now: OffsetDateTime, + ) -> Result { + crate::resolve_update( + tx, + ResolveUpdateArgs { + cfg: &self.cfg, + providers: &self.providers, + tenant_id: req.tenant_id.as_str(), + project_id: if note_data.scope.trim() == "org_shared" { + ORG_PROJECT_ID + } else { + req.project_id.as_str() + }, + agent_id: req.agent_id.as_str(), + scope: note_data.scope.as_str(), + note_type: note_data.note_type.as_str(), + key: note.key.as_deref(), + text: note_data.text.as_str(), + now, + }, + ) + .await + } +} diff --git a/packages/elf-service/src/add_event/tests.rs b/packages/elf-service/src/add_event/tests.rs new file mode 100644 index 00000000..c2295155 --- /dev/null +++ b/packages/elf-service/src/add_event/tests.rs @@ -0,0 +1,34 @@ +use crate::{ + Error, + add_event::{ + types::{AddEventRequest, EventMessage}, + validation, + }, +}; + +#[test] +fn rejects_long_non_english_message_content() { + let req = AddEventRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: None, + dry_run: None, + ingestion_profile: None, + messages: vec![EventMessage { + role: "user".to_string(), + content: "Bonjour, je veux m'assurer que ce texte est suffisamment long et riche en lettres pour declencher la detection de langue. Merci beaucoup." + .to_string(), + ts: None, + msg_id: None, + write_policy: None, + }], + }; + let err = + validation::validate_add_event_request(&req).expect_err("Expected English gate rejection."); + + assert!(matches!( + err, + Error::NonEnglishInput { field } if field == "$.messages[0].content" + )); +} diff --git a/packages/elf-service/src/add_event/types.rs b/packages/elf-service/src/add_event/types.rs new file mode 100644 index 00000000..7da814f2 --- /dev/null +++ b/packages/elf-service/src/add_event/types.rs @@ -0,0 +1,170 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + NoteOp, + ingestion_profiles::{IngestionProfileRef, IngestionProfileSelector}, + structured_fields::StructuredFields, +}; +use elf_domain::{ + memory_policy::MemoryPolicyDecision, + writegate::{WritePolicy, WritePolicyAudit}, +}; + +pub(super) type ProcessedEventOutput = + (Vec, Vec, Option>); +pub(super) type AddEventPersistOutput = (AddEventResult, Option); + +/// One chat or event message passed to the event extractor. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EventMessage { + /// Speaker or message role. + pub role: String, + /// Message body content. + pub content: String, + /// Optional source timestamp string. + pub ts: Option, + /// Optional message identifier from the upstream source. + pub msg_id: Option, + /// Optional write policy applied before extraction. + pub write_policy: Option, +} + +/// Request payload for event-driven note extraction. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddEventRequest { + /// Tenant that owns the request. + pub tenant_id: String, + /// Project that owns the request. + pub project_id: String, + /// Agent that emitted the event batch. + pub agent_id: String, + /// Optional explicit scope override for extracted notes. + pub scope: Option, + /// When true, performs validation and extraction without persisting notes. + pub dry_run: Option, + /// Optional ingestion profile selector. + pub ingestion_profile: Option, + /// Source messages to extract notes from. + pub messages: Vec, +} + +/// Per-note outcome for an `add_event` request. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddEventResult { + /// Note identifier when one was created or updated. + pub note_id: Option, + /// Persistence operation chosen for the extracted note. + pub op: NoteOp, + /// Memory-policy decision applied to the extracted note. + pub policy_decision: MemoryPolicyDecision, + /// Machine-readable rejection or ignore code, if any. + pub reason_code: Option, + /// Human-readable rejection or ignore message, if any. + pub reason: Option, + /// Field path associated with a validation failure, if any. + pub field_path: Option, + /// Per-message write-policy audits when write policies were applied. + pub write_policy_audits: Option>, +} + +/// Response payload for event-driven note extraction. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddEventResponse { + /// Raw structured extractor output after normalization. + pub extracted: Value, + /// One result per extracted note. + pub results: Vec, + /// Resolved ingestion profile used for the request. + pub ingestion_profile: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ExtractorOutput { + pub notes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ExtractedNote { + pub r#type: Option, + pub key: Option, + pub text: Option, + pub structured: Option, + pub importance: Option, + pub confidence: Option, + pub ttl_days: Option, + pub scope_suggestion: Option, + pub evidence: Option>, + pub reason: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct EvidenceQuote { + pub message_index: usize, + pub quote: String, +} + +pub(super) struct NoteProcessingData { + pub(super) note_type: String, + pub(super) text: String, + pub(super) structured: Option, + pub(super) importance: f32, + pub(super) confidence: f32, + pub(super) reason: Option, + pub(super) ttl_days: Option, + pub(super) scope: String, + pub(super) evidence: Vec, + pub(super) structured_present: bool, + pub(super) graph_present: bool, +} +impl NoteProcessingData { + pub(super) fn from_request_and_note(req: &AddEventRequest, note: &ExtractedNote) -> Self { + let note_type = note.r#type.clone().unwrap_or_default(); + let text = note.text.clone().unwrap_or_default(); + let structured = note.structured.clone(); + let structured_present = + structured.as_ref().is_some_and(|value| !value.is_effectively_empty()); + let graph_present = structured.as_ref().is_some_and(StructuredFields::has_graph_fields); + + Self { + note_type, + text, + structured, + importance: note.importance.unwrap_or(0.0), + confidence: note.confidence.unwrap_or(0.0), + reason: note.reason.clone(), + ttl_days: note.ttl_days, + scope: req.scope.clone().or(note.scope_suggestion.clone()).unwrap_or_default(), + evidence: note.evidence.clone().unwrap_or_default(), + structured_present, + graph_present, + } + } +} + +pub(super) struct PersistExtractedNoteArgs<'a> { + pub(super) req: &'a AddEventRequest, + pub(super) project_id: &'a str, + pub(super) structured: Option<&'a StructuredFields>, + pub(super) key: Option<&'a str>, + pub(super) reason: Option<&'a String>, + pub(super) note_type: &'a str, + pub(super) text: &'a str, + pub(super) scope: &'a str, + pub(super) importance: f32, + pub(super) confidence: f32, + pub(super) expires_at: Option, + pub(super) source_ref: Value, + pub(super) now: OffsetDateTime, + pub(super) embed_version: &'a str, +} + +pub(super) struct AddEventContext<'a> { + pub(super) tenant_id: &'a str, + pub(super) project_id: &'a str, + pub(super) agent_id: &'a str, + pub(super) scope: &'a str, + pub(super) now: OffsetDateTime, +} diff --git a/packages/elf-service/src/add_event/validation.rs b/packages/elf-service/src/add_event/validation.rs new file mode 100644 index 00000000..0c4e1d95 --- /dev/null +++ b/packages/elf-service/src/add_event/validation.rs @@ -0,0 +1,240 @@ +use crate::{ + Error, NoteOp, REJECT_EVIDENCE_MISMATCH, REJECT_WRITE_POLICY_MISMATCH, Result, + add_event::types::{ + AddEventRequest, AddEventResult, EventMessage, EvidenceQuote, ProcessedEventOutput, + }, + structured_fields::{self, StructuredFields}, +}; +use elf_config::Config; +use elf_domain::{ + english_gate, evidence, + memory_policy::MemoryPolicyDecision, + writegate::{self, NoteInput, WritePolicyAudit, WritePolicyError}, +}; + +pub(super) const REJECT_STRUCTURED_INVALID: &str = "REJECT_STRUCTURED_INVALID"; + +pub(super) fn validate_add_event_request(req: &AddEventRequest) -> Result<()> { + if req.messages.is_empty() { + return Err(Error::InvalidRequest { message: "Messages list is empty.".to_string() }); + } + if req.tenant_id.trim().is_empty() + || req.project_id.trim().is_empty() + || req.agent_id.trim().is_empty() + { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + if let Some(scope) = req.scope.as_ref() + && scope.trim().is_empty() + { + return Err(Error::InvalidRequest { + message: "scope must not be empty when provided.".to_string(), + }); + } + if let Some(profile) = req.ingestion_profile.as_ref() { + if profile.id.trim().is_empty() { + return Err(Error::InvalidRequest { + message: "ingestion_profile.id must not be empty.".to_string(), + }); + } + + if let Some(version) = profile.version + && version <= 0 + { + return Err(Error::InvalidRequest { + message: "ingestion_profile.version must be greater than zero.".to_string(), + }); + } + } + + for (idx, msg) in req.messages.iter().enumerate() { + if !english_gate::is_english_natural_language(msg.content.as_str()) { + return Err(Error::NonEnglishInput { field: format!("$.messages[{idx}].content") }); + } + } + + Ok(()) +} + +pub(super) fn apply_write_policies_to_messages( + messages: &[EventMessage], +) -> Result { + let mut message_policy_applied = Vec::with_capacity(messages.len()); + let mut write_policy_audits = Vec::new(); + let mut transformed_messages = Vec::with_capacity(messages.len()); + + for message in messages { + let (transformed_message, audit) = apply_write_policy_to_message(message)?; + + message_policy_applied.push(audit.is_some()); + + if let Some(audit) = audit { + write_policy_audits.push(audit); + } + + transformed_messages.push(transformed_message); + } + + Ok(( + transformed_messages, + message_policy_applied, + if write_policy_audits.is_empty() { None } else { Some(write_policy_audits) }, + )) +} + +pub(super) fn apply_write_policy_to_message( + message: &EventMessage, +) -> Result<(EventMessage, Option)> { + let result = + writegate::apply_write_policy(message.content.as_str(), message.write_policy.as_ref()) + .map_err(|err| { + let message = match err { + WritePolicyError::InvalidSpan => "Invalid write_policy span provided.", + WritePolicyError::OverlappingOps => "Overlapping write_policy spans provided.", + }; + + Error::InvalidRequest { message: message.to_string() } + })?; + let has_policy = message.write_policy.is_some(); + let mut transformed = message.clone(); + + transformed.content = result.transformed; + + Ok((transformed, if has_policy { Some(result.audit) } else { None })) +} + +pub(super) fn reject_extracted_note_if_evidence_invalid( + cfg: &Config, + reason: Option<&String>, + evidence: &[EvidenceQuote], + message_texts: &[String], + message_policy_applied: &[bool], +) -> Option { + if evidence.is_empty() + || evidence.len() < cfg.security.evidence_min_quotes as usize + || evidence.len() > cfg.security.evidence_max_quotes as usize + { + return Some(AddEventResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()), + reason: reason.cloned(), + field_path: None, + write_policy_audits: None, + }); + } + + for quote in evidence { + if quote.quote.len() > cfg.security.evidence_max_quote_chars as usize { + return Some(AddEventResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()), + reason: reason.cloned(), + field_path: None, + write_policy_audits: None, + }); + } + if !evidence::evidence_matches(message_texts, quote.message_index, quote.quote.as_str()) { + let reason_code = + message_policy_applied.get(quote.message_index).is_some_and(|applied| *applied); + + return Some(AddEventResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(if reason_code { + REJECT_WRITE_POLICY_MISMATCH.to_string() + } else { + REJECT_EVIDENCE_MISMATCH.to_string() + }), + reason: reason.cloned(), + field_path: None, + write_policy_audits: None, + }); + } + } + + None +} + +pub(super) fn reject_extracted_note_if_structured_invalid( + structured: Option<&StructuredFields>, + text: &str, + evidence: &[EvidenceQuote], + reason: Option<&String>, +) -> Option { + let structured = structured?; + + if structured.is_effectively_empty() { + return None; + } + + let event_evidence: Vec<(usize, String)> = + evidence.iter().map(|q| (q.message_index, q.quote.clone())).collect(); + + if let Err(err) = structured_fields::validate_structured_fields( + structured, + text, + &serde_json::json!({}), + Some(event_evidence.as_slice()), + ) { + tracing::info!(error = %err, "Rejecting extracted note due to invalid structured fields."); + + let field_path = extract_structured_rejection_field_path(&err); + + return Some(AddEventResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(REJECT_STRUCTURED_INVALID.to_string()), + reason: reason.cloned(), + field_path, + write_policy_audits: None, + }); + } + + None +} + +pub(super) fn reject_extracted_note_if_writegate_rejects( + cfg: &Config, + reason: Option<&String>, + note_type: &str, + scope: &str, + text: &str, +) -> Option { + let gate_input = NoteInput { + note_type: note_type.to_string(), + scope: scope.to_string(), + text: text.to_string(), + }; + + if let Err(code) = writegate::writegate(&gate_input, cfg) { + return Some(AddEventResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(crate::writegate_reason_code(code).to_string()), + reason: reason.cloned(), + field_path: None, + write_policy_audits: None, + }); + } + + None +} + +fn extract_structured_rejection_field_path(err: &Error) -> Option { + match err { + Error::NonEnglishInput { field } => Some(field.clone()), + Error::InvalidRequest { message } if message.starts_with("structured.") => + message.split_whitespace().next().map(ToString::to_string), + _ => None, + } +} diff --git a/packages/elf-service/src/add_note.rs b/packages/elf-service/src/add_note.rs index 4a67401c..c59fd762 100644 --- a/packages/elf-service/src/add_note.rs +++ b/packages/elf-service/src/add_note.rs @@ -1,1431 +1,14 @@ //! Direct note ingestion APIs. -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{Postgres, Transaction}; -use time::{Duration, OffsetDateTime}; -use uuid::Uuid; - -use crate::{ - ElfService, Error, InsertVersionArgs, NoteOp, ResolveUpdateArgs, Result, UpdateDecision, - UpdateDecisionMetadata, - access::{self, ORG_PROJECT_ID}, - graph_ingestion, - ingest_audit::{self, IngestAuditArgs}, - structured_fields::{self, StructuredFields}, -}; -use elf_config::Config; -use elf_domain::{ - english_gate, - memory_policy::{self, MemoryPolicyDecision}, - ttl, - writegate::{self, NoteInput, WritePolicy, WritePolicyAudit, WritePolicyError}, -}; -use elf_storage::models::MemoryNote; - -type AddNoteApplyOutput = (AddNoteResult, NoteOp, Option); - -const REJECT_STRUCTURED_INVALID: &str = "REJECT_STRUCTURED_INVALID"; -const IGNORE_DUPLICATE: &str = "IGNORE_DUPLICATE"; -const IGNORE_POLICY_THRESHOLD: &str = "IGNORE_POLICY_THRESHOLD"; - -/// Request payload for direct note ingestion. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddNoteRequest { - /// Tenant that owns the request. - pub tenant_id: String, - /// Project that owns the request. - pub project_id: String, - /// Agent that is writing the notes. - pub agent_id: String, - /// Scope to apply to all notes in the batch. - pub scope: String, - /// Notes to validate and persist. - pub notes: Vec, -} - -/// One note supplied to `add_note`. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddNoteInput { - /// Note type discriminator. - pub r#type: String, - /// Optional application-defined key for deduplication or lookup. - pub key: Option, - /// Note body text. - pub text: String, - /// Optional structured extraction payload to persist alongside the note. - pub structured: Option, - /// Importance score for ranking and retention. - pub importance: f32, - /// Confidence score for ranking and retention. - pub confidence: f32, - /// Optional TTL override in days. - pub ttl_days: Option, - #[serde(default = "default_source_ref")] - /// Structured source reference metadata. - pub source_ref: Value, - /// Optional write policy applied before validation and persistence. - pub write_policy: Option, -} - -/// Per-note outcome for an `add_note` request. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddNoteResult { - /// Note identifier when one was created or updated. - pub note_id: Option, - /// Persistence operation chosen for the note. - pub op: NoteOp, - /// Memory-policy decision applied to the note. - pub policy_decision: MemoryPolicyDecision, - /// Machine-readable rejection or ignore code, if any. - pub reason_code: Option, - /// Field path associated with a validation failure, if any. - pub field_path: Option, - /// Write-policy audit emitted for this note, if any. - pub write_policy_audit: Option, -} - -/// Response payload for direct note ingestion. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AddNoteResponse { - /// One result per requested note. - pub results: Vec, -} - -struct AddNoteContext<'a> { - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - scope: &'a str, - now: OffsetDateTime, - embed_version: &'a str, -} - -impl ElfService { - /// Validates and persists notes supplied directly by the caller. - pub async fn add_note(&self, req: AddNoteRequest) -> Result { - let req = normalize_add_note_request(req); - - validate_add_note_request(&req)?; - - let base_now = OffsetDateTime::now_utc(); - let embed_version = crate::embedding_version(&self.cfg); - let AddNoteRequest { tenant_id, project_id, agent_id, scope, notes } = req; - let effective_project_id = - if scope.trim() == "org_shared" { ORG_PROJECT_ID } else { project_id.as_str() }; - let mut results = Vec::with_capacity(notes.len()); - - for (note_idx, note) in notes.into_iter().enumerate() { - let now = base_now + Duration::microseconds(note_idx as i64); - let ctx = AddNoteContext { - tenant_id: tenant_id.as_str(), - project_id: effective_project_id, - agent_id: agent_id.as_str(), - scope: scope.as_str(), - now, - embed_version: embed_version.as_str(), - }; - - results.push(self.process_add_note_input(&ctx, note).await?); - } - - Ok(AddNoteResponse { results }) - } - - async fn process_add_note_input( - &self, - ctx: &AddNoteContext<'_>, - note: AddNoteInput, - ) -> Result { - let mut note = note; - let (transformed, write_policy_audit) = - apply_write_policy_to_note(note.write_policy.as_ref(), note.text.as_str())?; - - note.text = transformed; - - let (structured_present, graph_present) = - Self::structured_and_graph_present(note.structured.as_ref()); - let mut tx = self.db.pool.begin().await?; - - if let Some(result) = - self.handle_rejection_paths(&mut tx, ctx, ¬e, write_policy_audit.as_ref()).await? - { - tx.commit().await?; - - return Ok(result); - } - - let (decision, metadata) = self.resolve_update_decision(&mut tx, ctx, ¬e).await?; - let base_decision = - Self::base_decision_for_update(&decision, structured_present, graph_present); - let (policy_decision, decision_policy_rule, min_confidence, min_importance) = - self.decide_policy_decision(ctx.scope, ¬e, base_decision); - let note_id = decision.note_id(); - let ignore_reason_code = - Self::ignore_reason_code(policy_decision, base_decision, metadata.matched_dup); - let (result, note_op, note_version_id) = self - .apply_policy_result( - &mut tx, - &decision, - ctx, - ¬e, - note_id, - policy_decision, - ignore_reason_code, - ) - .await?; - let mut result = result; - - result.write_policy_audit = write_policy_audit.clone(); - - self.record_ingest_decision( - &mut tx, - ctx, - ¬e, - result.note_id, - note_version_id, - base_decision, - result.policy_decision, - note_op, - result.reason_code.as_deref(), - decision_policy_rule.as_deref(), - metadata.similarity_best, - metadata.key_match, - metadata.matched_dup, - min_confidence, - min_importance, - write_policy_audit, - ) - .await?; - tx.commit().await?; - - Ok(result) - } - - fn structured_and_graph_present(structured: Option<&StructuredFields>) -> (bool, bool) { - let structured_present = structured.is_some_and(|s| !s.is_effectively_empty()); - let graph_present = structured.is_some_and(StructuredFields::has_graph_fields); - - (structured_present, graph_present) - } - - async fn handle_rejection_paths( - &self, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddNoteContext<'_>, - note: &AddNoteInput, - write_policy_audit: Option<&WritePolicyAudit>, - ) -> Result> { - if let Some(result) = reject_note_if_structured_invalid(note) { - let mut result = result; - - result.write_policy_audit = write_policy_audit.cloned(); - - self.record_ingest_decision( - tx, - ctx, - note, - None, - None, - MemoryPolicyDecision::Reject, - MemoryPolicyDecision::Reject, - NoteOp::Rejected, - result.reason_code.as_deref(), - None, - None, - false, - false, - None, - None, - write_policy_audit.cloned(), - ) - .await?; - - return Ok(Some(result)); - } - if let Some(result) = reject_note_if_writegate_rejects(&self.cfg, ctx.scope, note) { - let mut result = result; - - result.write_policy_audit = write_policy_audit.cloned(); - - self.record_ingest_decision( - tx, - ctx, - note, - None, - None, - MemoryPolicyDecision::Reject, - MemoryPolicyDecision::Reject, - NoteOp::Rejected, - result.reason_code.as_deref(), - None, - None, - false, - false, - None, - None, - write_policy_audit.cloned(), - ) - .await?; - - return Ok(Some(result)); - } - - Ok(None) - } - - async fn resolve_update_decision( - &self, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddNoteContext<'_>, - note: &AddNoteInput, - ) -> Result<(UpdateDecision, UpdateDecisionMetadata)> { - let decision = crate::resolve_update( - &mut **tx, - ResolveUpdateArgs { - cfg: &self.cfg, - providers: &self.providers, - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: ctx.scope, - note_type: note.r#type.as_str(), - key: note.key.as_deref(), - text: note.text.as_str(), - now: ctx.now, - }, - ) - .await?; - let metadata = decision.metadata(); - - Ok((decision, metadata)) - } - - fn decide_policy_decision( - &self, - scope: &str, - note: &AddNoteInput, - base_decision: MemoryPolicyDecision, - ) -> (MemoryPolicyDecision, Option, Option, Option) { - if matches!(base_decision, MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update) { - let policy_eval = memory_policy::evaluate_memory_policy( - &self.cfg, - note.r#type.as_str(), - scope, - f64::from(note.confidence), - f64::from(note.importance), - base_decision, - ); - let decision_policy_rule = policy_eval.matched_rule.and_then(|rule| { - Self::policy_rule_id(rule.note_type.as_deref(), rule.scope.as_deref()) - }); - let min_confidence = policy_eval.matched_rule.and_then(|rule| rule.min_confidence); - let min_importance = policy_eval.matched_rule.and_then(|rule| rule.min_importance); - - (policy_eval.decision, decision_policy_rule, min_confidence, min_importance) - } else { - (MemoryPolicyDecision::Ignore, None, None, None) - } - } - - fn ignore_reason_code( - policy_decision: MemoryPolicyDecision, - base_decision: MemoryPolicyDecision, - matched_dup: bool, - ) -> Option<&'static str> { - if !matches!(policy_decision, MemoryPolicyDecision::Ignore) { - return None; - } - - match base_decision { - MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update => - Some(IGNORE_POLICY_THRESHOLD), - MemoryPolicyDecision::Ignore if matched_dup => Some(IGNORE_DUPLICATE), - _ => None, - } - } - - #[allow(clippy::too_many_arguments)] - async fn apply_policy_result( - &self, - tx: &mut Transaction<'_, Postgres>, - decision: &UpdateDecision, - ctx: &AddNoteContext<'_>, - note: &AddNoteInput, - note_id: Uuid, - policy_decision: MemoryPolicyDecision, - ignore_reason_code: Option<&'static str>, - ) -> Result { - let should_apply = matches!( - policy_decision, - MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update - ); - - if should_apply { - let (result, note_version_id) = match decision { - UpdateDecision::Add { .. } => { - let note_version_id = self.handle_add_note_add(tx, ctx, note, note_id).await?; - - ( - AddNoteResult { - note_id: Some(note_id), - op: NoteOp::Add, - policy_decision, - reason_code: None, - field_path: None, - write_policy_audit: None, - }, - Some(note_version_id), - ) - }, - UpdateDecision::Update { .. } => - self.handle_add_note_update( - tx, - note, - note_id, - ctx.agent_id, - ctx.now, - policy_decision, - ) - .await?, - UpdateDecision::None { .. } => { - let (mut none_result, note_version_id) = self - .handle_add_note_none( - tx, - ctx, - note, - note_id, - ctx.now, - ctx.embed_version, - policy_decision, - ) - .await?; - - none_result.policy_decision = policy_decision; - - (none_result, note_version_id) - }, - }; - let note_op = result.op; - - Ok((result, note_op, note_version_id)) - } else { - let mut result = AddNoteResult { - note_id: Some(note_id), - op: NoteOp::None, - policy_decision, - reason_code: ignore_reason_code.map(str::to_string), - field_path: None, - write_policy_audit: None, - }; - - match decision { - UpdateDecision::Add { .. } => { - result.note_id = None; - }, - UpdateDecision::Update { .. } | UpdateDecision::None { .. } => {}, - } - - Ok((result, NoteOp::None, None)) - } - } - - #[allow(clippy::too_many_arguments)] - async fn record_ingest_decision( - &self, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddNoteContext<'_>, - note: &AddNoteInput, - note_id: Option, - note_version_id: Option, - base_decision: MemoryPolicyDecision, - policy_decision: MemoryPolicyDecision, - note_op: NoteOp, - reason_code: Option<&str>, - policy_rule: Option<&str>, - similarity_best: Option, - key_match: bool, - matched_dup: bool, - min_confidence: Option, - min_importance: Option, - write_policy_audit: Option, - ) -> Result<()> { - let decision = IngestAuditArgs { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: ctx.scope, - pipeline: "add_note", - note_type: note.r#type.as_str(), - note_key: note.key.as_deref(), - note_id, - note_version_id, - base_decision, - policy_decision, - note_op, - reason_code, - similarity_best, - key_match, - matched_dup, - dup_sim_threshold: self.cfg.memory.dup_sim_threshold, - update_sim_threshold: self.cfg.memory.update_sim_threshold, - confidence: note.confidence, - importance: note.importance, - structured_present: note.structured.as_ref().is_some_and(|s| !s.is_effectively_empty()), - graph_present: note.structured.as_ref().is_some_and(StructuredFields::has_graph_fields), - policy_rule, - min_confidence, - min_importance, - write_policy_audits: write_policy_audit.map(|audit| vec![audit]), - ingestion_profile_id: None, - ingestion_profile_version: None, - ts: ctx.now, - }; - - ingest_audit::insert_ingest_decision(tx, decision).await - } - - fn base_decision_for_update( - decision: &UpdateDecision, - structured_present: bool, - graph_present: bool, - ) -> MemoryPolicyDecision { - match decision { - UpdateDecision::Update { .. } => MemoryPolicyDecision::Update, - UpdateDecision::Add { .. } => MemoryPolicyDecision::Remember, - UpdateDecision::None { .. } => - if structured_present || graph_present { - MemoryPolicyDecision::Update - } else { - MemoryPolicyDecision::Ignore - }, - } - } - - fn policy_rule_id(note_type: Option<&str>, scope: Option<&str>) -> Option { - match (note_type, scope) { - (Some(note_type), Some(scope)) => Some(format!("note_type={note_type},scope={scope}")), - (Some(note_type), None) => Some(format!("note_type={note_type}")), - (None, Some(scope)) => Some(format!("scope={scope}")), - (None, None) => None, - } - } - - async fn handle_add_note_add( - &self, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddNoteContext<'_>, - note: &AddNoteInput, - note_id: Uuid, - ) -> Result { - access::ensure_active_project_scope_grant( - &mut **tx, - ctx.tenant_id, - ctx.project_id, - ctx.scope, - ctx.agent_id, - ) - .await?; - - let expires_at = - ttl::compute_expires_at(note.ttl_days, note.r#type.as_str(), &self.cfg, ctx.now); - let memory_note = MemoryNote { - note_id, - tenant_id: ctx.tenant_id.to_string(), - project_id: ctx.project_id.to_string(), - agent_id: ctx.agent_id.to_string(), - scope: ctx.scope.to_string(), - r#type: note.r#type.clone(), - key: note.key.clone(), - text: note.text.clone(), - importance: note.importance, - confidence: note.confidence, - status: "active".to_string(), - created_at: ctx.now, - updated_at: ctx.now, - expires_at, - embedding_version: ctx.embed_version.to_string(), - source_ref: note.source_ref.clone(), - hit_count: 0, - last_hit_at: None, - }; - - insert_memory_note_tx(tx, &memory_note).await?; - - let note_version_id = crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id: memory_note.note_id, - op: "ADD", - prev_snapshot: None, - new_snapshot: Some(crate::note_snapshot(&memory_note)), - reason: "add_note", - actor: ctx.agent_id, - ts: ctx.now, - }, - ) - .await?; - - self.upsert_structured_and_enqueue_outbox( - tx, - note, - memory_note.note_id, - ctx.embed_version, - ctx.now, - ) - .await?; - self.persist_graph_fields_if_present( - tx, - ctx.tenant_id, - ctx.project_id, - ctx.agent_id, - ctx.scope, - memory_note.note_id, - ctx.now, - note.structured.as_ref(), - ) - .await?; - - Ok(note_version_id) - } - - async fn handle_add_note_update( - &self, - tx: &mut Transaction<'_, Postgres>, - note: &AddNoteInput, - note_id: Uuid, - agent_id: &str, - now: OffsetDateTime, - policy_decision: MemoryPolicyDecision, - ) -> Result<(AddNoteResult, Option)> { - let mut existing: MemoryNote = sqlx::query_as::<_, MemoryNote>( - "SELECT * FROM memory_notes WHERE note_id = $1 FOR UPDATE", - ) - .bind(note_id) - .fetch_one(&mut **tx) - .await?; - let prev_snapshot = crate::note_snapshot(&existing); - let requested_ttl = note.ttl_days.filter(|days| *days > 0); - let expires_at = match requested_ttl { - Some(ttl) => ttl::compute_expires_at(Some(ttl), note.r#type.as_str(), &self.cfg, now), - None => existing.expires_at, - }; - let expires_match = requested_ttl.map_or(existing.expires_at == expires_at, |ttl_days| { - match existing.expires_at { - Some(existing_expires_at) => { - let existing_ttl = - (existing_expires_at - existing.updated_at).whole_days() as i64; - - existing_ttl == ttl_days - }, - None => false, - } - }); - let float_eps = 1e-6_f32; - let unchanged = existing.text == note.text - && (existing.importance - note.importance).abs() <= float_eps - && (existing.confidence - note.confidence).abs() <= float_eps - && expires_match - && existing.source_ref == note.source_ref; - - if unchanged { - return Ok(( - AddNoteResult { - note_id: Some(note_id), - op: NoteOp::None, - policy_decision: MemoryPolicyDecision::Ignore, - reason_code: None, - field_path: None, - write_policy_audit: None, - }, - None, - )); - } - - access::ensure_active_project_scope_grant( - &mut **tx, - existing.tenant_id.as_str(), - existing.project_id.as_str(), - existing.scope.as_str(), - existing.agent_id.as_str(), - ) - .await?; - - existing.text = note.text.clone(); - existing.importance = note.importance; - existing.confidence = note.confidence; - existing.updated_at = now; - existing.expires_at = expires_at; - existing.source_ref = note.source_ref.clone(); - - update_memory_note_tx(tx, &existing).await?; - - let note_version_id = crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id: existing.note_id, - op: "UPDATE", - prev_snapshot: Some(prev_snapshot), - new_snapshot: Some(crate::note_snapshot(&existing)), - reason: "add_note", - actor: agent_id, - ts: now, - }, - ) - .await?; - - self.persist_graph_fields_if_present( - tx, - existing.tenant_id.as_str(), - existing.project_id.as_str(), - existing.agent_id.as_str(), - existing.scope.as_str(), - existing.note_id, - now, - note.structured.as_ref(), - ) - .await?; - self.upsert_structured_and_enqueue_outbox( - tx, - note, - existing.note_id, - existing.embedding_version.as_str(), - now, - ) - .await?; - - Ok(( - AddNoteResult { - note_id: Some(note_id), - op: NoteOp::Update, - policy_decision, - reason_code: None, - field_path: None, - write_policy_audit: None, - }, - Some(note_version_id), - )) - } - - #[allow(clippy::too_many_arguments)] - async fn handle_add_note_none( - &self, - tx: &mut Transaction<'_, Postgres>, - ctx: &AddNoteContext<'_>, - note: &AddNoteInput, - note_id: Uuid, - now: OffsetDateTime, - embed_version: &str, - policy_decision: MemoryPolicyDecision, - ) -> Result<(AddNoteResult, Option)> { - let mut should_update = false; - - if let Some(structured) = note.structured.as_ref() { - if !structured.is_effectively_empty() { - structured_fields::upsert_structured_fields_tx(tx, note_id, structured, now) - .await?; - crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", embed_version, now).await?; - - should_update = true; - } - if structured.has_graph_fields() { - self.persist_graph_fields_if_present( - tx, - ctx.tenant_id, - ctx.project_id, - ctx.agent_id, - ctx.scope, - note_id, - now, - Some(structured), - ) - .await?; - - should_update = true; - } - } - - if should_update { - let note_row: MemoryNote = - sqlx::query_as("SELECT * FROM memory_notes WHERE note_id = $1") - .bind(note_id) - .fetch_one(&mut **tx) - .await?; - let snapshot = crate::note_snapshot(¬e_row); - let note_version_id = crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id, - op: "UPDATE", - prev_snapshot: Some(snapshot.clone()), - new_snapshot: Some(snapshot), - reason: "add_note_structured", - actor: ctx.agent_id, - ts: now, - }, - ) - .await?; - - if matches!(ctx.scope, "project_shared" | "org_shared") { - access::ensure_active_project_scope_grant( - &mut **tx, - ctx.tenant_id, - ctx.project_id, - ctx.scope, - ctx.agent_id, - ) - .await?; - } - - return Ok(( - AddNoteResult { - note_id: Some(note_id), - op: NoteOp::Update, - policy_decision, - reason_code: None, - field_path: None, - write_policy_audit: None, - }, - Some(note_version_id), - )); - } - - Ok(( - AddNoteResult { - note_id: Some(note_id), - op: NoteOp::None, - policy_decision, - reason_code: None, - field_path: None, - write_policy_audit: None, - }, - None, - )) - } - - #[allow(clippy::too_many_arguments)] - async fn persist_graph_fields_if_present( - &self, - tx: &mut Transaction<'_, Postgres>, - tenant_id: &str, - project_id: &str, - agent_id: &str, - scope: &str, - note_id: Uuid, - now: OffsetDateTime, - structured: Option<&StructuredFields>, - ) -> Result<()> { - let Some(structured) = structured else { - return Ok(()); - }; - - if !structured.has_graph_fields() { - return Ok(()); - } - - graph_ingestion::persist_graph_fields_tx( - tx, tenant_id, project_id, agent_id, scope, note_id, structured, now, - ) - .await?; - - Ok(()) - } - - async fn upsert_structured_and_enqueue_outbox( - &self, - tx: &mut Transaction<'_, Postgres>, - note: &AddNoteInput, - note_id: Uuid, - embed_version: &str, - now: OffsetDateTime, - ) -> Result<()> { - if let Some(structured) = note.structured.as_ref() - && !structured.is_effectively_empty() - { - structured_fields::upsert_structured_fields_tx(tx, note_id, structured, now).await?; - } - - crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", embed_version, now).await?; - - Ok(()) - } -} - -fn default_source_ref() -> Value { - Value::Object(Default::default()) -} - -fn normalize_add_note_request(mut req: AddNoteRequest) -> AddNoteRequest { - for note in &mut req.notes { - if note.source_ref.is_null() { - note.source_ref = default_source_ref(); - } - } - - req -} - -fn validate_add_note_request(req: &AddNoteRequest) -> Result<()> { - if req.notes.is_empty() { - return Err(Error::InvalidRequest { message: "Notes list is empty.".to_string() }); - } - if req.tenant_id.trim().is_empty() - || req.project_id.trim().is_empty() - || req.agent_id.trim().is_empty() - || req.scope.trim().is_empty() - { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, agent_id, and scope are required.".to_string(), - }); - } - - for (idx, note) in req.notes.iter().enumerate() { - if !note.source_ref.is_object() { - return Err(Error::InvalidRequest { - message: "source_ref must be a JSON object.".to_string(), - }); - } - if !english_gate::is_english_natural_language(note.text.as_str()) { - return Err(Error::NonEnglishInput { field: format!("$.notes[{idx}].text") }); - } - - if let Some(key) = note.key.as_ref() - && !english_gate::is_english_identifier(key) - { - return Err(Error::NonEnglishInput { field: format!("$.notes[{idx}].key") }); - } - if let Some(path) = find_non_english_path_in_structured( - note.structured.as_ref(), - &format!("$.notes[{idx}].structured"), - ) { - return Err(Error::NonEnglishInput { field: path }); - } - if let Some(path) = - find_non_english_path(¬e.source_ref, &format!("$.notes[{idx}].source_ref")) - { - return Err(Error::NonEnglishInput { field: path }); - } - } - - Ok(()) -} - -fn reject_note_if_structured_invalid(note: &AddNoteInput) -> Option { - if let Some(structured) = note.structured.as_ref() - && let Err(err) = structured_fields::validate_structured_fields( - structured, - note.text.as_str(), - ¬e.source_ref, - None, - ) { - tracing::info!(error = %err, "Rejecting note due to invalid structured fields."); - - let field_path = extract_structured_rejection_field_path(&err); - - return Some(AddNoteResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(REJECT_STRUCTURED_INVALID.to_string()), - field_path, - write_policy_audit: None, - }); - } - - None -} - -fn reject_note_if_writegate_rejects( - cfg: &Config, - scope: &str, - note: &AddNoteInput, -) -> Option { - let gate_input = NoteInput { - note_type: note.r#type.clone(), - scope: scope.to_string(), - text: note.text.clone(), - }; - - if let Err(code) = writegate::writegate(&gate_input, cfg) { - return Some(AddNoteResult { - note_id: None, - op: NoteOp::Rejected, - policy_decision: MemoryPolicyDecision::Reject, - reason_code: Some(crate::writegate_reason_code(code).to_string()), - field_path: None, - write_policy_audit: None, - }); - } - - None -} - -fn apply_write_policy_to_note( - policy: Option<&WritePolicy>, - text: &str, -) -> Result<(String, Option)> { - let result = writegate::apply_write_policy(text, policy).map_err(|err| { - let message = match err { - WritePolicyError::InvalidSpan => "Invalid write_policy span provided.", - WritePolicyError::OverlappingOps => "Overlapping write_policy spans provided.", - }; - - Error::InvalidRequest { message: message.to_string() } - })?; - - Ok((result.transformed, policy.is_some().then_some(result.audit))) -} - -fn find_non_english_path_in_structured( - structured: Option<&StructuredFields>, - base: &str, -) -> Option { - let structured = structured?; - - if let Some(summary) = structured.summary.as_ref() - && !english_gate::is_english_natural_language(summary) - { - return Some(format!("{base}.summary")); - } - if let Some(items) = structured.facts.as_ref() { - for (idx, item) in items.iter().enumerate() { - if !english_gate::is_english_natural_language(item) { - return Some(format!("{base}.facts[{idx}]")); - } - } - } - if let Some(items) = structured.concepts.as_ref() { - for (idx, item) in items.iter().enumerate() { - if !english_gate::is_english_natural_language(item) { - return Some(format!("{base}.concepts[{idx}]")); - } - } - } - if let Some(items) = structured.entities.as_ref() { - for (idx, entity) in items.iter().enumerate() { - let base = format!("{base}.entities[{idx}]"); - - if let Some(canonical) = entity.canonical.as_ref() - && !english_gate::is_english_natural_language(canonical) - { - return Some(format!("{base}.canonical")); - } - if let Some(kind) = entity.kind.as_ref() - && !english_gate::is_english_natural_language(kind) - { - return Some(format!("{base}.kind")); - } - if let Some(aliases) = entity.aliases.as_ref() { - for (alias_idx, alias) in aliases.iter().enumerate() { - if !english_gate::is_english_natural_language(alias) { - return Some(format!("{base}.aliases[{alias_idx}]")); - } - } - } - } - } - if let Some(items) = structured.relations.as_ref() { - for (idx, relation) in items.iter().enumerate() { - let base = format!("{base}.relations[{idx}]"); - - if let Some(subject) = relation.subject.as_ref() { - let subject_base = format!("{base}.subject"); - - if let Some(canonical) = subject.canonical.as_ref() - && !english_gate::is_english_natural_language(canonical) - { - return Some(format!("{subject_base}.canonical")); - } - if let Some(kind) = subject.kind.as_ref() - && !english_gate::is_english_natural_language(kind) - { - return Some(format!("{subject_base}.kind")); - } - if let Some(aliases) = subject.aliases.as_ref() { - for (alias_idx, alias) in aliases.iter().enumerate() { - if !english_gate::is_english_natural_language(alias) { - return Some(format!("{subject_base}.aliases[{alias_idx}]")); - } - } - } - } - if let Some(predicate) = relation.predicate.as_ref() - && !english_gate::is_english_natural_language(predicate) - { - return Some(format!("{base}.predicate")); - } - if let Some(object) = relation.object.as_ref() { - if let Some(entity) = object.entity.as_ref() { - let object_base = format!("{base}.object.entity"); - - if let Some(canonical) = entity.canonical.as_ref() - && !english_gate::is_english_natural_language(canonical) - { - return Some(format!("{object_base}.canonical")); - } - if let Some(kind) = entity.kind.as_ref() - && !english_gate::is_english_natural_language(kind) - { - return Some(format!("{object_base}.kind")); - } - if let Some(aliases) = entity.aliases.as_ref() { - for (alias_idx, alias) in aliases.iter().enumerate() { - if !english_gate::is_english_natural_language(alias) { - return Some(format!("{object_base}.aliases[{alias_idx}]")); - } - } - } - } - if let Some(value) = object.value.as_ref() - && !english_gate::is_english_natural_language(value) - { - return Some(format!("{base}.object.value")); - } - } - } - } - - None -} - -fn find_non_english_path(value: &Value, path: &str) -> Option { - find_non_english_path_inner(value, path, true) -} - -fn find_non_english_path_inner( - value: &Value, - path: &str, - is_identifier_lane: bool, -) -> Option { - fn has_english_gate(text: &str, is_identifier_lane: bool) -> bool { - if is_identifier_lane { - return english_gate::is_english_identifier(text); - } - - english_gate::is_english_natural_language(text) - } - - match value { - Value::String(text) => - if !has_english_gate(text, is_identifier_lane) { - Some(path.to_string()) - } else { - None - }, - Value::Array(items) => { - for (idx, item) in items.iter().enumerate() { - let child_path = format!("{path}[{idx}]"); - - if let Some(found) = - find_non_english_path_inner(item, &child_path, is_identifier_lane) - { - return Some(found); - } - } - - None - }, - Value::Object(map) => { - for (key, value) in map.iter() { - let identifier_lane = is_identifier_lane - || matches!(key.as_str(), "ref" | "schema" | "resolver" | "hashes" | "state"); - let child_path = format!("{path}[\"{}\"]", escape_json_path_key(key)); - - if let Some(found) = - find_non_english_path_inner(value, &child_path, identifier_lane) - { - return Some(found); - } - } - - None - }, - _ => None, - } -} - -fn escape_json_path_key(key: &str) -> String { - key.replace('\\', "\\\\").replace('"', "\\\"") -} - -fn extract_structured_rejection_field_path(err: &Error) -> Option { - match err { - Error::NonEnglishInput { field } => Some(field.clone()), - Error::InvalidRequest { message } if message.starts_with("structured.") => - message.split_whitespace().next().map(ToString::to_string), - _ => None, - } -} - -async fn insert_memory_note_tx( - tx: &mut Transaction<'_, Postgres>, - memory_note: &MemoryNote, -) -> Result<()> { - sqlx::query( - "\ -INSERT INTO memory_notes ( - note_id, - tenant_id, - project_id, - agent_id, - scope, - type, - key, - text, - importance, - confidence, - status, - created_at, - updated_at, - expires_at, - embedding_version, - source_ref, - hit_count, - last_hit_at -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15, - $16, - $17, - $18 -)", - ) - .bind(memory_note.note_id) - .bind(memory_note.tenant_id.as_str()) - .bind(memory_note.project_id.as_str()) - .bind(memory_note.agent_id.as_str()) - .bind(memory_note.scope.as_str()) - .bind(memory_note.r#type.as_str()) - .bind(memory_note.key.as_deref()) - .bind(memory_note.text.as_str()) - .bind(memory_note.importance) - .bind(memory_note.confidence) - .bind(memory_note.status.as_str()) - .bind(memory_note.created_at) - .bind(memory_note.updated_at) - .bind(memory_note.expires_at) - .bind(memory_note.embedding_version.as_str()) - .bind(&memory_note.source_ref) - .bind(memory_note.hit_count) - .bind(memory_note.last_hit_at) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn update_memory_note_tx( - tx: &mut Transaction<'_, Postgres>, - memory_note: &MemoryNote, -) -> Result<()> { - sqlx::query( - "\ -UPDATE memory_notes -SET - text = $1, - importance = $2, - confidence = $3, - updated_at = $4, - expires_at = $5, - source_ref = $6 -WHERE note_id = $7", - ) - .bind(memory_note.text.as_str()) - .bind(memory_note.importance) - .bind(memory_note.confidence) - .bind(memory_note.updated_at) - .bind(memory_note.expires_at) - .bind(&memory_note.source_ref) - .bind(memory_note.note_id) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -#[cfg(test)] -mod english_gate_tests { - use serde_json; - - use crate::{ - Error, - add_note::{self, AddNoteInput, AddNoteRequest}, - }; - - #[test] - fn accepts_identifier_like_source_ref_ref_field() { - add_note::validate_add_note_request(&AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("test_key".to_string()), - text: "English text".to_string(), - structured: None, - importance: 0.5, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({"ref": "packages/elf-service/src/docs.rs:661"}), - write_policy: None, - }], - }) - .expect("Expected identifier-like source_ref to be accepted."); - } - - #[test] - fn rejects_non_english_source_ref_hints_quote() { - let req = AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("test_key".to_string()), - text: "English text".to_string(), - structured: None, - importance: 0.5, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({"hints": {"quote": "\u{4f60}\u{597d}\u{4e16}\u{754c}"}}), - write_policy: None, - }], - }; - let err = add_note::validate_add_note_request(&req).expect_err( - "Expected non-English free-text under source_ref.hints.quote to be rejected.", - ); - - match err { - Error::NonEnglishInput { field } => { - assert_eq!(field, "$.notes[0].source_ref[\"hints\"][\"quote\"]") - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn rejects_long_non_english_note_text() { - let req = AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("test_key".to_string()), - text: "Bonjour, je veux m'assurer que ce texte est suffisamment long et riche en lettres pour declencher la detection de langue. Merci beaucoup." - .to_string(), - structured: None, - importance: 0.5, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({}), - write_policy: None, - }], - }; - let err = add_note::validate_add_note_request(&req) - .expect_err("Expected English gate rejection."); - - assert!(matches!( - err, - Error::NonEnglishInput { field } if field == "$.notes[0].text" - )); - } - - #[test] - fn accepts_missing_source_ref_and_defaults_to_empty_object() { - let req: AddNoteRequest = serde_json::from_value(serde_json::json!({ - "tenant_id": "t", - "project_id": "p", - "agent_id": "a", - "scope": "agent_private", - "notes": [ - { - "type": "fact", - "text": "English text.", - "importance": 0.5, - "confidence": 0.9 - } - ] - })) - .expect("Expected request to deserialize with default source_ref."); - - assert_eq!(req.notes[0].source_ref, serde_json::json!({})); - - add_note::validate_add_note_request(&req) - .expect("Expected missing source_ref to be accepted."); - } - - #[test] - fn accepts_null_source_ref_and_normalizes_to_empty_object() { - let req = AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("test_key".to_string()), - text: "English text.".to_string(), - structured: None, - importance: 0.5, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!(null), - write_policy: None, - }], - }; - let req = super::normalize_add_note_request(req); - - assert_eq!(req.notes[0].source_ref, serde_json::json!({})); - - add_note::validate_add_note_request(&req) - .expect("Expected null source_ref to be accepted."); - } - - #[test] - fn rejects_non_object_source_ref() { - let req = AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("test_key".to_string()), - text: "English text.".to_string(), - structured: None, - importance: 0.5, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!("legacy-shape"), - write_policy: None, - }], - }; - let err = add_note::validate_add_note_request(&req) - .expect_err("Expected non-object source_ref rejection."); - - match err { - Error::InvalidRequest { message } => { - assert_eq!(message, "source_ref must be a JSON object."); - }, - other => panic!("Expected InvalidRequest for non-object source_ref, got {other:?}"), - } - } -} +mod audit; +mod materialize; +mod persistence; +mod policy; +mod rejection; +mod service; +mod types; +mod validation; + +pub use types::{AddNoteInput, AddNoteRequest, AddNoteResponse, AddNoteResult}; + +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/add_note/audit.rs b/packages/elf-service/src/add_note/audit.rs new file mode 100644 index 00000000..8881e749 --- /dev/null +++ b/packages/elf-service/src/add_note/audit.rs @@ -0,0 +1,66 @@ +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use crate::{ + NoteOp, Result, + add_note::types::{AddNoteContext, AddNoteInput}, + ingest_audit::{self, IngestAuditArgs}, + structured_fields::StructuredFields, +}; +use elf_config::Config; +use elf_domain::{memory_policy::MemoryPolicyDecision, writegate::WritePolicyAudit}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn record_ingest_decision( + tx: &mut Transaction<'_, Postgres>, + cfg: &Config, + ctx: &AddNoteContext<'_>, + note: &AddNoteInput, + note_id: Option, + note_version_id: Option, + base_decision: MemoryPolicyDecision, + policy_decision: MemoryPolicyDecision, + note_op: NoteOp, + reason_code: Option<&str>, + policy_rule: Option<&str>, + similarity_best: Option, + key_match: bool, + matched_dup: bool, + min_confidence: Option, + min_importance: Option, + write_policy_audit: Option, +) -> Result<()> { + let decision = IngestAuditArgs { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: ctx.scope, + pipeline: "add_note", + note_type: note.r#type.as_str(), + note_key: note.key.as_deref(), + note_id, + note_version_id, + base_decision, + policy_decision, + note_op, + reason_code, + similarity_best, + key_match, + matched_dup, + dup_sim_threshold: cfg.memory.dup_sim_threshold, + update_sim_threshold: cfg.memory.update_sim_threshold, + confidence: note.confidence, + importance: note.importance, + structured_present: note.structured.as_ref().is_some_and(|s| !s.is_effectively_empty()), + graph_present: note.structured.as_ref().is_some_and(StructuredFields::has_graph_fields), + policy_rule, + min_confidence, + min_importance, + write_policy_audits: write_policy_audit.map(|audit| vec![audit]), + ingestion_profile_id: None, + ingestion_profile_version: None, + ts: ctx.now, + }; + + ingest_audit::insert_ingest_decision(tx, decision).await +} diff --git a/packages/elf-service/src/add_note/materialize.rs b/packages/elf-service/src/add_note/materialize.rs new file mode 100644 index 00000000..df2e0f64 --- /dev/null +++ b/packages/elf-service/src/add_note/materialize.rs @@ -0,0 +1,341 @@ +use sqlx::{Postgres, Transaction}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + ElfService, InsertVersionArgs, NoteOp, Result, access, + add_note::{ + persistence::{self}, + types::{AddNoteContext, AddNoteInput, AddNoteResult}, + }, + graph_ingestion, + structured_fields::{self, StructuredFields}, +}; +use elf_domain::{memory_policy::MemoryPolicyDecision, ttl}; +use elf_storage::models::MemoryNote; + +pub(super) async fn handle_add_note_add( + service: &ElfService, + tx: &mut Transaction<'_, Postgres>, + ctx: &AddNoteContext<'_>, + note: &AddNoteInput, + note_id: Uuid, +) -> Result { + access::ensure_active_project_scope_grant( + &mut **tx, + ctx.tenant_id, + ctx.project_id, + ctx.scope, + ctx.agent_id, + ) + .await?; + + let expires_at = + ttl::compute_expires_at(note.ttl_days, note.r#type.as_str(), &service.cfg, ctx.now); + let memory_note = MemoryNote { + note_id, + tenant_id: ctx.tenant_id.to_string(), + project_id: ctx.project_id.to_string(), + agent_id: ctx.agent_id.to_string(), + scope: ctx.scope.to_string(), + r#type: note.r#type.clone(), + key: note.key.clone(), + text: note.text.clone(), + importance: note.importance, + confidence: note.confidence, + status: "active".to_string(), + created_at: ctx.now, + updated_at: ctx.now, + expires_at, + embedding_version: ctx.embed_version.to_string(), + source_ref: note.source_ref.clone(), + hit_count: 0, + last_hit_at: None, + }; + + persistence::insert_memory_note_tx(tx, &memory_note).await?; + + let note_version_id = crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id: memory_note.note_id, + op: "ADD", + prev_snapshot: None, + new_snapshot: Some(crate::note_snapshot(&memory_note)), + reason: "add_note", + actor: ctx.agent_id, + ts: ctx.now, + }, + ) + .await?; + + upsert_structured_and_enqueue_outbox(tx, note, memory_note.note_id, ctx.embed_version, ctx.now) + .await?; + persist_graph_fields_if_present( + tx, + ctx.tenant_id, + ctx.project_id, + ctx.agent_id, + ctx.scope, + memory_note.note_id, + ctx.now, + note.structured.as_ref(), + ) + .await?; + + Ok(note_version_id) +} + +pub(super) async fn handle_add_note_update( + service: &ElfService, + tx: &mut Transaction<'_, Postgres>, + note: &AddNoteInput, + note_id: Uuid, + agent_id: &str, + now: OffsetDateTime, + policy_decision: MemoryPolicyDecision, +) -> Result<(AddNoteResult, Option)> { + let mut existing: MemoryNote = + sqlx::query_as::<_, MemoryNote>("SELECT * FROM memory_notes WHERE note_id = $1 FOR UPDATE") + .bind(note_id) + .fetch_one(&mut **tx) + .await?; + let prev_snapshot = crate::note_snapshot(&existing); + let requested_ttl = note.ttl_days.filter(|days| *days > 0); + let expires_at = match requested_ttl { + Some(ttl) => ttl::compute_expires_at(Some(ttl), note.r#type.as_str(), &service.cfg, now), + None => existing.expires_at, + }; + let expires_match = requested_ttl.map_or(existing.expires_at == expires_at, |ttl_days| { + match existing.expires_at { + Some(existing_expires_at) => { + let existing_ttl = (existing_expires_at - existing.updated_at).whole_days() as i64; + + existing_ttl == ttl_days + }, + None => false, + } + }); + let float_eps = 1e-6_f32; + let unchanged = existing.text == note.text + && (existing.importance - note.importance).abs() <= float_eps + && (existing.confidence - note.confidence).abs() <= float_eps + && expires_match + && existing.source_ref == note.source_ref; + + if unchanged { + return Ok(( + AddNoteResult { + note_id: Some(note_id), + op: NoteOp::None, + policy_decision: MemoryPolicyDecision::Ignore, + reason_code: None, + field_path: None, + write_policy_audit: None, + }, + None, + )); + } + + access::ensure_active_project_scope_grant( + &mut **tx, + existing.tenant_id.as_str(), + existing.project_id.as_str(), + existing.scope.as_str(), + existing.agent_id.as_str(), + ) + .await?; + + existing.text = note.text.clone(); + existing.importance = note.importance; + existing.confidence = note.confidence; + existing.updated_at = now; + existing.expires_at = expires_at; + existing.source_ref = note.source_ref.clone(); + + persistence::update_memory_note_tx(tx, &existing).await?; + + let note_version_id = crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id: existing.note_id, + op: "UPDATE", + prev_snapshot: Some(prev_snapshot), + new_snapshot: Some(crate::note_snapshot(&existing)), + reason: "add_note", + actor: agent_id, + ts: now, + }, + ) + .await?; + + persist_graph_fields_if_present( + tx, + existing.tenant_id.as_str(), + existing.project_id.as_str(), + existing.agent_id.as_str(), + existing.scope.as_str(), + existing.note_id, + now, + note.structured.as_ref(), + ) + .await?; + upsert_structured_and_enqueue_outbox( + tx, + note, + existing.note_id, + existing.embedding_version.as_str(), + now, + ) + .await?; + + Ok(( + AddNoteResult { + note_id: Some(note_id), + op: NoteOp::Update, + policy_decision, + reason_code: None, + field_path: None, + write_policy_audit: None, + }, + Some(note_version_id), + )) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_add_note_none( + tx: &mut Transaction<'_, Postgres>, + ctx: &AddNoteContext<'_>, + note: &AddNoteInput, + note_id: Uuid, + now: OffsetDateTime, + embed_version: &str, + policy_decision: MemoryPolicyDecision, +) -> Result<(AddNoteResult, Option)> { + let mut should_update = false; + + if let Some(structured) = note.structured.as_ref() { + if !structured.is_effectively_empty() { + structured_fields::upsert_structured_fields_tx(tx, note_id, structured, now).await?; + crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", embed_version, now).await?; + + should_update = true; + } + if structured.has_graph_fields() { + persist_graph_fields_if_present( + tx, + ctx.tenant_id, + ctx.project_id, + ctx.agent_id, + ctx.scope, + note_id, + now, + Some(structured), + ) + .await?; + + should_update = true; + } + } + + if should_update { + let note_row: MemoryNote = sqlx::query_as("SELECT * FROM memory_notes WHERE note_id = $1") + .bind(note_id) + .fetch_one(&mut **tx) + .await?; + let snapshot = crate::note_snapshot(¬e_row); + let note_version_id = crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id, + op: "UPDATE", + prev_snapshot: Some(snapshot.clone()), + new_snapshot: Some(snapshot), + reason: "add_note_structured", + actor: ctx.agent_id, + ts: now, + }, + ) + .await?; + + if matches!(ctx.scope, "project_shared" | "org_shared") { + access::ensure_active_project_scope_grant( + &mut **tx, + ctx.tenant_id, + ctx.project_id, + ctx.scope, + ctx.agent_id, + ) + .await?; + } + + return Ok(( + AddNoteResult { + note_id: Some(note_id), + op: NoteOp::Update, + policy_decision, + reason_code: None, + field_path: None, + write_policy_audit: None, + }, + Some(note_version_id), + )); + } + + Ok(( + AddNoteResult { + note_id: Some(note_id), + op: NoteOp::None, + policy_decision, + reason_code: None, + field_path: None, + write_policy_audit: None, + }, + None, + )) +} + +#[allow(clippy::too_many_arguments)] +async fn persist_graph_fields_if_present( + tx: &mut Transaction<'_, Postgres>, + tenant_id: &str, + project_id: &str, + agent_id: &str, + scope: &str, + note_id: Uuid, + now: OffsetDateTime, + structured: Option<&StructuredFields>, +) -> Result<()> { + let Some(structured) = structured else { + return Ok(()); + }; + + if !structured.has_graph_fields() { + return Ok(()); + } + + graph_ingestion::persist_graph_fields_tx( + tx, tenant_id, project_id, agent_id, scope, note_id, structured, now, + ) + .await?; + + Ok(()) +} + +async fn upsert_structured_and_enqueue_outbox( + tx: &mut Transaction<'_, Postgres>, + note: &AddNoteInput, + note_id: Uuid, + embed_version: &str, + now: OffsetDateTime, +) -> Result<()> { + if let Some(structured) = note.structured.as_ref() + && !structured.is_effectively_empty() + { + structured_fields::upsert_structured_fields_tx(tx, note_id, structured, now).await?; + } + + crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", embed_version, now).await?; + + Ok(()) +} diff --git a/packages/elf-service/src/add_note/persistence.rs b/packages/elf-service/src/add_note/persistence.rs new file mode 100644 index 00000000..9dd8d9a9 --- /dev/null +++ b/packages/elf-service/src/add_note/persistence.rs @@ -0,0 +1,104 @@ +use sqlx::{Postgres, Transaction}; + +use crate::Result; +use elf_storage::models::MemoryNote; + +pub(super) async fn insert_memory_note_tx( + tx: &mut Transaction<'_, Postgres>, + memory_note: &MemoryNote, +) -> Result<()> { + sqlx::query( + "\ +INSERT INTO memory_notes ( + note_id, + tenant_id, + project_id, + agent_id, + scope, + type, + key, + text, + importance, + confidence, + status, + created_at, + updated_at, + expires_at, + embedding_version, + source_ref, + hit_count, + last_hit_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18 +)", + ) + .bind(memory_note.note_id) + .bind(memory_note.tenant_id.as_str()) + .bind(memory_note.project_id.as_str()) + .bind(memory_note.agent_id.as_str()) + .bind(memory_note.scope.as_str()) + .bind(memory_note.r#type.as_str()) + .bind(memory_note.key.as_deref()) + .bind(memory_note.text.as_str()) + .bind(memory_note.importance) + .bind(memory_note.confidence) + .bind(memory_note.status.as_str()) + .bind(memory_note.created_at) + .bind(memory_note.updated_at) + .bind(memory_note.expires_at) + .bind(memory_note.embedding_version.as_str()) + .bind(&memory_note.source_ref) + .bind(memory_note.hit_count) + .bind(memory_note.last_hit_at) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(super) async fn update_memory_note_tx( + tx: &mut Transaction<'_, Postgres>, + memory_note: &MemoryNote, +) -> Result<()> { + sqlx::query( + "\ +UPDATE memory_notes +SET + text = $1, + importance = $2, + confidence = $3, + updated_at = $4, + expires_at = $5, + source_ref = $6 +WHERE note_id = $7", + ) + .bind(memory_note.text.as_str()) + .bind(memory_note.importance) + .bind(memory_note.confidence) + .bind(memory_note.updated_at) + .bind(memory_note.expires_at) + .bind(&memory_note.source_ref) + .bind(memory_note.note_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} diff --git a/packages/elf-service/src/add_note/policy.rs b/packages/elf-service/src/add_note/policy.rs new file mode 100644 index 00000000..b86254c6 --- /dev/null +++ b/packages/elf-service/src/add_note/policy.rs @@ -0,0 +1,179 @@ +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use crate::{ + ElfService, NoteOp, Result, UpdateDecision, + add_note::{ + materialize::{self}, + types::{AddNoteContext, AddNoteInput, AddNoteResult}, + }, + structured_fields::StructuredFields, +}; +use elf_config::Config; +use elf_domain::memory_policy::{self, MemoryPolicyDecision}; + +type AddNoteApplyOutput = (AddNoteResult, NoteOp, Option); + +const IGNORE_DUPLICATE: &str = "IGNORE_DUPLICATE"; +const IGNORE_POLICY_THRESHOLD: &str = "IGNORE_POLICY_THRESHOLD"; + +pub(super) fn structured_and_graph_present(structured: Option<&StructuredFields>) -> (bool, bool) { + let structured_present = structured.is_some_and(|s| !s.is_effectively_empty()); + let graph_present = structured.is_some_and(StructuredFields::has_graph_fields); + + (structured_present, graph_present) +} + +pub(super) fn resolve_policy_for_update( + cfg: &Config, + scope: &str, + note: &AddNoteInput, + base_decision: MemoryPolicyDecision, +) -> (MemoryPolicyDecision, Option, Option, Option) { + if matches!(base_decision, MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update) { + let policy_eval = memory_policy::evaluate_memory_policy( + cfg, + note.r#type.as_str(), + scope, + f64::from(note.confidence), + f64::from(note.importance), + base_decision, + ); + let decision_policy_rule = policy_eval + .matched_rule + .and_then(|rule| policy_rule_id(rule.note_type.as_deref(), rule.scope.as_deref())); + let min_confidence = policy_eval.matched_rule.and_then(|rule| rule.min_confidence); + let min_importance = policy_eval.matched_rule.and_then(|rule| rule.min_importance); + + (policy_eval.decision, decision_policy_rule, min_confidence, min_importance) + } else { + (MemoryPolicyDecision::Ignore, None, None, None) + } +} + +pub(super) fn ignore_reason_code_for_policy( + base_decision: MemoryPolicyDecision, + policy_decision: MemoryPolicyDecision, + matched_dup: bool, +) -> Option<&'static str> { + if !matches!(policy_decision, MemoryPolicyDecision::Ignore) { + return None; + } + + match base_decision { + MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update => + Some(IGNORE_POLICY_THRESHOLD), + MemoryPolicyDecision::Ignore if matched_dup => Some(IGNORE_DUPLICATE), + _ => None, + } +} + +pub(super) fn base_decision_for_update( + decision: &UpdateDecision, + structured_present: bool, + graph_present: bool, +) -> MemoryPolicyDecision { + match decision { + UpdateDecision::Update { .. } => MemoryPolicyDecision::Update, + UpdateDecision::Add { .. } => MemoryPolicyDecision::Remember, + UpdateDecision::None { .. } => + if structured_present || graph_present { + MemoryPolicyDecision::Update + } else { + MemoryPolicyDecision::Ignore + }, + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn apply_policy_result( + service: &ElfService, + tx: &mut Transaction<'_, Postgres>, + decision: &UpdateDecision, + ctx: &AddNoteContext<'_>, + note: &AddNoteInput, + note_id: Uuid, + policy_decision: MemoryPolicyDecision, + ignore_reason_code: Option<&'static str>, +) -> Result { + let should_apply = + matches!(policy_decision, MemoryPolicyDecision::Remember | MemoryPolicyDecision::Update); + + if should_apply { + let (result, note_version_id) = match decision { + UpdateDecision::Add { .. } => { + let note_version_id = + materialize::handle_add_note_add(service, tx, ctx, note, note_id).await?; + + ( + AddNoteResult { + note_id: Some(note_id), + op: NoteOp::Add, + policy_decision, + reason_code: None, + field_path: None, + write_policy_audit: None, + }, + Some(note_version_id), + ) + }, + UpdateDecision::Update { .. } => + materialize::handle_add_note_update( + service, + tx, + note, + note_id, + ctx.agent_id, + ctx.now, + policy_decision, + ) + .await?, + UpdateDecision::None { .. } => { + let (mut none_result, note_version_id) = materialize::handle_add_note_none( + tx, + ctx, + note, + note_id, + ctx.now, + ctx.embed_version, + policy_decision, + ) + .await?; + + none_result.policy_decision = policy_decision; + + (none_result, note_version_id) + }, + }; + let note_op = result.op; + + Ok((result, note_op, note_version_id)) + } else { + let mut result = AddNoteResult { + note_id: Some(note_id), + op: NoteOp::None, + policy_decision, + reason_code: ignore_reason_code.map(str::to_string), + field_path: None, + write_policy_audit: None, + }; + + match decision { + UpdateDecision::Add { .. } => { + result.note_id = None; + }, + UpdateDecision::Update { .. } | UpdateDecision::None { .. } => {}, + } + + Ok((result, NoteOp::None, None)) + } +} + +fn policy_rule_id(note_type: Option<&str>, scope: Option<&str>) -> Option { + match (note_type, scope) { + (Some(note_type), Some(scope)) => Some(format!("note_type={note_type},scope={scope}")), + (Some(note_type), None) => Some(format!("note_type={note_type}")), + (None, Some(scope)) => Some(format!("scope={scope}")), + (None, None) => None, + } +} diff --git a/packages/elf-service/src/add_note/rejection.rs b/packages/elf-service/src/add_note/rejection.rs new file mode 100644 index 00000000..2d1bef7e --- /dev/null +++ b/packages/elf-service/src/add_note/rejection.rs @@ -0,0 +1,79 @@ +use sqlx::{Postgres, Transaction}; + +use crate::{ + NoteOp, Result, + add_note::{ + audit, + types::{AddNoteContext, AddNoteInput, AddNoteResult}, + validation::{self}, + }, +}; +use elf_config::Config; +use elf_domain::{memory_policy::MemoryPolicyDecision, writegate::WritePolicyAudit}; + +pub(super) async fn handle_rejection_paths( + tx: &mut Transaction<'_, Postgres>, + cfg: &Config, + ctx: &AddNoteContext<'_>, + note: &AddNoteInput, + write_policy_audit: Option<&WritePolicyAudit>, +) -> Result> { + if let Some(result) = validation::reject_note_if_structured_invalid(note) { + let mut result = result; + + result.write_policy_audit = write_policy_audit.cloned(); + + audit::record_ingest_decision( + tx, + cfg, + ctx, + note, + None, + None, + MemoryPolicyDecision::Reject, + MemoryPolicyDecision::Reject, + NoteOp::Rejected, + result.reason_code.as_deref(), + None, + None, + false, + false, + None, + None, + write_policy_audit.cloned(), + ) + .await?; + + return Ok(Some(result)); + } + if let Some(result) = validation::reject_note_if_writegate_rejects(cfg, ctx.scope, note) { + let mut result = result; + + result.write_policy_audit = write_policy_audit.cloned(); + + audit::record_ingest_decision( + tx, + cfg, + ctx, + note, + None, + None, + MemoryPolicyDecision::Reject, + MemoryPolicyDecision::Reject, + NoteOp::Rejected, + result.reason_code.as_deref(), + None, + None, + false, + false, + None, + None, + write_policy_audit.cloned(), + ) + .await?; + + return Ok(Some(result)); + } + + Ok(None) +} diff --git a/packages/elf-service/src/add_note/service.rs b/packages/elf-service/src/add_note/service.rs new file mode 100644 index 00000000..08ee41a1 --- /dev/null +++ b/packages/elf-service/src/add_note/service.rs @@ -0,0 +1,154 @@ +use sqlx::{Postgres, Transaction}; +use time::{Duration, OffsetDateTime}; + +use crate::{ + ElfService, ResolveUpdateArgs, Result, UpdateDecision, UpdateDecisionMetadata, + access::ORG_PROJECT_ID, + add_note::{ + audit, + policy::{self}, + rejection, + types::{AddNoteContext, AddNoteInput, AddNoteRequest, AddNoteResponse, AddNoteResult}, + validation::{self}, + }, +}; + +impl ElfService { + /// Validates and persists notes supplied directly by the caller. + pub async fn add_note(&self, req: AddNoteRequest) -> Result { + let req = validation::normalize_add_note_request(req); + + validation::validate_add_note_request(&req)?; + + let base_now = OffsetDateTime::now_utc(); + let embed_version = crate::embedding_version(&self.cfg); + let AddNoteRequest { tenant_id, project_id, agent_id, scope, notes } = req; + let effective_project_id = + if scope.trim() == "org_shared" { ORG_PROJECT_ID } else { project_id.as_str() }; + let mut results = Vec::with_capacity(notes.len()); + + for (note_idx, note) in notes.into_iter().enumerate() { + let now = base_now + Duration::microseconds(note_idx as i64); + let ctx = AddNoteContext { + tenant_id: tenant_id.as_str(), + project_id: effective_project_id, + agent_id: agent_id.as_str(), + scope: scope.as_str(), + now, + embed_version: embed_version.as_str(), + }; + + results.push(self.process_add_note_input(&ctx, note).await?); + } + + Ok(AddNoteResponse { results }) + } + + async fn process_add_note_input( + &self, + ctx: &AddNoteContext<'_>, + note: AddNoteInput, + ) -> Result { + let mut note = note; + let (transformed, write_policy_audit) = + validation::apply_write_policy_to_note(note.write_policy.as_ref(), note.text.as_str())?; + + note.text = transformed; + + let (structured_present, graph_present) = + policy::structured_and_graph_present(note.structured.as_ref()); + let mut tx = self.db.pool.begin().await?; + + if let Some(result) = rejection::handle_rejection_paths( + &mut tx, + &self.cfg, + ctx, + ¬e, + write_policy_audit.as_ref(), + ) + .await? + { + tx.commit().await?; + + return Ok(result); + } + + let (decision, metadata) = self.resolve_update_decision(&mut tx, ctx, ¬e).await?; + let base_decision = + policy::base_decision_for_update(&decision, structured_present, graph_present); + let (policy_decision, decision_policy_rule, min_confidence, min_importance) = + policy::resolve_policy_for_update(&self.cfg, ctx.scope, ¬e, base_decision); + let note_id = decision.note_id(); + let ignore_reason_code = policy::ignore_reason_code_for_policy( + base_decision, + policy_decision, + metadata.matched_dup, + ); + let (result, note_op, note_version_id) = policy::apply_policy_result( + self, + &mut tx, + &decision, + ctx, + ¬e, + note_id, + policy_decision, + ignore_reason_code, + ) + .await?; + let mut result = result; + + result.write_policy_audit = write_policy_audit.clone(); + + audit::record_ingest_decision( + &mut tx, + &self.cfg, + ctx, + ¬e, + result.note_id, + note_version_id, + base_decision, + result.policy_decision, + note_op, + result.reason_code.as_deref(), + decision_policy_rule.as_deref(), + metadata.similarity_best, + metadata.key_match, + metadata.matched_dup, + min_confidence, + min_importance, + write_policy_audit, + ) + .await?; + + tx.commit().await?; + + Ok(result) + } + + async fn resolve_update_decision( + &self, + tx: &mut Transaction<'_, Postgres>, + ctx: &AddNoteContext<'_>, + note: &AddNoteInput, + ) -> Result<(UpdateDecision, UpdateDecisionMetadata)> { + let decision = crate::resolve_update( + &mut **tx, + ResolveUpdateArgs { + cfg: &self.cfg, + providers: &self.providers, + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: ctx.scope, + note_type: note.r#type.as_str(), + key: note.key.as_deref(), + text: note.text.as_str(), + now: ctx.now, + }, + ) + .await?; + let metadata = decision.metadata(); + + Ok((decision, metadata)) + } +} diff --git a/packages/elf-service/src/add_note/tests.rs b/packages/elf-service/src/add_note/tests.rs new file mode 100644 index 00000000..77d07a35 --- /dev/null +++ b/packages/elf-service/src/add_note/tests.rs @@ -0,0 +1,168 @@ +use crate::{ + Error, + add_note::{ + types::{AddNoteInput, AddNoteRequest}, + validation, + }, +}; + +#[test] +fn accepts_identifier_like_source_ref_ref_field() { + validation::validate_add_note_request(&AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("test_key".to_string()), + text: "English text".to_string(), + structured: None, + importance: 0.5, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({"ref": "packages/elf-service/src/docs.rs:661"}), + write_policy: None, + }], + }) + .expect("Expected identifier-like source_ref to be accepted."); +} + +#[test] +fn rejects_non_english_source_ref_hints_quote() { + let req = AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("test_key".to_string()), + text: "English text".to_string(), + structured: None, + importance: 0.5, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({"hints": {"quote": "\u{4f60}\u{597d}\u{4e16}\u{754c}"}}), + write_policy: None, + }], + }; + let err = validation::validate_add_note_request(&req) + .expect_err("Expected non-English free-text under source_ref.hints.quote to be rejected."); + + match err { + Error::NonEnglishInput { field } => { + assert_eq!(field, "$.notes[0].source_ref[\"hints\"][\"quote\"]") + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn rejects_long_non_english_note_text() { + let req = AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("test_key".to_string()), + text: "Bonjour, je veux m'assurer que ce texte est suffisamment long et riche en lettres pour declencher la detection de langue. Merci beaucoup." + .to_string(), + structured: None, + importance: 0.5, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({}), + write_policy: None, + }], + }; + let err = + validation::validate_add_note_request(&req).expect_err("Expected English gate rejection."); + + assert!(matches!( + err, + Error::NonEnglishInput { field } if field == "$.notes[0].text" + )); +} + +#[test] +fn accepts_missing_source_ref_and_defaults_to_empty_object() { + let req: AddNoteRequest = serde_json::from_value(serde_json::json!({ + "tenant_id": "t", + "project_id": "p", + "agent_id": "a", + "scope": "agent_private", + "notes": [ + { + "type": "fact", + "text": "English text.", + "importance": 0.5, + "confidence": 0.9 + } + ] + })) + .expect("Expected request to deserialize with default source_ref."); + + assert_eq!(req.notes[0].source_ref, serde_json::json!({})); + + validation::validate_add_note_request(&req) + .expect("Expected missing source_ref to be accepted."); +} + +#[test] +fn accepts_null_source_ref_and_normalizes_to_empty_object() { + let req = AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("test_key".to_string()), + text: "English text.".to_string(), + structured: None, + importance: 0.5, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!(null), + write_policy: None, + }], + }; + let req = validation::normalize_add_note_request(req); + + assert_eq!(req.notes[0].source_ref, serde_json::json!({})); + + validation::validate_add_note_request(&req).expect("Expected null source_ref to be accepted."); +} + +#[test] +fn rejects_non_object_source_ref() { + let req = AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("test_key".to_string()), + text: "English text.".to_string(), + structured: None, + importance: 0.5, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!("legacy-shape"), + write_policy: None, + }], + }; + let err = validation::validate_add_note_request(&req) + .expect_err("Expected non-object source_ref rejection."); + + match err { + Error::InvalidRequest { message } => { + assert_eq!(message, "source_ref must be a JSON object."); + }, + other => panic!("Expected InvalidRequest for non-object source_ref, got {other:?}"), + } +} diff --git a/packages/elf-service/src/add_note/types.rs b/packages/elf-service/src/add_note/types.rs new file mode 100644 index 00000000..fe3f39c3 --- /dev/null +++ b/packages/elf-service/src/add_note/types.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{NoteOp, structured_fields::StructuredFields}; +use elf_domain::{ + memory_policy::MemoryPolicyDecision, + writegate::{WritePolicy, WritePolicyAudit}, +}; + +/// Request payload for direct note ingestion. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddNoteRequest { + /// Tenant that owns the request. + pub tenant_id: String, + /// Project that owns the request. + pub project_id: String, + /// Agent that is writing the notes. + pub agent_id: String, + /// Scope to apply to all notes in the batch. + pub scope: String, + /// Notes to validate and persist. + pub notes: Vec, +} + +/// One note supplied to `add_note`. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddNoteInput { + /// Note type discriminator. + pub r#type: String, + /// Optional application-defined key for deduplication or lookup. + pub key: Option, + /// Note body text. + pub text: String, + /// Optional structured extraction payload to persist alongside the note. + pub structured: Option, + /// Importance score for ranking and retention. + pub importance: f32, + /// Confidence score for ranking and retention. + pub confidence: f32, + /// Optional TTL override in days. + pub ttl_days: Option, + #[serde(default = "default_source_ref")] + /// Structured source reference metadata. + pub source_ref: Value, + /// Optional write policy applied before validation and persistence. + pub write_policy: Option, +} + +/// Per-note outcome for an `add_note` request. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddNoteResult { + /// Note identifier when one was created or updated. + pub note_id: Option, + /// Persistence operation chosen for the note. + pub op: NoteOp, + /// Memory-policy decision applied to the note. + pub policy_decision: MemoryPolicyDecision, + /// Machine-readable rejection or ignore code, if any. + pub reason_code: Option, + /// Field path associated with a validation failure, if any. + pub field_path: Option, + /// Write-policy audit emitted for this note, if any. + pub write_policy_audit: Option, +} + +/// Response payload for direct note ingestion. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddNoteResponse { + /// One result per requested note. + pub results: Vec, +} + +pub(super) struct AddNoteContext<'a> { + pub(super) tenant_id: &'a str, + pub(super) project_id: &'a str, + pub(super) agent_id: &'a str, + pub(super) scope: &'a str, + pub(super) now: OffsetDateTime, + pub(super) embed_version: &'a str, +} + +pub(super) fn default_source_ref() -> Value { + Value::Object(Default::default()) +} diff --git a/packages/elf-service/src/add_note/validation.rs b/packages/elf-service/src/add_note/validation.rs new file mode 100644 index 00000000..05dd2c3f --- /dev/null +++ b/packages/elf-service/src/add_note/validation.rs @@ -0,0 +1,317 @@ +use serde_json::Value; + +use crate::{ + Error, NoteOp, Result, StructuredFields, + add_note::types::{self, AddNoteInput, AddNoteRequest, AddNoteResult}, + structured_fields, +}; +use elf_config::Config; +use elf_domain::{ + english_gate, + memory_policy::MemoryPolicyDecision, + writegate::{self, NoteInput, WritePolicy, WritePolicyAudit, WritePolicyError}, +}; + +const REJECT_STRUCTURED_INVALID: &str = "REJECT_STRUCTURED_INVALID"; + +pub(super) fn normalize_add_note_request(mut req: AddNoteRequest) -> AddNoteRequest { + for note in &mut req.notes { + if note.source_ref.is_null() { + note.source_ref = types::default_source_ref(); + } + } + + req +} + +pub(super) fn validate_add_note_request(req: &AddNoteRequest) -> Result<()> { + if req.notes.is_empty() { + return Err(Error::InvalidRequest { message: "Notes list is empty.".to_string() }); + } + if req.tenant_id.trim().is_empty() + || req.project_id.trim().is_empty() + || req.agent_id.trim().is_empty() + || req.scope.trim().is_empty() + { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, agent_id, and scope are required.".to_string(), + }); + } + + for (idx, note) in req.notes.iter().enumerate() { + if !note.source_ref.is_object() { + return Err(Error::InvalidRequest { + message: "source_ref must be a JSON object.".to_string(), + }); + } + if !english_gate::is_english_natural_language(note.text.as_str()) { + return Err(Error::NonEnglishInput { field: format!("$.notes[{idx}].text") }); + } + + if let Some(key) = note.key.as_ref() + && !english_gate::is_english_identifier(key) + { + return Err(Error::NonEnglishInput { field: format!("$.notes[{idx}].key") }); + } + if let Some(path) = find_non_english_path_in_structured( + note.structured.as_ref(), + &format!("$.notes[{idx}].structured"), + ) { + return Err(Error::NonEnglishInput { field: path }); + } + if let Some(path) = + find_non_english_path(¬e.source_ref, &format!("$.notes[{idx}].source_ref")) + { + return Err(Error::NonEnglishInput { field: path }); + } + } + + Ok(()) +} + +pub(super) fn reject_note_if_structured_invalid(note: &AddNoteInput) -> Option { + if let Some(structured) = note.structured.as_ref() + && let Err(err) = structured_fields::validate_structured_fields( + structured, + note.text.as_str(), + ¬e.source_ref, + None, + ) { + tracing::info!(error = %err, "Rejecting note due to invalid structured fields."); + + let field_path = extract_structured_rejection_field_path(&err); + + return Some(AddNoteResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(REJECT_STRUCTURED_INVALID.to_string()), + field_path, + write_policy_audit: None, + }); + } + + None +} + +pub(super) fn reject_note_if_writegate_rejects( + cfg: &Config, + scope: &str, + note: &AddNoteInput, +) -> Option { + let gate_input = NoteInput { + note_type: note.r#type.clone(), + scope: scope.to_string(), + text: note.text.clone(), + }; + + if let Err(code) = writegate::writegate(&gate_input, cfg) { + return Some(AddNoteResult { + note_id: None, + op: NoteOp::Rejected, + policy_decision: MemoryPolicyDecision::Reject, + reason_code: Some(crate::writegate_reason_code(code).to_string()), + field_path: None, + write_policy_audit: None, + }); + } + + None +} + +pub(super) fn apply_write_policy_to_note( + policy: Option<&WritePolicy>, + text: &str, +) -> Result<(String, Option)> { + let result = writegate::apply_write_policy(text, policy).map_err(|err| { + let message = match err { + WritePolicyError::InvalidSpan => "Invalid write_policy span provided.", + WritePolicyError::OverlappingOps => "Overlapping write_policy spans provided.", + }; + + Error::InvalidRequest { message: message.to_string() } + })?; + + Ok((result.transformed, policy.is_some().then_some(result.audit))) +} + +fn find_non_english_path_in_structured( + structured: Option<&StructuredFields>, + base: &str, +) -> Option { + let structured = structured?; + + if let Some(summary) = structured.summary.as_ref() + && !english_gate::is_english_natural_language(summary) + { + return Some(format!("{base}.summary")); + } + if let Some(items) = structured.facts.as_ref() { + for (idx, item) in items.iter().enumerate() { + if !english_gate::is_english_natural_language(item) { + return Some(format!("{base}.facts[{idx}]")); + } + } + } + if let Some(items) = structured.concepts.as_ref() { + for (idx, item) in items.iter().enumerate() { + if !english_gate::is_english_natural_language(item) { + return Some(format!("{base}.concepts[{idx}]")); + } + } + } + if let Some(items) = structured.entities.as_ref() { + for (idx, entity) in items.iter().enumerate() { + let base = format!("{base}.entities[{idx}]"); + + if let Some(canonical) = entity.canonical.as_ref() + && !english_gate::is_english_natural_language(canonical) + { + return Some(format!("{base}.canonical")); + } + if let Some(kind) = entity.kind.as_ref() + && !english_gate::is_english_natural_language(kind) + { + return Some(format!("{base}.kind")); + } + if let Some(aliases) = entity.aliases.as_ref() { + for (alias_idx, alias) in aliases.iter().enumerate() { + if !english_gate::is_english_natural_language(alias) { + return Some(format!("{base}.aliases[{alias_idx}]")); + } + } + } + } + } + if let Some(items) = structured.relations.as_ref() { + for (idx, relation) in items.iter().enumerate() { + let base = format!("{base}.relations[{idx}]"); + + if let Some(subject) = relation.subject.as_ref() { + let subject_base = format!("{base}.subject"); + + if let Some(canonical) = subject.canonical.as_ref() + && !english_gate::is_english_natural_language(canonical) + { + return Some(format!("{subject_base}.canonical")); + } + if let Some(kind) = subject.kind.as_ref() + && !english_gate::is_english_natural_language(kind) + { + return Some(format!("{subject_base}.kind")); + } + if let Some(aliases) = subject.aliases.as_ref() { + for (alias_idx, alias) in aliases.iter().enumerate() { + if !english_gate::is_english_natural_language(alias) { + return Some(format!("{subject_base}.aliases[{alias_idx}]")); + } + } + } + } + if let Some(predicate) = relation.predicate.as_ref() + && !english_gate::is_english_natural_language(predicate) + { + return Some(format!("{base}.predicate")); + } + if let Some(object) = relation.object.as_ref() { + if let Some(entity) = object.entity.as_ref() { + let object_base = format!("{base}.object.entity"); + + if let Some(canonical) = entity.canonical.as_ref() + && !english_gate::is_english_natural_language(canonical) + { + return Some(format!("{object_base}.canonical")); + } + if let Some(kind) = entity.kind.as_ref() + && !english_gate::is_english_natural_language(kind) + { + return Some(format!("{object_base}.kind")); + } + if let Some(aliases) = entity.aliases.as_ref() { + for (alias_idx, alias) in aliases.iter().enumerate() { + if !english_gate::is_english_natural_language(alias) { + return Some(format!("{object_base}.aliases[{alias_idx}]")); + } + } + } + } + if let Some(value) = object.value.as_ref() + && !english_gate::is_english_natural_language(value) + { + return Some(format!("{base}.object.value")); + } + } + } + } + + None +} + +fn find_non_english_path(value: &Value, path: &str) -> Option { + find_non_english_path_inner(value, path, true) +} + +fn find_non_english_path_inner( + value: &Value, + path: &str, + is_identifier_lane: bool, +) -> Option { + fn has_english_gate(text: &str, is_identifier_lane: bool) -> bool { + if is_identifier_lane { + return english_gate::is_english_identifier(text); + } + + english_gate::is_english_natural_language(text) + } + + match value { + Value::String(text) => + if !has_english_gate(text, is_identifier_lane) { + Some(path.to_string()) + } else { + None + }, + Value::Array(items) => { + for (idx, item) in items.iter().enumerate() { + let child_path = format!("{path}[{idx}]"); + + if let Some(found) = + find_non_english_path_inner(item, &child_path, is_identifier_lane) + { + return Some(found); + } + } + + None + }, + Value::Object(map) => { + for (key, value) in map.iter() { + let identifier_lane = is_identifier_lane + || matches!(key.as_str(), "ref" | "schema" | "resolver" | "hashes" | "state"); + let child_path = format!("{path}[\"{}\"]", escape_json_path_key(key)); + + if let Some(found) = + find_non_english_path_inner(value, &child_path, identifier_lane) + { + return Some(found); + } + } + + None + }, + _ => None, + } +} + +fn escape_json_path_key(key: &str) -> String { + key.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn extract_structured_rejection_field_path(err: &Error) -> Option { + match err { + Error::NonEnglishInput { field } => Some(field.clone()), + Error::InvalidRequest { message } if message.starts_with("structured.") => + message.split_whitespace().next().map(ToString::to_string), + _ => None, + } +} diff --git a/packages/elf-service/src/admin_graph_predicates.rs b/packages/elf-service/src/admin_graph_predicates.rs index b451c571..81472b6d 100644 --- a/packages/elf-service/src/admin_graph_predicates.rs +++ b/packages/elf-service/src/admin_graph_predicates.rs @@ -1,495 +1,12 @@ //! Administrative graph-predicate APIs. -use serde::Serialize; -use sqlx::PgConnection; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ElfService, Result}; -use elf_config::SecurityAuthRole; -use elf_storage::{ - graph, - models::{GraphPredicate, GraphPredicateAlias}, +mod helpers; +mod service; +mod types; + +pub use types::{ + AdminGraphPredicateAliasAddRequest, AdminGraphPredicateAliasResponse, + AdminGraphPredicateAliasesListRequest, AdminGraphPredicateAliasesResponse, + AdminGraphPredicatePatchRequest, AdminGraphPredicateResponse, AdminGraphPredicatesListRequest, + AdminGraphPredicatesListResponse, }; - -const GRAPH_PREDICATE_SCOPE_GLOBAL: &str = "__global__"; -const GRAPH_PREDICATE_SCOPE_PROJECT_PREFIX: &str = "__project__:"; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AdminGraphPredicateScope { - TenantProject, - Project, - Global, - All, -} -impl AdminGraphPredicateScope { - fn parse(raw: &str) -> Option { - match raw.trim() { - "tenant_project" => Some(Self::TenantProject), - "project" => Some(Self::Project), - "global" => Some(Self::Global), - "all" => Some(Self::All), - _ => None, - } - } -} - -/// Request payload for listing graph predicates visible in admin scope. -#[derive(Clone, Debug)] -pub struct AdminGraphPredicatesListRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the list. - pub agent_id: String, - /// Optional admin scope filter. - pub scope: Option, -} - -/// Request payload for patching a graph predicate. -#[derive(Clone, Debug)] -pub struct AdminGraphPredicatePatchRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the mutation. - pub agent_id: String, - /// Optional auth token identifier used for super-admin checks. - pub token_id: Option, - /// Predicate identifier to mutate. - pub predicate_id: Uuid, - /// Optional new predicate status. - pub status: Option, - /// Optional new cardinality value. - pub cardinality: Option, -} - -/// Request payload for adding a graph predicate alias. -#[derive(Clone, Debug)] -pub struct AdminGraphPredicateAliasAddRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the mutation. - pub agent_id: String, - /// Optional auth token identifier used for super-admin checks. - pub token_id: Option, - /// Predicate identifier to extend. - pub predicate_id: Uuid, - /// Alias surface to add. - pub alias: String, -} - -/// Request payload for listing graph predicate aliases. -#[derive(Clone, Debug)] -pub struct AdminGraphPredicateAliasesListRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the list. - pub agent_id: String, - /// Predicate identifier to inspect. - pub predicate_id: Uuid, -} - -/// Serialized graph predicate returned by admin APIs. -#[derive(Clone, Debug, Serialize)] -pub struct AdminGraphPredicateResponse { - /// Predicate identifier. - pub predicate_id: Uuid, - /// Predicate scope key. - pub scope_key: String, - /// Tenant scope when tenant-specific. - pub tenant_id: Option, - /// Project scope when project-specific. - pub project_id: Option, - /// Canonical predicate surface. - pub canonical: String, - /// Normalized canonical predicate surface. - pub canonical_norm: String, - /// Cardinality policy. - pub cardinality: String, - /// Lifecycle status. - pub status: String, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Serialized graph predicate alias returned by admin APIs. -#[derive(Clone, Debug, Serialize)] -pub struct AdminGraphPredicateAliasResponse { - /// Alias identifier. - pub alias_id: Uuid, - /// Predicate identifier that owns the alias. - pub predicate_id: Uuid, - /// Scope key where the alias resolves. - pub scope_key: String, - /// Alias surface. - pub alias: String, - /// Normalized alias surface. - pub alias_norm: String, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Response payload for listing graph predicates. -#[derive(Clone, Debug, Serialize)] -pub struct AdminGraphPredicatesListResponse { - /// Returned predicates. - pub predicates: Vec, -} - -/// Response payload for graph predicate alias operations. -#[derive(Clone, Debug, Serialize)] -pub struct AdminGraphPredicateAliasesResponse { - /// Predicate identifier. - pub predicate_id: Uuid, - /// Returned aliases. - pub aliases: Vec, -} - -impl ElfService { - fn is_super_admin_token_id(&self, token_id: Option<&str>) -> bool { - if self.cfg.security.auth_mode.trim() != "static_keys" { - return false; - } - - let Some(token_id) = token_id.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - - self.cfg - .security - .auth_keys - .iter() - .any(|key| key.token_id == token_id && matches!(key.role, SecurityAuthRole::SuperAdmin)) - } - - /// Lists graph predicates visible to the caller's admin context. - pub async fn admin_graph_predicates_list( - &self, - req: AdminGraphPredicatesListRequest, - ) -> Result { - let raw = req.scope.as_deref().unwrap_or("all"); - let scope = - AdminGraphPredicateScope::parse(raw).ok_or_else(|| crate::Error::InvalidRequest { - message: "scope must be one of tenant_project|project|global|all".to_string(), - })?; - let scope_keys = - graph_predicate_scope_keys(req.tenant_id.as_str(), req.project_id.as_str(), scope); - let mut conn = self.db.pool.acquire().await?; - let predicates = graph::list_predicates_by_scope_keys(&mut conn, &scope_keys) - .await - .map_err(map_storage_error)?; - let predicates = predicates.into_iter().map(to_predicate_response).collect(); - - Ok(AdminGraphPredicatesListResponse { predicates }) - } - - /// Updates a mutable graph predicate field inside the allowed admin scope. - pub async fn admin_graph_predicate_patch( - &self, - req: AdminGraphPredicatePatchRequest, - ) -> Result { - if req.status.is_none() && req.cardinality.is_none() { - return Err(crate::Error::InvalidRequest { - message: "At least one of status or cardinality is required.".to_string(), - }); - } - - let status = req.status.as_deref().map(str::trim); - - if status.is_some_and(str::is_empty) { - return Err(crate::Error::InvalidRequest { - message: "status must be non-empty.".to_string(), - }); - } - - let cardinality = req.cardinality.as_deref().map(str::trim); - - if cardinality.is_some_and(str::is_empty) { - return Err(crate::Error::InvalidRequest { - message: "cardinality must be non-empty.".to_string(), - }); - } - - let allow_global_mutation = self.is_super_admin_token_id(req.token_id.as_deref()); - let mut conn = self.db.pool.acquire().await?; - let existing = load_predicate_in_context( - &mut conn, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.predicate_id, - PredicateAccess::Mutate, - allow_global_mutation, - ) - .await?; - let old_status = existing.status.clone(); - let old_cardinality = existing.cardinality.clone(); - - if old_status == "deprecated" { - return Err(crate::Error::Conflict { - message: "graph predicate is deprecated and cannot be modified.".to_string(), - }); - } - - let new_status = match status { - None => None, - Some(raw) => { - let raw = raw.to_string(); - - if !matches!(raw.as_str(), "pending" | "active" | "deprecated") { - return Err(crate::Error::InvalidRequest { - message: "status must be one of pending|active|deprecated.".to_string(), - }); - } - if raw != old_status - && !predicate_status_transition_allowed(old_status.as_str(), raw.as_str()) - { - return Err(crate::Error::Conflict { - message: format!( - "Invalid graph predicate status transition; from={old_status} to={raw}.", - ), - }); - } - - Some(raw) - }, - }; - let new_cardinality = match cardinality { - None => None, - Some(raw) => { - let raw = raw.to_string(); - - if !matches!(raw.as_str(), "single" | "multi") { - return Err(crate::Error::InvalidRequest { - message: "cardinality must be one of single|multi.".to_string(), - }); - } - - Some(raw) - }, - }; - let updated = graph::update_predicate_guarded( - &mut conn, - req.predicate_id, - old_status.as_str(), - old_cardinality.as_str(), - new_status.as_deref(), - new_cardinality.as_deref(), - ) - .await - .map_err(map_storage_error)?; - - tracing::info!( - actor_agent_id = %req.agent_id, - predicate_id = %req.predicate_id, - old_status = %old_status, - new_status = %updated.status, - old_cardinality = %old_cardinality, - new_cardinality = %updated.cardinality, - "Admin graph predicate patched." - ); - - Ok(to_predicate_response(updated)) - } - - /// Adds an alias to a mutable graph predicate. - pub async fn admin_graph_predicate_alias_add( - &self, - req: AdminGraphPredicateAliasAddRequest, - ) -> Result { - let alias = req.alias.trim(); - - if alias.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "alias must be non-empty.".to_string(), - }); - } - - let allow_global_mutation = self.is_super_admin_token_id(req.token_id.as_deref()); - let mut conn = self.db.pool.acquire().await?; - let predicate = load_predicate_in_context( - &mut conn, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.predicate_id, - PredicateAccess::Mutate, - allow_global_mutation, - ) - .await?; - - if predicate.status == "deprecated" { - return Err(crate::Error::Conflict { - message: "graph predicate is deprecated and cannot be modified.".to_string(), - }); - } - - graph::add_predicate_alias(&mut conn, req.predicate_id, alias) - .await - .map_err(map_storage_error)?; - - tracing::info!( - actor_agent_id = %req.agent_id, - predicate_id = %req.predicate_id, - alias = %alias, - "Admin graph predicate alias added." - ); - - let mut aliases = graph::list_predicate_aliases(&mut conn, req.predicate_id) - .await - .map_err(map_storage_error)?; - - stable_sort_aliases(&mut aliases); - - let aliases = aliases.into_iter().map(to_alias_response).collect(); - - Ok(AdminGraphPredicateAliasesResponse { predicate_id: req.predicate_id, aliases }) - } - - /// Lists aliases for a graph predicate visible in admin scope. - pub async fn admin_graph_predicate_aliases_list( - &self, - req: AdminGraphPredicateAliasesListRequest, - ) -> Result { - let mut conn = self.db.pool.acquire().await?; - - load_predicate_in_context( - &mut conn, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.predicate_id, - PredicateAccess::Read, - false, - ) - .await?; - - let mut aliases = graph::list_predicate_aliases(&mut conn, req.predicate_id) - .await - .map_err(map_storage_error)?; - - stable_sort_aliases(&mut aliases); - - let aliases = aliases.into_iter().map(to_alias_response).collect(); - - Ok(AdminGraphPredicateAliasesResponse { predicate_id: req.predicate_id, aliases }) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PredicateAccess { - Read, - Mutate, -} - -fn graph_predicate_scope_keys( - tenant_id: &str, - project_id: &str, - scope: AdminGraphPredicateScope, -) -> Vec { - let tenant_project_key = format!("{tenant_id}:{project_id}"); - let project_key = format!("{GRAPH_PREDICATE_SCOPE_PROJECT_PREFIX}{project_id}"); - let global_key = GRAPH_PREDICATE_SCOPE_GLOBAL.to_string(); - - match scope { - AdminGraphPredicateScope::TenantProject => vec![tenant_project_key], - AdminGraphPredicateScope::Project => vec![project_key], - AdminGraphPredicateScope::Global => vec![global_key], - AdminGraphPredicateScope::All => vec![tenant_project_key, project_key, global_key], - } -} - -fn predicate_status_transition_allowed(old: &str, new: &str) -> bool { - matches!( - (old, new), - ("pending", "active") | ("pending", "deprecated") | ("active", "deprecated") - ) -} - -fn stable_sort_aliases(aliases: &mut [GraphPredicateAlias]) { - aliases.sort_by(|a, b| { - a.created_at - .cmp(&b.created_at) - .then_with(|| a.alias_norm.cmp(&b.alias_norm)) - .then_with(|| a.alias.cmp(&b.alias)) - }); -} - -fn to_predicate_response(predicate: GraphPredicate) -> AdminGraphPredicateResponse { - AdminGraphPredicateResponse { - predicate_id: predicate.predicate_id, - scope_key: predicate.scope_key, - tenant_id: predicate.tenant_id, - project_id: predicate.project_id, - canonical: predicate.canonical, - canonical_norm: predicate.canonical_norm, - cardinality: predicate.cardinality, - status: predicate.status, - created_at: predicate.created_at, - updated_at: predicate.updated_at, - } -} - -fn to_alias_response(alias: GraphPredicateAlias) -> AdminGraphPredicateAliasResponse { - AdminGraphPredicateAliasResponse { - alias_id: alias.alias_id, - predicate_id: alias.predicate_id, - scope_key: alias.scope_key, - alias: alias.alias, - alias_norm: alias.alias_norm, - created_at: alias.created_at, - } -} - -fn map_storage_error(err: elf_storage::Error) -> crate::Error { - match err { - elf_storage::Error::InvalidArgument(message) => crate::Error::InvalidRequest { message }, - elf_storage::Error::NotFound(message) => crate::Error::NotFound { message }, - elf_storage::Error::Conflict(message) => crate::Error::Conflict { message }, - elf_storage::Error::Sqlx(err) => crate::Error::Storage { message: err.to_string() }, - elf_storage::Error::Qdrant(err) => crate::Error::Qdrant { message: err.to_string() }, - } -} - -async fn load_predicate_in_context( - conn: &mut PgConnection, - tenant_id: &str, - project_id: &str, - predicate_id: Uuid, - access: PredicateAccess, - allow_global_mutation: bool, -) -> Result { - let predicate = graph::get_predicate_by_id(conn, predicate_id) - .await - .map_err(map_storage_error)? - .ok_or_else(|| crate::Error::NotFound { - message: format!("graph predicate not found; predicate_id={predicate_id}"), - })?; - let tenant_project_key = format!("{tenant_id}:{project_id}"); - let project_key = format!("{GRAPH_PREDICATE_SCOPE_PROJECT_PREFIX}{project_id}"); - let is_in_context = - predicate.scope_key == tenant_project_key || predicate.scope_key == project_key; - let is_global = predicate.scope_key == GRAPH_PREDICATE_SCOPE_GLOBAL; - - if !is_in_context && !is_global { - return Err(crate::Error::NotFound { - message: format!("graph predicate not found; predicate_id={predicate_id}"), - }); - } - if access == PredicateAccess::Mutate && is_global && !allow_global_mutation { - return Err(crate::Error::ScopeDenied { - message: "Super-admin token required to modify global graph predicates.".to_string(), - }); - } - - Ok(predicate) -} diff --git a/packages/elf-service/src/admin_graph_predicates/helpers.rs b/packages/elf-service/src/admin_graph_predicates/helpers.rs new file mode 100644 index 00000000..13685ac3 --- /dev/null +++ b/packages/elf-service/src/admin_graph_predicates/helpers.rs @@ -0,0 +1,144 @@ +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{ + Result, + admin_graph_predicates::types::{ + AdminGraphPredicateAliasResponse, AdminGraphPredicateResponse, + }, +}; +use elf_storage::{ + graph, + models::{GraphPredicate, GraphPredicateAlias}, +}; + +const GRAPH_PREDICATE_SCOPE_GLOBAL: &str = "__global__"; +const GRAPH_PREDICATE_SCOPE_PROJECT_PREFIX: &str = "__project__:"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum AdminGraphPredicateScope { + TenantProject, + Project, + Global, + All, +} +impl AdminGraphPredicateScope { + pub(super) fn parse(raw: &str) -> Option { + match raw.trim() { + "tenant_project" => Some(Self::TenantProject), + "project" => Some(Self::Project), + "global" => Some(Self::Global), + "all" => Some(Self::All), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PredicateAccess { + Read, + Mutate, +} + +pub(super) fn graph_predicate_scope_keys( + tenant_id: &str, + project_id: &str, + scope: AdminGraphPredicateScope, +) -> Vec { + let tenant_project_key = format!("{tenant_id}:{project_id}"); + let project_key = format!("{GRAPH_PREDICATE_SCOPE_PROJECT_PREFIX}{project_id}"); + let global_key = GRAPH_PREDICATE_SCOPE_GLOBAL.to_string(); + + match scope { + AdminGraphPredicateScope::TenantProject => vec![tenant_project_key], + AdminGraphPredicateScope::Project => vec![project_key], + AdminGraphPredicateScope::Global => vec![global_key], + AdminGraphPredicateScope::All => vec![tenant_project_key, project_key, global_key], + } +} + +pub(super) fn predicate_status_transition_allowed(old: &str, new: &str) -> bool { + matches!( + (old, new), + ("pending", "active") | ("pending", "deprecated") | ("active", "deprecated") + ) +} + +pub(super) fn stable_sort_aliases(aliases: &mut [GraphPredicateAlias]) { + aliases.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.alias_norm.cmp(&b.alias_norm)) + .then_with(|| a.alias.cmp(&b.alias)) + }); +} + +pub(super) fn to_predicate_response(predicate: GraphPredicate) -> AdminGraphPredicateResponse { + AdminGraphPredicateResponse { + predicate_id: predicate.predicate_id, + scope_key: predicate.scope_key, + tenant_id: predicate.tenant_id, + project_id: predicate.project_id, + canonical: predicate.canonical, + canonical_norm: predicate.canonical_norm, + cardinality: predicate.cardinality, + status: predicate.status, + created_at: predicate.created_at, + updated_at: predicate.updated_at, + } +} + +pub(super) fn to_alias_response(alias: GraphPredicateAlias) -> AdminGraphPredicateAliasResponse { + AdminGraphPredicateAliasResponse { + alias_id: alias.alias_id, + predicate_id: alias.predicate_id, + scope_key: alias.scope_key, + alias: alias.alias, + alias_norm: alias.alias_norm, + created_at: alias.created_at, + } +} + +pub(super) fn map_storage_error(err: elf_storage::Error) -> crate::Error { + match err { + elf_storage::Error::InvalidArgument(message) => crate::Error::InvalidRequest { message }, + elf_storage::Error::NotFound(message) => crate::Error::NotFound { message }, + elf_storage::Error::Conflict(message) => crate::Error::Conflict { message }, + elf_storage::Error::Sqlx(err) => crate::Error::Storage { message: err.to_string() }, + elf_storage::Error::Qdrant(err) => crate::Error::Qdrant { message: err.to_string() }, + } +} + +pub(super) async fn load_predicate_in_context( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + predicate_id: Uuid, + access: PredicateAccess, + allow_global_mutation: bool, +) -> Result { + let predicate = graph::get_predicate_by_id(conn, predicate_id) + .await + .map_err(map_storage_error)? + .ok_or_else(|| crate::Error::NotFound { + message: format!("graph predicate not found; predicate_id={predicate_id}"), + })?; + let tenant_project_key = format!("{tenant_id}:{project_id}"); + let project_key = format!("{GRAPH_PREDICATE_SCOPE_PROJECT_PREFIX}{project_id}"); + let is_in_context = + predicate.scope_key == tenant_project_key || predicate.scope_key == project_key; + let is_global = predicate.scope_key == GRAPH_PREDICATE_SCOPE_GLOBAL; + + if !is_in_context && !is_global { + return Err(crate::Error::NotFound { + message: format!("graph predicate not found; predicate_id={predicate_id}"), + }); + } + if access == PredicateAccess::Mutate && is_global && !allow_global_mutation { + return Err(crate::Error::ScopeDenied { + message: "Super-admin token required to modify global graph predicates.".to_string(), + }); + } + + Ok(predicate) +} diff --git a/packages/elf-service/src/admin_graph_predicates/service.rs b/packages/elf-service/src/admin_graph_predicates/service.rs new file mode 100644 index 00000000..3662e474 --- /dev/null +++ b/packages/elf-service/src/admin_graph_predicates/service.rs @@ -0,0 +1,247 @@ +use crate::{ + ElfService, Error, Result, + admin_graph_predicates::{ + helpers::{ + self, AdminGraphPredicateScope, PredicateAccess, map_storage_error, to_alias_response, + }, + types::{ + AdminGraphPredicateAliasAddRequest, AdminGraphPredicateAliasesListRequest, + AdminGraphPredicateAliasesResponse, AdminGraphPredicatePatchRequest, + AdminGraphPredicateResponse, AdminGraphPredicatesListRequest, + AdminGraphPredicatesListResponse, + }, + }, +}; +use elf_config::SecurityAuthRole; +use elf_storage::graph; + +impl ElfService { + fn is_super_admin_token_id(&self, token_id: Option<&str>) -> bool { + if self.cfg.security.auth_mode.trim() != "static_keys" { + return false; + } + + let Some(token_id) = token_id.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + + self.cfg + .security + .auth_keys + .iter() + .any(|key| key.token_id == token_id && matches!(key.role, SecurityAuthRole::SuperAdmin)) + } + + /// Lists graph predicates visible to the caller's admin context. + pub async fn admin_graph_predicates_list( + &self, + req: AdminGraphPredicatesListRequest, + ) -> Result { + let raw = req.scope.as_deref().unwrap_or("all"); + let scope = AdminGraphPredicateScope::parse(raw).ok_or_else(|| Error::InvalidRequest { + message: "scope must be one of tenant_project|project|global|all".to_string(), + })?; + let scope_keys = helpers::graph_predicate_scope_keys( + req.tenant_id.as_str(), + req.project_id.as_str(), + scope, + ); + let mut conn = self.db.pool.acquire().await?; + let predicates = graph::list_predicates_by_scope_keys(&mut conn, &scope_keys) + .await + .map_err(map_storage_error)?; + let predicates = predicates + .into_iter() + .map(crate::admin_graph_predicates::helpers::to_predicate_response) + .collect(); + + Ok(AdminGraphPredicatesListResponse { predicates }) + } + + /// Updates a mutable graph predicate field inside the allowed admin scope. + pub async fn admin_graph_predicate_patch( + &self, + req: AdminGraphPredicatePatchRequest, + ) -> Result { + if req.status.is_none() && req.cardinality.is_none() { + return Err(Error::InvalidRequest { + message: "At least one of status or cardinality is required.".to_string(), + }); + } + + let status = req.status.as_deref().map(str::trim); + + if status.is_some_and(str::is_empty) { + return Err(Error::InvalidRequest { message: "status must be non-empty.".to_string() }); + } + + let cardinality = req.cardinality.as_deref().map(str::trim); + + if cardinality.is_some_and(str::is_empty) { + return Err(Error::InvalidRequest { + message: "cardinality must be non-empty.".to_string(), + }); + } + + let allow_global_mutation = self.is_super_admin_token_id(req.token_id.as_deref()); + let mut conn = self.db.pool.acquire().await?; + let existing = helpers::load_predicate_in_context( + &mut conn, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.predicate_id, + PredicateAccess::Mutate, + allow_global_mutation, + ) + .await?; + let old_status = existing.status.clone(); + let old_cardinality = existing.cardinality.clone(); + + if old_status == "deprecated" { + return Err(Error::Conflict { + message: "graph predicate is deprecated and cannot be modified.".to_string(), + }); + } + + let new_status = match status { + None => None, + Some(raw) => { + let raw = raw.to_string(); + + if !matches!(raw.as_str(), "pending" | "active" | "deprecated") { + return Err(Error::InvalidRequest { + message: "status must be one of pending|active|deprecated.".to_string(), + }); + } + if raw != old_status + && !helpers::predicate_status_transition_allowed( + old_status.as_str(), + raw.as_str(), + ) { + return Err(Error::Conflict { + message: format!( + "Invalid graph predicate status transition; from={old_status} to={raw}.", + ), + }); + } + + Some(raw) + }, + }; + let new_cardinality = match cardinality { + None => None, + Some(raw) => { + let raw = raw.to_string(); + + if !matches!(raw.as_str(), "single" | "multi") { + return Err(Error::InvalidRequest { + message: "cardinality must be one of single|multi.".to_string(), + }); + } + + Some(raw) + }, + }; + let updated = graph::update_predicate_guarded( + &mut conn, + req.predicate_id, + old_status.as_str(), + old_cardinality.as_str(), + new_status.as_deref(), + new_cardinality.as_deref(), + ) + .await + .map_err(map_storage_error)?; + + tracing::info!( + actor_agent_id = %req.agent_id, + predicate_id = %req.predicate_id, + old_status = %old_status, + new_status = %updated.status, + old_cardinality = %old_cardinality, + new_cardinality = %updated.cardinality, + "Admin graph predicate patched." + ); + + Ok(helpers::to_predicate_response(updated)) + } + + /// Adds an alias to a mutable graph predicate. + pub async fn admin_graph_predicate_alias_add( + &self, + req: AdminGraphPredicateAliasAddRequest, + ) -> Result { + let alias = req.alias.trim(); + + if alias.is_empty() { + return Err(Error::InvalidRequest { message: "alias must be non-empty.".to_string() }); + } + + let allow_global_mutation = self.is_super_admin_token_id(req.token_id.as_deref()); + let mut conn = self.db.pool.acquire().await?; + let predicate = helpers::load_predicate_in_context( + &mut conn, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.predicate_id, + PredicateAccess::Mutate, + allow_global_mutation, + ) + .await?; + + if predicate.status == "deprecated" { + return Err(Error::Conflict { + message: "graph predicate is deprecated and cannot be modified.".to_string(), + }); + } + + graph::add_predicate_alias(&mut conn, req.predicate_id, alias) + .await + .map_err(map_storage_error)?; + + tracing::info!( + actor_agent_id = %req.agent_id, + predicate_id = %req.predicate_id, + alias = %alias, + "Admin graph predicate alias added." + ); + + let mut aliases = graph::list_predicate_aliases(&mut conn, req.predicate_id) + .await + .map_err(map_storage_error)?; + + helpers::stable_sort_aliases(&mut aliases); + + let aliases = aliases.into_iter().map(to_alias_response).collect(); + + Ok(AdminGraphPredicateAliasesResponse { predicate_id: req.predicate_id, aliases }) + } + + /// Lists aliases for a graph predicate visible in admin scope. + pub async fn admin_graph_predicate_aliases_list( + &self, + req: AdminGraphPredicateAliasesListRequest, + ) -> Result { + let mut conn = self.db.pool.acquire().await?; + + helpers::load_predicate_in_context( + &mut conn, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.predicate_id, + PredicateAccess::Read, + false, + ) + .await?; + + let mut aliases = graph::list_predicate_aliases(&mut conn, req.predicate_id) + .await + .map_err(map_storage_error)?; + + helpers::stable_sort_aliases(&mut aliases); + + let aliases = aliases.into_iter().map(to_alias_response).collect(); + + Ok(AdminGraphPredicateAliasesResponse { predicate_id: req.predicate_id, aliases }) + } +} diff --git a/packages/elf-service/src/admin_graph_predicates/types.rs b/packages/elf-service/src/admin_graph_predicates/types.rs new file mode 100644 index 00000000..4e91e657 --- /dev/null +++ b/packages/elf-service/src/admin_graph_predicates/types.rs @@ -0,0 +1,126 @@ +use serde::Serialize; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Request payload for listing graph predicates visible in admin scope. +#[derive(Clone, Debug)] +pub struct AdminGraphPredicatesListRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the list. + pub agent_id: String, + /// Optional admin scope filter. + pub scope: Option, +} + +/// Request payload for patching a graph predicate. +#[derive(Clone, Debug)] +pub struct AdminGraphPredicatePatchRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the mutation. + pub agent_id: String, + /// Optional auth token identifier used for super-admin checks. + pub token_id: Option, + /// Predicate identifier to mutate. + pub predicate_id: Uuid, + /// Optional new predicate status. + pub status: Option, + /// Optional new cardinality value. + pub cardinality: Option, +} + +/// Request payload for adding a graph predicate alias. +#[derive(Clone, Debug)] +pub struct AdminGraphPredicateAliasAddRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the mutation. + pub agent_id: String, + /// Optional auth token identifier used for super-admin checks. + pub token_id: Option, + /// Predicate identifier to extend. + pub predicate_id: Uuid, + /// Alias surface to add. + pub alias: String, +} + +/// Request payload for listing graph predicate aliases. +#[derive(Clone, Debug)] +pub struct AdminGraphPredicateAliasesListRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the list. + pub agent_id: String, + /// Predicate identifier to inspect. + pub predicate_id: Uuid, +} + +/// Serialized graph predicate returned by admin APIs. +#[derive(Clone, Debug, Serialize)] +pub struct AdminGraphPredicateResponse { + /// Predicate identifier. + pub predicate_id: Uuid, + /// Predicate scope key. + pub scope_key: String, + /// Tenant scope when tenant-specific. + pub tenant_id: Option, + /// Project scope when project-specific. + pub project_id: Option, + /// Canonical predicate surface. + pub canonical: String, + /// Normalized canonical predicate surface. + pub canonical_norm: String, + /// Cardinality policy. + pub cardinality: String, + /// Lifecycle status. + pub status: String, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Serialized graph predicate alias returned by admin APIs. +#[derive(Clone, Debug, Serialize)] +pub struct AdminGraphPredicateAliasResponse { + /// Alias identifier. + pub alias_id: Uuid, + /// Predicate identifier that owns the alias. + pub predicate_id: Uuid, + /// Scope key where the alias resolves. + pub scope_key: String, + /// Alias surface. + pub alias: String, + /// Normalized alias surface. + pub alias_norm: String, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Response payload for listing graph predicates. +#[derive(Clone, Debug, Serialize)] +pub struct AdminGraphPredicatesListResponse { + /// Returned predicates. + pub predicates: Vec, +} + +/// Response payload for graph predicate alias operations. +#[derive(Clone, Debug, Serialize)] +pub struct AdminGraphPredicateAliasesResponse { + /// Predicate identifier. + pub predicate_id: Uuid, + /// Returned aliases. + pub aliases: Vec, +} diff --git a/packages/elf-service/src/consolidation.rs b/packages/elf-service/src/consolidation.rs index 43307b0d..c4695053 100644 --- a/packages/elf-service/src/consolidation.rs +++ b/packages/elf-service/src/consolidation.rs @@ -1,1185 +1,16 @@ //! Fixture-driven consolidation run and proposal service APIs. -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use sqlx::{Postgres, Transaction}; -use time::{Duration, OffsetDateTime}; -use uuid::Uuid; - -use crate::{ - ElfService, Error, InsertVersionArgs, Result, - access::{self, ORG_PROJECT_ID}, -}; -use elf_config::Config; -use elf_domain::{ - consolidation::{ - self, CONSOLIDATION_CONTRACT_SCHEMA_V1, ConsolidationApplyIntent, ConsolidationInputRef, - ConsolidationJobPayload, ConsolidationLineage, ConsolidationMarkers, - ConsolidationProposalContract, ConsolidationProposalDiff, ConsolidationReviewAction, - ConsolidationReviewState, ConsolidationRunState, ConsolidationUnsupportedClaimFlag, - ConsolidationValidationError, - }, - ttl, - writegate::{self, NoteInput}, +mod promotion; +mod service; +mod types; +mod validation; + +pub use types::{ + ConsolidationProposalGetRequest, ConsolidationProposalInput, ConsolidationProposalResponse, + ConsolidationProposalReviewEventResponse, ConsolidationProposalReviewRequest, + ConsolidationProposalsListRequest, ConsolidationProposalsListResponse, + ConsolidationRunCreateRequest, ConsolidationRunCreateResponse, ConsolidationRunGetRequest, + ConsolidationRunResponse, ConsolidationRunsListRequest, ConsolidationRunsListResponse, }; -use elf_storage::{ - consolidation::{ - ConsolidationProposalReviewEventInsert, ConsolidationProposalReviewUpdate, - ConsolidationProposalTargetRefUpdate, ConsolidationRunJobInsert, - }, - models::{ - ConsolidationProposal, ConsolidationProposalReviewEvent, ConsolidationRun, MemoryNote, - }, - queries, -}; - -const DEFAULT_LIST_LIMIT: i64 = 50; -const MAX_LIST_LIMIT: i64 = 200; - -/// Request to create a fixture-backed consolidation run. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationRunCreateRequest { - /// Tenant that owns the run. - pub tenant_id: String, - /// Project that owns the run. - pub project_id: String, - /// Agent registering the run. - pub agent_id: String, - /// Job kind, such as `fixture` or `manual`. - pub job_kind: String, - /// Input references considered by the run. - pub input_refs: Vec, - #[serde(default = "empty_object")] - /// Aggregate source snapshot metadata for the run. - pub source_snapshot: Value, - /// Run lineage. - pub lineage: ConsolidationLineage, - #[serde(default)] - /// Fixture-generated proposals to persist with this run. - pub proposals: Vec, -} - -/// Fixture proposal input for a consolidation run. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationProposalInput { - /// Proposal kind, such as `derived_note` or `knowledge_page`. - pub proposal_kind: String, - /// Derived-output apply intent. - pub apply_intent: ConsolidationApplyIntent, - /// Source references directly supporting the proposal. - pub source_refs: Vec, - #[serde(default = "empty_object")] - /// Aggregate source snapshot metadata for reviewer inspection. - pub source_snapshot: Value, - /// Proposal lineage. - pub lineage: ConsolidationLineage, - /// Fixture confidence in the proposal. - pub confidence: f32, - #[serde(default)] - /// Unsupported claims reviewers must inspect before accepting the proposal. - pub unsupported_claim_flags: Vec, - #[serde(default)] - /// Review markers for contradiction and staleness checks. - pub markers: ConsolidationMarkers, - /// Reviewable derived-output diff. - pub diff: ConsolidationProposalDiff, - #[serde(default = "empty_object")] - /// Derived target reference, when the target already exists. - pub target_ref: Value, - #[serde(default = "empty_object")] - /// Proposed derived output payload. - pub proposed_payload: Value, -} -impl ConsolidationProposalInput { - fn into_contract(self) -> ConsolidationProposalContract { - ConsolidationProposalContract { - proposal_kind: self.proposal_kind, - apply_intent: self.apply_intent, - source_refs: self.source_refs, - source_snapshot: self.source_snapshot, - lineage: self.lineage, - confidence: self.confidence, - unsupported_claim_flags: self.unsupported_claim_flags, - markers: self.markers, - diff: self.diff, - target_ref: self.target_ref, - proposed_payload: self.proposed_payload, - } - } -} - -/// Response returned after creating one consolidation run. -#[derive(Clone, Debug, Serialize)] -pub struct ConsolidationRunCreateResponse { - /// Created run. - pub run: ConsolidationRunResponse, - /// Enqueued worker job identifier. - pub job_id: Uuid, - /// Proposals stored with the run. - pub proposals: Vec, -} - -/// Request to get one consolidation run. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationRunGetRequest { - /// Tenant that owns the run. - pub tenant_id: String, - /// Project that owns the run. - pub project_id: String, - /// Run identifier. - pub run_id: Uuid, -} - -/// Request to list consolidation runs. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationRunsListRequest { - /// Tenant that owns the runs. - pub tenant_id: String, - /// Project that owns the runs. - pub project_id: String, - /// Maximum number of runs to return. - pub limit: Option, -} - -/// Response returned by consolidation run listing. -#[derive(Clone, Debug, Serialize)] -pub struct ConsolidationRunsListResponse { - /// Returned runs. - pub runs: Vec, -} - -/// Public consolidation run DTO. -#[derive(Clone, Debug, Serialize)] -pub struct ConsolidationRunResponse { - /// Consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the run. - pub tenant_id: String, - /// Project that owns the run. - pub project_id: String, - /// Agent that registered the run. - pub agent_id: String, - /// Versioned consolidation contract schema. - pub contract_schema: String, - /// Job kind, such as fixture or manual. - pub job_kind: String, - /// Current run state. - pub status: String, - /// Serialized input references. - pub input_refs: Value, - /// Aggregate source snapshot metadata. - pub source_snapshot: Value, - /// Serialized run lineage. - pub lineage: Value, - /// Structured error payload for failed runs. - pub error: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, - /// Completion timestamp for terminal runs. - pub completed_at: Option, -} -impl From for ConsolidationRunResponse { - fn from(run: ConsolidationRun) -> Self { - Self { - run_id: run.run_id, - tenant_id: run.tenant_id, - project_id: run.project_id, - agent_id: run.agent_id, - contract_schema: run.contract_schema, - job_kind: run.job_kind, - status: run.status, - input_refs: run.input_refs, - source_snapshot: run.source_snapshot, - lineage: run.lineage, - error: run.error, - created_at: run.created_at, - updated_at: run.updated_at, - completed_at: run.completed_at, - } - } -} - -/// Request to get one consolidation proposal. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationProposalGetRequest { - /// Tenant that owns the proposal. - pub tenant_id: String, - /// Project that owns the proposal. - pub project_id: String, - /// Proposal identifier. - pub proposal_id: Uuid, -} - -/// Request to list consolidation proposals. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationProposalsListRequest { - /// Tenant that owns the proposals. - pub tenant_id: String, - /// Project that owns the proposals. - pub project_id: String, - /// Optional run filter. - pub run_id: Option, - /// Optional review-state filter. - pub review_state: Option, - /// Maximum number of proposals to return. - pub limit: Option, -} - -/// Response returned by consolidation proposal listing. -#[derive(Clone, Debug, Serialize)] -pub struct ConsolidationProposalsListResponse { - /// Returned proposals. - pub proposals: Vec, -} - -/// Request to apply one proposal review action. -#[derive(Clone, Debug, Deserialize)] -pub struct ConsolidationProposalReviewRequest { - /// Tenant that owns the proposal. - pub tenant_id: String, - /// Project that owns the proposal. - pub project_id: String, - /// Agent performing the review action. - pub reviewer_agent_id: String, - /// Proposal identifier. - pub proposal_id: Uuid, - /// Requested review action. - pub review_action: ConsolidationReviewAction, - /// Optional reviewer comment. - pub review_comment: Option, -} - -/// Public consolidation proposal review audit DTO. -#[derive(Clone, Debug, Serialize)] -pub struct ConsolidationProposalReviewEventResponse { - /// Review event identifier. - pub review_id: Uuid, - /// Reviewed proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the proposal. - pub tenant_id: String, - /// Project that owns the proposal. - pub project_id: String, - /// Agent that performed the review action. - pub reviewer_agent_id: String, - /// Review action requested by the reviewer. - pub action: String, - /// Review state before the transition. - pub from_review_state: String, - /// Review state after the transition. - pub to_review_state: String, - /// Optional reviewer comment. - pub review_comment: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} -impl From for ConsolidationProposalReviewEventResponse { - fn from(event: ConsolidationProposalReviewEvent) -> Self { - Self { - review_id: event.review_id, - proposal_id: event.proposal_id, - run_id: event.run_id, - tenant_id: event.tenant_id, - project_id: event.project_id, - reviewer_agent_id: event.reviewer_agent_id, - action: event.action, - from_review_state: event.from_review_state, - to_review_state: event.to_review_state, - review_comment: event.review_comment, - created_at: event.created_at, - } - } -} - -/// Public consolidation proposal DTO. -#[derive(Clone, Debug, Serialize)] -pub struct ConsolidationProposalResponse { - /// Consolidation proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the proposal. - pub tenant_id: String, - /// Project that owns the proposal. - pub project_id: String, - /// Agent that registered the proposal. - pub agent_id: String, - /// Versioned consolidation contract schema. - pub contract_schema: String, - /// Proposal kind, such as derived_note or knowledge_page. - pub proposal_kind: String, - /// Derived-output apply intent. - pub apply_intent: String, - /// Current review state. - pub review_state: String, - /// Serialized source references. - pub source_refs: Value, - /// Aggregate source snapshot metadata. - pub source_snapshot: Value, - /// Serialized proposal lineage. - pub lineage: Value, - /// Serialized reviewable diff. - pub diff: Value, - /// Proposal confidence score. - pub confidence: f32, - /// Serialized unsupported-claim flags. - pub unsupported_claim_flags: Value, - /// Serialized contradiction markers. - pub contradiction_markers: Value, - /// Serialized staleness markers. - pub staleness_markers: Value, - /// Serialized derived target reference. - pub target_ref: Value, - /// Serialized proposed derived output payload. - pub proposed_payload: Value, - /// Agent that last reviewed the proposal. - pub reviewer_agent_id: Option, - /// Optional reviewer comment. - pub review_comment: Option, - /// Timestamp of the last review transition. - pub reviewed_at: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, - /// Append-only review events for detail readback. - pub review_events: Vec, -} -impl From for ConsolidationProposalResponse { - fn from(proposal: ConsolidationProposal) -> Self { - Self { - proposal_id: proposal.proposal_id, - run_id: proposal.run_id, - tenant_id: proposal.tenant_id, - project_id: proposal.project_id, - agent_id: proposal.agent_id, - contract_schema: proposal.contract_schema, - proposal_kind: proposal.proposal_kind, - apply_intent: proposal.apply_intent, - review_state: proposal.review_state, - source_refs: proposal.source_refs, - source_snapshot: proposal.source_snapshot, - lineage: proposal.lineage, - diff: proposal.diff, - confidence: proposal.confidence, - unsupported_claim_flags: proposal.unsupported_claim_flags, - contradiction_markers: proposal.contradiction_markers, - staleness_markers: proposal.staleness_markers, - target_ref: proposal.target_ref, - proposed_payload: proposal.proposed_payload, - reviewer_agent_id: proposal.reviewer_agent_id, - review_comment: proposal.review_comment, - reviewed_at: proposal.reviewed_at, - created_at: proposal.created_at, - updated_at: proposal.updated_at, - review_events: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Deserialize)] -struct PromotedMemoryPayload { - #[serde(rename = "type")] - note_type: String, - text: String, - scope: Option, - key: Option, - importance: Option, - confidence: Option, - ttl_days: Option, - #[serde(default = "empty_object")] - source_ref: Value, -} - -impl ElfService { - /// Creates a fixture-backed consolidation run and optional proposals. - pub async fn consolidation_run_create( - &self, - req: ConsolidationRunCreateRequest, - ) -> Result { - validate_context(req.tenant_id.as_str(), req.project_id.as_str(), req.agent_id.as_str())?; - validate_job_kind(req.job_kind.as_str())?; - - consolidation::validate_source_refs(&req.input_refs).map_err(validation_error)?; - - validate_object("source_snapshot", &req.source_snapshot)?; - - req.lineage.validate().map_err(validation_error)?; - - let proposal_contracts = - req.proposals.into_iter().map(ConsolidationProposalInput::into_contract).collect(); - let payload = ConsolidationJobPayload { - contract_schema: CONSOLIDATION_CONTRACT_SCHEMA_V1.to_string(), - proposals: proposal_contracts, - }; - - payload.validate().map_err(validation_error)?; - - let now = OffsetDateTime::now_utc(); - let run_state = ConsolidationRunState::Pending; - let run_id = Uuid::new_v4(); - let job_id = Uuid::new_v4(); - let run = ConsolidationRun { - run_id, - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - agent_id: req.agent_id.clone(), - contract_schema: CONSOLIDATION_CONTRACT_SCHEMA_V1.to_string(), - job_kind: req.job_kind.clone(), - status: run_state.as_str().to_string(), - input_refs: to_value(&req.input_refs)?, - source_snapshot: req.source_snapshot, - lineage: to_value(&req.lineage)?, - error: empty_object(), - created_at: now, - updated_at: now, - completed_at: terminal_time(run_state, now), - }; - let payload_value = to_value(&payload)?; - let mut tx = self.db.pool.begin().await?; - - elf_storage::consolidation::insert_consolidation_run(&mut *tx, &run).await?; - elf_storage::consolidation::insert_consolidation_run_job( - &mut *tx, - ConsolidationRunJobInsert { - job_id, - run_id, - tenant_id: req.tenant_id.as_str(), - project_id: req.project_id.as_str(), - agent_id: req.agent_id.as_str(), - job_kind: req.job_kind.as_str(), - payload: &payload_value, - now, - }, - ) - .await?; - - tx.commit().await?; - - Ok(ConsolidationRunCreateResponse { - run: ConsolidationRunResponse::from(run), - job_id, - proposals: Vec::new(), - }) - } - - /// Fetches one consolidation run. - pub async fn consolidation_run_get( - &self, - req: ConsolidationRunGetRequest, - ) -> Result { - let run = elf_storage::consolidation::get_consolidation_run( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.run_id, - ) - .await? - .ok_or_else(|| Error::NotFound { message: "consolidation run not found".to_string() })?; - - Ok(ConsolidationRunResponse::from(run)) - } - - /// Lists consolidation runs. - pub async fn consolidation_runs_list( - &self, - req: ConsolidationRunsListRequest, - ) -> Result { - let limit = bounded_limit(req.limit); - let rows = elf_storage::consolidation::list_consolidation_runs( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - limit, - ) - .await?; - let runs = rows.into_iter().map(ConsolidationRunResponse::from).collect(); - - Ok(ConsolidationRunsListResponse { runs }) - } - - /// Fetches one consolidation proposal. - pub async fn consolidation_proposal_get( - &self, - req: ConsolidationProposalGetRequest, - ) -> Result { - let proposal = elf_storage::consolidation::get_consolidation_proposal( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.proposal_id, - ) - .await? - .ok_or_else(|| Error::NotFound { - message: "consolidation proposal not found".to_string(), - })?; - let review_events = self - .consolidation_proposal_review_events( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.proposal_id, - ) - .await?; - let mut response = ConsolidationProposalResponse::from(proposal); - - response.review_events = review_events; - - Ok(response) - } - - /// Lists consolidation proposals. - pub async fn consolidation_proposals_list( - &self, - req: ConsolidationProposalsListRequest, - ) -> Result { - let limit = bounded_limit(req.limit); - let review_state = req.review_state.map(ConsolidationReviewState::as_str); - let rows = elf_storage::consolidation::list_consolidation_proposals( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.run_id, - review_state, - limit, - ) - .await?; - let proposals = rows.into_iter().map(ConsolidationProposalResponse::from).collect(); - - Ok(ConsolidationProposalsListResponse { proposals }) - } - - /// Applies one allowed proposal review action. - pub async fn consolidation_proposal_review( - &self, - req: ConsolidationProposalReviewRequest, - ) -> Result { - validate_context( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.reviewer_agent_id.as_str(), - )?; - - let now = OffsetDateTime::now_utc(); - let mut tx = self.db.pool.begin().await?; - let existing = elf_storage::consolidation::lock_consolidation_proposal( - &mut *tx, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.proposal_id, - ) - .await? - .ok_or_else(|| Error::NotFound { - message: "consolidation proposal not found".to_string(), - })?; - let current = - ConsolidationReviewState::parse(existing.review_state.as_str()).ok_or_else(|| { - Error::InvalidRequest { - message: "stored proposal review_state is invalid".to_string(), - } - })?; - let steps = review_steps(current, req.review_action)?; - let mut last_state = current; - let mut updated = existing; - - for (step_index, (action, next_state)) in steps.into_iter().enumerate() { - last_state.validate_transition(next_state).map_err(validation_error)?; - - let transition_time = now.saturating_add(Duration::milliseconds(step_index as i64)); - - elf_storage::consolidation::insert_consolidation_proposal_review_event( - &mut *tx, - ConsolidationProposalReviewEventInsert { - review_id: Uuid::new_v4(), - proposal_id: req.proposal_id, - run_id: updated.run_id, - tenant_id: req.tenant_id.as_str(), - project_id: req.project_id.as_str(), - reviewer_agent_id: req.reviewer_agent_id.as_str(), - action: action.as_str(), - from_review_state: last_state.as_str(), - to_review_state: next_state.as_str(), - review_comment: req.review_comment.as_deref(), - created_at: transition_time, - }, - ) - .await?; - - updated = elf_storage::consolidation::update_consolidation_proposal_review( - &mut *tx, - ConsolidationProposalReviewUpdate { - tenant_id: req.tenant_id.as_str(), - project_id: req.project_id.as_str(), - proposal_id: req.proposal_id, - review_state: next_state.as_str(), - reviewer_agent_id: req.reviewer_agent_id.as_str(), - review_comment: req.review_comment.as_deref(), - now: transition_time, - }, - ) - .await? - .ok_or_else(|| Error::NotFound { - message: "consolidation proposal not found".to_string(), - })?; - - if action == ConsolidationReviewAction::Apply { - updated = self - .apply_consolidation_proposal_to_memory( - &mut tx, - updated, - req.reviewer_agent_id.as_str(), - req.review_comment.as_deref(), - transition_time, - ) - .await?; - } - - last_state = next_state; - } - - tx.commit().await?; - - let review_events = self - .consolidation_proposal_review_events( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.proposal_id, - ) - .await?; - let mut response = ConsolidationProposalResponse::from(updated); - - response.review_events = review_events; - - Ok(response) - } - - async fn apply_consolidation_proposal_to_memory( - &self, - tx: &mut Transaction<'_, Postgres>, - proposal: ConsolidationProposal, - reviewer_agent_id: &str, - review_comment: Option<&str>, - now: OffsetDateTime, - ) -> Result { - let note_id = match proposal.apply_intent.as_str() { - "create_derived_note" => - create_promoted_memory_note( - tx, - &proposal, - reviewer_agent_id, - review_comment, - &self.cfg, - now, - ) - .await?, - "update_derived_note" => - update_promoted_memory_note( - tx, - &proposal, - reviewer_agent_id, - review_comment, - &self.cfg, - now, - ) - .await?, - _ => return Ok(proposal), - }; - let target_ref = promoted_memory_target_ref(note_id, now); - - elf_storage::consolidation::update_consolidation_proposal_target_ref( - &mut **tx, - ConsolidationProposalTargetRefUpdate { - tenant_id: proposal.tenant_id.as_str(), - project_id: proposal.project_id.as_str(), - proposal_id: proposal.proposal_id, - target_ref: &target_ref, - now, - }, - ) - .await? - .ok_or_else(|| Error::NotFound { message: "consolidation proposal not found".to_string() }) - } - - async fn consolidation_proposal_review_events( - &self, - tenant_id: &str, - project_id: &str, - proposal_id: Uuid, - ) -> Result> { - let events = elf_storage::consolidation::list_consolidation_proposal_review_events( - &self.db.pool, - tenant_id, - project_id, - proposal_id, - ) - .await?; - - Ok(events.into_iter().map(ConsolidationProposalReviewEventResponse::from).collect()) - } -} - -fn validate_context(tenant_id: &str, project_id: &str, agent_id: &str) -> Result<()> { - validate_non_empty("tenant_id", tenant_id)?; - validate_non_empty("project_id", project_id)?; - - validate_non_empty("agent_id", agent_id) -} - -fn validate_job_kind(job_kind: &str) -> Result<()> { - validate_non_empty("job_kind", job_kind)?; - - match job_kind { - "fixture" | "manual" => Ok(()), - _ => Err(Error::InvalidRequest { - message: "job_kind must be fixture or manual for consolidation v1.".to_string(), - }), - } -} - -fn validate_non_empty(field: &'static str, value: &str) -> Result<()> { - if value.trim().is_empty() { - return Err(Error::InvalidRequest { message: format!("{field} must not be empty.") }); - } - - Ok(()) -} - -fn validate_object(field: &str, value: &Value) -> Result<()> { - if matches!(value, Value::Object(_)) { - Ok(()) - } else { - Err(Error::InvalidRequest { message: format!("{field} must be a JSON object.") }) - } -} - -fn validation_error(err: ConsolidationValidationError) -> Error { - Error::InvalidRequest { message: err.to_string() } -} - -fn review_steps( - current: ConsolidationReviewState, - action: ConsolidationReviewAction, -) -> Result> { - let steps = match action { - ConsolidationReviewAction::Approve => - vec![(ConsolidationReviewAction::Approve, ConsolidationReviewState::Approved)], - ConsolidationReviewAction::Apply => match current { - ConsolidationReviewState::Proposed => vec![ - (ConsolidationReviewAction::Approve, ConsolidationReviewState::Approved), - (ConsolidationReviewAction::Apply, ConsolidationReviewState::Applied), - ], - ConsolidationReviewState::Approved => - vec![(ConsolidationReviewAction::Apply, ConsolidationReviewState::Applied)], - ConsolidationReviewState::Rejected - | ConsolidationReviewState::Applied - | ConsolidationReviewState::Archived => - vec![(ConsolidationReviewAction::Apply, ConsolidationReviewState::Applied)], - }, - ConsolidationReviewAction::Discard => - vec![(ConsolidationReviewAction::Discard, ConsolidationReviewState::Rejected)], - ConsolidationReviewAction::Defer => - vec![(ConsolidationReviewAction::Defer, ConsolidationReviewState::Archived)], - }; - let mut state = current; - - for (_, next_state) in &steps { - state.validate_transition(*next_state).map_err(validation_error)?; - - state = *next_state; - } - - Ok(steps) -} - -fn decode_promoted_memory_payload( - proposal: &ConsolidationProposal, -) -> Result { - let payload: PromotedMemoryPayload = serde_json::from_value(proposal.proposed_payload.clone()) - .map_err(|err| Error::InvalidRequest { - message: format!("proposed_payload is not a memory note payload: {err}"), - })?; - - if !matches!(payload.source_ref, Value::Object(_)) { - return Err(Error::InvalidRequest { - message: "proposed_payload.source_ref must be a JSON object when provided.".to_string(), - }); - } - if payload.importance.is_some_and(invalid_score) - || payload.confidence.is_some_and(invalid_score) - { - return Err(Error::InvalidRequest { - message: "proposed memory scores must be finite values in 0.0..=1.0.".to_string(), - }); - } - - Ok(payload) -} - -fn validate_promoted_memory_payload( - payload: &PromotedMemoryPayload, - effective_scope: &str, - cfg: &Config, -) -> Result<()> { - let gate = NoteInput { - note_type: payload.note_type.clone(), - scope: effective_scope.to_string(), - text: payload.text.clone(), - }; - - if let Err(code) = writegate::writegate(&gate, cfg) { - return Err(Error::InvalidRequest { - message: format!( - "proposed memory failed writegate: {}", - crate::writegate_reason_code(code) - ), - }); - } - - Ok(()) -} - -fn invalid_score(score: f32) -> bool { - !score.is_finite() || !(0.0..=1.0).contains(&score) -} - -fn target_note_id(proposal: &ConsolidationProposal) -> Result { - let raw = proposal - .target_ref - .get("id") - .or_else(|| proposal.target_ref.get("note_id")) - .and_then(Value::as_str) - .ok_or_else(|| Error::InvalidRequest { - message: "update_derived_note requires target_ref.id or target_ref.note_id." - .to_string(), - })?; - - Uuid::parse_str(raw).map_err(|err| Error::InvalidRequest { - message: format!("target_ref note id is invalid: {err}"), - }) -} - -fn normalized_optional_string(value: Option) -> Option { - value.map(|raw| raw.trim().to_string()).filter(|trimmed| !trimmed.is_empty()) -} - -fn promoted_memory_scope(payload: &PromotedMemoryPayload, default_scope: &str) -> Result { - match payload.scope.as_deref() { - Some(raw) => { - let scope = raw.trim(); - - if scope.is_empty() { - return Err(Error::InvalidRequest { - message: "proposed_payload.scope must not be empty when provided.".to_string(), - }); - } - - Ok(scope.to_string()) - }, - None => Ok(default_scope.to_string()), - } -} - -fn promoted_memory_project_id<'a>(proposal_project_id: &'a str, scope: &str) -> &'a str { - if scope == "org_shared" { ORG_PROJECT_ID } else { proposal_project_id } -} - -fn promotion_source_ref( - proposal: &ConsolidationProposal, - proposed_source_ref: &Value, - reviewer_agent_id: &str, - review_comment: Option<&str>, - now: OffsetDateTime, -) -> Value { - serde_json::json!({ - "schema": "elf.memory_promotion/v1", - "proposal_id": proposal.proposal_id, - "run_id": proposal.run_id, - "proposal_kind": proposal.proposal_kind, - "apply_intent": proposal.apply_intent, - "source_refs": proposal.source_refs, - "source_snapshot": proposal.source_snapshot, - "lineage": proposal.lineage, - "unsupported_claim_flags": proposal.unsupported_claim_flags, - "review": { - "action": "apply", - "reviewer_agent_id": reviewer_agent_id, - "review_comment": review_comment, - "applied_at": now, - }, - "proposed_source_ref": proposed_source_ref, - }) -} - -fn promoted_memory_target_ref(note_id: Uuid, now: OffsetDateTime) -> Value { - serde_json::json!({ - "schema": "elf.memory_record_ref/v1", - "kind": "note", - "id": note_id, - "status": "active", - "applied_at": now, - }) -} - -fn bounded_limit(limit: Option) -> i64 { - limit.map(i64::from).unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, MAX_LIST_LIMIT) -} - -fn to_value(value: &T) -> Result -where - T: Serialize, -{ - serde_json::to_value(value).map_err(|err| Error::InvalidRequest { - message: format!("failed to serialize consolidation contract: {err}"), - }) -} - -fn empty_object() -> Value { - Value::Object(Map::new()) -} - -fn terminal_time(state: ConsolidationRunState, now: OffsetDateTime) -> Option { - match state { - ConsolidationRunState::Completed - | ConsolidationRunState::Failed - | ConsolidationRunState::Cancelled => Some(now), - ConsolidationRunState::Pending | ConsolidationRunState::Running => None, - } -} - -async fn create_promoted_memory_note( - tx: &mut Transaction<'_, Postgres>, - proposal: &ConsolidationProposal, - reviewer_agent_id: &str, - review_comment: Option<&str>, - cfg: &Config, - now: OffsetDateTime, -) -> Result { - let payload = decode_promoted_memory_payload(proposal)?; - let scope = promoted_memory_scope(&payload, "agent_private")?; - - validate_promoted_memory_payload(&payload, &scope, cfg)?; - - let project_id = promoted_memory_project_id(proposal.project_id.as_str(), &scope); - let note_type = payload.note_type; - let expires_at = ttl::compute_expires_at(payload.ttl_days, ¬e_type, cfg, now); - let source_ref = - promotion_source_ref(proposal, &payload.source_ref, reviewer_agent_id, review_comment, now); - let note_id = Uuid::new_v4(); - - access::ensure_active_project_scope_grant( - &mut **tx, - proposal.tenant_id.as_str(), - project_id, - scope.as_str(), - proposal.agent_id.as_str(), - ) - .await?; - - let note = MemoryNote { - note_id, - tenant_id: proposal.tenant_id.clone(), - project_id: project_id.to_string(), - agent_id: proposal.agent_id.clone(), - scope, - r#type: note_type, - key: normalized_optional_string(payload.key), - text: payload.text, - importance: payload.importance.unwrap_or(proposal.confidence), - confidence: payload.confidence.unwrap_or(proposal.confidence), - status: "active".to_string(), - created_at: now, - updated_at: now, - expires_at, - embedding_version: crate::embedding_version(cfg), - source_ref, - hit_count: 0, - last_hit_at: None, - }; - - queries::insert_note(&mut **tx, ¬e).await?; - crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id, - op: "ADD", - prev_snapshot: None, - new_snapshot: Some(crate::note_snapshot(¬e)), - reason: "consolidation_apply.create_derived_note", - actor: reviewer_agent_id, - ts: now, - }, - ) - .await?; - crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", ¬e.embedding_version, now).await?; - - Ok(note_id) -} - -async fn update_promoted_memory_note( - tx: &mut Transaction<'_, Postgres>, - proposal: &ConsolidationProposal, - reviewer_agent_id: &str, - review_comment: Option<&str>, - cfg: &Config, - now: OffsetDateTime, -) -> Result { - let payload = decode_promoted_memory_payload(proposal)?; - let note_id = target_note_id(proposal)?; - let mut note = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 AND tenant_id = $2 AND project_id IN ($3, $4) -FOR UPDATE", - ) - .bind(note_id) - .bind(proposal.tenant_id.as_str()) - .bind(proposal.project_id.as_str()) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut **tx) - .await? - .ok_or_else(|| Error::InvalidRequest { - message: "Target memory note was not found.".to_string(), - })?; - - if note.status != "active" { - return Err(Error::InvalidRequest { - message: "Only active target memory can be updated by proposal apply.".to_string(), - }); - } - if note.agent_id != proposal.agent_id { - return Err(Error::InvalidRequest { - message: "Target memory note owner does not match the proposal owner.".to_string(), - }); - } - - let scope = promoted_memory_scope(&payload, note.scope.as_str())?; - - validate_promoted_memory_payload(&payload, &scope, cfg)?; - - let project_id = promoted_memory_project_id(proposal.project_id.as_str(), &scope); - let prev_snapshot = crate::note_snapshot(¬e); - - note.project_id = project_id.to_string(); - note.scope = scope; - note.r#type = payload.note_type; - note.key = normalized_optional_string(payload.key); - note.text = payload.text; - note.importance = payload.importance.unwrap_or(note.importance); - note.confidence = payload.confidence.unwrap_or(note.confidence); - - if payload.ttl_days.is_some() { - note.expires_at = ttl::compute_expires_at(payload.ttl_days, ¬e.r#type, cfg, now); - } - - note.updated_at = now; - note.source_ref = - promotion_source_ref(proposal, &payload.source_ref, reviewer_agent_id, review_comment, now); - - access::ensure_active_project_scope_grant( - &mut **tx, - note.tenant_id.as_str(), - note.project_id.as_str(), - note.scope.as_str(), - note.agent_id.as_str(), - ) - .await?; - - update_promoted_note_row(tx, ¬e).await?; - - crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id, - op: "UPDATE", - prev_snapshot: Some(prev_snapshot), - new_snapshot: Some(crate::note_snapshot(¬e)), - reason: "consolidation_apply.update_derived_note", - actor: reviewer_agent_id, - ts: now, - }, - ) - .await?; - crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", ¬e.embedding_version, now).await?; - - Ok(note_id) -} - -async fn update_promoted_note_row( - tx: &mut Transaction<'_, Postgres>, - note: &MemoryNote, -) -> Result<()> { - sqlx::query( - "\ -UPDATE memory_notes -SET - project_id = $1, - scope = $2, - type = $3, - key = $4, - text = $5, - importance = $6, - confidence = $7, - updated_at = $8, - expires_at = $9, - source_ref = $10 -WHERE note_id = $11", - ) - .bind(note.project_id.as_str()) - .bind(note.scope.as_str()) - .bind(note.r#type.as_str()) - .bind(note.key.as_deref()) - .bind(note.text.as_str()) - .bind(note.importance) - .bind(note.confidence) - .bind(note.updated_at) - .bind(note.expires_at) - .bind(¬e.source_ref) - .bind(note.note_id) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - fn payload_with_scope(scope: Option<&str>) -> super::PromotedMemoryPayload { - super::PromotedMemoryPayload { - note_type: "fact".to_string(), - text: "Fact: Reviewed memory promotion is explicit.".to_string(), - scope: scope.map(str::to_string), - key: None, - importance: None, - confidence: None, - ttl_days: None, - source_ref: serde_json::json!({}), - } - } - - #[test] - fn promoted_memory_scope_uses_default_and_rejects_blank_override() { - let defaulted = super::promoted_memory_scope(&payload_with_scope(None), "project_shared") - .expect("missing scope should use target default"); - - assert_eq!(defaulted, "project_shared"); - assert!( - super::promoted_memory_scope(&payload_with_scope(Some(" ")), "agent_private").is_err() - ); - } - #[test] - fn promoted_memory_project_id_normalizes_org_shared_scope() { - assert_eq!( - super::promoted_memory_project_id("source-project", "project_shared"), - "source-project" - ); - assert_eq!( - super::promoted_memory_project_id("source-project", "org_shared"), - crate::access::ORG_PROJECT_ID - ); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/consolidation/promotion.rs b/packages/elf-service/src/consolidation/promotion.rs new file mode 100644 index 00000000..ea0a7023 --- /dev/null +++ b/packages/elf-service/src/consolidation/promotion.rs @@ -0,0 +1,347 @@ +use serde_json::Value; +use sqlx::{Postgres, Transaction}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Error, InsertVersionArgs, Result, + access::{self, ORG_PROJECT_ID}, + consolidation::types::PromotedMemoryPayload, +}; +use elf_config::Config; +use elf_domain::{ + ttl, + writegate::{self, NoteInput}, +}; +use elf_storage::{ + models::{ConsolidationProposal, MemoryNote}, + queries, +}; + +pub(super) fn decode_promoted_memory_payload( + proposal: &ConsolidationProposal, +) -> Result { + let payload: PromotedMemoryPayload = serde_json::from_value(proposal.proposed_payload.clone()) + .map_err(|err| Error::InvalidRequest { + message: format!("proposed_payload is not a memory note payload: {err}"), + })?; + + if !matches!(payload.source_ref, Value::Object(_)) { + return Err(Error::InvalidRequest { + message: "proposed_payload.source_ref must be a JSON object when provided.".to_string(), + }); + } + if payload.importance.is_some_and(invalid_score) + || payload.confidence.is_some_and(invalid_score) + { + return Err(Error::InvalidRequest { + message: "proposed memory scores must be finite values in 0.0..=1.0.".to_string(), + }); + } + + Ok(payload) +} + +pub(super) fn validate_promoted_memory_payload( + payload: &PromotedMemoryPayload, + effective_scope: &str, + cfg: &Config, +) -> Result<()> { + let gate = NoteInput { + note_type: payload.note_type.clone(), + scope: effective_scope.to_string(), + text: payload.text.clone(), + }; + + if let Err(code) = writegate::writegate(&gate, cfg) { + return Err(Error::InvalidRequest { + message: format!( + "proposed memory failed writegate: {}", + crate::writegate_reason_code(code) + ), + }); + } + + Ok(()) +} + +pub(super) fn target_note_id(proposal: &ConsolidationProposal) -> Result { + let raw = proposal + .target_ref + .get("id") + .or_else(|| proposal.target_ref.get("note_id")) + .and_then(Value::as_str) + .ok_or_else(|| Error::InvalidRequest { + message: "update_derived_note requires target_ref.id or target_ref.note_id." + .to_string(), + })?; + + Uuid::parse_str(raw).map_err(|err| Error::InvalidRequest { + message: format!("target_ref note id is invalid: {err}"), + }) +} + +pub(super) fn promoted_memory_scope( + payload: &PromotedMemoryPayload, + default_scope: &str, +) -> Result { + match payload.scope.as_deref() { + Some(raw) => { + let scope = raw.trim(); + + if scope.is_empty() { + return Err(Error::InvalidRequest { + message: "proposed_payload.scope must not be empty when provided.".to_string(), + }); + } + + Ok(scope.to_string()) + }, + None => Ok(default_scope.to_string()), + } +} + +pub(super) fn promoted_memory_project_id<'a>(proposal_project_id: &'a str, scope: &str) -> &'a str { + if scope == "org_shared" { ORG_PROJECT_ID } else { proposal_project_id } +} + +pub(super) fn promotion_source_ref( + proposal: &ConsolidationProposal, + proposed_source_ref: &Value, + reviewer_agent_id: &str, + review_comment: Option<&str>, + now: OffsetDateTime, +) -> Value { + serde_json::json!({ + "schema": "elf.memory_promotion/v1", + "proposal_id": proposal.proposal_id, + "run_id": proposal.run_id, + "proposal_kind": proposal.proposal_kind, + "apply_intent": proposal.apply_intent, + "source_refs": proposal.source_refs, + "source_snapshot": proposal.source_snapshot, + "lineage": proposal.lineage, + "unsupported_claim_flags": proposal.unsupported_claim_flags, + "review": { + "action": "apply", + "reviewer_agent_id": reviewer_agent_id, + "review_comment": review_comment, + "applied_at": now, + }, + "proposed_source_ref": proposed_source_ref, + }) +} + +pub(super) fn promoted_memory_target_ref(note_id: Uuid, now: OffsetDateTime) -> Value { + serde_json::json!({ + "schema": "elf.memory_record_ref/v1", + "kind": "note", + "id": note_id, + "status": "active", + "applied_at": now, + }) +} + +pub(super) async fn create_promoted_memory_note( + tx: &mut Transaction<'_, Postgres>, + proposal: &ConsolidationProposal, + reviewer_agent_id: &str, + review_comment: Option<&str>, + cfg: &Config, + now: OffsetDateTime, +) -> Result { + let payload = decode_promoted_memory_payload(proposal)?; + let scope = promoted_memory_scope(&payload, "agent_private")?; + + validate_promoted_memory_payload(&payload, &scope, cfg)?; + + let project_id = promoted_memory_project_id(proposal.project_id.as_str(), &scope); + let note_type = payload.note_type; + let expires_at = ttl::compute_expires_at(payload.ttl_days, ¬e_type, cfg, now); + let source_ref = + promotion_source_ref(proposal, &payload.source_ref, reviewer_agent_id, review_comment, now); + let note_id = Uuid::new_v4(); + + access::ensure_active_project_scope_grant( + &mut **tx, + proposal.tenant_id.as_str(), + project_id, + scope.as_str(), + proposal.agent_id.as_str(), + ) + .await?; + + let note = MemoryNote { + note_id, + tenant_id: proposal.tenant_id.clone(), + project_id: project_id.to_string(), + agent_id: proposal.agent_id.clone(), + scope, + r#type: note_type, + key: normalized_optional_string(payload.key), + text: payload.text, + importance: payload.importance.unwrap_or(proposal.confidence), + confidence: payload.confidence.unwrap_or(proposal.confidence), + status: "active".to_string(), + created_at: now, + updated_at: now, + expires_at, + embedding_version: crate::embedding_version(cfg), + source_ref, + hit_count: 0, + last_hit_at: None, + }; + + queries::insert_note(&mut **tx, ¬e).await?; + crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id, + op: "ADD", + prev_snapshot: None, + new_snapshot: Some(crate::note_snapshot(¬e)), + reason: "consolidation_apply.create_derived_note", + actor: reviewer_agent_id, + ts: now, + }, + ) + .await?; + crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", ¬e.embedding_version, now).await?; + + Ok(note_id) +} + +pub(super) async fn update_promoted_memory_note( + tx: &mut Transaction<'_, Postgres>, + proposal: &ConsolidationProposal, + reviewer_agent_id: &str, + review_comment: Option<&str>, + cfg: &Config, + now: OffsetDateTime, +) -> Result { + let payload = decode_promoted_memory_payload(proposal)?; + let note_id = target_note_id(proposal)?; + let mut note = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 AND tenant_id = $2 AND project_id IN ($3, $4) +FOR UPDATE", + ) + .bind(note_id) + .bind(proposal.tenant_id.as_str()) + .bind(proposal.project_id.as_str()) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| Error::InvalidRequest { + message: "Target memory note was not found.".to_string(), + })?; + + if note.status != "active" { + return Err(Error::InvalidRequest { + message: "Only active target memory can be updated by proposal apply.".to_string(), + }); + } + if note.agent_id != proposal.agent_id { + return Err(Error::InvalidRequest { + message: "Target memory note owner does not match the proposal owner.".to_string(), + }); + } + + let scope = promoted_memory_scope(&payload, note.scope.as_str())?; + + validate_promoted_memory_payload(&payload, &scope, cfg)?; + + let project_id = promoted_memory_project_id(proposal.project_id.as_str(), &scope); + let prev_snapshot = crate::note_snapshot(¬e); + + note.project_id = project_id.to_string(); + note.scope = scope; + note.r#type = payload.note_type; + note.key = normalized_optional_string(payload.key); + note.text = payload.text; + note.importance = payload.importance.unwrap_or(note.importance); + note.confidence = payload.confidence.unwrap_or(note.confidence); + + if payload.ttl_days.is_some() { + note.expires_at = ttl::compute_expires_at(payload.ttl_days, ¬e.r#type, cfg, now); + } + + note.updated_at = now; + note.source_ref = + promotion_source_ref(proposal, &payload.source_ref, reviewer_agent_id, review_comment, now); + + access::ensure_active_project_scope_grant( + &mut **tx, + note.tenant_id.as_str(), + note.project_id.as_str(), + note.scope.as_str(), + note.agent_id.as_str(), + ) + .await?; + + update_promoted_note_row(tx, ¬e).await?; + + crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id, + op: "UPDATE", + prev_snapshot: Some(prev_snapshot), + new_snapshot: Some(crate::note_snapshot(¬e)), + reason: "consolidation_apply.update_derived_note", + actor: reviewer_agent_id, + ts: now, + }, + ) + .await?; + crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", ¬e.embedding_version, now).await?; + + Ok(note_id) +} + +fn invalid_score(score: f32) -> bool { + !score.is_finite() || !(0.0..=1.0).contains(&score) +} + +fn normalized_optional_string(value: Option) -> Option { + value.map(|raw| raw.trim().to_string()).filter(|trimmed| !trimmed.is_empty()) +} + +async fn update_promoted_note_row( + tx: &mut Transaction<'_, Postgres>, + note: &MemoryNote, +) -> Result<()> { + sqlx::query( + "\ +UPDATE memory_notes +SET + project_id = $1, + scope = $2, + type = $3, + key = $4, + text = $5, + importance = $6, + confidence = $7, + updated_at = $8, + expires_at = $9, + source_ref = $10 +WHERE note_id = $11", + ) + .bind(note.project_id.as_str()) + .bind(note.scope.as_str()) + .bind(note.r#type.as_str()) + .bind(note.key.as_deref()) + .bind(note.text.as_str()) + .bind(note.importance) + .bind(note.confidence) + .bind(note.updated_at) + .bind(note.expires_at) + .bind(¬e.source_ref) + .bind(note.note_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} diff --git a/packages/elf-service/src/consolidation/service.rs b/packages/elf-service/src/consolidation/service.rs new file mode 100644 index 00000000..7c34a058 --- /dev/null +++ b/packages/elf-service/src/consolidation/service.rs @@ -0,0 +1,358 @@ +use sqlx::{Postgres, Transaction}; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +use crate::{ + ElfService, Error, Result, + consolidation::{ + promotion::{self}, + types::{ + self, ConsolidationProposalGetRequest, ConsolidationProposalInput, + ConsolidationProposalResponse, ConsolidationProposalReviewEventResponse, + ConsolidationProposalReviewRequest, ConsolidationProposalsListRequest, + ConsolidationProposalsListResponse, ConsolidationRunCreateRequest, + ConsolidationRunCreateResponse, ConsolidationRunGetRequest, ConsolidationRunResponse, + ConsolidationRunsListRequest, ConsolidationRunsListResponse, + }, + validation::{self, validation_error}, + }, +}; +use elf_domain::consolidation::{ + self, CONSOLIDATION_CONTRACT_SCHEMA_V1, ConsolidationJobPayload, ConsolidationReviewAction, + ConsolidationReviewState, ConsolidationRunState, +}; +use elf_storage::{ + consolidation::{ + ConsolidationProposalReviewEventInsert, ConsolidationProposalReviewUpdate, + ConsolidationProposalTargetRefUpdate, ConsolidationRunJobInsert, + }, + models::{ConsolidationProposal, ConsolidationRun}, +}; + +impl ElfService { + /// Creates a fixture-backed consolidation run and optional proposals. + pub async fn consolidation_run_create( + &self, + req: ConsolidationRunCreateRequest, + ) -> Result { + validation::validate_context( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + )?; + validation::validate_job_kind(req.job_kind.as_str())?; + consolidation::validate_source_refs(&req.input_refs).map_err(validation_error)?; + validation::validate_object("source_snapshot", &req.source_snapshot)?; + + req.lineage.validate().map_err(validation_error)?; + + let proposal_contracts = + req.proposals.into_iter().map(ConsolidationProposalInput::into_contract).collect(); + let payload = ConsolidationJobPayload { + contract_schema: CONSOLIDATION_CONTRACT_SCHEMA_V1.to_string(), + proposals: proposal_contracts, + }; + + payload.validate().map_err(validation_error)?; + + let now = OffsetDateTime::now_utc(); + let run_state = ConsolidationRunState::Pending; + let run_id = Uuid::new_v4(); + let job_id = Uuid::new_v4(); + let run = ConsolidationRun { + run_id, + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + agent_id: req.agent_id.clone(), + contract_schema: CONSOLIDATION_CONTRACT_SCHEMA_V1.to_string(), + job_kind: req.job_kind.clone(), + status: run_state.as_str().to_string(), + input_refs: validation::to_value(&req.input_refs)?, + source_snapshot: req.source_snapshot, + lineage: validation::to_value(&req.lineage)?, + error: types::empty_object(), + created_at: now, + updated_at: now, + completed_at: validation::terminal_time(run_state, now), + }; + let payload_value = validation::to_value(&payload)?; + let mut tx = self.db.pool.begin().await?; + + elf_storage::consolidation::insert_consolidation_run(&mut *tx, &run).await?; + elf_storage::consolidation::insert_consolidation_run_job( + &mut *tx, + ConsolidationRunJobInsert { + job_id, + run_id, + tenant_id: req.tenant_id.as_str(), + project_id: req.project_id.as_str(), + agent_id: req.agent_id.as_str(), + job_kind: req.job_kind.as_str(), + payload: &payload_value, + now, + }, + ) + .await?; + + tx.commit().await?; + + Ok(ConsolidationRunCreateResponse { + run: ConsolidationRunResponse::from(run), + job_id, + proposals: Vec::new(), + }) + } + + /// Fetches one consolidation run. + pub async fn consolidation_run_get( + &self, + req: ConsolidationRunGetRequest, + ) -> Result { + let run = elf_storage::consolidation::get_consolidation_run( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.run_id, + ) + .await? + .ok_or_else(|| Error::NotFound { message: "consolidation run not found".to_string() })?; + + Ok(ConsolidationRunResponse::from(run)) + } + + /// Lists consolidation runs. + pub async fn consolidation_runs_list( + &self, + req: ConsolidationRunsListRequest, + ) -> Result { + let limit = validation::bounded_limit(req.limit); + let rows = elf_storage::consolidation::list_consolidation_runs( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + limit, + ) + .await?; + let runs = rows.into_iter().map(ConsolidationRunResponse::from).collect(); + + Ok(ConsolidationRunsListResponse { runs }) + } + + /// Fetches one consolidation proposal. + pub async fn consolidation_proposal_get( + &self, + req: ConsolidationProposalGetRequest, + ) -> Result { + let proposal = elf_storage::consolidation::get_consolidation_proposal( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.proposal_id, + ) + .await? + .ok_or_else(|| Error::NotFound { + message: "consolidation proposal not found".to_string(), + })?; + let review_events = self + .consolidation_proposal_review_events( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.proposal_id, + ) + .await?; + let mut response = ConsolidationProposalResponse::from(proposal); + + response.review_events = review_events; + + Ok(response) + } + + /// Lists consolidation proposals. + pub async fn consolidation_proposals_list( + &self, + req: ConsolidationProposalsListRequest, + ) -> Result { + let limit = validation::bounded_limit(req.limit); + let review_state = req.review_state.map(ConsolidationReviewState::as_str); + let rows = elf_storage::consolidation::list_consolidation_proposals( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.run_id, + review_state, + limit, + ) + .await?; + let proposals = rows.into_iter().map(ConsolidationProposalResponse::from).collect(); + + Ok(ConsolidationProposalsListResponse { proposals }) + } + + /// Applies one allowed proposal review action. + pub async fn consolidation_proposal_review( + &self, + req: ConsolidationProposalReviewRequest, + ) -> Result { + validation::validate_context( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.reviewer_agent_id.as_str(), + )?; + + let now = OffsetDateTime::now_utc(); + let mut tx = self.db.pool.begin().await?; + let existing = elf_storage::consolidation::lock_consolidation_proposal( + &mut *tx, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.proposal_id, + ) + .await? + .ok_or_else(|| Error::NotFound { + message: "consolidation proposal not found".to_string(), + })?; + let current = + ConsolidationReviewState::parse(existing.review_state.as_str()).ok_or_else(|| { + Error::InvalidRequest { + message: "stored proposal review_state is invalid".to_string(), + } + })?; + let steps = validation::review_steps(current, req.review_action)?; + let mut last_state = current; + let mut updated = existing; + + for (step_index, (action, next_state)) in steps.into_iter().enumerate() { + last_state.validate_transition(next_state).map_err(validation_error)?; + + let transition_time = now.saturating_add(Duration::milliseconds(step_index as i64)); + + elf_storage::consolidation::insert_consolidation_proposal_review_event( + &mut *tx, + ConsolidationProposalReviewEventInsert { + review_id: Uuid::new_v4(), + proposal_id: req.proposal_id, + run_id: updated.run_id, + tenant_id: req.tenant_id.as_str(), + project_id: req.project_id.as_str(), + reviewer_agent_id: req.reviewer_agent_id.as_str(), + action: action.as_str(), + from_review_state: last_state.as_str(), + to_review_state: next_state.as_str(), + review_comment: req.review_comment.as_deref(), + created_at: transition_time, + }, + ) + .await?; + + updated = elf_storage::consolidation::update_consolidation_proposal_review( + &mut *tx, + ConsolidationProposalReviewUpdate { + tenant_id: req.tenant_id.as_str(), + project_id: req.project_id.as_str(), + proposal_id: req.proposal_id, + review_state: next_state.as_str(), + reviewer_agent_id: req.reviewer_agent_id.as_str(), + review_comment: req.review_comment.as_deref(), + now: transition_time, + }, + ) + .await? + .ok_or_else(|| Error::NotFound { + message: "consolidation proposal not found".to_string(), + })?; + + if action == ConsolidationReviewAction::Apply { + updated = self + .apply_consolidation_proposal_to_memory( + &mut tx, + updated, + req.reviewer_agent_id.as_str(), + req.review_comment.as_deref(), + transition_time, + ) + .await?; + } + + last_state = next_state; + } + + tx.commit().await?; + + let review_events = self + .consolidation_proposal_review_events( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.proposal_id, + ) + .await?; + let mut response = ConsolidationProposalResponse::from(updated); + + response.review_events = review_events; + + Ok(response) + } + + async fn apply_consolidation_proposal_to_memory( + &self, + tx: &mut Transaction<'_, Postgres>, + proposal: ConsolidationProposal, + reviewer_agent_id: &str, + review_comment: Option<&str>, + now: OffsetDateTime, + ) -> Result { + let note_id = match proposal.apply_intent.as_str() { + "create_derived_note" => + promotion::create_promoted_memory_note( + tx, + &proposal, + reviewer_agent_id, + review_comment, + &self.cfg, + now, + ) + .await?, + "update_derived_note" => + promotion::update_promoted_memory_note( + tx, + &proposal, + reviewer_agent_id, + review_comment, + &self.cfg, + now, + ) + .await?, + _ => return Ok(proposal), + }; + let target_ref = promotion::promoted_memory_target_ref(note_id, now); + + elf_storage::consolidation::update_consolidation_proposal_target_ref( + &mut **tx, + ConsolidationProposalTargetRefUpdate { + tenant_id: proposal.tenant_id.as_str(), + project_id: proposal.project_id.as_str(), + proposal_id: proposal.proposal_id, + target_ref: &target_ref, + now, + }, + ) + .await? + .ok_or_else(|| Error::NotFound { message: "consolidation proposal not found".to_string() }) + } + + async fn consolidation_proposal_review_events( + &self, + tenant_id: &str, + project_id: &str, + proposal_id: Uuid, + ) -> Result> { + let events = elf_storage::consolidation::list_consolidation_proposal_review_events( + &self.db.pool, + tenant_id, + project_id, + proposal_id, + ) + .await?; + + Ok(events.into_iter().map(ConsolidationProposalReviewEventResponse::from).collect()) + } +} diff --git a/packages/elf-service/src/consolidation/tests.rs b/packages/elf-service/src/consolidation/tests.rs new file mode 100644 index 00000000..52394f44 --- /dev/null +++ b/packages/elf-service/src/consolidation/tests.rs @@ -0,0 +1,37 @@ +use crate::consolidation::{promotion, types}; + +fn payload_with_scope(scope: Option<&str>) -> types::PromotedMemoryPayload { + types::PromotedMemoryPayload { + note_type: "fact".to_string(), + text: "Fact: Reviewed memory promotion is explicit.".to_string(), + scope: scope.map(str::to_string), + key: None, + importance: None, + confidence: None, + ttl_days: None, + source_ref: serde_json::json!({}), + } +} + +#[test] +fn promoted_memory_scope_uses_default_and_rejects_blank_override() { + let defaulted = promotion::promoted_memory_scope(&payload_with_scope(None), "project_shared") + .expect("missing scope should use target default"); + + assert_eq!(defaulted, "project_shared"); + assert!( + promotion::promoted_memory_scope(&payload_with_scope(Some(" ")), "agent_private").is_err() + ); +} + +#[test] +fn promoted_memory_project_id_normalizes_org_shared_scope() { + assert_eq!( + promotion::promoted_memory_project_id("source-project", "project_shared"), + "source-project" + ); + assert_eq!( + promotion::promoted_memory_project_id("source-project", "org_shared"), + crate::access::ORG_PROJECT_ID + ); +} diff --git a/packages/elf-service/src/consolidation/types.rs b/packages/elf-service/src/consolidation/types.rs new file mode 100644 index 00000000..5f99ec66 --- /dev/null +++ b/packages/elf-service/src/consolidation/types.rs @@ -0,0 +1,379 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use time::OffsetDateTime; +use uuid::Uuid; + +use elf_domain::consolidation::{ + ConsolidationApplyIntent, ConsolidationInputRef, ConsolidationLineage, ConsolidationMarkers, + ConsolidationProposalContract, ConsolidationProposalDiff, ConsolidationReviewAction, + ConsolidationReviewState, ConsolidationUnsupportedClaimFlag, +}; +use elf_storage::models::{ + ConsolidationProposal, ConsolidationProposalReviewEvent, ConsolidationRun, +}; + +pub(super) const DEFAULT_LIST_LIMIT: i64 = 50; +pub(super) const MAX_LIST_LIMIT: i64 = 200; + +/// Request to create a fixture-backed consolidation run. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationRunCreateRequest { + /// Tenant that owns the run. + pub tenant_id: String, + /// Project that owns the run. + pub project_id: String, + /// Agent registering the run. + pub agent_id: String, + /// Job kind, such as `fixture` or `manual`. + pub job_kind: String, + /// Input references considered by the run. + pub input_refs: Vec, + #[serde(default = "empty_object")] + /// Aggregate source snapshot metadata for the run. + pub source_snapshot: Value, + /// Run lineage. + pub lineage: ConsolidationLineage, + #[serde(default)] + /// Fixture-generated proposals to persist with this run. + pub proposals: Vec, +} + +/// Fixture proposal input for a consolidation run. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationProposalInput { + /// Proposal kind, such as `derived_note` or `knowledge_page`. + pub proposal_kind: String, + /// Derived-output apply intent. + pub apply_intent: ConsolidationApplyIntent, + /// Source references directly supporting the proposal. + pub source_refs: Vec, + #[serde(default = "empty_object")] + /// Aggregate source snapshot metadata for reviewer inspection. + pub source_snapshot: Value, + /// Proposal lineage. + pub lineage: ConsolidationLineage, + /// Fixture confidence in the proposal. + pub confidence: f32, + #[serde(default)] + /// Unsupported claims reviewers must inspect before accepting the proposal. + pub unsupported_claim_flags: Vec, + #[serde(default)] + /// Review markers for contradiction and staleness checks. + pub markers: ConsolidationMarkers, + /// Reviewable derived-output diff. + pub diff: ConsolidationProposalDiff, + #[serde(default = "empty_object")] + /// Derived target reference, when the target already exists. + pub target_ref: Value, + #[serde(default = "empty_object")] + /// Proposed derived output payload. + pub proposed_payload: Value, +} +impl ConsolidationProposalInput { + pub(super) fn into_contract(self) -> ConsolidationProposalContract { + ConsolidationProposalContract { + proposal_kind: self.proposal_kind, + apply_intent: self.apply_intent, + source_refs: self.source_refs, + source_snapshot: self.source_snapshot, + lineage: self.lineage, + confidence: self.confidence, + unsupported_claim_flags: self.unsupported_claim_flags, + markers: self.markers, + diff: self.diff, + target_ref: self.target_ref, + proposed_payload: self.proposed_payload, + } + } +} + +/// Response returned after creating one consolidation run. +#[derive(Clone, Debug, Serialize)] +pub struct ConsolidationRunCreateResponse { + /// Created run. + pub run: ConsolidationRunResponse, + /// Enqueued worker job identifier. + pub job_id: Uuid, + /// Proposals stored with the run. + pub proposals: Vec, +} + +/// Request to get one consolidation run. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationRunGetRequest { + /// Tenant that owns the run. + pub tenant_id: String, + /// Project that owns the run. + pub project_id: String, + /// Run identifier. + pub run_id: Uuid, +} + +/// Request to list consolidation runs. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationRunsListRequest { + /// Tenant that owns the runs. + pub tenant_id: String, + /// Project that owns the runs. + pub project_id: String, + /// Maximum number of runs to return. + pub limit: Option, +} + +/// Response returned by consolidation run listing. +#[derive(Clone, Debug, Serialize)] +pub struct ConsolidationRunsListResponse { + /// Returned runs. + pub runs: Vec, +} + +/// Public consolidation run DTO. +#[derive(Clone, Debug, Serialize)] +pub struct ConsolidationRunResponse { + /// Consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the run. + pub tenant_id: String, + /// Project that owns the run. + pub project_id: String, + /// Agent that registered the run. + pub agent_id: String, + /// Versioned consolidation contract schema. + pub contract_schema: String, + /// Job kind, such as fixture or manual. + pub job_kind: String, + /// Current run state. + pub status: String, + /// Serialized input references. + pub input_refs: Value, + /// Aggregate source snapshot metadata. + pub source_snapshot: Value, + /// Serialized run lineage. + pub lineage: Value, + /// Structured error payload for failed runs. + pub error: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, + /// Completion timestamp for terminal runs. + pub completed_at: Option, +} +impl From for ConsolidationRunResponse { + fn from(run: ConsolidationRun) -> Self { + Self { + run_id: run.run_id, + tenant_id: run.tenant_id, + project_id: run.project_id, + agent_id: run.agent_id, + contract_schema: run.contract_schema, + job_kind: run.job_kind, + status: run.status, + input_refs: run.input_refs, + source_snapshot: run.source_snapshot, + lineage: run.lineage, + error: run.error, + created_at: run.created_at, + updated_at: run.updated_at, + completed_at: run.completed_at, + } + } +} + +/// Request to get one consolidation proposal. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationProposalGetRequest { + /// Tenant that owns the proposal. + pub tenant_id: String, + /// Project that owns the proposal. + pub project_id: String, + /// Proposal identifier. + pub proposal_id: Uuid, +} + +/// Request to list consolidation proposals. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationProposalsListRequest { + /// Tenant that owns the proposals. + pub tenant_id: String, + /// Project that owns the proposals. + pub project_id: String, + /// Optional run filter. + pub run_id: Option, + /// Optional review-state filter. + pub review_state: Option, + /// Maximum number of proposals to return. + pub limit: Option, +} + +/// Response returned by consolidation proposal listing. +#[derive(Clone, Debug, Serialize)] +pub struct ConsolidationProposalsListResponse { + /// Returned proposals. + pub proposals: Vec, +} + +/// Request to apply one proposal review action. +#[derive(Clone, Debug, Deserialize)] +pub struct ConsolidationProposalReviewRequest { + /// Tenant that owns the proposal. + pub tenant_id: String, + /// Project that owns the proposal. + pub project_id: String, + /// Agent performing the review action. + pub reviewer_agent_id: String, + /// Proposal identifier. + pub proposal_id: Uuid, + /// Requested review action. + pub review_action: ConsolidationReviewAction, + /// Optional reviewer comment. + pub review_comment: Option, +} + +/// Public consolidation proposal review audit DTO. +#[derive(Clone, Debug, Serialize)] +pub struct ConsolidationProposalReviewEventResponse { + /// Review event identifier. + pub review_id: Uuid, + /// Reviewed proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the proposal. + pub tenant_id: String, + /// Project that owns the proposal. + pub project_id: String, + /// Agent that performed the review action. + pub reviewer_agent_id: String, + /// Review action requested by the reviewer. + pub action: String, + /// Review state before the transition. + pub from_review_state: String, + /// Review state after the transition. + pub to_review_state: String, + /// Optional reviewer comment. + pub review_comment: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} +impl From for ConsolidationProposalReviewEventResponse { + fn from(event: ConsolidationProposalReviewEvent) -> Self { + Self { + review_id: event.review_id, + proposal_id: event.proposal_id, + run_id: event.run_id, + tenant_id: event.tenant_id, + project_id: event.project_id, + reviewer_agent_id: event.reviewer_agent_id, + action: event.action, + from_review_state: event.from_review_state, + to_review_state: event.to_review_state, + review_comment: event.review_comment, + created_at: event.created_at, + } + } +} + +/// Public consolidation proposal DTO. +#[derive(Clone, Debug, Serialize)] +pub struct ConsolidationProposalResponse { + /// Consolidation proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the proposal. + pub tenant_id: String, + /// Project that owns the proposal. + pub project_id: String, + /// Agent that registered the proposal. + pub agent_id: String, + /// Versioned consolidation contract schema. + pub contract_schema: String, + /// Proposal kind, such as derived_note or knowledge_page. + pub proposal_kind: String, + /// Derived-output apply intent. + pub apply_intent: String, + /// Current review state. + pub review_state: String, + /// Serialized source references. + pub source_refs: Value, + /// Aggregate source snapshot metadata. + pub source_snapshot: Value, + /// Serialized proposal lineage. + pub lineage: Value, + /// Serialized reviewable diff. + pub diff: Value, + /// Proposal confidence score. + pub confidence: f32, + /// Serialized unsupported-claim flags. + pub unsupported_claim_flags: Value, + /// Serialized contradiction markers. + pub contradiction_markers: Value, + /// Serialized staleness markers. + pub staleness_markers: Value, + /// Serialized derived target reference. + pub target_ref: Value, + /// Serialized proposed derived output payload. + pub proposed_payload: Value, + /// Agent that last reviewed the proposal. + pub reviewer_agent_id: Option, + /// Optional reviewer comment. + pub review_comment: Option, + /// Timestamp of the last review transition. + pub reviewed_at: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, + /// Append-only review events for detail readback. + pub review_events: Vec, +} +impl From for ConsolidationProposalResponse { + fn from(proposal: ConsolidationProposal) -> Self { + Self { + proposal_id: proposal.proposal_id, + run_id: proposal.run_id, + tenant_id: proposal.tenant_id, + project_id: proposal.project_id, + agent_id: proposal.agent_id, + contract_schema: proposal.contract_schema, + proposal_kind: proposal.proposal_kind, + apply_intent: proposal.apply_intent, + review_state: proposal.review_state, + source_refs: proposal.source_refs, + source_snapshot: proposal.source_snapshot, + lineage: proposal.lineage, + diff: proposal.diff, + confidence: proposal.confidence, + unsupported_claim_flags: proposal.unsupported_claim_flags, + contradiction_markers: proposal.contradiction_markers, + staleness_markers: proposal.staleness_markers, + target_ref: proposal.target_ref, + proposed_payload: proposal.proposed_payload, + reviewer_agent_id: proposal.reviewer_agent_id, + review_comment: proposal.review_comment, + reviewed_at: proposal.reviewed_at, + created_at: proposal.created_at, + updated_at: proposal.updated_at, + review_events: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct PromotedMemoryPayload { + #[serde(rename = "type")] + pub(super) note_type: String, + pub(super) text: String, + pub(super) scope: Option, + pub(super) key: Option, + pub(super) importance: Option, + pub(super) confidence: Option, + pub(super) ttl_days: Option, + #[serde(default = "empty_object")] + pub(super) source_ref: Value, +} + +pub(super) fn empty_object() -> Value { + Value::Object(Map::new()) +} diff --git a/packages/elf-service/src/consolidation/validation.rs b/packages/elf-service/src/consolidation/validation.rs new file mode 100644 index 00000000..ad73d553 --- /dev/null +++ b/packages/elf-service/src/consolidation/validation.rs @@ -0,0 +1,110 @@ +use serde::Serialize; +use serde_json::Value; +use time::OffsetDateTime; + +use crate::{ + Error, Result, + consolidation::types::{DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT}, +}; +use elf_domain::consolidation::{ + ConsolidationReviewAction, ConsolidationReviewState, ConsolidationRunState, + ConsolidationValidationError, +}; + +pub(super) fn validate_context(tenant_id: &str, project_id: &str, agent_id: &str) -> Result<()> { + validate_non_empty("tenant_id", tenant_id)?; + validate_non_empty("project_id", project_id)?; + + validate_non_empty("agent_id", agent_id) +} + +pub(super) fn validate_job_kind(job_kind: &str) -> Result<()> { + validate_non_empty("job_kind", job_kind)?; + + match job_kind { + "fixture" | "manual" => Ok(()), + _ => Err(Error::InvalidRequest { + message: "job_kind must be fixture or manual for consolidation v1.".to_string(), + }), + } +} + +pub(super) fn validate_object(field: &str, value: &Value) -> Result<()> { + if matches!(value, Value::Object(_)) { + Ok(()) + } else { + Err(Error::InvalidRequest { message: format!("{field} must be a JSON object.") }) + } +} + +pub(super) fn validation_error(err: ConsolidationValidationError) -> Error { + Error::InvalidRequest { message: err.to_string() } +} + +pub(super) fn review_steps( + current: ConsolidationReviewState, + action: ConsolidationReviewAction, +) -> Result> { + let steps = match action { + ConsolidationReviewAction::Approve => + vec![(ConsolidationReviewAction::Approve, ConsolidationReviewState::Approved)], + ConsolidationReviewAction::Apply => match current { + ConsolidationReviewState::Proposed => vec![ + (ConsolidationReviewAction::Approve, ConsolidationReviewState::Approved), + (ConsolidationReviewAction::Apply, ConsolidationReviewState::Applied), + ], + ConsolidationReviewState::Approved => + vec![(ConsolidationReviewAction::Apply, ConsolidationReviewState::Applied)], + ConsolidationReviewState::Rejected + | ConsolidationReviewState::Applied + | ConsolidationReviewState::Archived => + vec![(ConsolidationReviewAction::Apply, ConsolidationReviewState::Applied)], + }, + ConsolidationReviewAction::Discard => + vec![(ConsolidationReviewAction::Discard, ConsolidationReviewState::Rejected)], + ConsolidationReviewAction::Defer => + vec![(ConsolidationReviewAction::Defer, ConsolidationReviewState::Archived)], + }; + let mut state = current; + + for (_, next_state) in &steps { + state.validate_transition(*next_state).map_err(validation_error)?; + + state = *next_state; + } + + Ok(steps) +} + +pub(super) fn bounded_limit(limit: Option) -> i64 { + limit.map(i64::from).unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, MAX_LIST_LIMIT) +} + +pub(super) fn to_value(value: &T) -> Result +where + T: Serialize, +{ + serde_json::to_value(value).map_err(|err| Error::InvalidRequest { + message: format!("failed to serialize consolidation contract: {err}"), + }) +} + +pub(super) fn terminal_time( + state: ConsolidationRunState, + now: OffsetDateTime, +) -> Option { + match state { + ConsolidationRunState::Completed + | ConsolidationRunState::Failed + | ConsolidationRunState::Cancelled => Some(now), + ConsolidationRunState::Pending | ConsolidationRunState::Running => None, + } +} + +fn validate_non_empty(field: &'static str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} must not be empty.") }); + } + + Ok(()) +} diff --git a/packages/elf-service/src/constants.rs b/packages/elf-service/src/constants.rs new file mode 100644 index 00000000..d60c6b6c --- /dev/null +++ b/packages/elf-service/src/constants.rs @@ -0,0 +1,4 @@ +/// Rejection code emitted when event evidence quotes do not match the source messages. +pub const REJECT_EVIDENCE_MISMATCH: &str = "REJECT_EVIDENCE_MISMATCH"; +/// Rejection code emitted when a write policy and extracted output disagree. +pub const REJECT_WRITE_POLICY_MISMATCH: &str = "REJECT_WRITE_POLICY_MISMATCH"; diff --git a/packages/elf-service/src/core_blocks.rs b/packages/elf-service/src/core_blocks.rs index 3ff42bf9..47bb8494 100644 --- a/packages/elf-service/src/core_blocks.rs +++ b/packages/elf-service/src/core_blocks.rs @@ -1,1230 +1,13 @@ //! Scoped core memory block APIs. -use std::collections::{HashMap, HashSet}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{FromRow, PgExecutor, Postgres, Transaction}; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ - ElfService, Error, Result, - access::{self, ORG_PROJECT_ID}, - search, +mod persistence; +mod service; +mod types; +mod validation; + +pub use types::{ + CoreBlockAttachRequest, CoreBlockAttachResponse, CoreBlockAuditEvent, CoreBlockDetachRequest, + CoreBlockDetachResponse, CoreBlockItem, CoreBlockRecord, CoreBlockUpsertRequest, + CoreBlockUpsertResponse, CoreBlocksGetRequest, CoreBlocksResponse, + ELF_CORE_MEMORY_BLOCKS_SCHEMA_V1, }; -use elf_config::Config; -use elf_domain::english_gate::{self, EnglishGateKind}; - -/// Core memory blocks response schema identifier. -pub const ELF_CORE_MEMORY_BLOCKS_SCHEMA_V1: &str = "elf.core_memory_blocks/v1"; - -const MAX_CORE_BLOCK_CONTENT_CHARS: usize = 2_000; - -/// Request payload for attached core block readback. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlocksGetRequest { - /// Tenant that owns the request. - pub tenant_id: String, - /// Project context for attachment lookup. - pub project_id: String, - /// Agent requesting attached blocks. - pub agent_id: String, - /// Read profile whose exact attachments should be returned. - pub read_profile: String, -} - -/// Response payload for attached core block readback. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlocksResponse { - /// Response schema identifier. - pub schema: String, - /// Tenant that owns the request. - pub tenant_id: String, - /// Project context for attachment lookup. - pub project_id: String, - /// Agent requesting attached blocks. - pub agent_id: String, - /// Read profile used for attachment lookup. - pub read_profile: String, - /// Attached core blocks visible to the caller. - pub items: Vec, -} - -/// One attached core memory block. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockItem { - /// Core block identifier. - pub block_id: Uuid, - /// Active attachment identifier that made the block visible. - pub attachment_id: Uuid, - /// Tenant that owns the block. - pub tenant_id: String, - /// Project that owns the block. - pub project_id: String, - /// Agent that owns the block's scope. - pub agent_id: String, - /// Scope key for the block. - pub scope: String, - /// Stable block key. - pub key: String, - /// Human-readable block title. - pub title: String, - /// Small always-attached context payload. - pub content: String, - /// Structured source/provenance metadata for the block. - pub source_ref: Value, - /// Lifecycle status for the block. - pub status: String, - #[serde(with = "crate::time_serde")] - /// Last block update timestamp. - pub updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Attachment creation timestamp. - pub attached_at: OffsetDateTime, - /// Agent that created the attachment. - pub attached_by_agent_id: String, - /// Append-only block and attachment audit events. - pub audit_history: Vec, -} - -/// One core block audit event. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockAuditEvent { - /// Audit event identifier. - pub event_id: Uuid, - /// Block identifier affected by the event. - pub block_id: Uuid, - /// Attachment identifier affected by the event, when applicable. - pub attachment_id: Option, - /// Agent that performed the event. - pub actor_agent_id: String, - /// Event type. - pub event_type: String, - /// Attachment target agent, when applicable. - pub target_agent_id: Option, - /// Attachment read profile, when applicable. - pub read_profile: Option, - /// Optional previous state snapshot. - pub prev_snapshot: Option, - /// Optional new state snapshot. - pub new_snapshot: Option, - /// Human-readable event reason. - pub reason: String, - #[serde(with = "crate::time_serde")] - /// Event timestamp. - pub ts: OffsetDateTime, -} - -/// Request payload for creating or updating a core block through admin APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockUpsertRequest { - /// Tenant that owns the request. - pub tenant_id: String, - /// Project context for the block. - pub project_id: String, - /// Agent creating or updating the block. - pub agent_id: String, - /// Existing block id to update. Omit to create. - pub block_id: Option, - /// Scope key for the block. - pub scope: String, - /// Stable block key. - pub key: String, - /// Human-readable block title. - pub title: String, - /// Small always-attached context payload. - pub content: String, - /// Structured source/provenance metadata for the block. - pub source_ref: Value, - /// Optional audit reason. - pub reason: Option, -} - -/// Response payload for core block creation or update. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockUpsertResponse { - /// Stored block record. - pub block: CoreBlockRecord, -} - -/// Core block record returned by admin mutation APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockRecord { - /// Core block identifier. - pub block_id: Uuid, - /// Tenant that owns the block. - pub tenant_id: String, - /// Project that owns the block. - pub project_id: String, - /// Agent that owns the block's scope. - pub agent_id: String, - /// Scope key for the block. - pub scope: String, - /// Stable block key. - pub key: String, - /// Human-readable block title. - pub title: String, - /// Small always-attached context payload. - pub content: String, - /// Structured source/provenance metadata for the block. - pub source_ref: Value, - /// Lifecycle status for the block. - pub status: String, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Request payload for attaching a block to an agent/read-profile pair. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockAttachRequest { - /// Tenant that owns the request. - pub tenant_id: String, - /// Project context for the attachment. - pub project_id: String, - /// Agent creating the attachment. - pub agent_id: String, - /// Block to attach. - pub block_id: Uuid, - /// Target agent that should receive the block. - pub target_agent_id: String, - /// Exact read profile for the attachment. - pub read_profile: String, - /// Optional audit reason. - pub reason: Option, -} - -/// Response payload for attaching a core block. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockAttachResponse { - /// Attachment identifier. - pub attachment_id: Uuid, - /// Block identifier. - pub block_id: Uuid, - /// Target agent for the attachment. - pub target_agent_id: String, - /// Exact read profile for the attachment. - pub read_profile: String, - /// Agent that created the attachment. - pub attached_by_agent_id: String, - #[serde(with = "crate::time_serde")] - /// Attachment timestamp. - pub attached_at: OffsetDateTime, -} - -/// Request payload for detaching a block attachment. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockDetachRequest { - /// Tenant that owns the request. - pub tenant_id: String, - /// Project context for the attachment. - pub project_id: String, - /// Agent detaching the block. - pub agent_id: String, - /// Attachment to detach. - pub attachment_id: Uuid, - /// Optional audit reason. - pub reason: Option, -} - -/// Response payload for detaching a core block. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CoreBlockDetachResponse { - /// Attachment identifier. - pub attachment_id: Uuid, - /// Whether an active attachment was detached. - pub detached: bool, -} - -#[derive(Clone, Debug, FromRow)] -struct CoreBlockRow { - block_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - scope: String, - key: String, - title: String, - content: String, - source_ref: Value, - status: String, - created_at: OffsetDateTime, - updated_at: OffsetDateTime, -} -impl CoreBlockRow { - fn into_record(self) -> CoreBlockRecord { - CoreBlockRecord { - block_id: self.block_id, - tenant_id: self.tenant_id, - project_id: self.project_id, - agent_id: self.agent_id, - scope: self.scope, - key: self.key, - title: self.title, - content: self.content, - source_ref: self.source_ref, - status: self.status, - created_at: self.created_at, - updated_at: self.updated_at, - } - } -} - -#[derive(Clone, Debug, FromRow)] -struct CoreBlockAttachmentRow { - attachment_id: Uuid, - block_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - attached_by_agent_id: String, - attached_at: OffsetDateTime, - detached_by_agent_id: Option, - detached_at: Option, -} - -#[derive(Clone, Debug, FromRow)] -struct CoreBlockJoinedRow { - attachment_id: Uuid, - attachment_agent_id: String, - attached_by_agent_id: String, - attached_at: OffsetDateTime, - block_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - scope: String, - key: String, - title: String, - content: String, - source_ref: Value, - status: String, - created_at: OffsetDateTime, - updated_at: OffsetDateTime, -} -impl CoreBlockJoinedRow { - fn into_item(self, audit_by_block: &HashMap>) -> CoreBlockItem { - let audit_history = audit_by_block.get(&self.block_id).cloned().unwrap_or_else(Vec::new); - - CoreBlockItem { - block_id: self.block_id, - attachment_id: self.attachment_id, - tenant_id: self.tenant_id, - project_id: self.project_id, - agent_id: self.agent_id, - scope: self.scope, - key: self.key, - title: self.title, - content: self.content, - source_ref: self.source_ref, - status: self.status, - updated_at: self.updated_at, - attached_at: self.attached_at, - attached_by_agent_id: self.attached_by_agent_id, - audit_history, - } - } -} - -#[derive(Clone, Debug, FromRow)] -struct CoreBlockEventRow { - event_id: Uuid, - block_id: Uuid, - attachment_id: Option, - actor_agent_id: String, - event_type: String, - target_agent_id: Option, - read_profile: Option, - prev_snapshot: Option, - new_snapshot: Option, - reason: String, - ts: OffsetDateTime, -} - -struct PreparedGetRequest { - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - allowed_scopes: Vec, -} - -struct PreparedUpsertRequest { - tenant_id: String, - project_id: String, - agent_id: String, - block_id: Option, - scope: String, - key: String, - title: String, - content: String, - source_ref: Value, - reason: String, -} - -struct PreparedAttachRequest { - tenant_id: String, - project_id: String, - agent_id: String, - block_id: Uuid, - target_agent_id: String, - read_profile: String, - allowed_scopes: Vec, - reason: String, -} - -struct PreparedDetachRequest { - tenant_id: String, - project_id: String, - agent_id: String, - attachment_id: Uuid, - reason: String, -} - -struct CoreBlockEventInput<'a> { - block_id: Uuid, - attachment_id: Option, - tenant_id: &'a str, - project_id: &'a str, - actor_agent_id: &'a str, - event_type: &'a str, - target_agent_id: Option<&'a str>, - read_profile: Option<&'a str>, - prev_snapshot: Option, - new_snapshot: Option, - reason: &'a str, - ts: OffsetDateTime, -} - -impl ElfService { - /// Returns core memory blocks explicitly attached for one agent/read-profile pair. - pub async fn core_blocks_get(&self, req: CoreBlocksGetRequest) -> Result { - let prepared = prepare_get_request(&self.cfg, req)?; - let rows = fetch_attached_block_rows( - &self.db.pool, - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.agent_id.as_str(), - prepared.read_profile.as_str(), - ) - .await?; - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.agent_id.as_str(), - prepared.allowed_scopes.iter().any(|scope| scope == "org_shared"), - ) - .await?; - let visible_rows = filter_visible_rows(rows, &prepared.allowed_scopes, &shared_grants); - let block_ids = visible_rows.iter().map(|row| row.block_id).collect::>(); - let audit_by_block = fetch_audit_history(&self.db.pool, &block_ids).await?; - let items = - visible_rows.into_iter().map(|row| row.into_item(&audit_by_block)).collect::>(); - - Ok(CoreBlocksResponse { - schema: ELF_CORE_MEMORY_BLOCKS_SCHEMA_V1.to_string(), - tenant_id: prepared.tenant_id, - project_id: prepared.project_id, - agent_id: prepared.agent_id, - read_profile: prepared.read_profile, - items, - }) - } - - /// Creates or updates a core memory block and records append-only audit history. - pub async fn core_block_upsert( - &self, - req: CoreBlockUpsertRequest, - ) -> Result { - let prepared = prepare_upsert_request(&self.cfg, req)?; - let now = OffsetDateTime::now_utc(); - let mut tx = self.db.pool.begin().await?; - let (row, prev_snapshot) = match prepared.block_id { - Some(block_id) => update_core_block(&mut tx, &prepared, block_id, now).await?, - None => (insert_core_block(&mut tx, &prepared, now).await?, None), - }; - - insert_core_block_event( - &mut tx, - CoreBlockEventInput { - block_id: row.block_id, - attachment_id: None, - tenant_id: prepared.tenant_id.as_str(), - project_id: prepared.project_id.as_str(), - actor_agent_id: prepared.agent_id.as_str(), - event_type: if prepared.block_id.is_some() { - "block_updated" - } else { - "block_created" - }, - target_agent_id: None, - read_profile: None, - prev_snapshot, - new_snapshot: Some(block_snapshot(&row)), - reason: prepared.reason.as_str(), - ts: now, - }, - ) - .await?; - - tx.commit().await?; - - Ok(CoreBlockUpsertResponse { block: row.into_record() }) - } - - /// Attaches an active core block to one exact agent/read-profile pair. - pub async fn core_block_attach( - &self, - req: CoreBlockAttachRequest, - ) -> Result { - let prepared = prepare_attach_request(&self.cfg, req)?; - let now = OffsetDateTime::now_utc(); - let mut tx = self.db.pool.begin().await?; - let block = fetch_active_block_for_attachment(&mut tx, &prepared).await?; - let shared_grants = access::load_shared_read_grants_with_org_shared( - &mut *tx, - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.target_agent_id.as_str(), - prepared.allowed_scopes.iter().any(|scope| scope == "org_shared"), - ) - .await?; - - if !block_read_allowed( - &block, - prepared.target_agent_id.as_str(), - &prepared.allowed_scopes, - &shared_grants, - ) { - return Err(Error::ScopeDenied { - message: "Block scope is not allowed for this attachment.".to_string(), - }); - } - - let attachment = upsert_core_block_attachment(&mut tx, &prepared, now).await?; - - insert_core_block_event( - &mut tx, - CoreBlockEventInput { - block_id: attachment.block_id, - attachment_id: Some(attachment.attachment_id), - tenant_id: prepared.tenant_id.as_str(), - project_id: prepared.project_id.as_str(), - actor_agent_id: prepared.agent_id.as_str(), - event_type: "attachment_added", - target_agent_id: Some(prepared.target_agent_id.as_str()), - read_profile: Some(prepared.read_profile.as_str()), - prev_snapshot: None, - new_snapshot: Some(attachment_snapshot(&attachment)), - reason: prepared.reason.as_str(), - ts: now, - }, - ) - .await?; - - tx.commit().await?; - - Ok(CoreBlockAttachResponse { - attachment_id: attachment.attachment_id, - block_id: attachment.block_id, - target_agent_id: attachment.agent_id, - read_profile: attachment.read_profile, - attached_by_agent_id: attachment.attached_by_agent_id, - attached_at: attachment.attached_at, - }) - } - - /// Detaches an active core block attachment and records an audit event. - pub async fn core_block_detach( - &self, - req: CoreBlockDetachRequest, - ) -> Result { - let prepared = prepare_detach_request(req)?; - let now = OffsetDateTime::now_utc(); - let mut tx = self.db.pool.begin().await?; - let Some(prev) = fetch_active_attachment_for_update(&mut tx, &prepared).await? else { - tx.commit().await?; - - return Ok(CoreBlockDetachResponse { - attachment_id: prepared.attachment_id, - detached: false, - }); - }; - let updated = detach_core_block_attachment(&mut tx, &prepared, now).await?; - - insert_core_block_event( - &mut tx, - CoreBlockEventInput { - block_id: updated.block_id, - attachment_id: Some(updated.attachment_id), - tenant_id: prepared.tenant_id.as_str(), - project_id: prepared.project_id.as_str(), - actor_agent_id: prepared.agent_id.as_str(), - event_type: "attachment_removed", - target_agent_id: Some(updated.agent_id.as_str()), - read_profile: Some(updated.read_profile.as_str()), - prev_snapshot: Some(attachment_snapshot(&prev)), - new_snapshot: Some(attachment_snapshot(&updated)), - reason: prepared.reason.as_str(), - ts: now, - }, - ) - .await?; - - tx.commit().await?; - - Ok(CoreBlockDetachResponse { attachment_id: updated.attachment_id, detached: true }) - } -} - -fn prepare_get_request(cfg: &Config, req: CoreBlocksGetRequest) -> Result { - let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; - let project_id = normalize_required(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; - let read_profile = normalize_required(req.read_profile.as_str(), "read_profile")?; - let allowed_scopes = search::resolve_read_profile_scopes(cfg, read_profile.as_str())?; - - Ok(PreparedGetRequest { tenant_id, project_id, agent_id, read_profile, allowed_scopes }) -} - -fn prepare_upsert_request( - cfg: &Config, - req: CoreBlockUpsertRequest, -) -> Result { - let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; - let requested_project_id = normalize_required(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; - let scope = normalize_required(req.scope.as_str(), "scope")?; - let key = normalize_required(req.key.as_str(), "key")?; - let title = normalize_required(req.title.as_str(), "title")?; - let content = normalize_required(req.content.as_str(), "content")?; - let reason = req - .reason - .as_deref() - .map(|value| normalize_required(value, "reason")) - .transpose()? - .unwrap_or_else(|| "core block upsert".to_string()); - let project_id = - if scope == "org_shared" { ORG_PROJECT_ID.to_string() } else { requested_project_id }; - - validate_write_scope(cfg, scope.as_str())?; - validate_english(key.as_str(), EnglishGateKind::Identifier, "$.key")?; - validate_english(title.as_str(), EnglishGateKind::NaturalLanguage, "$.title")?; - validate_english(content.as_str(), EnglishGateKind::NaturalLanguage, "$.content")?; - validate_source_ref(&req.source_ref)?; - - if content.chars().count() > MAX_CORE_BLOCK_CONTENT_CHARS { - return Err(Error::InvalidRequest { message: "content is too long.".to_string() }); - } - - Ok(PreparedUpsertRequest { - tenant_id, - project_id, - agent_id, - block_id: req.block_id, - scope, - key, - title, - content, - source_ref: req.source_ref, - reason, - }) -} - -fn prepare_attach_request( - cfg: &Config, - req: CoreBlockAttachRequest, -) -> Result { - let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; - let project_id = normalize_required(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; - let target_agent_id = normalize_required(req.target_agent_id.as_str(), "target_agent_id")?; - let read_profile = normalize_required(req.read_profile.as_str(), "read_profile")?; - let allowed_scopes = search::resolve_read_profile_scopes(cfg, read_profile.as_str())?; - let reason = req - .reason - .as_deref() - .map(|value| normalize_required(value, "reason")) - .transpose()? - .unwrap_or_else(|| "core block attachment".to_string()); - - validate_english(target_agent_id.as_str(), EnglishGateKind::Identifier, "$.target_agent_id")?; - - Ok(PreparedAttachRequest { - tenant_id, - project_id, - agent_id, - block_id: req.block_id, - target_agent_id, - read_profile, - allowed_scopes, - reason, - }) -} - -fn prepare_detach_request(req: CoreBlockDetachRequest) -> Result { - let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; - let project_id = normalize_required(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; - let reason = req - .reason - .as_deref() - .map(|value| normalize_required(value, "reason")) - .transpose()? - .unwrap_or_else(|| "core block detach".to_string()); - - Ok(PreparedDetachRequest { - tenant_id, - project_id, - agent_id, - attachment_id: req.attachment_id, - reason, - }) -} - -fn filter_visible_rows( - rows: Vec, - allowed_scopes: &[String], - shared_grants: &HashSet, -) -> Vec { - rows.into_iter() - .filter(|row| { - let block = CoreBlockRow { - block_id: row.block_id, - tenant_id: row.tenant_id.clone(), - project_id: row.project_id.clone(), - agent_id: row.agent_id.clone(), - scope: row.scope.clone(), - key: row.key.clone(), - title: row.title.clone(), - content: row.content.clone(), - source_ref: row.source_ref.clone(), - status: row.status.clone(), - created_at: row.created_at, - updated_at: row.updated_at, - }; - - block_read_allowed( - &block, - row.attachment_agent_id.as_str(), - allowed_scopes, - shared_grants, - ) - }) - .collect() -} - -fn block_read_allowed( - block: &CoreBlockRow, - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, -) -> bool { - if block.status != "active" { - return false; - } - if !allowed_scopes.iter().any(|scope| scope == &block.scope) { - return false; - } - if block.scope == "agent_private" { - return block.agent_id == requester_agent_id; - } - if !matches!(block.scope.as_str(), "project_shared" | "org_shared") { - return false; - } - if block.agent_id == requester_agent_id { - return true; - } - - shared_grants.contains(&access::SharedSpaceGrantKey { - scope: block.scope.clone(), - space_owner_agent_id: block.agent_id.clone(), - }) -} - -fn block_snapshot(block: &CoreBlockRow) -> Value { - serde_json::json!({ - "block_id": block.block_id, - "tenant_id": block.tenant_id, - "project_id": block.project_id, - "agent_id": block.agent_id, - "scope": block.scope, - "key": block.key, - "title": block.title, - "content": block.content, - "source_ref": block.source_ref, - "status": block.status, - "created_at": block.created_at, - "updated_at": block.updated_at, - }) -} - -fn attachment_snapshot(attachment: &CoreBlockAttachmentRow) -> Value { - serde_json::json!({ - "attachment_id": attachment.attachment_id, - "block_id": attachment.block_id, - "tenant_id": attachment.tenant_id, - "project_id": attachment.project_id, - "agent_id": attachment.agent_id, - "read_profile": attachment.read_profile, - "attached_by_agent_id": attachment.attached_by_agent_id, - "attached_at": attachment.attached_at, - "detached_by_agent_id": attachment.detached_by_agent_id, - "detached_at": attachment.detached_at, - }) -} - -fn normalize_required(raw: &str, field: &str) -> Result { - let trimmed = raw.trim(); - - if trimmed.is_empty() { - return Err(Error::InvalidRequest { message: format!("{field} is required.") }); - } - - Ok(trimmed.to_string()) -} - -fn validate_write_scope(cfg: &Config, scope: &str) -> Result<()> { - if !cfg.scopes.allowed.iter().any(|allowed| allowed == scope) { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - - let write_allowed = match scope { - "agent_private" => cfg.scopes.write_allowed.agent_private, - "project_shared" => cfg.scopes.write_allowed.project_shared, - "org_shared" => cfg.scopes.write_allowed.org_shared, - _ => false, - }; - - if !write_allowed { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - - Ok(()) -} - -fn validate_english(input: &str, kind: EnglishGateKind, field: &str) -> Result<()> { - english_gate::english_gate(input, kind) - .map_err(|_| Error::NonEnglishInput { field: field.to_string() }) -} - -fn validate_source_ref(source_ref: &Value) -> Result<()> { - if !source_ref.is_object() { - return Err(Error::InvalidRequest { - message: "source_ref must be a JSON object.".to_string(), - }); - } - - Ok(()) -} - -async fn insert_core_block( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedUpsertRequest, - now: OffsetDateTime, -) -> Result { - ensure_no_active_key_conflict(tx, req, None).await?; - - sqlx::query_as::<_, CoreBlockRow>( - "\ -INSERT INTO core_memory_blocks ( - block_id, - tenant_id, - project_id, - agent_id, - scope, - key, - title, - content, - source_ref, - status, - created_at, - updated_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, $10) -RETURNING *", - ) - .bind(Uuid::new_v4()) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(req.agent_id.as_str()) - .bind(req.scope.as_str()) - .bind(req.key.as_str()) - .bind(req.title.as_str()) - .bind(req.content.as_str()) - .bind(&req.source_ref) - .bind(now) - .fetch_one(&mut **tx) - .await - .map_err(Into::into) -} - -async fn update_core_block( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedUpsertRequest, - block_id: Uuid, - now: OffsetDateTime, -) -> Result<(CoreBlockRow, Option)> { - let prev = fetch_owned_block_for_update(tx, req, block_id).await?; - let prev_snapshot = Some(block_snapshot(&prev)); - - ensure_no_active_key_conflict(tx, req, Some(block_id)).await?; - - let row = sqlx::query_as::<_, CoreBlockRow>( - "\ -UPDATE core_memory_blocks -SET - key = $6, - title = $7, - content = $8, - source_ref = $9, - updated_at = $10 -WHERE block_id = $1 - AND tenant_id = $2 - AND project_id = $3 - AND agent_id = $4 - AND scope = $5 - AND status = 'active' -RETURNING *", - ) - .bind(block_id) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(req.agent_id.as_str()) - .bind(req.scope.as_str()) - .bind(req.key.as_str()) - .bind(req.title.as_str()) - .bind(req.content.as_str()) - .bind(&req.source_ref) - .bind(now) - .fetch_optional(&mut **tx) - .await? - .ok_or_else(|| Error::NotFound { message: "Core block not found.".to_string() })?; - - Ok((row, prev_snapshot)) -} - -async fn fetch_owned_block_for_update( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedUpsertRequest, - block_id: Uuid, -) -> Result { - sqlx::query_as::<_, CoreBlockRow>( - "\ -SELECT * -FROM core_memory_blocks -WHERE block_id = $1 - AND tenant_id = $2 - AND project_id = $3 - AND agent_id = $4 - AND scope = $5 - AND status = 'active' -FOR UPDATE", - ) - .bind(block_id) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(req.agent_id.as_str()) - .bind(req.scope.as_str()) - .fetch_optional(&mut **tx) - .await? - .ok_or_else(|| Error::NotFound { message: "Core block not found.".to_string() }) -} - -async fn ensure_no_active_key_conflict( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedUpsertRequest, - block_id: Option, -) -> Result<()> { - let conflict: Option = sqlx::query_scalar( - "\ -SELECT block_id -FROM core_memory_blocks -WHERE tenant_id = $1 - AND project_id = $2 - AND agent_id = $3 - AND scope = $4 - AND key = $5 - AND status = 'active' - AND ($6::uuid IS NULL OR block_id <> $6) -LIMIT 1", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(req.agent_id.as_str()) - .bind(req.scope.as_str()) - .bind(req.key.as_str()) - .bind(block_id) - .fetch_optional(&mut **tx) - .await?; - - if conflict.is_some() { - return Err(Error::Conflict { message: "Core block key already exists.".to_string() }); - } - - Ok(()) -} - -async fn fetch_active_block_for_attachment( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedAttachRequest, -) -> Result { - sqlx::query_as::<_, CoreBlockRow>( - "\ -SELECT * -FROM core_memory_blocks -WHERE block_id = $1 - AND tenant_id = $2 - AND status = 'active' - AND ( - project_id = $3 - OR (project_id = $4 AND scope = 'org_shared') - )", - ) - .bind(req.block_id) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut **tx) - .await? - .ok_or_else(|| Error::NotFound { message: "Core block not found.".to_string() }) -} - -async fn upsert_core_block_attachment( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedAttachRequest, - now: OffsetDateTime, -) -> Result { - sqlx::query_as::<_, CoreBlockAttachmentRow>( - "\ -INSERT INTO core_memory_block_attachments ( - attachment_id, - block_id, - tenant_id, - project_id, - agent_id, - read_profile, - attached_by_agent_id, - attached_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT (tenant_id, project_id, agent_id, read_profile, block_id) -WHERE detached_at IS NULL -DO UPDATE -SET - attached_by_agent_id = EXCLUDED.attached_by_agent_id, - attached_at = EXCLUDED.attached_at, - detached_by_agent_id = NULL, - detached_at = NULL -RETURNING *", - ) - .bind(Uuid::new_v4()) - .bind(req.block_id) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(req.target_agent_id.as_str()) - .bind(req.read_profile.as_str()) - .bind(req.agent_id.as_str()) - .bind(now) - .fetch_one(&mut **tx) - .await - .map_err(Into::into) -} - -async fn fetch_active_attachment_for_update( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedDetachRequest, -) -> Result> { - sqlx::query_as::<_, CoreBlockAttachmentRow>( - "\ -SELECT * -FROM core_memory_block_attachments -WHERE attachment_id = $1 - AND tenant_id = $2 - AND project_id = $3 - AND detached_at IS NULL -FOR UPDATE", - ) - .bind(req.attachment_id) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .fetch_optional(&mut **tx) - .await - .map_err(Into::into) -} - -async fn detach_core_block_attachment( - tx: &mut Transaction<'_, Postgres>, - req: &PreparedDetachRequest, - now: OffsetDateTime, -) -> Result { - sqlx::query_as::<_, CoreBlockAttachmentRow>( - "\ -UPDATE core_memory_block_attachments -SET - detached_by_agent_id = $4, - detached_at = $5 -WHERE attachment_id = $1 - AND tenant_id = $2 - AND project_id = $3 - AND detached_at IS NULL -RETURNING *", - ) - .bind(req.attachment_id) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(req.agent_id.as_str()) - .bind(now) - .fetch_one(&mut **tx) - .await - .map_err(Into::into) -} - -async fn fetch_attached_block_rows<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - agent_id: &str, - read_profile: &str, -) -> Result> -where - E: PgExecutor<'e>, -{ - sqlx::query_as::<_, CoreBlockJoinedRow>( - "\ -SELECT - a.attachment_id, - a.agent_id AS attachment_agent_id, - a.attached_by_agent_id, - a.attached_at, - b.block_id, - b.tenant_id, - b.project_id, - b.agent_id, - b.scope, - b.key, - b.title, - b.content, - b.source_ref, - b.status, - b.created_at, - b.updated_at -FROM core_memory_block_attachments a -JOIN core_memory_blocks b ON b.block_id = a.block_id -WHERE a.tenant_id = $1 - AND a.project_id = $2 - AND a.agent_id = $3 - AND a.read_profile = $4 - AND a.detached_at IS NULL - AND b.status = 'active' -ORDER BY a.attached_at ASC, b.key ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(read_profile) - .fetch_all(executor) - .await - .map_err(Into::into) -} - -async fn fetch_audit_history<'e, E>( - executor: E, - block_ids: &[Uuid], -) -> Result>> -where - E: PgExecutor<'e>, -{ - if block_ids.is_empty() { - return Ok(HashMap::new()); - } - - let rows = sqlx::query_as::<_, CoreBlockEventRow>( - "\ -SELECT - event_id, - block_id, - attachment_id, - actor_agent_id, - event_type, - target_agent_id, - read_profile, - prev_snapshot, - new_snapshot, - reason, - ts -FROM core_memory_block_events -WHERE block_id = ANY($1) -ORDER BY ts ASC, event_id ASC", - ) - .bind(block_ids) - .fetch_all(executor) - .await?; - let mut by_block: HashMap> = HashMap::new(); - - for row in rows { - by_block.entry(row.block_id).or_default().push(CoreBlockAuditEvent { - event_id: row.event_id, - block_id: row.block_id, - attachment_id: row.attachment_id, - actor_agent_id: row.actor_agent_id, - event_type: row.event_type, - target_agent_id: row.target_agent_id, - read_profile: row.read_profile, - prev_snapshot: row.prev_snapshot, - new_snapshot: row.new_snapshot, - reason: row.reason, - ts: row.ts, - }); - } - - Ok(by_block) -} - -async fn insert_core_block_event( - tx: &mut Transaction<'_, Postgres>, - event: CoreBlockEventInput<'_>, -) -> Result<()> { - sqlx::query( - "\ -INSERT INTO core_memory_block_events ( - event_id, - block_id, - attachment_id, - tenant_id, - project_id, - actor_agent_id, - event_type, - target_agent_id, - read_profile, - prev_snapshot, - new_snapshot, - reason, - ts -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", - ) - .bind(Uuid::new_v4()) - .bind(event.block_id) - .bind(event.attachment_id) - .bind(event.tenant_id) - .bind(event.project_id) - .bind(event.actor_agent_id) - .bind(event.event_type) - .bind(event.target_agent_id) - .bind(event.read_profile) - .bind(event.prev_snapshot) - .bind(event.new_snapshot) - .bind(event.reason) - .bind(event.ts) - .execute(&mut **tx) - .await?; - - Ok(()) -} diff --git a/packages/elf-service/src/core_blocks/persistence.rs b/packages/elf-service/src/core_blocks/persistence.rs new file mode 100644 index 00000000..df6f12ab --- /dev/null +++ b/packages/elf-service/src/core_blocks/persistence.rs @@ -0,0 +1,424 @@ +use std::collections::HashMap; + +use serde_json::Value; +use sqlx::{PgExecutor, Postgres, Transaction}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Error, Result, + access::ORG_PROJECT_ID, + core_blocks::{ + types::{ + CoreBlockAttachmentRow, CoreBlockAuditEvent, CoreBlockEventInput, CoreBlockEventRow, + CoreBlockJoinedRow, CoreBlockRow, PreparedAttachRequest, PreparedDetachRequest, + PreparedUpsertRequest, + }, + validation, + }, +}; + +pub(super) async fn insert_core_block( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedUpsertRequest, + now: OffsetDateTime, +) -> Result { + ensure_no_active_key_conflict(tx, req, None).await?; + + sqlx::query_as::<_, CoreBlockRow>( + "\ +INSERT INTO core_memory_blocks ( + block_id, + tenant_id, + project_id, + agent_id, + scope, + key, + title, + content, + source_ref, + status, + created_at, + updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, $10) +RETURNING *", + ) + .bind(Uuid::new_v4()) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(req.agent_id.as_str()) + .bind(req.scope.as_str()) + .bind(req.key.as_str()) + .bind(req.title.as_str()) + .bind(req.content.as_str()) + .bind(&req.source_ref) + .bind(now) + .fetch_one(&mut **tx) + .await + .map_err(Into::into) +} + +pub(super) async fn update_core_block( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedUpsertRequest, + block_id: Uuid, + now: OffsetDateTime, +) -> Result<(CoreBlockRow, Option)> { + let prev = fetch_owned_block_for_update(tx, req, block_id).await?; + let prev_snapshot = Some(validation::block_snapshot(&prev)); + + ensure_no_active_key_conflict(tx, req, Some(block_id)).await?; + + let row = sqlx::query_as::<_, CoreBlockRow>( + "\ +UPDATE core_memory_blocks +SET + key = $6, + title = $7, + content = $8, + source_ref = $9, + updated_at = $10 +WHERE block_id = $1 + AND tenant_id = $2 + AND project_id = $3 + AND agent_id = $4 + AND scope = $5 + AND status = 'active' +RETURNING *", + ) + .bind(block_id) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(req.agent_id.as_str()) + .bind(req.scope.as_str()) + .bind(req.key.as_str()) + .bind(req.title.as_str()) + .bind(req.content.as_str()) + .bind(&req.source_ref) + .bind(now) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| Error::NotFound { message: "Core block not found.".to_string() })?; + + Ok((row, prev_snapshot)) +} + +pub(super) async fn fetch_active_block_for_attachment( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedAttachRequest, +) -> Result { + sqlx::query_as::<_, CoreBlockRow>( + "\ +SELECT * +FROM core_memory_blocks +WHERE block_id = $1 + AND tenant_id = $2 + AND status = 'active' + AND ( + project_id = $3 + OR (project_id = $4 AND scope = 'org_shared') + )", + ) + .bind(req.block_id) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| Error::NotFound { message: "Core block not found.".to_string() }) +} + +pub(super) async fn upsert_core_block_attachment( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedAttachRequest, + now: OffsetDateTime, +) -> Result { + sqlx::query_as::<_, CoreBlockAttachmentRow>( + "\ +INSERT INTO core_memory_block_attachments ( + attachment_id, + block_id, + tenant_id, + project_id, + agent_id, + read_profile, + attached_by_agent_id, + attached_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (tenant_id, project_id, agent_id, read_profile, block_id) +WHERE detached_at IS NULL +DO UPDATE +SET + attached_by_agent_id = EXCLUDED.attached_by_agent_id, + attached_at = EXCLUDED.attached_at, + detached_by_agent_id = NULL, + detached_at = NULL +RETURNING *", + ) + .bind(Uuid::new_v4()) + .bind(req.block_id) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(req.target_agent_id.as_str()) + .bind(req.read_profile.as_str()) + .bind(req.agent_id.as_str()) + .bind(now) + .fetch_one(&mut **tx) + .await + .map_err(Into::into) +} + +pub(super) async fn fetch_active_attachment_for_update( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedDetachRequest, +) -> Result> { + sqlx::query_as::<_, CoreBlockAttachmentRow>( + "\ +SELECT * +FROM core_memory_block_attachments +WHERE attachment_id = $1 + AND tenant_id = $2 + AND project_id = $3 + AND detached_at IS NULL +FOR UPDATE", + ) + .bind(req.attachment_id) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .fetch_optional(&mut **tx) + .await + .map_err(Into::into) +} + +pub(super) async fn detach_core_block_attachment( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedDetachRequest, + now: OffsetDateTime, +) -> Result { + sqlx::query_as::<_, CoreBlockAttachmentRow>( + "\ +UPDATE core_memory_block_attachments +SET + detached_by_agent_id = $4, + detached_at = $5 +WHERE attachment_id = $1 + AND tenant_id = $2 + AND project_id = $3 + AND detached_at IS NULL +RETURNING *", + ) + .bind(req.attachment_id) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(req.agent_id.as_str()) + .bind(now) + .fetch_one(&mut **tx) + .await + .map_err(Into::into) +} + +pub(super) async fn fetch_attached_block_rows<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + agent_id: &str, + read_profile: &str, +) -> Result> +where + E: PgExecutor<'e>, +{ + sqlx::query_as::<_, CoreBlockJoinedRow>( + "\ +SELECT + a.attachment_id, + a.agent_id AS attachment_agent_id, + a.attached_by_agent_id, + a.attached_at, + b.block_id, + b.tenant_id, + b.project_id, + b.agent_id, + b.scope, + b.key, + b.title, + b.content, + b.source_ref, + b.status, + b.created_at, + b.updated_at +FROM core_memory_block_attachments a +JOIN core_memory_blocks b ON b.block_id = a.block_id +WHERE a.tenant_id = $1 + AND a.project_id = $2 + AND a.agent_id = $3 + AND a.read_profile = $4 + AND a.detached_at IS NULL + AND b.status = 'active' +ORDER BY a.attached_at ASC, b.key ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(read_profile) + .fetch_all(executor) + .await + .map_err(Into::into) +} + +pub(super) async fn fetch_audit_history<'e, E>( + executor: E, + block_ids: &[Uuid], +) -> Result>> +where + E: PgExecutor<'e>, +{ + if block_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows = sqlx::query_as::<_, CoreBlockEventRow>( + "\ +SELECT + event_id, + block_id, + attachment_id, + actor_agent_id, + event_type, + target_agent_id, + read_profile, + prev_snapshot, + new_snapshot, + reason, + ts +FROM core_memory_block_events +WHERE block_id = ANY($1) +ORDER BY ts ASC, event_id ASC", + ) + .bind(block_ids) + .fetch_all(executor) + .await?; + let mut by_block: HashMap> = HashMap::new(); + + for row in rows { + by_block.entry(row.block_id).or_default().push(CoreBlockAuditEvent { + event_id: row.event_id, + block_id: row.block_id, + attachment_id: row.attachment_id, + actor_agent_id: row.actor_agent_id, + event_type: row.event_type, + target_agent_id: row.target_agent_id, + read_profile: row.read_profile, + prev_snapshot: row.prev_snapshot, + new_snapshot: row.new_snapshot, + reason: row.reason, + ts: row.ts, + }); + } + + Ok(by_block) +} + +pub(super) async fn insert_core_block_event( + tx: &mut Transaction<'_, Postgres>, + event: CoreBlockEventInput<'_>, +) -> Result<()> { + sqlx::query( + "\ +INSERT INTO core_memory_block_events ( + event_id, + block_id, + attachment_id, + tenant_id, + project_id, + actor_agent_id, + event_type, + target_agent_id, + read_profile, + prev_snapshot, + new_snapshot, + reason, + ts +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + ) + .bind(Uuid::new_v4()) + .bind(event.block_id) + .bind(event.attachment_id) + .bind(event.tenant_id) + .bind(event.project_id) + .bind(event.actor_agent_id) + .bind(event.event_type) + .bind(event.target_agent_id) + .bind(event.read_profile) + .bind(event.prev_snapshot) + .bind(event.new_snapshot) + .bind(event.reason) + .bind(event.ts) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn fetch_owned_block_for_update( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedUpsertRequest, + block_id: Uuid, +) -> Result { + sqlx::query_as::<_, CoreBlockRow>( + "\ +SELECT * +FROM core_memory_blocks +WHERE block_id = $1 + AND tenant_id = $2 + AND project_id = $3 + AND agent_id = $4 + AND scope = $5 + AND status = 'active' +FOR UPDATE", + ) + .bind(block_id) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(req.agent_id.as_str()) + .bind(req.scope.as_str()) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| Error::NotFound { message: "Core block not found.".to_string() }) +} + +async fn ensure_no_active_key_conflict( + tx: &mut Transaction<'_, Postgres>, + req: &PreparedUpsertRequest, + block_id: Option, +) -> Result<()> { + let conflict: Option = sqlx::query_scalar( + "\ +SELECT block_id +FROM core_memory_blocks +WHERE tenant_id = $1 + AND project_id = $2 + AND agent_id = $3 + AND scope = $4 + AND key = $5 + AND status = 'active' + AND ($6::uuid IS NULL OR block_id <> $6) +LIMIT 1", + ) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(req.agent_id.as_str()) + .bind(req.scope.as_str()) + .bind(req.key.as_str()) + .bind(block_id) + .fetch_optional(&mut **tx) + .await?; + + if conflict.is_some() { + return Err(Error::Conflict { message: "Core block key already exists.".to_string() }); + } + + Ok(()) +} diff --git a/packages/elf-service/src/core_blocks/service.rs b/packages/elf-service/src/core_blocks/service.rs new file mode 100644 index 00000000..15cc25fa --- /dev/null +++ b/packages/elf-service/src/core_blocks/service.rs @@ -0,0 +1,201 @@ +use time::OffsetDateTime; + +use crate::{ + ElfService, Error, Result, access, + core_blocks::{ + persistence::{self}, + types::{ + CoreBlockAttachRequest, CoreBlockAttachResponse, CoreBlockDetachRequest, + CoreBlockDetachResponse, CoreBlockEventInput, CoreBlockUpsertRequest, + CoreBlockUpsertResponse, CoreBlocksGetRequest, CoreBlocksResponse, + ELF_CORE_MEMORY_BLOCKS_SCHEMA_V1, + }, + validation::{self}, + }, +}; + +impl ElfService { + /// Returns core memory blocks explicitly attached for one agent/read-profile pair. + pub async fn core_blocks_get(&self, req: CoreBlocksGetRequest) -> Result { + let prepared = validation::prepare_get_request(&self.cfg, req)?; + let rows = persistence::fetch_attached_block_rows( + &self.db.pool, + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + prepared.read_profile.as_str(), + ) + .await?; + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + prepared.allowed_scopes.iter().any(|scope| scope == "org_shared"), + ) + .await?; + let visible_rows = + validation::filter_visible_rows(rows, &prepared.allowed_scopes, &shared_grants); + let block_ids = visible_rows.iter().map(|row| row.block_id).collect::>(); + let audit_by_block = persistence::fetch_audit_history(&self.db.pool, &block_ids).await?; + let items = + visible_rows.into_iter().map(|row| row.into_item(&audit_by_block)).collect::>(); + + Ok(CoreBlocksResponse { + schema: ELF_CORE_MEMORY_BLOCKS_SCHEMA_V1.to_string(), + tenant_id: prepared.tenant_id, + project_id: prepared.project_id, + agent_id: prepared.agent_id, + read_profile: prepared.read_profile, + items, + }) + } + + /// Creates or updates a core memory block and records append-only audit history. + pub async fn core_block_upsert( + &self, + req: CoreBlockUpsertRequest, + ) -> Result { + let prepared = validation::prepare_upsert_request(&self.cfg, req)?; + let now = OffsetDateTime::now_utc(); + let mut tx = self.db.pool.begin().await?; + let (row, prev_snapshot) = match prepared.block_id { + Some(block_id) => + persistence::update_core_block(&mut tx, &prepared, block_id, now).await?, + None => (persistence::insert_core_block(&mut tx, &prepared, now).await?, None), + }; + + persistence::insert_core_block_event( + &mut tx, + CoreBlockEventInput { + block_id: row.block_id, + attachment_id: None, + tenant_id: prepared.tenant_id.as_str(), + project_id: prepared.project_id.as_str(), + actor_agent_id: prepared.agent_id.as_str(), + event_type: if prepared.block_id.is_some() { + "block_updated" + } else { + "block_created" + }, + target_agent_id: None, + read_profile: None, + prev_snapshot, + new_snapshot: Some(validation::block_snapshot(&row)), + reason: prepared.reason.as_str(), + ts: now, + }, + ) + .await?; + + tx.commit().await?; + + Ok(CoreBlockUpsertResponse { block: row.into_record() }) + } + + /// Attaches an active core block to one exact agent/read-profile pair. + pub async fn core_block_attach( + &self, + req: CoreBlockAttachRequest, + ) -> Result { + let prepared = validation::prepare_attach_request(&self.cfg, req)?; + let now = OffsetDateTime::now_utc(); + let mut tx = self.db.pool.begin().await?; + let block = persistence::fetch_active_block_for_attachment(&mut tx, &prepared).await?; + let shared_grants = access::load_shared_read_grants_with_org_shared( + &mut *tx, + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.target_agent_id.as_str(), + prepared.allowed_scopes.iter().any(|scope| scope == "org_shared"), + ) + .await?; + + if !validation::block_read_allowed( + &block, + prepared.target_agent_id.as_str(), + &prepared.allowed_scopes, + &shared_grants, + ) { + return Err(Error::ScopeDenied { + message: "Block scope is not allowed for this attachment.".to_string(), + }); + } + + let attachment = persistence::upsert_core_block_attachment(&mut tx, &prepared, now).await?; + + persistence::insert_core_block_event( + &mut tx, + CoreBlockEventInput { + block_id: attachment.block_id, + attachment_id: Some(attachment.attachment_id), + tenant_id: prepared.tenant_id.as_str(), + project_id: prepared.project_id.as_str(), + actor_agent_id: prepared.agent_id.as_str(), + event_type: "attachment_added", + target_agent_id: Some(prepared.target_agent_id.as_str()), + read_profile: Some(prepared.read_profile.as_str()), + prev_snapshot: None, + new_snapshot: Some(validation::attachment_snapshot(&attachment)), + reason: prepared.reason.as_str(), + ts: now, + }, + ) + .await?; + + tx.commit().await?; + + Ok(CoreBlockAttachResponse { + attachment_id: attachment.attachment_id, + block_id: attachment.block_id, + target_agent_id: attachment.agent_id, + read_profile: attachment.read_profile, + attached_by_agent_id: attachment.attached_by_agent_id, + attached_at: attachment.attached_at, + }) + } + + /// Detaches an active core block attachment and records an audit event. + pub async fn core_block_detach( + &self, + req: CoreBlockDetachRequest, + ) -> Result { + let prepared = validation::prepare_detach_request(req)?; + let now = OffsetDateTime::now_utc(); + let mut tx = self.db.pool.begin().await?; + let Some(prev) = + persistence::fetch_active_attachment_for_update(&mut tx, &prepared).await? + else { + tx.commit().await?; + + return Ok(CoreBlockDetachResponse { + attachment_id: prepared.attachment_id, + detached: false, + }); + }; + let updated = persistence::detach_core_block_attachment(&mut tx, &prepared, now).await?; + + persistence::insert_core_block_event( + &mut tx, + CoreBlockEventInput { + block_id: updated.block_id, + attachment_id: Some(updated.attachment_id), + tenant_id: prepared.tenant_id.as_str(), + project_id: prepared.project_id.as_str(), + actor_agent_id: prepared.agent_id.as_str(), + event_type: "attachment_removed", + target_agent_id: Some(updated.agent_id.as_str()), + read_profile: Some(updated.read_profile.as_str()), + prev_snapshot: Some(validation::attachment_snapshot(&prev)), + new_snapshot: Some(validation::attachment_snapshot(&updated)), + reason: prepared.reason.as_str(), + ts: now, + }, + ) + .await?; + + tx.commit().await?; + + Ok(CoreBlockDetachResponse { attachment_id: updated.attachment_id, detached: true }) + } +} diff --git a/packages/elf-service/src/core_blocks/types.rs b/packages/elf-service/src/core_blocks/types.rs new file mode 100644 index 00000000..6b45a822 --- /dev/null +++ b/packages/elf-service/src/core_blocks/types.rs @@ -0,0 +1,395 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Core memory blocks response schema identifier. +pub const ELF_CORE_MEMORY_BLOCKS_SCHEMA_V1: &str = "elf.core_memory_blocks/v1"; + +pub(super) const MAX_CORE_BLOCK_CONTENT_CHARS: usize = 2_000; + +/// Request payload for attached core block readback. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlocksGetRequest { + /// Tenant that owns the request. + pub tenant_id: String, + /// Project context for attachment lookup. + pub project_id: String, + /// Agent requesting attached blocks. + pub agent_id: String, + /// Read profile whose exact attachments should be returned. + pub read_profile: String, +} + +/// Response payload for attached core block readback. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlocksResponse { + /// Response schema identifier. + pub schema: String, + /// Tenant that owns the request. + pub tenant_id: String, + /// Project context for attachment lookup. + pub project_id: String, + /// Agent requesting attached blocks. + pub agent_id: String, + /// Read profile used for attachment lookup. + pub read_profile: String, + /// Attached core blocks visible to the caller. + pub items: Vec, +} + +/// One attached core memory block. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockItem { + /// Core block identifier. + pub block_id: Uuid, + /// Active attachment identifier that made the block visible. + pub attachment_id: Uuid, + /// Tenant that owns the block. + pub tenant_id: String, + /// Project that owns the block. + pub project_id: String, + /// Agent that owns the block's scope. + pub agent_id: String, + /// Scope key for the block. + pub scope: String, + /// Stable block key. + pub key: String, + /// Human-readable block title. + pub title: String, + /// Small always-attached context payload. + pub content: String, + /// Structured source/provenance metadata for the block. + pub source_ref: Value, + /// Lifecycle status for the block. + pub status: String, + #[serde(with = "crate::time_serde")] + /// Last block update timestamp. + pub updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Attachment creation timestamp. + pub attached_at: OffsetDateTime, + /// Agent that created the attachment. + pub attached_by_agent_id: String, + /// Append-only block and attachment audit events. + pub audit_history: Vec, +} + +/// One core block audit event. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockAuditEvent { + /// Audit event identifier. + pub event_id: Uuid, + /// Block identifier affected by the event. + pub block_id: Uuid, + /// Attachment identifier affected by the event, when applicable. + pub attachment_id: Option, + /// Agent that performed the event. + pub actor_agent_id: String, + /// Event type. + pub event_type: String, + /// Attachment target agent, when applicable. + pub target_agent_id: Option, + /// Attachment read profile, when applicable. + pub read_profile: Option, + /// Optional previous state snapshot. + pub prev_snapshot: Option, + /// Optional new state snapshot. + pub new_snapshot: Option, + /// Human-readable event reason. + pub reason: String, + #[serde(with = "crate::time_serde")] + /// Event timestamp. + pub ts: OffsetDateTime, +} + +/// Request payload for creating or updating a core block through admin APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockUpsertRequest { + /// Tenant that owns the request. + pub tenant_id: String, + /// Project context for the block. + pub project_id: String, + /// Agent creating or updating the block. + pub agent_id: String, + /// Existing block id to update. Omit to create. + pub block_id: Option, + /// Scope key for the block. + pub scope: String, + /// Stable block key. + pub key: String, + /// Human-readable block title. + pub title: String, + /// Small always-attached context payload. + pub content: String, + /// Structured source/provenance metadata for the block. + pub source_ref: Value, + /// Optional audit reason. + pub reason: Option, +} + +/// Response payload for core block creation or update. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockUpsertResponse { + /// Stored block record. + pub block: CoreBlockRecord, +} + +/// Core block record returned by admin mutation APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockRecord { + /// Core block identifier. + pub block_id: Uuid, + /// Tenant that owns the block. + pub tenant_id: String, + /// Project that owns the block. + pub project_id: String, + /// Agent that owns the block's scope. + pub agent_id: String, + /// Scope key for the block. + pub scope: String, + /// Stable block key. + pub key: String, + /// Human-readable block title. + pub title: String, + /// Small always-attached context payload. + pub content: String, + /// Structured source/provenance metadata for the block. + pub source_ref: Value, + /// Lifecycle status for the block. + pub status: String, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Request payload for attaching a block to an agent/read-profile pair. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockAttachRequest { + /// Tenant that owns the request. + pub tenant_id: String, + /// Project context for the attachment. + pub project_id: String, + /// Agent creating the attachment. + pub agent_id: String, + /// Block to attach. + pub block_id: Uuid, + /// Target agent that should receive the block. + pub target_agent_id: String, + /// Exact read profile for the attachment. + pub read_profile: String, + /// Optional audit reason. + pub reason: Option, +} + +/// Response payload for attaching a core block. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockAttachResponse { + /// Attachment identifier. + pub attachment_id: Uuid, + /// Block identifier. + pub block_id: Uuid, + /// Target agent for the attachment. + pub target_agent_id: String, + /// Exact read profile for the attachment. + pub read_profile: String, + /// Agent that created the attachment. + pub attached_by_agent_id: String, + #[serde(with = "crate::time_serde")] + /// Attachment timestamp. + pub attached_at: OffsetDateTime, +} + +/// Request payload for detaching a block attachment. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockDetachRequest { + /// Tenant that owns the request. + pub tenant_id: String, + /// Project context for the attachment. + pub project_id: String, + /// Agent detaching the block. + pub agent_id: String, + /// Attachment to detach. + pub attachment_id: Uuid, + /// Optional audit reason. + pub reason: Option, +} + +/// Response payload for detaching a core block. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CoreBlockDetachResponse { + /// Attachment identifier. + pub attachment_id: Uuid, + /// Whether an active attachment was detached. + pub detached: bool, +} + +#[derive(Clone, Debug, FromRow)] +pub(super) struct CoreBlockRow { + pub(super) block_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) scope: String, + pub(super) key: String, + pub(super) title: String, + pub(super) content: String, + pub(super) source_ref: Value, + pub(super) status: String, + pub(super) created_at: OffsetDateTime, + pub(super) updated_at: OffsetDateTime, +} +impl CoreBlockRow { + pub(super) fn into_record(self) -> CoreBlockRecord { + CoreBlockRecord { + block_id: self.block_id, + tenant_id: self.tenant_id, + project_id: self.project_id, + agent_id: self.agent_id, + scope: self.scope, + key: self.key, + title: self.title, + content: self.content, + source_ref: self.source_ref, + status: self.status, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(Clone, Debug, FromRow)] +pub(super) struct CoreBlockAttachmentRow { + pub(super) attachment_id: Uuid, + pub(super) block_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) attached_by_agent_id: String, + pub(super) attached_at: OffsetDateTime, + pub(super) detached_by_agent_id: Option, + pub(super) detached_at: Option, +} + +#[derive(Clone, Debug, FromRow)] +pub(super) struct CoreBlockJoinedRow { + pub(super) attachment_id: Uuid, + pub(super) attachment_agent_id: String, + pub(super) attached_by_agent_id: String, + pub(super) attached_at: OffsetDateTime, + pub(super) block_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) scope: String, + pub(super) key: String, + pub(super) title: String, + pub(super) content: String, + pub(super) source_ref: Value, + pub(super) status: String, + pub(super) created_at: OffsetDateTime, + pub(super) updated_at: OffsetDateTime, +} +impl CoreBlockJoinedRow { + pub(super) fn into_item( + self, + audit_by_block: &HashMap>, + ) -> CoreBlockItem { + let audit_history = audit_by_block.get(&self.block_id).cloned().unwrap_or_else(Vec::new); + + CoreBlockItem { + block_id: self.block_id, + attachment_id: self.attachment_id, + tenant_id: self.tenant_id, + project_id: self.project_id, + agent_id: self.agent_id, + scope: self.scope, + key: self.key, + title: self.title, + content: self.content, + source_ref: self.source_ref, + status: self.status, + updated_at: self.updated_at, + attached_at: self.attached_at, + attached_by_agent_id: self.attached_by_agent_id, + audit_history, + } + } +} + +#[derive(Clone, Debug, FromRow)] +pub(super) struct CoreBlockEventRow { + pub(super) event_id: Uuid, + pub(super) block_id: Uuid, + pub(super) attachment_id: Option, + pub(super) actor_agent_id: String, + pub(super) event_type: String, + pub(super) target_agent_id: Option, + pub(super) read_profile: Option, + pub(super) prev_snapshot: Option, + pub(super) new_snapshot: Option, + pub(super) reason: String, + pub(super) ts: OffsetDateTime, +} + +pub(super) struct PreparedGetRequest { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) allowed_scopes: Vec, +} + +pub(super) struct PreparedUpsertRequest { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) block_id: Option, + pub(super) scope: String, + pub(super) key: String, + pub(super) title: String, + pub(super) content: String, + pub(super) source_ref: Value, + pub(super) reason: String, +} + +pub(super) struct PreparedAttachRequest { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) block_id: Uuid, + pub(super) target_agent_id: String, + pub(super) read_profile: String, + pub(super) allowed_scopes: Vec, + pub(super) reason: String, +} + +pub(super) struct PreparedDetachRequest { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) attachment_id: Uuid, + pub(super) reason: String, +} + +pub(super) struct CoreBlockEventInput<'a> { + pub(super) block_id: Uuid, + pub(super) attachment_id: Option, + pub(super) tenant_id: &'a str, + pub(super) project_id: &'a str, + pub(super) actor_agent_id: &'a str, + pub(super) event_type: &'a str, + pub(super) target_agent_id: Option<&'a str>, + pub(super) read_profile: Option<&'a str>, + pub(super) prev_snapshot: Option, + pub(super) new_snapshot: Option, + pub(super) reason: &'a str, + pub(super) ts: OffsetDateTime, +} diff --git a/packages/elf-service/src/core_blocks/validation.rs b/packages/elf-service/src/core_blocks/validation.rs new file mode 100644 index 00000000..7fe10886 --- /dev/null +++ b/packages/elf-service/src/core_blocks/validation.rs @@ -0,0 +1,260 @@ +use std::collections::HashSet; + +use serde_json::Value; + +use crate::{ + Error, Result, + access::{self, ORG_PROJECT_ID}, + core_blocks::types::{ + CoreBlockAttachRequest, CoreBlockAttachmentRow, CoreBlockDetachRequest, CoreBlockJoinedRow, + CoreBlockRow, CoreBlockUpsertRequest, CoreBlocksGetRequest, MAX_CORE_BLOCK_CONTENT_CHARS, + PreparedAttachRequest, PreparedDetachRequest, PreparedGetRequest, PreparedUpsertRequest, + }, + search, +}; +use elf_config::Config; +use elf_domain::english_gate::{self, EnglishGateKind}; + +pub(super) fn prepare_get_request( + cfg: &Config, + req: CoreBlocksGetRequest, +) -> Result { + let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; + let read_profile = normalize_required(req.read_profile.as_str(), "read_profile")?; + let allowed_scopes = search::resolve_read_profile_scopes(cfg, read_profile.as_str())?; + + Ok(PreparedGetRequest { tenant_id, project_id, agent_id, read_profile, allowed_scopes }) +} + +pub(super) fn prepare_upsert_request( + cfg: &Config, + req: CoreBlockUpsertRequest, +) -> Result { + let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; + let requested_project_id = normalize_required(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; + let scope = normalize_required(req.scope.as_str(), "scope")?; + let key = normalize_required(req.key.as_str(), "key")?; + let title = normalize_required(req.title.as_str(), "title")?; + let content = normalize_required(req.content.as_str(), "content")?; + let reason = req + .reason + .as_deref() + .map(|value| normalize_required(value, "reason")) + .transpose()? + .unwrap_or_else(|| "core block upsert".to_string()); + let project_id = + if scope == "org_shared" { ORG_PROJECT_ID.to_string() } else { requested_project_id }; + + validate_write_scope(cfg, scope.as_str())?; + validate_english(key.as_str(), EnglishGateKind::Identifier, "$.key")?; + validate_english(title.as_str(), EnglishGateKind::NaturalLanguage, "$.title")?; + validate_english(content.as_str(), EnglishGateKind::NaturalLanguage, "$.content")?; + validate_source_ref(&req.source_ref)?; + + if content.chars().count() > MAX_CORE_BLOCK_CONTENT_CHARS { + return Err(Error::InvalidRequest { message: "content is too long.".to_string() }); + } + + Ok(PreparedUpsertRequest { + tenant_id, + project_id, + agent_id, + block_id: req.block_id, + scope, + key, + title, + content, + source_ref: req.source_ref, + reason, + }) +} + +pub(super) fn prepare_attach_request( + cfg: &Config, + req: CoreBlockAttachRequest, +) -> Result { + let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; + let target_agent_id = normalize_required(req.target_agent_id.as_str(), "target_agent_id")?; + let read_profile = normalize_required(req.read_profile.as_str(), "read_profile")?; + let allowed_scopes = search::resolve_read_profile_scopes(cfg, read_profile.as_str())?; + let reason = req + .reason + .as_deref() + .map(|value| normalize_required(value, "reason")) + .transpose()? + .unwrap_or_else(|| "core block attachment".to_string()); + + validate_english(target_agent_id.as_str(), EnglishGateKind::Identifier, "$.target_agent_id")?; + + Ok(PreparedAttachRequest { + tenant_id, + project_id, + agent_id, + block_id: req.block_id, + target_agent_id, + read_profile, + allowed_scopes, + reason, + }) +} + +pub(super) fn prepare_detach_request(req: CoreBlockDetachRequest) -> Result { + let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; + let reason = req + .reason + .as_deref() + .map(|value| normalize_required(value, "reason")) + .transpose()? + .unwrap_or_else(|| "core block detach".to_string()); + + Ok(PreparedDetachRequest { + tenant_id, + project_id, + agent_id, + attachment_id: req.attachment_id, + reason, + }) +} + +pub(super) fn filter_visible_rows( + rows: Vec, + allowed_scopes: &[String], + shared_grants: &HashSet, +) -> Vec { + rows.into_iter() + .filter(|row| { + let block = CoreBlockRow { + block_id: row.block_id, + tenant_id: row.tenant_id.clone(), + project_id: row.project_id.clone(), + agent_id: row.agent_id.clone(), + scope: row.scope.clone(), + key: row.key.clone(), + title: row.title.clone(), + content: row.content.clone(), + source_ref: row.source_ref.clone(), + status: row.status.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + }; + + block_read_allowed( + &block, + row.attachment_agent_id.as_str(), + allowed_scopes, + shared_grants, + ) + }) + .collect() +} + +pub(super) fn block_read_allowed( + block: &CoreBlockRow, + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, +) -> bool { + if block.status != "active" { + return false; + } + if !allowed_scopes.iter().any(|scope| scope == &block.scope) { + return false; + } + if block.scope == "agent_private" { + return block.agent_id == requester_agent_id; + } + if !matches!(block.scope.as_str(), "project_shared" | "org_shared") { + return false; + } + if block.agent_id == requester_agent_id { + return true; + } + + shared_grants.contains(&access::SharedSpaceGrantKey { + scope: block.scope.clone(), + space_owner_agent_id: block.agent_id.clone(), + }) +} + +pub(super) fn block_snapshot(block: &CoreBlockRow) -> Value { + serde_json::json!({ + "block_id": block.block_id, + "tenant_id": block.tenant_id, + "project_id": block.project_id, + "agent_id": block.agent_id, + "scope": block.scope, + "key": block.key, + "title": block.title, + "content": block.content, + "source_ref": block.source_ref, + "status": block.status, + "created_at": block.created_at, + "updated_at": block.updated_at, + }) +} + +pub(super) fn attachment_snapshot(attachment: &CoreBlockAttachmentRow) -> Value { + serde_json::json!({ + "attachment_id": attachment.attachment_id, + "block_id": attachment.block_id, + "tenant_id": attachment.tenant_id, + "project_id": attachment.project_id, + "agent_id": attachment.agent_id, + "read_profile": attachment.read_profile, + "attached_by_agent_id": attachment.attached_by_agent_id, + "attached_at": attachment.attached_at, + "detached_by_agent_id": attachment.detached_by_agent_id, + "detached_at": attachment.detached_at, + }) +} + +fn normalize_required(raw: &str, field: &str) -> Result { + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} is required.") }); + } + + Ok(trimmed.to_string()) +} + +fn validate_write_scope(cfg: &Config, scope: &str) -> Result<()> { + if !cfg.scopes.allowed.iter().any(|allowed| allowed == scope) { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + + let write_allowed = match scope { + "agent_private" => cfg.scopes.write_allowed.agent_private, + "project_shared" => cfg.scopes.write_allowed.project_shared, + "org_shared" => cfg.scopes.write_allowed.org_shared, + _ => false, + }; + + if !write_allowed { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + + Ok(()) +} + +fn validate_english(input: &str, kind: EnglishGateKind, field: &str) -> Result<()> { + english_gate::english_gate(input, kind) + .map_err(|_| Error::NonEnglishInput { field: field.to_string() }) +} + +fn validate_source_ref(source_ref: &Value) -> Result<()> { + if !source_ref.is_object() { + return Err(Error::InvalidRequest { + message: "source_ref must be a JSON object.".to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-service/src/docs.rs b/packages/elf-service/src/docs.rs index 2c4c74f5..c5534dcb 100644 --- a/packages/elf-service/src/docs.rs +++ b/packages/elf-service/src/docs.rs @@ -1,5 +1,24 @@ //! Document ingestion and retrieval APIs. +mod api; +mod chunking; +mod excerpts; +mod queries; +mod search_support; +mod service; +mod source_capture; +mod types; +mod validation; + +pub use api::{ + DocRetrievalTrajectory, DocRetrievalTrajectoryStage, DocType, DocsDeleteRequest, + DocsDeleteResponse, DocsExcerptLocator, DocsExcerptResponse, DocsExcerptVerification, + DocsExcerptsGetRequest, DocsGetRequest, DocsGetResponse, DocsPutRequest, DocsPutResponse, + DocsSearchL0Item, DocsSearchL0ItemHashes, DocsSearchL0ItemLocator, DocsSearchL0ItemPointer, + DocsSearchL0ItemReference, DocsSearchL0ItemState, DocsSearchL0Request, DocsSearchL0Response, + DocsSourceCaptureSummary, DocsSourceSpanRef, TextPositionSelector, TextQuoteSelector, +}; + use std::{ collections::{HashMap, HashSet}, slice, @@ -12,7 +31,6 @@ use qdrant_client::{ QueryPointsBuilder, ScoredPoint, Timestamp, point_id::PointIdOptions, }, }; -use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use sqlx::{FromRow, PgExecutor, PgPool}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -21,4258 +39,47 @@ use uuid::Uuid; use crate::{ ElfService, Error, NoteOp, Result, - access::{self, ORG_PROJECT_ID, SharedSpaceGrantKey}, - search, + access::{ORG_PROJECT_ID, SharedSpaceGrantKey}, }; +use chunking::{load_tokenizer, split_tokens_by_offsets}; use elf_config::Config; use elf_domain::{ english_gate, - writegate::{self, WritePolicy, WritePolicyAudit}, + writegate::{self, WritePolicyAudit}, }; use elf_storage::{ doc_outbox, docs, models::{DocChunk, DocDocument}, qdrant::{BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME}, }; - -const MAX_TOP_K: u32 = 32; -const MAX_CANDIDATE_K: u32 = 1_024; -const DEFAULT_DOC_MAX_BYTES: usize = 4 * 1_024 * 1_024; -const DEFAULT_MAX_CHUNKS_PER_DOC: usize = 4_096; -const DEFAULT_L0_MAX_BYTES: usize = 256; -const DEFAULT_L1_MAX_BYTES: usize = 8 * 1_024; -const DEFAULT_L2_MAX_BYTES: usize = 32 * 1_024; -const DOC_RETRIEVAL_TRAJECTORY_SCHEMA_V1: &str = "doc_retrieval_trajectory/v1"; -const DOC_SOURCE_REF_SCHEMA_V1: &str = "source_ref/v1"; -const DOC_SOURCE_REF_RESOLVER_V1: &str = "elf_doc_ext/v1"; -const DOC_SOURCE_CAPTURE_SCHEMA_V1: &str = "doc_source_capture/v1"; -const DOC_SOURCE_SPAN_SCHEMA_V1: &str = "doc_source_span/v1"; -const DOC_STATUSES: [&str; 2] = ["active", "deleted"]; -const SOURCE_LIBRARY_FIELD_KEYS: [&str; 9] = [ - "source_kind", - "canonical_uri", - "captured_at", - "source_created_at", - "trust_label", - "author", - "handle", - "excerpt_locator", - "source_content_hash", -]; -const SOURCE_LIBRARY_KINDS: [&str; 7] = - ["article", "social_thread", "pdf", "text_export", "repo_file", "chat_excerpt", "web_page"]; -const SOURCE_LIBRARY_TRUST_LABELS: [&str; 5] = - ["trusted", "user_captured", "public_web", "third_party", "unverified"]; - -/// Document classification used for persistence and retrieval filters. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DocType { - /// Long-lived knowledge-base material. - Knowledge, - /// Chat transcripts or conversational context. - Chat, - /// Search-produced reference material. - Search, - /// Development-oriented artifacts such as code or plans. - Dev, -} -impl DocType { - /// Returns the canonical storage and API string for this document type. - pub fn as_str(self) -> &'static str { - match self { - Self::Knowledge => "knowledge", - Self::Chat => "chat", - Self::Search => "search", - Self::Dev => "dev", - } - } - - /// Parses a canonical document-type string. - pub fn parse(raw_doc_type: &str) -> Result { - match raw_doc_type { - "knowledge" => Ok(Self::Knowledge), - "chat" => Ok(Self::Chat), - "search" => Ok(Self::Search), - "dev" => Ok(Self::Dev), - _ => Err(Error::InvalidRequest { - message: "doc_type must be one of: knowledge, chat, search, dev.".to_string(), - }), - } - } -} - -/// Request payload for document ingestion. -#[derive(Clone, Debug, Deserialize)] -pub struct DocsPutRequest { - /// Tenant that owns the document. - pub tenant_id: String, - /// Project that owns the document. - pub project_id: String, - /// Agent ingesting the document. - pub agent_id: String, - /// Scope to assign to the document. - pub scope: String, - /// Optional raw document-type string. - pub doc_type: Option, - /// Optional display title for the document. - pub title: Option, - /// Optional write policy applied before persistence. - pub write_policy: Option, - #[serde(default)] - /// Structured provenance metadata for the document. - pub source_ref: Value, - /// Full document body to store and chunk. - pub content: String, -} - -/// Response payload for document ingestion. -#[derive(Clone, Debug, Serialize)] -pub struct DocsPutResponse { - /// Identifier of the stored document. - pub doc_id: Uuid, - /// Normalized Source Library capture metadata for the stored document. - pub source_capture: DocsSourceCaptureSummary, - /// Number of persisted chunks generated from the content. - pub chunk_count: u32, - /// Byte length of the stored content. - pub content_bytes: u32, - /// Whole-document BLAKE3 hash. - pub content_hash: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Write-policy audit emitted for the stored document, when applicable. - pub write_policy_audit: Option, -} - -/// Normalized Source Library capture metadata returned by `docs_put`. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSourceCaptureSummary { - /// Schema identifier for this capture summary. - pub schema: String, - /// Stable source record identifier. This is also the stored `doc_id`. - pub source_record_id: Uuid, - /// Canonical source origin used for operator inspection and deduplication. - pub origin: String, - /// RFC3339 timestamp when ELF captured the source. - pub captured_at: String, - /// Whole-document BLAKE3 hash for the persisted content. - pub content_hash: String, - /// Visibility scope assigned to the source record. - pub visibility_scope: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional display title associated with the source record. - pub title: Option, - /// Normalized source type, derived from `source_kind` when present. - pub source_type: String, - /// Stable span references for persisted source chunks. - pub source_spans: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - /// Typed audit records for redacted or excluded source spans. - pub policy_spans: Vec, -} - -/// Stable reference to one captured or policy-affected source span. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSourceSpanRef { - /// Schema identifier for this span reference. - pub schema: String, - /// Stable span identifier derived from content hash and byte offsets. - pub span_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - /// Chunk identifier when this span is backed by a persisted chunk. - pub chunk_id: Option, - /// Span lifecycle status such as `captured`, `excluded`, or `redacted`. - pub status: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Typed reason code for non-captured spans. - pub reason_code: Option, - /// Inclusive start byte offset in the relevant content hash. - pub start_offset: usize, - /// Exclusive end byte offset in the relevant content hash. - pub end_offset: usize, - /// Whole-content hash that makes the offsets replayable. - pub content_hash: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Chunk hash when this span is backed by a persisted chunk. - pub chunk_hash: Option, -} - -/// Request payload for document metadata lookup. -#[derive(Clone, Debug, Deserialize)] -pub struct DocsGetRequest { - /// Tenant that owns the document. - pub tenant_id: String, - /// Project that owns the document. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Identifier of the document to fetch. - pub doc_id: Uuid, -} - -/// Response payload for document metadata lookup. -#[derive(Clone, Debug, Serialize)] -pub struct DocsGetResponse { - /// Document identifier. - pub doc_id: Uuid, - /// Tenant that owns the document. - pub tenant_id: String, - /// Project that owns the document. - pub project_id: String, - /// Agent that ingested the document. - pub agent_id: String, - /// Scope key for the document. - pub scope: String, - /// Stored document type. - pub doc_type: String, - /// Lifecycle status for the document. - pub status: String, - /// Optional document title. - pub title: Option, - /// Structured provenance metadata. - pub source_ref: Value, - /// Byte length of the stored content. - pub content_bytes: u32, - /// Whole-document BLAKE3 hash. - pub content_hash: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Request payload for Source Library document deletion. -#[derive(Clone, Debug, Deserialize)] -pub struct DocsDeleteRequest { - /// Tenant that owns the document. - pub tenant_id: String, - /// Project that owns the document. - pub project_id: String, - /// Agent requesting the deletion. - pub agent_id: String, - /// Identifier of the document to delete. - pub doc_id: Uuid, -} - -/// Response payload for Source Library document deletion. -#[derive(Clone, Debug, Serialize)] -pub struct DocsDeleteResponse { - /// Identifier of the affected document. - pub doc_id: Uuid, - /// Operation that was applied. - pub op: NoteOp, - /// Number of persisted chunks queued for derived-index deletion. - pub chunk_delete_count: u32, -} - -/// Request payload for L0 document retrieval. -#[derive(Clone, Debug, Deserialize)] -pub struct DocsSearchL0Request { - /// Tenant to search within. - pub tenant_id: String, - /// Project to search within. - pub project_id: String, - /// Agent used for access-control checks. - pub caller_agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Search query text. - pub query: String, - /// Optional scope filter. - pub scope: Option, - /// Optional status filter. - pub status: Option, - /// Optional document-type filter. - pub doc_type: Option, - /// Sparse-retrieval mode override. - pub sparse_mode: Option, - /// Optional domain filter from source metadata. - pub domain: Option, - /// Optional repository filter from source metadata. - pub repo: Option, - /// Optional agent filter. - pub agent_id: Option, - /// Optional thread filter. - pub thread_id: Option, - /// Optional lower bound for `updated_at`. - pub updated_after: Option, - /// Optional upper bound for `updated_at`. - pub updated_before: Option, - /// Optional lower bound for source timestamp metadata. - pub ts_gte: Option, - /// Optional upper bound for source timestamp metadata. - pub ts_lte: Option, - /// Maximum number of returned items. - pub top_k: Option, - /// Retrieval breadth before deduplication and projection. - pub candidate_k: Option, - /// When true, includes retrieval trajectory output. - pub explain: Option, -} - -/// One chunk-level hit returned by `docs_search_l0`. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0Item { - /// Document identifier. - pub doc_id: Uuid, - /// Chunk identifier. - pub chunk_id: Uuid, - /// Stable pointer bundle for later excerpt or resolution workflows. - pub pointer: DocsSearchL0ItemPointer, - /// Final score after retrieval and boosting. - pub score: f32, - /// Returned snippet text. - pub snippet: String, - /// Scope key for the document. - pub scope: String, - /// Stored document type. - pub doc_type: String, - /// Project that owns the document. - pub project_id: String, - /// Agent that ingested the document. - pub agent_id: String, - /// Last update timestamp for the document. - pub updated_at: OffsetDateTime, - /// Whole-document BLAKE3 hash. - pub content_hash: String, - /// Chunk-level BLAKE3 hash. - pub chunk_hash: String, -} - -/// Response payload for `docs_search_l0`. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0Response { - /// Retrieval trace identifier. - pub trace_id: Uuid, - /// Returned chunk hits. - pub items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional retrieval trajectory emitted in explain mode. - pub trajectory: Option, -} - -/// Stable pointer for a chunk hit returned by document search. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0ItemPointer { - /// Pointer schema identifier. - pub schema: String, - /// Pointer resolver identifier. - pub resolver: String, - #[serde(rename = "ref")] - /// Logical identifiers used by the resolver. - pub reference: DocsSearchL0ItemReference, - /// Freshness guard for the pointer target. - pub state: DocsSearchL0ItemState, - /// Hash aliases for simpler pointer consumers. - pub hashes: DocsSearchL0ItemHashes, - /// Selector hints that can hydrate this chunk through `docs_excerpts_get`. - pub locator: DocsSearchL0ItemLocator, -} - -/// Logical identifiers for a document-search hit. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0ItemReference { - /// Document identifier. - pub doc_id: Uuid, - /// Chunk identifier. - pub chunk_id: Uuid, - /// Stable source record identifier. - pub source_record_id: Uuid, - /// Stable source span identifier for this chunk. - pub source_span_id: Uuid, -} - -/// Freshness guard for a document-search hit. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0ItemState { - /// Whole-document BLAKE3 hash. - pub content_hash: String, - /// Chunk-level BLAKE3 hash. - pub chunk_hash: String, - #[serde(with = "crate::time_serde")] - /// Last update timestamp for the document. - pub doc_updated_at: OffsetDateTime, -} - -/// Hash values carried with a document-search pointer. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0ItemHashes { - /// Whole-document BLAKE3 hash. - pub content_hash: String, - /// Chunk-level BLAKE3 hash. - pub chunk_hash: String, -} - -/// Locator hints carried with a document-search pointer. -#[derive(Clone, Debug, Serialize)] -pub struct DocsSearchL0ItemLocator { - /// Stable source span identifier for the locator. - pub span_id: Uuid, - /// Chunk byte position in the authoritative document content. - pub position: TextPositionSelector, -} - -/// Explain payload for a document retrieval run. -#[derive(Clone, Debug, Serialize)] -pub struct DocRetrievalTrajectory { - /// Trajectory schema identifier. - pub schema: String, - /// Ordered retrieval stages. - pub stages: Vec, -} - -/// One stage in a document retrieval trajectory. -#[derive(Clone, Debug, Serialize)] -pub struct DocRetrievalTrajectoryStage { - /// Zero-based stage order. - pub stage_order: u32, - /// Stable stage name. - pub stage_name: String, - /// Free-form stage statistics. - pub stats: Value, -} - -/// Quote-based selector for excerpt extraction. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TextQuoteSelector { - /// Exact quote text to resolve. - pub exact: String, - /// Optional leading context used to disambiguate repeated quotes. - pub prefix: Option, - /// Optional trailing context used to disambiguate repeated quotes. - pub suffix: Option, -} - -/// Byte-position selector for excerpt extraction. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TextPositionSelector { - /// Inclusive start byte offset. - pub start: usize, - /// Exclusive end byte offset. - pub end: usize, -} - -/// Request payload for excerpt retrieval. -#[derive(Clone, Debug, Deserialize)] -pub struct DocsExcerptsGetRequest { - /// Tenant that owns the document. - pub tenant_id: String, - /// Project that owns the document. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Identifier of the source document. - pub doc_id: Uuid, - /// Excerpt budget level: `L0`, `L1`, or `L2`. - pub level: String, // "L0" | "L1" | "L2" - /// Optional chunk identifier when the caller already knows the chunk. - pub chunk_id: Option, - /// Optional quote-based selector. - pub quote: Option, - /// Optional byte-position selector. - pub position: Option, - /// When true, includes retrieval trajectory output. - pub explain: Option, -} - -/// Verification metadata for one extracted excerpt. -#[derive(Clone, Debug, Serialize)] -pub struct DocsExcerptVerification { - /// Whether the excerpt selectors verified against current content. - pub verified: bool, - /// Verification failure codes. - pub verification_errors: Vec, - /// Whole-document BLAKE3 hash. - pub content_hash: String, - /// BLAKE3 hash of the returned excerpt. - pub excerpt_hash: String, -} - -/// Response payload for excerpt retrieval. -#[derive(Clone, Debug, Serialize)] -pub struct DocsExcerptResponse { - /// Excerpt trace identifier. - pub trace_id: Uuid, - /// Identifier of the source document. - pub doc_id: Uuid, - /// Returned excerpt text. - pub excerpt: String, - /// Inclusive start offset of the returned window. - pub start_offset: usize, - /// Exclusive end offset of the returned window. - pub end_offset: usize, - /// Concrete selector resolution result. - pub locator: DocsExcerptLocator, - /// Verification metadata for the returned excerpt. - pub verification: DocsExcerptVerification, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional retrieval trajectory emitted in explain mode. - pub trajectory: Option, -} - -/// Selector resolution metadata for an excerpt. -#[derive(Clone, Debug, Serialize)] -pub struct DocsExcerptLocator { - /// Stable source span identifier for the matched selector span. - pub span_id: Uuid, - /// Selector kind that produced the match. - pub selector_kind: String, - /// Inclusive start offset of the matched selector span. - pub match_start_offset: usize, - /// Exclusive end offset of the matched selector span. - pub match_end_offset: usize, - #[serde(skip_serializing_if = "Option::is_none")] - /// Matched chunk identifier, when known. - pub chunk_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Quote selector actually used for resolution. - pub quote: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Position selector actually used for resolution. - pub position: Option, -} - -struct SourceCaptureSummaryInput<'a> { - doc_id: Uuid, - source_ref: &'a Map, - doc_type: DocType, - scope: &'a str, - title: Option<&'a str>, - content_hash: &'a str, - raw_content_hash: &'a str, - now: OffsetDateTime, - chunks: &'a [DocChunk], - write_policy_audit: Option<&'a WritePolicyAudit>, -} - -#[derive(Clone, Copy)] -struct DocExcerptMatch { - selector_kind: ExcerptsSelectorKind, - match_start_offset: usize, - match_end_offset: usize, -} - -struct DocExcerptRange { - selector_kind: ExcerptsSelectorKind, - match_start_offset: usize, - match_end_offset: usize, - start_offset: usize, - end_offset: usize, -} - -struct DocTrajectoryBuilder { - explain: bool, - stages: Vec, - stage_order: u32, -} -impl DocTrajectoryBuilder { - fn new(explain: bool) -> Self { - Self { explain, stages: Vec::new(), stage_order: 0 } - } - - fn push(&mut self, stage_name: &str, stats: Value) { - if !self.explain { - return; - } - - self.stages.push(DocRetrievalTrajectoryStage { - stage_order: self.stage_order, - stage_name: stage_name.to_string(), - stats, - }); - - self.stage_order += 1; - } - - fn into_trajectory(self) -> Option { - if !self.explain { - return None; - } - - Some(DocRetrievalTrajectory { - schema: DOC_RETRIEVAL_TRAJECTORY_SCHEMA_V1.to_string(), - stages: self.stages, - }) - } -} - -#[derive(Clone, Debug)] -struct DocsSearchL0Filters { - scope: Option, - status: String, - doc_type: Option, - sparse_mode: DocsSparseMode, - domain: Option, - repo: Option, - agent_id: Option, - thread_id: Option, - updated_after: Option, - updated_before: Option, - ts_gte: Option, - ts_lte: Option, -} - -#[derive(Clone, Copy, Debug)] -struct DocChunkingProfile { - max_tokens: usize, - overlap_tokens: usize, - max_chunks: usize, -} - -#[derive(Clone, Debug)] -struct ByteChunk { - chunk_id: Uuid, - start_offset: usize, - end_offset: usize, - text: String, -} - -#[derive(Debug)] -struct ValidatedDocsPut { - doc_type: DocType, - content: String, - write_policy_audit: Option, -} - -#[derive(Clone, Debug, FromRow)] -struct DocSearchRow { - chunk_id: Uuid, - doc_id: Uuid, - scope: String, - doc_type: String, - project_id: String, - agent_id: String, - updated_at: OffsetDateTime, - content_hash: String, - chunk_hash: String, - start_offset: i32, - end_offset: i32, - chunk_text: String, -} - -struct DocsSearchL0Prepared { - top_k: u32, - candidate_k: u32, - sparse_mode: DocsSparseMode, - sparse_enabled: bool, - now: OffsetDateTime, - trajectory: DocTrajectoryBuilder, - allowed_scopes: Vec, - shared_grants: HashSet, - filter: Filter, - vector: Vec, - status: String, -} - -#[derive(Debug)] -struct DocsSearchL0FiltersParsed { - scope: Option, - status: String, - doc_type: Option, - sparse_mode: DocsSparseMode, - domain: Option, - repo: Option, - agent_id: Option, - thread_id: Option, -} - -#[derive(Debug)] -struct DocsSearchL0RangesParsed { - updated_after: Option, - updated_before: Option, - ts_gte: Option, - ts_lte: Option, -} - -impl ElfService { - /// Validates, chunks, stores, and enqueues a document for indexing. - pub async fn docs_put(&self, req: DocsPutRequest) -> Result { - let ValidatedDocsPut { doc_type, content, write_policy_audit } = validate_docs_put(&req)?; - let now = OffsetDateTime::now_utc(); - let embed_version = crate::embedding_version(&self.cfg); - let chunking_profile = resolve_doc_chunking_profile(doc_type); - let tokenizer = load_tokenizer(&self.cfg)?; - let tenant_id = req.tenant_id.clone(); - let project_id = req.project_id.clone(); - let agent_id = req.agent_id.clone(); - let scope = req.scope.clone(); - let title = req.title.clone(); - let source_ref = req.source_ref.clone(); - let source_ref_map = source_ref.as_object().ok_or_else(|| Error::InvalidRequest { - message: "source_ref must be a JSON object.".to_string(), - })?; - let effective_project_id = - if scope.trim() == "org_shared" { ORG_PROJECT_ID } else { project_id.as_str() }; - let content_bytes = content.len(); - let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string(); - let raw_content_hash = blake3::hash(req.content.as_bytes()).to_hex().to_string(); - let doc_id = source_record_id_for( - tenant_id.as_str(), - effective_project_id, - agent_id.as_str(), - scope.as_str(), - doc_type, - source_ref_map, - content_hash.as_str(), - ); - let mut chunks = split_tokens_by_offsets( - content.as_str(), - chunking_profile.max_tokens, - chunking_profile.overlap_tokens, - chunking_profile.max_chunks, - &tokenizer, - )?; - - for (chunk_index, chunk) in chunks.iter_mut().enumerate() { - chunk.chunk_id = doc_chunk_id_for(doc_id, chunk_index as i32); - } - - let chunk_rows = build_doc_chunk_rows(doc_id, &chunks, now); - let source_capture = build_source_capture_summary(SourceCaptureSummaryInput { - doc_id, - source_ref: source_ref_map, - doc_type, - scope: scope.as_str(), - title: title.as_deref(), - content_hash: content_hash.as_str(), - raw_content_hash: raw_content_hash.as_str(), - now, - chunks: &chunk_rows, - write_policy_audit: write_policy_audit.as_ref(), - })?; - let normalized_source_ref = normalize_source_ref_for_capture(source_ref, &source_capture)?; - let doc_row = DocDocument { - doc_id, - tenant_id: tenant_id.clone(), - project_id: effective_project_id.to_string(), - agent_id: agent_id.clone(), - scope: scope.clone(), - doc_type: doc_type.as_str().to_string(), - status: "active".to_string(), - title, - source_ref: docs::normalize_source_ref(Some(normalized_source_ref)), - content, - content_bytes: content_bytes as i32, - content_hash: content_hash.clone(), - created_at: now, - updated_at: now, - }; - let mut tx = self.db.pool.begin().await?; - - docs::insert_doc_document(&mut *tx, &doc_row).await?; - - for chunk_row in &chunk_rows { - docs::insert_doc_chunk(&mut *tx, chunk_row).await?; - doc_outbox::enqueue_doc_outbox( - &mut *tx, - doc_id, - chunk_row.chunk_id, - "UPSERT", - embed_version.as_str(), - ) - .await?; - } - - if scope.trim() != "agent_private" { - access::ensure_active_project_scope_grant( - &mut *tx, - tenant_id.as_str(), - effective_project_id, - scope.as_str(), - agent_id.as_str(), - ) - .await?; - } - - tx.commit().await?; - - Ok(DocsPutResponse { - doc_id, - source_capture, - chunk_count: chunk_rows.len() as u32, - content_bytes: content_bytes as u32, - content_hash, - write_policy_audit, - }) - } - - /// Loads document metadata when the caller can read the requested scope. - pub async fn docs_get(&self, req: DocsGetRequest) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - let read_profile = req.read_profile.trim(); - - if tenant_id.is_empty() - || project_id.is_empty() - || agent_id.is_empty() - || read_profile.is_empty() - { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, agent_id, and read_profile are required." - .to_string(), - }); - } - - let allowed_scopes = search::resolve_read_profile_scopes(&self.cfg, read_profile)?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let row: Option = sqlx::query_as::<_, DocDocument>( - "\ -SELECT - doc_id, - tenant_id, - project_id, - agent_id, - scope, - doc_type, - status, - title, - COALESCE(source_ref, '{}'::jsonb) AS source_ref, - content, - content_bytes, - content_hash, - created_at, - updated_at -FROM doc_documents -WHERE doc_id = $1 - AND tenant_id = $2 - AND ( - project_id = $3 - OR (project_id = $4 AND scope = 'org_shared') - ) -LIMIT 1", - ) - .bind(req.doc_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(&self.db.pool) - .await?; - let Some(row) = row else { - return Err(Error::NotFound { message: "Doc not found.".to_string() }); - }; - let shared_grants = if row.scope == "agent_private" { - HashSet::new() - } else { - access::load_shared_read_grants_with_org_shared( - &self.db.pool, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await? - }; - - if row.status != "active" - || !doc_read_allowed( - agent_id, - &allowed_scopes, - &shared_grants, - row.agent_id.as_str(), - row.scope.as_str(), - ) { - return Err(Error::NotFound { message: "Doc not found.".to_string() }); - } - - Ok(DocsGetResponse { - doc_id: row.doc_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - scope: row.scope, - doc_type: row.doc_type, - status: row.status, - title: row.title, - source_ref: row.source_ref, - content_bytes: row.content_bytes.max(0) as u32, - content_hash: row.content_hash, - created_at: row.created_at, - updated_at: row.updated_at, - }) - } - - /// Soft-deletes one Source Library document and enqueues doc-vector deletion. - pub async fn docs_delete(&self, req: DocsDeleteRequest) -> Result { - let now = OffsetDateTime::now_utc(); - let embed_version = crate::embedding_version(&self.cfg); - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let mut tx = self.db.pool.begin().await?; - let row: DocDocument = sqlx::query_as::<_, DocDocument>( - "\ -SELECT - doc_id, - tenant_id, - project_id, - agent_id, - scope, - doc_type, - status, - title, - COALESCE(source_ref, '{}'::jsonb) AS source_ref, - content, - content_bytes, - content_hash, - created_at, - updated_at -FROM doc_documents -WHERE doc_id = $1 - AND tenant_id = $2 - AND ( - project_id = $3 - OR (project_id = $4 AND scope = 'org_shared') - ) -FOR UPDATE", - ) - .bind(req.doc_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| Error::NotFound { message: "Doc not found.".to_string() })?; - - if row.agent_id != agent_id { - return Err(Error::NotFound { message: "Doc not found.".to_string() }); - } - - let scope_allowed = self.cfg.scopes.allowed.iter().any(|scope| scope == &row.scope); - let write_allowed = match row.scope.as_str() { - "agent_private" => self.cfg.scopes.write_allowed.agent_private, - "project_shared" => self.cfg.scopes.write_allowed.project_shared, - "org_shared" => self.cfg.scopes.write_allowed.org_shared, - _ => false, - }; - - if !scope_allowed || !write_allowed { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - if row.status == "deleted" { - tx.commit().await?; - - return Ok(DocsDeleteResponse { - doc_id: row.doc_id, - op: NoteOp::None, - chunk_delete_count: 0, - }); - } - - let chunks = docs::list_doc_chunks(&mut *tx, row.doc_id).await?; - - docs::mark_doc_deleted(&mut *tx, tenant_id, row.doc_id, now).await?; - - for chunk in &chunks { - doc_outbox::enqueue_doc_outbox( - &mut *tx, - row.doc_id, - chunk.chunk_id, - "DELETE", - embed_version.as_str(), - ) - .await?; - } - - tx.commit().await?; - - Ok(DocsDeleteResponse { - doc_id: row.doc_id, - op: NoteOp::Delete, - chunk_delete_count: chunks.len() as u32, - }) - } - - /// Runs L0 document retrieval with access filtering and optional explain output. - pub async fn docs_search_l0(&self, req: DocsSearchL0Request) -> Result { - let trace_id = Uuid::new_v4(); - let filters = validate_docs_search_l0(&req)?; - let mut prepared = self.prepare_docs_search_l0_request(&req, &filters).await?; - let scored = run_doc_fusion_query( - &self.qdrant.client, - self.cfg.storage.qdrant.docs_collection.as_str(), - req.query.as_str(), - &prepared.vector, - &prepared.filter, - prepared.sparse_mode, - prepared.candidate_k, - ) - .await?; - - self.record_docs_search_l0_vector_stats( - &mut prepared.trajectory, - &scored, - prepared.sparse_enabled, - prepared.sparse_mode, - ); - - let scored_chunks = - docs_search_l0_deduplicated_chunks(&scored, prepared.candidate_k as usize)?; - let chunk_ids: Vec = scored_chunks.iter().map(|(chunk_id, _)| *chunk_id).collect(); - let rows = self - .load_doc_search_rows(&req, &prepared.status, &chunk_ids, &mut prepared.trajectory) - .await?; - let mut items = self.build_docs_search_l0_items( - &req, - &scored_chunks, - &rows, - &prepared.allowed_scopes, - &prepared.shared_grants, - &mut prepared.trajectory, - ); - - apply_doc_recency_boost( - &mut items, - prepared.now, - self.cfg.ranking.recency_tau_days, - self.cfg.ranking.tie_breaker_weight, - ); - - items.sort_by(|a, b| b.score.total_cmp(&a.score)); - items.truncate(prepared.top_k as usize); - - record_result_projection_stage( - &mut prepared.trajectory, - rows.len(), - items.len(), - self.cfg.ranking.recency_tau_days, - self.cfg.ranking.tie_breaker_weight, - ); - - Ok(DocsSearchL0Response { - trace_id, - items, - trajectory: prepared.trajectory.into_trajectory(), - }) - } - - async fn load_doc_search_rows( - &self, - req: &DocsSearchL0Request, - status: &str, - chunk_ids: &[Uuid], - trajectory: &mut DocTrajectoryBuilder, - ) -> Result> { - let rows = load_doc_search_rows( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - status, - chunk_ids, - ) - .await?; - - trajectory.push( - "chunk_lookup", - serde_json::json!({ - "requested_chunks": chunk_ids.len(), - "loaded_chunks": rows.len(), - }), - ); - - Ok(rows) - } - - fn build_docs_search_l0_items( - &self, - req: &DocsSearchL0Request, - scored_chunks: &[(Uuid, f32)], - rows: &HashMap, - allowed_scopes: &[String], - shared_grants: &HashSet, - trajectory: &mut DocTrajectoryBuilder, - ) -> Vec { - let items = docs_search_l0_project_items( - scored_chunks, - rows, - req.caller_agent_id.as_str(), - allowed_scopes, - shared_grants, - ); - - trajectory.push( - "dedupe", - serde_json::json!({ - "raw_candidates": scored_chunks.len(), - "deduped_candidates": items.len(), - }), - ); - - items - } - - async fn prepare_docs_search_l0_request( - &self, - req: &DocsSearchL0Request, - filters: &DocsSearchL0Filters, - ) -> Result { - let explain = req.explain.unwrap_or(false); - let top_k = req.top_k.unwrap_or(12).min(MAX_TOP_K); - let candidate_k = req.candidate_k.unwrap_or(60).min(MAX_CANDIDATE_K); - let sparse_mode = filters.sparse_mode; - let sparse_enabled = docs_search_sparse_enabled(sparse_mode, req.query.as_str()); - let now = OffsetDateTime::now_utc(); - let mut trajectory = DocTrajectoryBuilder::new(explain); - - trajectory.push( - "request_validation", - serde_json::json!({ - "query_len": req.query.len(), - "top_k": top_k, - "candidate_k": candidate_k, - "sparse_mode": sparse_mode.as_str(), - "doc_type": filters - .doc_type - .as_ref() - .map(|doc_type| doc_type.as_str()) - .unwrap_or(""), - "status": &filters.status, - }), - ); - - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, req.read_profile.as_str())?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.caller_agent_id.as_str(), - org_shared_allowed, - ) - .await?; - let filter = build_doc_search_filter( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.caller_agent_id.as_str(), - &allowed_scopes, - filters, - ); - let embedded = self - .providers - .embedding - .embed(&self.cfg.providers.embedding, slice::from_ref(&req.query)) - .await?; - - trajectory.push("query_embedding", serde_json::json!({ "provider": "embedding" })); - - let vector = embedded.first().ok_or_else(|| Error::Provider { - message: "Embedding provider returned no vectors.".to_string(), - })?; - - trajectory.push( - "vector_dimension_check", - serde_json::json!({ - "provided_dim": vector.len(), - "expected_dim": self.cfg.storage.qdrant.vector_dim as usize, - }), - ); - - if vector.len() != self.cfg.storage.qdrant.vector_dim as usize { - return Err(Error::Provider { - message: "Embedding vector dimension mismatch.".to_string(), - }); - } - - Ok(DocsSearchL0Prepared { - top_k, - candidate_k, - sparse_mode, - sparse_enabled, - now, - trajectory, - allowed_scopes, - shared_grants, - filter, - vector: vector.to_vec(), - status: filters.status.clone(), - }) - } - - fn record_docs_search_l0_vector_stats( - &self, - trajectory: &mut DocTrajectoryBuilder, - scored: &[ScoredPoint], - sparse_enabled: bool, - sparse_mode: DocsSparseMode, - ) { - let channels = if sparse_enabled { vec!["dense", "sparse"] } else { vec!["dense"] }; - - trajectory.push( - "vector_search", - serde_json::json!({ - "raw_points": scored.len(), - "sparse_mode": sparse_mode.as_str(), - "channels": channels, - }), - ); - } - - /// Resolves and verifies an excerpt window from quote, position, or chunk selectors. - pub async fn docs_excerpts_get( - &self, - req: DocsExcerptsGetRequest, - ) -> Result { - let explain = req.explain.unwrap_or(false); - let trace_id = Uuid::new_v4(); - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - let read_profile = req.read_profile.trim(); - let mut trajectory = DocTrajectoryBuilder::new(explain); - - trajectory.push( - "request_validation", - serde_json::json!({ - "doc_id": req.doc_id, - "read_profile": read_profile, - }), - ); - - validate_docs_excerpts_get( - tenant_id, - project_id, - agent_id, - read_profile, - req.quote.as_ref(), - )?; - - let doc = load_docs_excerpt_context( - &self.cfg, - &self.db.pool, - tenant_id, - project_id, - agent_id, - read_profile, - req.doc_id, - ) - .await?; - let level_max = excerpt_level_max(req.level.as_str())?; - - trajectory.push( - "level_selection", - serde_json::json!({ - "level": req.level, - "max_bytes": level_max, - }), - ); - - let mut verified = true; - let mut verification_errors = Vec::new(); - let DocExcerptRange { - selector_kind, - match_start_offset, - match_end_offset, - start_offset, - end_offset, - } = docs_excerpts_resolve_windowed_match( - &self.db.pool, - &doc, - &req, - level_max, - &mut trajectory, - &mut verified, - &mut verification_errors, - ) - .await?; - let excerpt = doc.content.get(start_offset..end_offset).unwrap_or("").to_string(); - - if excerpt.is_empty() { - verified = false; - - verification_errors.push("EMPTY_EXCERPT".to_string()); - } - - let excerpt_hash = blake3::hash(excerpt.as_bytes()).to_hex().to_string(); - - trajectory.push( - "verification", - serde_json::json!({ - "verified": verified, - "error_count": verification_errors.len(), - }), - ); - - Ok(DocsExcerptResponse { - trace_id, - doc_id: doc.doc_id, - excerpt, - start_offset, - end_offset, - locator: docs_excerpt_locator( - &req, - &selector_kind, - match_start_offset, - match_end_offset, - doc.content_hash.as_str(), - ), - verification: DocsExcerptVerification { - verified, - verification_errors, - content_hash: doc.content_hash.clone(), - excerpt_hash, - }, - trajectory: trajectory.into_trajectory(), - }) - } -} - -#[derive(Clone, Copy, Debug)] -enum DocsSparseMode { - Auto, - On, - Off, -} -impl DocsSparseMode { - fn as_str(self) -> &'static str { - match self { - Self::Auto => "auto", - Self::On => "on", - Self::Off => "off", - } - } -} - -#[derive(Clone, Copy)] -enum ExcerptsSelectorKind { - ChunkId, - Quote, - Position, -} -impl ExcerptsSelectorKind { - fn as_str(&self) -> &'static str { - match self { - Self::ChunkId => "chunk_id", - Self::Quote => "quote", - Self::Position => "position", - } - } - - fn span_kind(&self) -> &'static str { - match self { - Self::ChunkId => "captured", - Self::Quote => "quote", - Self::Position => "position", - } - } -} - -fn docs_search_l0_deduplicated_chunks( - scored: &[ScoredPoint], - candidate_k: usize, -) -> Result> { - let mut seen = HashSet::new(); - let mut chunks = Vec::new(); - - for point in scored.iter().take(candidate_k) { - let chunk_id = parse_scored_point_uuid_id(point)?; - - if seen.insert(chunk_id) { - chunks.push((chunk_id, point.score)); - } - } - - Ok(chunks) -} - -fn docs_search_l0_project_items( - scored_chunks: &[(Uuid, f32)], - rows: &HashMap, - caller_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, -) -> Vec { - let mut items = Vec::with_capacity(scored_chunks.len()); - - for (chunk_id, score) in scored_chunks { - let Some(row) = rows.get(chunk_id) else { continue }; - - if !doc_read_allowed( - caller_agent_id, - allowed_scopes, - shared_grants, - row.agent_id.as_str(), - row.scope.as_str(), - ) { - continue; - } - - items.push(DocsSearchL0Item { - doc_id: row.doc_id, - chunk_id: *chunk_id, - pointer: build_docs_l0_pointer(row, *chunk_id), - score: *score, - snippet: truncate_bytes(row.chunk_text.as_str(), DEFAULT_L0_MAX_BYTES), - scope: row.scope.clone(), - doc_type: row.doc_type.clone(), - project_id: row.project_id.clone(), - agent_id: row.agent_id.clone(), - updated_at: row.updated_at, - content_hash: row.content_hash.clone(), - chunk_hash: row.chunk_hash.clone(), - }); - } - - items -} - -fn apply_doc_recency_boost( - items: &mut [DocsSearchL0Item], - now: OffsetDateTime, - recency_tau_days: f32, - tie_breaker_weight: f32, -) { - if tie_breaker_weight <= 0.0 || items.is_empty() { - return; - } - - for item in items.iter_mut() { - let age_days = ((now - item.updated_at).as_seconds_f32() / 86_400.0).max(0.0); - let recency_decay = - if recency_tau_days > 0.0 { (-age_days / recency_tau_days).exp() } else { 1.0 }; - - item.score += tie_breaker_weight * recency_decay; - } -} - -fn record_result_projection_stage( - trajectory: &mut DocTrajectoryBuilder, - pre_authorization_candidates: usize, - returned_items: usize, - recency_tau_days: f32, - tie_breaker_weight: f32, -) { - trajectory.push( - "result_projection", - serde_json::json!({ - "pre_authorization_candidates": pre_authorization_candidates, - "returned_items": returned_items, - "recency_tau_days": recency_tau_days, - "tie_breaker_weight": tie_breaker_weight, - "recency_boost_applied": tie_breaker_weight > 0.0 && !pre_authorization_candidates.eq(&0), - }), - ) -} - -fn docs_excerpt_locator( - req: &DocsExcerptsGetRequest, - selector_kind: &ExcerptsSelectorKind, - match_start_offset: usize, - match_end_offset: usize, - content_hash: &str, -) -> DocsExcerptLocator { - DocsExcerptLocator { - span_id: source_span_id( - content_hash, - match_start_offset, - match_end_offset, - selector_kind.span_kind(), - ), - selector_kind: selector_kind.as_str().to_string(), - match_start_offset, - match_end_offset, - chunk_id: req.chunk_id, - quote: req.quote.clone(), - position: req.position.clone(), - } -} - -fn build_docs_l0_pointer(row: &DocSearchRow, chunk_id: Uuid) -> DocsSearchL0ItemPointer { - let hashes = DocsSearchL0ItemHashes { - content_hash: row.content_hash.clone(), - chunk_hash: row.chunk_hash.clone(), - }; - - DocsSearchL0ItemPointer { - schema: DOC_SOURCE_REF_SCHEMA_V1.to_string(), - resolver: DOC_SOURCE_REF_RESOLVER_V1.to_string(), - reference: DocsSearchL0ItemReference { - doc_id: row.doc_id, - chunk_id, - source_record_id: row.doc_id, - source_span_id: source_span_id( - row.content_hash.as_str(), - row.start_offset.max(0) as usize, - row.end_offset.max(0) as usize, - "captured", - ), - }, - state: DocsSearchL0ItemState { - content_hash: hashes.content_hash.clone(), - chunk_hash: hashes.chunk_hash.clone(), - doc_updated_at: row.updated_at, - }, - hashes, - locator: DocsSearchL0ItemLocator { - span_id: source_span_id( - row.content_hash.as_str(), - row.start_offset.max(0) as usize, - row.end_offset.max(0) as usize, - "captured", - ), - position: TextPositionSelector { - start: row.start_offset.max(0) as usize, - end: row.end_offset.max(0) as usize, - }, - }, - } -} - -fn build_doc_chunk_rows(doc_id: Uuid, chunks: &[ByteChunk], now: OffsetDateTime) -> Vec { - chunks - .iter() - .enumerate() - .map(|(chunk_index, chunk)| DocChunk { - chunk_id: doc_chunk_id_for(doc_id, chunk_index as i32), - doc_id, - chunk_index: chunk_index as i32, - start_offset: chunk.start_offset as i32, - end_offset: chunk.end_offset as i32, - chunk_text: chunk.text.clone(), - chunk_hash: blake3::hash(chunk.text.as_bytes()).to_hex().to_string(), - created_at: now, - }) - .collect() -} - -fn doc_chunk_id_for(doc_id: Uuid, chunk_index: i32) -> Uuid { - let name = format!("elf-doc-chunk/v1:{doc_id}:{chunk_index}"); - - Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) -} - -fn source_record_id_for( - tenant_id: &str, - project_id: &str, - agent_id: &str, - scope: &str, - doc_type: DocType, - source_ref: &Map, - content_hash: &str, -) -> Uuid { - let name = serde_json::json!([ - "elf-doc-source-record/v1", - tenant_id, - project_id, - agent_id, - scope, - doc_type.as_str(), - source_identity_value(source_ref, doc_type), - content_hash, - ]) - .to_string(); - - Uuid::new_v5(&Uuid::NAMESPACE_URL, name.as_bytes()) -} - -fn source_span_id(content_hash: &str, start: usize, end: usize, span_kind: &str) -> Uuid { - let name = serde_json::json!(["elf-doc-source-span/v1", content_hash, start, end, span_kind,]) - .to_string(); - - Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) -} - -fn build_source_capture_summary( - input: SourceCaptureSummaryInput<'_>, -) -> Result { - let SourceCaptureSummaryInput { - doc_id, - source_ref, - doc_type, - scope, - title, - content_hash, - raw_content_hash, - now, - chunks, - write_policy_audit, - } = input; - let captured_at = source_ref - .get("captured_at") - .and_then(Value::as_str) - .map(ToString::to_string) - .unwrap_or(format_timestamp(now)?); - let source_spans = chunks - .iter() - .map(|chunk| DocsSourceSpanRef { - schema: DOC_SOURCE_SPAN_SCHEMA_V1.to_string(), - span_id: source_span_id( - content_hash, - chunk.start_offset.max(0) as usize, - chunk.end_offset.max(0) as usize, - "captured", - ), - chunk_id: Some(chunk.chunk_id), - status: "captured".to_string(), - reason_code: None, - start_offset: chunk.start_offset.max(0) as usize, - end_offset: chunk.end_offset.max(0) as usize, - content_hash: content_hash.to_string(), - chunk_hash: Some(chunk.chunk_hash.clone()), - }) - .collect(); - let policy_spans = source_policy_spans(raw_content_hash, write_policy_audit); - - Ok(DocsSourceCaptureSummary { - schema: DOC_SOURCE_CAPTURE_SCHEMA_V1.to_string(), - source_record_id: doc_id, - origin: source_origin(source_ref, doc_type), - captured_at, - content_hash: content_hash.to_string(), - visibility_scope: scope.to_string(), - title: title.map(ToString::to_string), - source_type: source_type(source_ref, doc_type), - source_spans, - policy_spans, - }) -} - -fn source_policy_spans( - raw_content_hash: &str, - audit: Option<&WritePolicyAudit>, -) -> Vec { - let Some(audit) = audit else { - return Vec::new(); - }; - let mut spans = Vec::with_capacity(audit.exclusions.len() + audit.redactions.len()); - - for span in &audit.exclusions { - spans.push(policy_span_ref( - raw_content_hash, - span.start, - span.end, - "excluded", - "WRITE_POLICY_EXCLUSION", - )); - } - for redaction in &audit.redactions { - spans.push(policy_span_ref( - raw_content_hash, - redaction.span.start, - redaction.span.end, - "redacted", - "WRITE_POLICY_REDACTION", - )); - } - - spans -} - -fn policy_span_ref( - content_hash: &str, - start: usize, - end: usize, - status: &str, - reason_code: &str, -) -> DocsSourceSpanRef { - DocsSourceSpanRef { - schema: DOC_SOURCE_SPAN_SCHEMA_V1.to_string(), - span_id: source_span_id(content_hash, start, end, reason_code), - chunk_id: None, - status: status.to_string(), - reason_code: Some(reason_code.to_string()), - start_offset: start, - end_offset: end, - content_hash: content_hash.to_string(), - chunk_hash: None, - } -} - -fn normalize_source_ref_for_capture( - source_ref: Value, - source_capture: &DocsSourceCaptureSummary, -) -> Result { - let mut source_ref = source_ref.as_object().cloned().ok_or_else(|| Error::InvalidRequest { - message: "source_ref must be a JSON object.".to_string(), - })?; - - source_ref.insert( - "source_record_id".to_string(), - Value::String(source_capture.source_record_id.to_string()), - ); - source_ref.insert("origin".to_string(), Value::String(source_capture.origin.clone())); - source_ref.insert("captured_at".to_string(), Value::String(source_capture.captured_at.clone())); - source_ref - .insert("content_hash".to_string(), Value::String(source_capture.content_hash.clone())); - source_ref.insert( - "visibility_scope".to_string(), - Value::String(source_capture.visibility_scope.clone()), - ); - - if let Some(title) = source_capture.title.as_ref() { - source_ref.entry("title".to_string()).or_insert_with(|| Value::String(title.clone())); - } - - source_ref.insert("source_type".to_string(), Value::String(source_capture.source_type.clone())); - source_ref - .insert("source_spans".to_string(), source_spans_to_value(&source_capture.source_spans)?); - - if !source_capture.policy_spans.is_empty() { - source_ref.insert( - "policy_spans".to_string(), - source_spans_to_value(&source_capture.policy_spans)?, - ); - } - - Ok(Value::Object(source_ref)) -} - -fn source_spans_to_value(spans: &[DocsSourceSpanRef]) -> Result { - serde_json::to_value(spans).map_err(|err| Error::InvalidRequest { - message: format!("failed to encode source span metadata: {err}"), - }) -} - -fn source_type(source_ref: &Map, doc_type: DocType) -> String { - source_ref - .get("source_kind") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| doc_type.as_str()) - .to_string() -} - -fn source_origin(source_ref: &Map, doc_type: DocType) -> String { - if let Some(origin) = source_ref_string(source_ref, "canonical_uri") - .or_else(|| source_ref_string(source_ref, "url")) - .or_else(|| source_ref_string(source_ref, "uri")) - { - return origin.to_string(); - } - - match doc_type { - DocType::Chat => source_ref_string(source_ref, "message_id") - .map(|message_id| { - format!( - "thread:{}#{}", - source_ref_string(source_ref, "thread_id").unwrap_or("unknown"), - message_id - ) - }) - .unwrap_or_else(|| { - format!( - "thread:{}", - source_ref_string(source_ref, "thread_id").unwrap_or("unknown") - ) - }), - DocType::Search => source_ref_string(source_ref, "domain") - .map(|domain| format!("search:{domain}")) - .unwrap_or_else(|| "search:unknown".to_string()), - DocType::Dev => dev_origin(source_ref), - DocType::Knowledge => source_ref_string(source_ref, "ts") - .map(|ts| format!("knowledge:{ts}")) - .unwrap_or_else(|| "knowledge:unknown".to_string()), - } -} - -fn dev_origin(source_ref: &Map) -> String { - let repo = source_ref_string(source_ref, "repo").unwrap_or("unknown"); - let path = source_ref_string(source_ref, "path").unwrap_or(""); - let revision = source_ref_string(source_ref, "commit_sha") - .map(|commit| format!("@{commit}")) - .or_else(|| source_ref_i64(source_ref, "pr_number").map(|pr| format!("#pr-{pr}"))) - .or_else(|| { - source_ref_i64(source_ref, "issue_number").map(|issue| format!("#issue-{issue}")) - }) - .unwrap_or_default(); - - if path.is_empty() { - format!("repo:{repo}{revision}") - } else { - format!("repo:{repo}/{path}{revision}") - } -} - -fn source_identity_value(source_ref: &Map, doc_type: DocType) -> Value { - if let Some(canonical_uri) = source_ref_string(source_ref, "canonical_uri") { - return serde_json::json!(["canonical_uri", canonical_uri]); - } - - match doc_type { - DocType::Chat => serde_json::json!([ - "chat", - source_ref_string(source_ref, "thread_id"), - source_ref_string(source_ref, "message_id"), - source_ref_string(source_ref, "role"), - source_ref_string(source_ref, "ts"), - ]), - DocType::Search => serde_json::json!([ - "search", - source_ref_string(source_ref, "url"), - source_ref_string(source_ref, "domain"), - source_ref_string(source_ref, "query"), - source_ref_string(source_ref, "ts"), - ]), - DocType::Dev => serde_json::json!([ - "dev", - source_ref_string(source_ref, "repo"), - source_ref_string(source_ref, "path"), - source_ref_string(source_ref, "commit_sha"), - source_ref_i64(source_ref, "pr_number"), - source_ref_i64(source_ref, "issue_number"), - ]), - DocType::Knowledge => serde_json::json!([ - "knowledge", - source_ref_string(source_ref, "uri"), - source_ref_string(source_ref, "ts"), - ]), - } -} - -fn source_ref_string<'a>(source_ref: &'a Map, key: &str) -> Option<&'a str> { - source_ref.get(key).and_then(Value::as_str).filter(|value| !value.trim().is_empty()) -} - -fn source_ref_i64(source_ref: &Map, key: &str) -> Option { - source_ref.get(key).and_then(Value::as_i64) -} - -fn format_timestamp(ts: OffsetDateTime) -> Result { - ts.format(&Rfc3339).map_err(|err| Error::InvalidRequest { - message: format!("failed to format RFC3339 timestamp: {err}"), - }) -} - -fn resolve_doc_chunking_profile(doc_type: DocType) -> DocChunkingProfile { - match doc_type { - DocType::Chat | DocType::Search => DocChunkingProfile { - max_tokens: 1_024, - overlap_tokens: 128, - max_chunks: DEFAULT_MAX_CHUNKS_PER_DOC, - }, - DocType::Knowledge | DocType::Dev => DocChunkingProfile { - max_tokens: 2_048, - overlap_tokens: 256, - max_chunks: DEFAULT_MAX_CHUNKS_PER_DOC, - }, - } -} - -fn validate_docs_excerpts_get( - tenant_id: &str, - project_id: &str, - agent_id: &str, - read_profile: &str, - quote: Option<&TextQuoteSelector>, -) -> Result<()> { - if tenant_id.is_empty() - || project_id.is_empty() - || agent_id.is_empty() - || read_profile.is_empty() - { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, agent_id, and read_profile are required.".to_string(), - }); - } - - if let Some(quote) = quote { - validate_quote_selector_english(quote)?; - } - - Ok(()) -} - -fn validate_quote_selector_english(quote: &TextQuoteSelector) -> Result<()> { - if !english_gate::is_english_natural_language(quote.exact.as_str()) { - return Err(Error::NonEnglishInput { field: "$.quote.exact".to_string() }); - } - - if let Some(prefix) = quote.prefix.as_ref() - && !english_gate::is_english_natural_language(prefix.as_str()) - { - return Err(Error::NonEnglishInput { field: "$.quote.prefix".to_string() }); - } - if let Some(suffix) = quote.suffix.as_ref() - && !english_gate::is_english_natural_language(suffix.as_str()) - { - return Err(Error::NonEnglishInput { field: "$.quote.suffix".to_string() }); - } - - Ok(()) -} - -fn excerpt_level_max(level: &str) -> Result { - match level { - "L0" => Ok(DEFAULT_L0_MAX_BYTES), - "L1" => Ok(DEFAULT_L1_MAX_BYTES), - "L2" => Ok(DEFAULT_L2_MAX_BYTES), - _ => Err(Error::InvalidRequest { message: "level must be L0, L1, or L2.".to_string() }), - } -} - -fn validate_docs_put(req: &DocsPutRequest) -> Result { - if req.content.trim().is_empty() { - return Err(Error::InvalidRequest { message: "content must be non-empty.".to_string() }); - } - if req.scope.trim().is_empty() { - return Err(Error::InvalidRequest { message: "scope must be non-empty.".to_string() }); - } - if !matches!(req.scope.as_str(), "agent_private" | "project_shared" | "org_shared") { - return Err(Error::InvalidRequest { message: "Unknown scope.".to_string() }); - } - - let source_ref = req.source_ref.as_object().ok_or_else(|| Error::InvalidRequest { - message: "source_ref must be a JSON object.".to_string(), - })?; - let source_ref_doc_type = - extract_source_ref_string(source_ref, "doc_type", "$.source_ref[\"doc_type\"]")?; - let source_ref_doc_type = DocType::parse(&source_ref_doc_type)?; - let source_ref_schema = - extract_source_ref_string(source_ref, "schema", "$.source_ref[\"schema\"]")?; - - if source_ref_schema != "doc_source_ref/v1" { - return Err(Error::InvalidRequest { - message: "source_ref.schema must be 'doc_source_ref/v1'.".to_string(), - }); - } - - let ts = extract_source_ref_string(source_ref, "ts", "$.source_ref[\"ts\"]")?; - - OffsetDateTime::parse(ts.as_str(), &Rfc3339).map_err(|_| Error::InvalidRequest { - message: "$.source_ref[\"ts\"] must be an RFC3339 datetime string.".to_string(), - })?; - - let doc_type = if let Some(doc_type) = req.doc_type.as_ref() { - let doc_type = DocType::parse(doc_type.as_str())?; - - if doc_type != source_ref_doc_type { - return Err(Error::InvalidRequest { - message: "doc_type must match source_ref.doc_type.".to_string(), - }); - } - - doc_type - } else { - source_ref_doc_type - }; - - validate_doc_source_ref_requirements(source_ref_doc_type.as_str(), source_ref)?; - validate_source_library_metadata(source_ref_doc_type.as_str(), source_ref)?; - - let write_policy = - writegate::apply_write_policy(req.content.as_str(), req.write_policy.as_ref()).map_err( - |err| Error::InvalidRequest { message: format!("write_policy is invalid: {err:?}") }, - )?; - let write_policy_audit = - if req.write_policy.is_some() { Some(write_policy.audit) } else { None }; - let content = write_policy.transformed; - - if content.trim().is_empty() { - return Err(Error::InvalidRequest { message: "content must be non-empty.".to_string() }); - } - if content.len() > DEFAULT_DOC_MAX_BYTES { - return Err(Error::InvalidRequest { - message: "content exceeds max_doc_bytes.".to_string(), - }); - } - if writegate::contains_secrets(content.as_str()) { - return Err(Error::InvalidRequest { message: "content contains secrets.".to_string() }); - } - - if let Some(found) = find_non_english_path(&req.source_ref, "$.source_ref") { - return Err(Error::NonEnglishInput { field: found }); - } - - if !english_gate::is_english_natural_language(content.as_str()) { - return Err(Error::NonEnglishInput { field: "$.content".to_string() }); - } - - if let Some(title) = req.title.as_ref() - && !english_gate::is_english_natural_language(title.as_str()) - { - return Err(Error::NonEnglishInput { field: "$.title".to_string() }); - } - - Ok(ValidatedDocsPut { doc_type, content, write_policy_audit }) -} - -fn extract_source_ref_string( - source_ref: &Map, - key: &str, - path: &str, -) -> Result { - source_ref - .get(key) - .and_then(Value::as_str) - .map(|text| text.trim().to_string()) - .filter(|text| !text.is_empty()) - .ok_or_else(|| Error::InvalidRequest { message: format!("{path} is required.") }) -} - -fn validate_doc_source_ref_requirements( - source_doc_type: &str, - source_ref: &Map, -) -> Result<()> { - match source_doc_type { - "chat" => { - extract_source_ref_string(source_ref, "thread_id", "$.source_ref[\"thread_id\"]")?; - extract_source_ref_string(source_ref, "role", "$.source_ref[\"role\"]")?; - }, - "search" => { - extract_source_ref_string(source_ref, "query", "$.source_ref[\"query\"]")?; - extract_source_ref_string(source_ref, "url", "$.source_ref[\"url\"]")?; - extract_source_ref_string(source_ref, "domain", "$.source_ref[\"domain\"]")?; - }, - "dev" => { - extract_source_ref_string(source_ref, "repo", "$.source_ref[\"repo\"]")?; - - let commit_sha_present = source_ref - .get("commit_sha") - .and_then(Value::as_str) - .is_some_and(|value| !value.trim().is_empty()); - let pr_number_present = source_ref - .get("pr_number") - .is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some()); - let issue_number_present = source_ref - .get("issue_number") - .is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some()); - let present_count = - commit_sha_present as u8 + pr_number_present as u8 + issue_number_present as u8; - - if present_count != 1 { - return Err(Error::InvalidRequest { - message: - "For doc_type=dev, exactly one of commit_sha, pr_number, or issue_number is required." - .to_string(), - }); - } - }, - "knowledge" => {}, - _ => unreachable!(), - } - - Ok(()) -} - -fn validate_source_library_metadata( - source_doc_type: &str, - source_ref: &Map, -) -> Result<()> { - if !source_library_metadata_present(source_ref) { - return Ok(()); - } - - let source_kind = - extract_source_ref_string(source_ref, "source_kind", "$.source_ref[\"source_kind\"]")?; - - if !SOURCE_LIBRARY_KINDS.contains(&source_kind.as_str()) { - return Err(Error::InvalidRequest { - message: format!( - "$.source_ref[\"source_kind\"] must be one of: {}.", - SOURCE_LIBRARY_KINDS.join("|") - ), - }); - } - - validate_source_kind_doc_type(source_kind.as_str(), source_doc_type)?; - extract_source_ref_string(source_ref, "canonical_uri", "$.source_ref[\"canonical_uri\"]")?; - validate_source_ref_rfc3339(source_ref, "captured_at")?; - - if source_ref.contains_key("source_created_at") { - validate_source_ref_rfc3339(source_ref, "source_created_at")?; - } - - let trust_label = - extract_source_ref_string(source_ref, "trust_label", "$.source_ref[\"trust_label\"]")?; - - if !SOURCE_LIBRARY_TRUST_LABELS.contains(&trust_label.as_str()) { - return Err(Error::InvalidRequest { - message: format!( - "$.source_ref[\"trust_label\"] must be one of: {}.", - SOURCE_LIBRARY_TRUST_LABELS.join("|") - ), - }); - } - - validate_optional_source_ref_string(source_ref, "author")?; - validate_optional_source_ref_string(source_ref, "handle")?; - validate_optional_source_ref_string(source_ref, "source_content_hash")?; - - if let Some(locator) = source_ref.get("excerpt_locator") { - validate_source_library_excerpt_locator(locator)?; - } - - Ok(()) -} - -fn source_library_metadata_present(source_ref: &Map) -> bool { - SOURCE_LIBRARY_FIELD_KEYS.iter().any(|key| source_ref.contains_key(*key)) -} - -fn validate_source_kind_doc_type(source_kind: &str, source_doc_type: &str) -> Result<()> { - let expected_doc_type = match source_kind { - "social_thread" | "chat_excerpt" => Some("chat"), - "repo_file" => Some("dev"), - _ => None, - }; - - if let Some(expected_doc_type) = expected_doc_type - && source_doc_type != expected_doc_type - { - return Err(Error::InvalidRequest { - message: format!( - "$.source_ref[\"source_kind\"]={source_kind} requires doc_type={expected_doc_type}." - ), - }); - } - - Ok(()) -} - -fn validate_source_ref_rfc3339(source_ref: &Map, key: &str) -> Result<()> { - let path = format!("$.source_ref[\"{key}\"]"); - let value = extract_source_ref_string(source_ref, key, path.as_str())?; - - OffsetDateTime::parse(value.as_str(), &Rfc3339).map_err(|_| Error::InvalidRequest { - message: format!("{path} must be an RFC3339 datetime string."), - })?; - - Ok(()) -} - -fn validate_optional_source_ref_string(source_ref: &Map, key: &str) -> Result<()> { - let path = format!("$.source_ref[\"{key}\"]"); - - validate_optional_source_ref_string_at(source_ref, key, path.as_str()) -} - -fn validate_optional_source_ref_string_at( - source_ref: &Map, - key: &str, - path: &str, -) -> Result<()> { - let Some(value) = source_ref.get(key) else { - return Ok(()); - }; - - value.as_str().map(str::trim).filter(|value| !value.is_empty()).ok_or_else(|| { - Error::InvalidRequest { message: format!("{path} must be a non-empty string.") } - })?; - - Ok(()) -} - -fn validate_source_library_excerpt_locator(locator: &Value) -> Result<()> { - let locator = locator.as_object().ok_or_else(|| Error::InvalidRequest { - message: "$.source_ref[\"excerpt_locator\"] must be a JSON object.".to_string(), - })?; - let has_quote = locator.contains_key("quote"); - let has_position = locator.contains_key("position"); - - if !has_quote && !has_position { - return Err(Error::InvalidRequest { - message: "$.source_ref[\"excerpt_locator\"] requires quote or position.".to_string(), - }); - } - - if let Some(quote) = locator.get("quote") { - validate_source_library_quote_locator(quote)?; - } - if let Some(position) = locator.get("position") { - validate_source_library_position_locator(position)?; - } - - Ok(()) -} - -fn validate_source_library_quote_locator(quote: &Value) -> Result<()> { - let quote = quote.as_object().ok_or_else(|| Error::InvalidRequest { - message: "$.source_ref[\"excerpt_locator\"][\"quote\"] must be a JSON object.".to_string(), - })?; - - extract_source_ref_string( - quote, - "exact", - "$.source_ref[\"excerpt_locator\"][\"quote\"][\"exact\"]", - )?; - validate_optional_source_ref_string_at( - quote, - "prefix", - "$.source_ref[\"excerpt_locator\"][\"quote\"][\"prefix\"]", - )?; - validate_optional_source_ref_string_at( - quote, - "suffix", - "$.source_ref[\"excerpt_locator\"][\"quote\"][\"suffix\"]", - )?; - - Ok(()) -} - -fn validate_source_library_position_locator(position: &Value) -> Result<()> { - let position = position.as_object().ok_or_else(|| Error::InvalidRequest { - message: "$.source_ref[\"excerpt_locator\"][\"position\"] must be a JSON object." - .to_string(), - })?; - let start = source_ref_u64( - position, - "start", - "$.source_ref[\"excerpt_locator\"][\"position\"][\"start\"]", - )?; - let end = source_ref_u64( - position, - "end", - "$.source_ref[\"excerpt_locator\"][\"position\"][\"end\"]", - )?; - - if start >= end { - return Err(Error::InvalidRequest { - message: "$.source_ref[\"excerpt_locator\"][\"position\"] start must be before end." - .to_string(), - }); - } - - Ok(()) -} - -fn source_ref_u64(source_ref: &Map, key: &str, path: &str) -> Result { - source_ref - .get(key) - .and_then(Value::as_u64) - .ok_or_else(|| Error::InvalidRequest { message: format!("{path} must be an integer.") }) -} - -fn validate_docs_search_l0(req: &DocsSearchL0Request) -> Result { - validate_docs_search_l0_query(req)?; - - let filters = parse_docs_search_l0_filters(req)?; - let ranges = parse_docs_search_l0_ranges(req)?; - - validate_docs_search_l0_temporal_ranges( - ranges.updated_after.as_ref(), - ranges.updated_before.as_ref(), - ranges.ts_gte.as_ref(), - ranges.ts_lte.as_ref(), - )?; - - Ok(DocsSearchL0Filters { - scope: filters.scope, - status: filters.status, - doc_type: filters.doc_type, - sparse_mode: filters.sparse_mode, - domain: filters.domain, - repo: filters.repo, - agent_id: filters.agent_id, - thread_id: filters.thread_id, - updated_after: ranges.updated_after, - updated_before: ranges.updated_before, - ts_gte: ranges.ts_gte, - ts_lte: ranges.ts_lte, - }) -} - -fn validate_docs_search_l0_query(req: &DocsSearchL0Request) -> Result<()> { - if req.query.trim().is_empty() { - return Err(Error::InvalidRequest { message: "query must be non-empty.".to_string() }); - } - if !english_gate::is_english_natural_language(req.query.as_str()) { - return Err(Error::NonEnglishInput { field: "$.query".to_string() }); - } - - Ok(()) -} - -fn parse_docs_search_l0_filters(req: &DocsSearchL0Request) -> Result { - let scope = if let Some(scope) = req.scope.as_ref() { - let scope = scope.trim(); - - if scope.is_empty() { - return Err(Error::InvalidRequest { message: "scope must be non-empty.".to_string() }); - } - if !matches!(scope, "agent_private" | "project_shared" | "org_shared") { - return Err(Error::InvalidRequest { message: "Unknown scope.".to_string() }); - } - - Some(scope.to_string()) - } else { - None - }; - let status = req - .status - .as_ref() - .map(|status| status.trim().to_string()) - .filter(|status| !status.is_empty()) - .unwrap_or_else(|| "active".to_string()) - .to_lowercase(); - let status = if DOC_STATUSES.contains(&status.as_str()) { - status - } else { - return Err(Error::InvalidRequest { - message: "status must be one of: active|deleted.".to_string(), - }); - }; - let sparse_mode = parse_sparse_mode(req.sparse_mode.as_ref())?; - let doc_type = if let Some(doc_type) = req.doc_type.as_ref() { - let doc_type = doc_type.trim(); - - if doc_type.is_empty() { - return Err(Error::InvalidRequest { - message: "doc_type must be non-empty.".to_string(), - }); - } - - Some(DocType::parse(doc_type)?) - } else { - None - }; - let domain = req - .domain - .as_ref() - .map(|domain| domain.trim().to_string()) - .filter(|domain| !domain.is_empty()); - let repo = - req.repo.as_ref().map(|repo| repo.trim().to_string()).filter(|repo| !repo.is_empty()); - - if domain.is_some() && doc_type != Some(DocType::Search) { - return Err(Error::InvalidRequest { - message: "domain requires doc_type=search.".to_string(), - }); - } - if repo.is_some() && doc_type != Some(DocType::Dev) { - return Err(Error::InvalidRequest { message: "repo requires doc_type=dev.".to_string() }); - } - - let agent_id = req - .agent_id - .as_ref() - .map(|agent_id| agent_id.trim().to_string()) - .filter(|agent_id| !agent_id.is_empty()); - let thread_id = req - .thread_id - .as_ref() - .map(|thread_id| thread_id.trim().to_string()) - .filter(|thread_id| !thread_id.is_empty()); - - if thread_id.is_some() && doc_type != Some(DocType::Chat) { - return Err(Error::InvalidRequest { - message: "thread_id requires doc_type=chat.".to_string(), - }); - } - - Ok(DocsSearchL0FiltersParsed { - scope, - status, - doc_type, - sparse_mode, - domain, - repo, - agent_id, - thread_id, - }) -} - -fn parse_docs_search_l0_ranges(req: &DocsSearchL0Request) -> Result { - let updated_after = parse_optional_rfc3339(req.updated_after.as_ref(), "$.updated_after")?; - let updated_before = parse_optional_rfc3339(req.updated_before.as_ref(), "$.updated_before")?; - let ts_gte = parse_optional_rfc3339(req.ts_gte.as_ref(), "$.ts_gte")?; - let ts_lte = parse_optional_rfc3339(req.ts_lte.as_ref(), "$.ts_lte")?; - - Ok(DocsSearchL0RangesParsed { updated_after, updated_before, ts_gte, ts_lte }) -} - -fn validate_docs_search_l0_temporal_ranges( - updated_after: Option<&OffsetDateTime>, - updated_before: Option<&OffsetDateTime>, - ts_gte: Option<&OffsetDateTime>, - ts_lte: Option<&OffsetDateTime>, -) -> Result<()> { - if let (Some(updated_after), Some(updated_before)) = (updated_after, updated_before) - && updated_after >= updated_before - { - return Err(Error::InvalidRequest { - message: "updated_after must be earlier than updated_before.".to_string(), - }); - } - if let (Some(ts_gte), Some(ts_lte)) = (ts_gte, ts_lte) - && ts_gte >= ts_lte - { - return Err(Error::InvalidRequest { - message: "ts_gte must be earlier than ts_lte.".to_string(), - }); - } - - Ok(()) -} - -fn parse_sparse_mode(raw: Option<&String>) -> Result { - let raw = raw.as_ref().map(|mode| mode.trim().to_lowercase()); - let Some(mode) = raw else { - return Ok(DocsSparseMode::Auto); - }; - let mode = mode.as_str(); - - match mode { - "auto" => Ok(DocsSparseMode::Auto), - "on" => Ok(DocsSparseMode::On), - "off" => Ok(DocsSparseMode::Off), - _ => Err(Error::InvalidRequest { - message: "sparse_mode must be one of: auto|on|off.".to_string(), - }), - } -} - -fn parse_optional_rfc3339(raw: Option<&String>, path: &str) -> Result> { - let Some(raw) = raw else { - return Ok(None); - }; - let raw = raw.trim(); - - if raw.is_empty() { - return Err(Error::InvalidRequest { message: format!("{path} must be non-empty.") }); - } - - OffsetDateTime::parse(raw, &Rfc3339).map(Some).map_err(|_| Error::InvalidRequest { - message: format!("{path} must be an RFC3339 datetime string."), - }) -} - -fn find_non_english_path(value: &Value, path: &str) -> Option { - find_non_english_path_inner(value, path, false) -} - -fn find_non_english_path_inner( - value: &Value, - path: &str, - is_identifier_lane: bool, -) -> Option { - fn has_english_gate(text: &str, is_identifier_lane: bool) -> bool { - if is_identifier_lane { - return english_gate::is_english_identifier(text); - } - - english_gate::is_english_natural_language(text) - } - - match value { - Value::String(text) => - if !has_english_gate(text, is_identifier_lane) { - Some(path.to_string()) - } else { - None - }, - Value::Array(items) => { - for (idx, item) in items.iter().enumerate() { - let child_path = format!("{path}[{idx}]"); - - if let Some(found) = - find_non_english_path_inner(item, &child_path, is_identifier_lane) - { - return Some(found); - } - } - - None - }, - Value::Object(map) => { - for (key, value) in map.iter() { - let identifier_lane = is_identifier_lane - || matches!(key.as_str(), "ref" | "schema" | "resolver" | "hashes" | "state"); - let child_path = format!("{path}[\"{}\"]", escape_json_path_key(key)); - - if let Some(found) = - find_non_english_path_inner(value, &child_path, identifier_lane) - { - return Some(found); - } - } - - None - }, - _ => None, - } -} - -fn escape_json_path_key(key: &str) -> String { - key.replace('\\', "\\\\").replace('"', "\\\"") -} - -fn load_tokenizer(cfg: &Config) -> Result { - let tokenizer_repo = cfg.chunking.tokenizer_repo.trim(); - - if tokenizer_repo.is_empty() { - return Err(Error::InvalidRequest { - message: "chunking.tokenizer_repo must be set.".to_string(), - }); - } - - elf_chunking::load_tokenizer(tokenizer_repo).map_err(|err| Error::InvalidRequest { - message: format!("failed to load tokenizer: {err}"), - }) -} - -fn split_tokens_by_offsets( - text: &str, - profile_max_tokens: usize, - profile_overlap_tokens: usize, - max_chunks: usize, - tokenizer: &Tokenizer, -) -> Result> { - if profile_max_tokens == 0 { - return Err(Error::InvalidRequest { - message: "max_tokens must be greater than zero.".to_string(), - }); - } - if profile_overlap_tokens >= profile_max_tokens { - return Err(Error::InvalidRequest { - message: "overlap_tokens must be less than max_tokens.".to_string(), - }); - } - - let encoding = tokenizer.encode(text, false).map_err(|err| Error::InvalidRequest { - message: format!("failed to tokenize content: {err}"), - })?; - let offsets = encoding.get_offsets(); - let mut chunks = Vec::new(); - - if offsets.is_empty() { - return Ok(Vec::new()); - } - - let mut chunk_start_token = 0_usize; - - while chunk_start_token < offsets.len() { - let chunk_end_token = (chunk_start_token + profile_max_tokens).min(offsets.len()); - let (start_offset, end_offset) = { - let (start, _) = offsets[chunk_start_token]; - let (_, end) = offsets[chunk_end_token.saturating_sub(1)]; - - (start, end) - }; - let chunk_text = - text.get(start_offset..end_offset).ok_or_else(|| Error::InvalidRequest { - message: "computed chunk offset is invalid UTF-8 boundary.".to_string(), - })?; - - chunks.push(ByteChunk { - chunk_id: Uuid::new_v4(), - start_offset, - end_offset, - text: chunk_text.to_string(), - }); - - if chunk_end_token >= offsets.len() { - break; - } - if chunks.len() >= max_chunks { - return Err(Error::InvalidRequest { - message: "doc exceeds max_chunks_per_doc.".to_string(), - }); - } - - chunk_start_token = chunk_end_token.saturating_sub(profile_overlap_tokens); - } - - Ok(chunks) -} - -fn build_doc_search_filter( - tenant_id: &str, - project_id: &str, - caller_agent_id: &str, - allowed_scopes: &[String], - filters: &DocsSearchL0Filters, -) -> Filter { - let private_scope = "agent_private".to_string(); - let non_private_scopes: Vec = - allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); - let mut scope_should_conditions = Vec::new(); - - if allowed_scopes.iter().any(|scope| scope == "agent_private") { - let private_filter = Filter::all([ - Condition::matches("scope", private_scope), - Condition::matches("agent_id", caller_agent_id.to_string()), - ]); - - scope_should_conditions.push(Condition::from(private_filter)); - } - if !non_private_scopes.is_empty() { - scope_should_conditions.push(Condition::matches("scope", non_private_scopes)); - } - - let scope_min_should = if scope_should_conditions.is_empty() { - None - } else { - Some(MinShould { min_count: 1, conditions: scope_should_conditions }) - }; - let mut project_or_org_branches = vec![Condition::from(Filter { - must: vec![Condition::matches("project_id", project_id.to_string())], - should: Vec::new(), - must_not: Vec::new(), - min_should: scope_min_should, - })]; - - if allowed_scopes.iter().any(|scope| scope == "org_shared") { - let org_filter = Filter::all([ - Condition::matches("project_id", ORG_PROJECT_ID.to_string()), - Condition::matches("scope", "org_shared".to_string()), - ]); - - project_or_org_branches.push(Condition::from(org_filter)); - } - - Filter { - must: { - let mut must = vec![ - Condition::matches("tenant_id", tenant_id.to_string()), - Condition::matches("status", filters.status.clone()), - ]; - - if let Some(scope) = filters.scope.as_ref() { - must.push(Condition::matches("scope", scope.to_string())); - } - if let Some(doc_type) = filters.doc_type.as_ref() { - must.push(Condition::matches("doc_type", doc_type.as_str().to_string())); - } - if let Some(domain) = filters.domain.as_ref() { - must.push(Condition::matches("domain", domain.to_string())); - } - if let Some(repo) = filters.repo.as_ref() { - must.push(Condition::matches("repo", repo.to_string())); - } - if let Some(agent_id) = filters.agent_id.as_ref() { - must.push(Condition::matches("agent_id", agent_id.to_string())); - } - if let Some(thread_id) = filters.thread_id.as_ref() { - must.push(Condition::matches("thread_id", thread_id.to_string())); - } - if let Some(datetime_filter) = datetime_filter_range( - filters.updated_after.as_ref(), - filters.updated_before.as_ref(), - ) { - must.push(datetime_filter); - } - if let Some(datetime_filter) = - doc_ts_filter_range(filters.ts_gte.as_ref(), filters.ts_lte.as_ref()) - { - must.push(datetime_filter); - } - - must - }, - should: Vec::new(), - must_not: Vec::new(), - min_should: Some(MinShould { min_count: 1, conditions: project_or_org_branches }), - } -} - -fn datetime_filter_range( - updated_after: Option<&OffsetDateTime>, - updated_before: Option<&OffsetDateTime>, -) -> Option { - let gt = updated_after.map(|updated_after| Timestamp { - seconds: updated_after.unix_timestamp(), - nanos: updated_after.nanosecond() as i32, - }); - let lt = updated_before.map(|updated_before| Timestamp { - seconds: updated_before.unix_timestamp(), - nanos: updated_before.nanosecond() as i32, - }); - - if gt.is_none() && lt.is_none() { - return None; - } - - Some(Condition::datetime_range("updated_at", DatetimeRange { lt, gt, gte: None, lte: None })) -} - -fn doc_ts_filter_range( - ts_gte: Option<&OffsetDateTime>, - ts_lte: Option<&OffsetDateTime>, -) -> Option { - let gte = ts_gte.map(|ts_gte| Timestamp { - seconds: ts_gte.unix_timestamp(), - nanos: ts_gte.nanosecond() as i32, - }); - let lte = ts_lte.map(|ts_lte| Timestamp { - seconds: ts_lte.unix_timestamp(), - nanos: ts_lte.nanosecond() as i32, - }); - - if gte.is_none() && lte.is_none() { - return None; - } - - Some(Condition::datetime_range("doc_ts", DatetimeRange { lt: None, gt: None, gte, lte })) -} - -fn doc_read_allowed( - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, - owner_agent_id: &str, - scope: &str, -) -> bool { - if !allowed_scopes.iter().any(|s| s == scope) { - return false; - } - if scope == "agent_private" { - return owner_agent_id == requester_agent_id; - } - if owner_agent_id == requester_agent_id { - return true; - } - - shared_grants.contains(&SharedSpaceGrantKey { - scope: scope.to_string(), - space_owner_agent_id: owner_agent_id.to_string(), - }) -} - -fn parse_scored_point_uuid_id(point: &ScoredPoint) -> Result { - let id = point - .id - .as_ref() - .ok_or_else(|| Error::Qdrant { message: "Qdrant returned item without id.".to_string() })?; - - match id.point_id_options.as_ref() { - Some(PointIdOptions::Uuid(s)) => Uuid::parse_str(s.as_str()) - .map_err(|_| Error::Qdrant { message: "Qdrant returned invalid uuid id.".to_string() }), - Some(other) => Err(Error::Qdrant { - message: format!("Qdrant returned unsupported id type: {other:?}."), - }), - None => Err(Error::Qdrant { message: "Qdrant returned item with missing id.".to_string() }), - } -} - -fn truncate_bytes(text: &str, max: usize) -> String { - if text.len() <= max { - return text.to_string(); - } - - let mut cut = max; - - while cut > 0 && !text.is_char_boundary(cut) { - cut -= 1; - } - - text.get(0..cut).unwrap_or("").to_string() -} - -fn locate_quote(text: &str, quote: &TextQuoteSelector) -> Option<(usize, usize)> { - let prefix = quote.prefix.as_deref().unwrap_or(""); - let suffix = quote.suffix.as_deref().unwrap_or(""); - - for (start, _) in text.match_indices(quote.exact.as_str()) { - let end = start + quote.exact.len(); - - if !text[..start].ends_with(prefix) { - continue; - } - if !text[end..].starts_with(suffix) { - continue; - } - - return Some((start, end)); - } - - None -} - -fn bounded_window( - match_start: usize, - match_end: usize, - text: &str, - max_bytes: usize, -) -> (usize, usize) { - let len = text.len(); - let match_center = match_start.saturating_add(match_end.saturating_sub(match_start) / 2); - let half = max_bytes / 2; - let mut start = match_center.saturating_sub(half); - let mut end = (start + max_bytes).min(len); - - if end - start < max_bytes && start > 0 { - start = start.saturating_sub(max_bytes - (end - start)); - } - - while start < len && !text.is_char_boundary(start) { - start += 1; - } - while end > start && !text.is_char_boundary(end) { - end -= 1; - } - - (start, end) -} - -fn docs_search_sparse_enabled(mode: DocsSparseMode, query: &str) -> bool { - match mode { - DocsSparseMode::Auto => should_enable_sparse_auto(query), - DocsSparseMode::On => true, - DocsSparseMode::Off => false, - } -} - -fn should_enable_sparse_auto(query: &str) -> bool { - let trimmed = query.trim(); - - if trimmed.is_empty() { - return false; - } - if trimmed.contains("://") - || trimmed.contains('/') - || trimmed.contains('\\') - || trimmed.contains('?') - { - return true; - } - - let has_mixed_alpha_num = trimmed.split_whitespace().any(|token| { - token.chars().any(|ch| ch.is_ascii_alphabetic()) - && token.chars().any(|ch| ch.is_ascii_digit()) - }); - let special_count = trimmed - .chars() - .filter(|ch| !(ch.is_ascii_alphanumeric() || ch.is_ascii_whitespace() || *ch == '_')) - .count(); - let compact_hex_like = { - let compact = trimmed.chars().filter(|ch| !ch.is_ascii_whitespace()).collect::(); - - compact.len() >= 12 && compact.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-') - }; - - special_count >= 2 || compact_hex_like || (has_mixed_alpha_num && trimmed.len() > 12) -} - -async fn load_docs_excerpt_context( - cfg: &Config, - pool: &PgPool, - tenant_id: &str, - project_id: &str, - agent_id: &str, - read_profile: &str, - doc_id: Uuid, -) -> Result { - let allowed_scopes = search::resolve_read_profile_scopes(cfg, read_profile)?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - pool, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await?; - let doc = load_doc_document_for_read(pool, doc_id, tenant_id, project_id) - .await? - .ok_or_else(|| Error::NotFound { message: "Doc not found.".to_string() })?; - - if doc.status != "active" - || !doc_read_allowed( - agent_id, - &allowed_scopes, - &shared_grants, - doc.agent_id.as_str(), - doc.scope.as_str(), - ) { - return Err(Error::NotFound { message: "Doc not found.".to_string() }); - } - - Ok(doc) -} - -async fn docs_excerpts_resolve_windowed_match( - pool: &PgPool, - doc: &DocDocument, - req: &DocsExcerptsGetRequest, - level_max: usize, - trajectory: &mut DocTrajectoryBuilder, - verified: &mut bool, - verification_errors: &mut Vec, -) -> Result { - let DocExcerptMatch { selector_kind, match_start_offset, match_end_offset } = - docs_excerpts_resolve_match(pool, doc, req, verified, verification_errors).await?; - - trajectory.push( - "match_resolution", - serde_json::json!({ - "selector_kind": selector_kind.as_str(), - "match_start": match_start_offset, - "match_end": match_end_offset, - }), - ); - - let (start_offset, end_offset) = - bounded_window(match_start_offset, match_end_offset, doc.content.as_str(), level_max); - - trajectory.push( - "window_projection", - serde_json::json!({ - "window_start": start_offset, - "window_end": end_offset, - "content_len": doc.content.len(), - }), - ); - - Ok(DocExcerptRange { - selector_kind, - match_start_offset, - match_end_offset, - start_offset, - end_offset, - }) -} - -async fn docs_excerpts_resolve_match( - pool: &PgPool, - doc: &DocDocument, - req: &DocsExcerptsGetRequest, - verified: &mut bool, - verification_errors: &mut Vec, -) -> Result { - let (match_start_offset, match_end_offset, selector_kind) = - resolve_excerpts_match_range(pool, doc, req, verified, verification_errors).await?; - - Ok(DocExcerptMatch { selector_kind, match_start_offset, match_end_offset }) -} - -async fn load_doc_document_for_read( - executor: impl PgExecutor<'_>, - doc_id: Uuid, - tenant_id: &str, - project_id: &str, -) -> Result> { - let row: Option = sqlx::query_as::<_, DocDocument>( - "\ -SELECT - doc_id, - tenant_id, - project_id, - agent_id, - scope, - doc_type, - status, - title, - COALESCE(source_ref, '{}'::jsonb) AS source_ref, - content, - content_bytes, - content_hash, - created_at, - updated_at -FROM doc_documents -WHERE doc_id = $1 - AND tenant_id = $2 - AND ( - project_id = $3 - OR (project_id = $4 AND scope = 'org_shared') - ) -LIMIT 1", - ) - .bind(doc_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -async fn resolve_excerpts_match_range( - pool: &PgPool, - doc: &DocDocument, - req: &DocsExcerptsGetRequest, - verified: &mut bool, - verification_errors: &mut Vec, -) -> Result<(usize, usize, ExcerptsSelectorKind)> { - if let Some(chunk_id) = req.chunk_id { - let chunk = docs::get_doc_chunk(pool, chunk_id).await?; - let Some(chunk) = chunk else { - return Err(Error::NotFound { message: "Chunk not found.".to_string() }); - }; - - if chunk.doc_id != doc.doc_id { - return Err(Error::NotFound { message: "Chunk not found.".to_string() }); - } - - return Ok(( - chunk.start_offset.max(0) as usize, - chunk.end_offset.max(0) as usize, - ExcerptsSelectorKind::ChunkId, - )); - } - if let Some(quote) = req.quote.as_ref() { - return Ok(match locate_quote(&doc.content, quote) { - Some((s, e)) => (s, e, ExcerptsSelectorKind::Quote), - None => { - *verified = false; - - verification_errors.push("QUOTE_SELECTOR_NOT_FOUND".to_string()); - - if let Some(pos) = req.position.as_ref() { - ( - pos.start.min(doc.content.len()), - pos.end.min(doc.content.len()), - ExcerptsSelectorKind::Position, - ) - } else { - return Err(Error::NotFound { - message: "Selector did not match document.".to_string(), - }); - } - }, - }); - } - if let Some(pos) = req.position.as_ref() { - return Ok(( - pos.start.min(doc.content.len()), - pos.end.min(doc.content.len()), - ExcerptsSelectorKind::Position, - )); - } - - Err(Error::InvalidRequest { - message: "One of chunk_id, quote, or position is required.".to_string(), - }) -} - -async fn run_doc_fusion_query( - client: &Qdrant, - collection: &str, - query_text: &str, - vector: &[f32], - filter: &Filter, - sparse_mode: DocsSparseMode, - candidate_k: u32, -) -> Result> { - let sparse_enabled = docs_search_sparse_enabled(sparse_mode, query_text); - let dense_prefetch = PrefetchQueryBuilder::default() - .query(Query::new_nearest(vector.to_vec())) - .using(DENSE_VECTOR_NAME) - .filter(filter.clone()) - .limit(candidate_k as u64); - let mut search = QueryPointsBuilder::new(collection.to_string()); - - search = search.add_prefetch(dense_prefetch); - - if sparse_enabled { - let bm25_prefetch = PrefetchQueryBuilder::default() - .query(Query::new_nearest(Document::new(query_text.to_string(), BM25_MODEL))) - .using(BM25_VECTOR_NAME) - .filter(filter.clone()) - .limit(candidate_k as u64); - - search = search.add_prefetch(bm25_prefetch); - } - - let search = search.with_payload(false).query(Fusion::Rrf).limit(candidate_k as u64); - let response = - client.query(search).await.map_err(|err| Error::Qdrant { message: err.to_string() })?; - - Ok(response.result) -} - -async fn load_doc_search_rows( - executor: impl PgExecutor<'_>, - tenant_id: &str, - project_id: &str, - status: &str, - chunk_ids: &[Uuid], -) -> Result> { - if chunk_ids.is_empty() { - return Ok(HashMap::new()); - } - - let rows: Vec = sqlx::query_as( - "\ -SELECT - c.chunk_id, - c.doc_id, - d.scope, - d.doc_type, - d.project_id, - d.agent_id, - d.updated_at, - d.content_hash, - c.chunk_hash, - c.start_offset, - c.end_offset, - c.chunk_text -FROM doc_chunks c -JOIN doc_documents d ON d.doc_id = c.doc_id -WHERE c.chunk_id = ANY($1) - AND d.tenant_id = $2 - AND d.status = $4 - AND ( - d.project_id = $3 - OR (d.project_id = $5 AND d.scope = 'org_shared') - )", - ) - .bind(chunk_ids) - .bind(tenant_id) - .bind(project_id) - .bind(status) - .bind(ORG_PROJECT_ID) - .fetch_all(executor) - .await?; - let mut map = HashMap::with_capacity(rows.len()); - - for row in rows { - map.insert(row.chunk_id, row); - } - - Ok(map) -} - +#[cfg(test)] use excerpts::should_enable_sparse_auto; +use excerpts::{ + build_doc_search_filter, build_docs_l0_pointer, doc_read_allowed, docs_excerpt_locator, + docs_excerpts_resolve_windowed_match, docs_search_sparse_enabled, load_docs_excerpt_context, + parse_scored_point_uuid_id, truncate_bytes, +}; +use queries::{load_doc_search_rows, run_doc_fusion_query}; +use search_support::{ + apply_doc_recency_boost, docs_search_l0_deduplicated_chunks, docs_search_l0_project_items, + record_result_projection_stage, +}; +use source_capture::{ + build_doc_chunk_rows, build_source_capture_summary, doc_chunk_id_for, + normalize_source_ref_for_capture, source_record_id_for, source_span_id, +}; +use types::{ + ByteChunk, DEFAULT_DOC_MAX_BYTES, DEFAULT_L0_MAX_BYTES, DEFAULT_L1_MAX_BYTES, + DEFAULT_L2_MAX_BYTES, DEFAULT_MAX_CHUNKS_PER_DOC, DOC_SOURCE_CAPTURE_SCHEMA_V1, + DOC_SOURCE_REF_RESOLVER_V1, DOC_SOURCE_REF_SCHEMA_V1, DOC_SOURCE_SPAN_SCHEMA_V1, DOC_STATUSES, + DocChunkingProfile, DocExcerptMatch, DocExcerptRange, DocSearchRow, DocTrajectoryBuilder, + DocsSearchL0Filters, DocsSearchL0FiltersParsed, DocsSearchL0Prepared, DocsSearchL0RangesParsed, + DocsSparseMode, ExcerptsSelectorKind, MAX_CANDIDATE_K, MAX_TOP_K, SOURCE_LIBRARY_FIELD_KEYS, + SOURCE_LIBRARY_KINDS, SOURCE_LIBRARY_TRUST_LABELS, SourceCaptureSummaryInput, ValidatedDocsPut, +}; +use validation::{ + excerpt_level_max, resolve_doc_chunking_profile, validate_docs_excerpts_get, validate_docs_put, + validate_docs_search_l0, +}; #[cfg(test)] -mod tests { - use ahash::AHashMap; - use qdrant_client::qdrant::{ - DatetimeRange, Filter, condition::ConditionOneOf, r#match::MatchValue, - }; - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - use tokenizers::{ - Tokenizer, models::wordlevel::WordLevel, pre_tokenizers::whitespace::Whitespace, - }; - use uuid::Uuid; - - use crate::docs::{ - self, DocType, DocsPutRequest, DocsSearchL0Filters, DocsSearchL0Request, DocsSparseMode, - Error, - }; - use elf_domain::writegate::{WritePolicy, WritePolicyAudit, WriteRedactionResult, WriteSpan}; - use elf_storage::models::DocChunk; - - const TENANT_ID: &str = "tenant"; - const PROJECT_ID: &str = "project"; - - fn test_request_with_query(query: &str) -> DocsSearchL0Request { - DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: query.to_string(), - scope: None, - status: None, - doc_type: None, - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - } - } - - fn first_datetime_range(filter: &Filter, key: &str) -> Option { - for condition in &filter.must { - if let Some(ConditionOneOf::Field(field)) = condition.condition_one_of.as_ref() { - if field.key != key { - continue; - } - - if let Some(range) = field.datetime_range.as_ref() { - return Some(*range); - } - } - } - - None - } - - fn first_match_value(filter: &Filter, key: &str) -> Option { - for condition in &filter.must { - if let Some(ConditionOneOf::Field(field)) = condition.condition_one_of.as_ref() { - if field.key != key { - continue; - } - - if let Some(r#match) = field.r#match.as_ref() { - let Some(match_value) = r#match.match_value.as_ref() else { - continue; - }; - - return match match_value { - MatchValue::Keyword(value) => Some(value.clone()), - _ => None, - }; - } - } - } - - None - } - - fn test_tokenizer() -> Tokenizer { - let mut vocab = AHashMap::new(); - - vocab.insert("alpha".to_string(), 1_u32); - vocab.insert("beta".to_string(), 2_u32); - vocab.insert("charlie".to_string(), 3_u32); - vocab.insert("delta".to_string(), 4_u32); - vocab.insert("".to_string(), 0_u32); - - let model = WordLevel::builder() - .vocab(vocab) - .unk_token("".to_string()) - .build() - .expect("Failed to build test tokenizer."); - let mut tokenizer = Tokenizer::new(model); - - tokenizer.with_pre_tokenizer(Some(Whitespace)); - - tokenizer - } - - #[test] - fn doc_type_parses_and_serializes() { - let encoded = - serde_json::to_string(&DocType::Knowledge).expect("Expected DocType serialization."); - let parsed = - serde_json::from_str::("\"knowledge\"").expect("Expected parse to succeed."); - let invalid: Result = serde_json::from_str("\"invalid\""); - - assert_eq!(encoded, "\"knowledge\""); - assert_eq!(parsed, DocType::Knowledge); - assert!(invalid.is_err()); - } - - #[test] - fn docs_search_l0_requires_chat_doc_type_for_thread_id() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "thread".to_string(), - scope: None, - status: None, - doc_type: Some("search".to_string()), - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: Some("thread-1".to_string()), - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected thread_id to require doc_type=chat."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("thread_id requires")), - other => panic!("Unexpected error: {other:?}"), - } - - docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "thread".to_string(), - scope: None, - status: None, - doc_type: Some("chat".to_string()), - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: Some("thread-1".to_string()), - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect("Expected thread_id filter to be accepted for chat."); - } - - #[test] - fn validate_docs_put_rejects_invalid_doc_type() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "invalid", - "ts": "2026-02-25T12:00:00Z", - }), - content: "Hello world.".to_string(), - }) - .expect_err("Expected invalid doc_type to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("doc_type")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn resolve_doc_chunking_profile_is_deterministic_by_doc_type() { - let small = docs::resolve_doc_chunking_profile(DocType::Chat); - - assert_eq!(small.max_tokens, 1_024); - assert_eq!(small.overlap_tokens, 128); - - let default = docs::resolve_doc_chunking_profile(DocType::Knowledge); - - assert_eq!(default.max_tokens, 2_048); - assert_eq!(default.overlap_tokens, 256); - } - - #[test] - fn validate_docs_search_l0_defaults_status_and_filters_dates() { - let filters = docs::validate_docs_search_l0(&test_request_with_query("hello world")) - .expect("valid request"); - - assert_eq!(filters.status, "active"); - - let bad_dates = DocsSearchL0Request { - updated_after: Some("2026-02-25T12:00:00Z".to_string()), - updated_before: Some("2026-02-25T11:00:00Z".to_string()), - sparse_mode: None, - domain: None, - repo: None, - ..test_request_with_query("status") - }; - let err = docs::validate_docs_search_l0(&bad_dates) - .expect_err("Expected bad date order to be rejected."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("earlier")); - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_search_l0_rejects_invalid_status() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "status".to_string(), - scope: None, - status: Some("archived".to_string()), - doc_type: None, - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected invalid status to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("status")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_search_l0_rejects_invalid_datetime_format() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "status".to_string(), - scope: None, - status: None, - doc_type: None, - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: None, - updated_after: Some("2026-02-25T12:00:00".to_string()), - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected invalid RFC3339 datetime to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("RFC3339")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn build_doc_search_filter_applies_status_and_requested_filters() { - let filters = DocsSearchL0Filters { - scope: Some("project_shared".to_string()), - status: "deleted".to_string(), - doc_type: Some(DocType::Chat), - sparse_mode: DocsSparseMode::Auto, - domain: None, - repo: None, - agent_id: Some("owner".to_string()), - thread_id: Some("thread-7".to_string()), - updated_after: Some( - OffsetDateTime::parse("2026-02-20T00:00:00Z", &Rfc3339) - .expect("Invalid timestamp."), - ), - updated_before: Some( - OffsetDateTime::parse("2026-02-28T00:00:00Z", &Rfc3339) - .expect("Invalid timestamp."), - ), - ts_gte: Some( - OffsetDateTime::parse("2026-01-01T00:00:00Z", &Rfc3339) - .expect("Invalid timestamp."), - ), - ts_lte: Some( - OffsetDateTime::parse("2026-12-31T00:00:00Z", &Rfc3339) - .expect("Invalid timestamp."), - ), - }; - let filter = super::build_doc_search_filter( - TENANT_ID, - PROJECT_ID, - "requester", - &["agent_private".to_string(), "project_shared".to_string()], - &filters, - ); - - assert_eq!(first_match_value(&filter, "tenant_id").as_deref(), Some("tenant")); - assert_eq!(first_match_value(&filter, "status").as_deref(), Some("deleted")); - assert_eq!(first_match_value(&filter, "scope").as_deref(), Some("project_shared")); - assert_eq!(first_match_value(&filter, "doc_type").as_deref(), Some("chat")); - assert_eq!(first_match_value(&filter, "agent_id").as_deref(), Some("owner")); - assert_eq!(first_match_value(&filter, "thread_id").as_deref(), Some("thread-7")); - assert_eq!(first_match_value(&filter, "domain").as_deref(), None); - assert_eq!(first_match_value(&filter, "repo").as_deref(), None); - - let datetime_range = first_datetime_range(&filter, "updated_at") - .expect("Expected datetime filter for updated_at."); - let after = - OffsetDateTime::parse("2026-02-20T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); - let before = - OffsetDateTime::parse("2026-02-28T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); - let lt = datetime_range.lt.as_ref().expect("Expected datetime filter .lt value."); - let gt = datetime_range.gt.as_ref().expect("Expected datetime filter .gt value."); - - assert_eq!(lt.seconds, before.unix_timestamp()); - assert_eq!(lt.nanos, before.nanosecond() as i32); - assert_eq!(gt.seconds, after.unix_timestamp()); - assert_eq!(gt.nanos, after.nanosecond() as i32); - assert!(datetime_range.gte.is_none()); - assert!(datetime_range.lte.is_none()); - - let doc_ts_range = - first_datetime_range(&filter, "doc_ts").expect("Expected datetime filter for doc_ts."); - let gte = doc_ts_range.gte.as_ref().expect("Expected datetime filter .gte value."); - let lte = doc_ts_range.lte.as_ref().expect("Expected datetime filter .lte value."); - let doc_ts_gte = - OffsetDateTime::parse("2026-01-01T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); - let doc_ts_lte = - OffsetDateTime::parse("2026-12-31T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); - - assert_eq!(gte.seconds, doc_ts_gte.unix_timestamp()); - assert_eq!(gte.nanos, doc_ts_gte.nanosecond() as i32); - assert_eq!(lte.seconds, doc_ts_lte.unix_timestamp()); - assert_eq!(lte.nanos, doc_ts_lte.nanosecond() as i32); - assert!(doc_ts_range.gt.is_none()); - assert!(doc_ts_range.lt.is_none()); - } - - #[test] - fn validate_docs_search_l0_rejects_invalid_doc_ts_order() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "status".to_string(), - scope: None, - status: None, - doc_type: None, - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: Some("2026-02-25T12:00:00Z".to_string()), - ts_lte: Some("2026-02-25T11:00:00Z".to_string()), - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected bad doc_ts order to be rejected."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("earlier")); - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_search_l0_rejects_invalid_sparse_mode() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "status".to_string(), - scope: None, - status: None, - doc_type: None, - sparse_mode: Some("invalid".to_string()), - domain: None, - repo: None, - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected invalid sparse mode to be rejected."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("sparse_mode")); - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_search_l0_rejects_domain_without_doc_type_search() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "status".to_string(), - scope: None, - status: None, - doc_type: None, - sparse_mode: None, - domain: Some("example.com".to_string()), - repo: None, - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected domain without doc_type=search to be rejected."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("doc_type=search")); - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_search_l0_rejects_repo_without_doc_type_dev() { - let err = docs::validate_docs_search_l0(&DocsSearchL0Request { - tenant_id: TENANT_ID.to_string(), - project_id: PROJECT_ID.to_string(), - caller_agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "status".to_string(), - scope: None, - status: None, - doc_type: None, - sparse_mode: None, - domain: None, - repo: Some("hack-ink/ELF".to_string()), - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: None, - candidate_k: None, - explain: None, - }) - .expect_err("Expected repo without doc_type=dev to be rejected."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("doc_type=dev")); - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_search_l0_default_sparse_mode() { - let filters = docs::validate_docs_search_l0(&test_request_with_query("status")) - .expect("valid request"); - - assert!(matches!(filters.sparse_mode, DocsSparseMode::Auto)); - } - - #[test] - fn should_enable_sparse_auto_uses_symbol_cues() { - assert!(super::should_enable_sparse_auto("https://example.com/search?q=abc")); - assert!(!super::should_enable_sparse_auto("how to debug a timeout")); - } - - #[test] - fn excerpt_level_max_supports_l0_and_rejects_unknown_level() { - assert_eq!( - super::excerpt_level_max("L0").expect("Expected L0 to be supported."), - super::DEFAULT_L0_MAX_BYTES - ); - assert!(super::excerpt_level_max("L3").is_err()); - } - - #[test] - fn validate_docs_put_rejects_missing_source_ref() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Knowledge.as_str().to_string()), - title: None, - write_policy: None, - source_ref: serde_json::json!({"schema":"doc_source_ref/v1", "doc_type":"knowledge"}), - content: "Hello world.".to_string(), - }) - .expect_err("Expected missing source_ref.ts to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("source_ref[\"ts\"]")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_rejects_non_object_source_ref() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: None, - write_policy: None, - source_ref: serde_json::json!("legacy-shape"), - content: "Hello world.".to_string(), - }) - .expect_err("Expected non-object source_ref to be rejected."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("source_ref must be a JSON object")) - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_rejects_mismatched_request_and_source_ref_doc_type() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Chat.as_str().to_string()), - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - }), - content: "Hello world.".to_string(), - }) - .expect_err("Expected mismatched doc_type to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("match")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_rejects_wrong_source_ref_schema() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "note_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - }), - content: "Hello world.".to_string(), - }) - .expect_err("Expected wrong source_ref.schema to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("doc_source_ref/v1")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_rejects_chat_source_ref_with_missing_thread_metadata() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Chat.as_str().to_string()), - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "chat", - "ts": "2026-02-25T12:00:00Z", - }), - content: "Hello world.".to_string(), - }) - .expect_err("Expected chat source_ref to require thread_id/role."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("thread_id")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_rejects_search_source_ref_with_missing_domain() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Search.as_str().to_string()), - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "search", - "ts": "2026-02-25T12:00:00Z", - "query": "test", - "url": "https://example.com", - }), - content: "Hello world.".to_string(), - }) - .expect_err("Expected search source_ref to require domain."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("domain")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_rejects_dev_source_ref_with_multiple_identifiers() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Dev.as_str().to_string()), - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "dev", - "ts": "2026-02-25T12:00:00Z", - "repo": "hack-ink/ELF", - "commit_sha": "9f0a3f4c4eb58bfcf4a5f4f9d0c7be0e13c2f8d19", - "issue_number": 123, - }), - content: "Hello world.".to_string(), - }) - .expect_err("Expected dev source_ref to enforce exactly one identifier field."); - - match err { - Error::InvalidRequest { message } => { - assert!(message.contains("exactly one of commit_sha, pr_number, or issue_number")) - }, - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_uses_source_ref_doc_type_when_request_doc_type_is_absent() { - let resolved_doc_type = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: None, - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "chat", - "ts": "2026-02-25T12:00:00Z", - "thread_id": "thread-1", - "role": "assistant" - }), - content: "Hello world.".to_string(), - }) - .expect("Expected valid source_ref to resolve doc_type."); - - assert_eq!(resolved_doc_type.doc_type, DocType::Chat); - } - - #[test] - fn validate_docs_put_accepts_source_library_article_metadata() { - let validated = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Knowledge.as_str().to_string()), - title: Some("Saved article".to_string()), - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "source_kind": "article", - "canonical_uri": "https://example.com/research/source-library", - "captured_at": "2026-02-25T12:10:00Z", - "source_created_at": "2026-02-24T09:00:00Z", - "trust_label": "public_web", - "author": "Example Author", - "handle": "example-author", - "excerpt_locator": { - "quote": { - "exact": "Source libraries preserve long-form evidence." - }, - "position": { - "start": 0, - "end": 48 - } - } - }), - content: "Source libraries preserve long-form evidence. Agents can hydrate exact excerpts later.".to_string(), - }) - .expect("Expected source library metadata to be accepted."); - - assert_eq!(validated.doc_type, DocType::Knowledge); - } - - #[test] - fn source_capture_metadata_uses_stable_record_and_span_ids() { - let now = OffsetDateTime::parse("2026-02-25T12:15:00Z", &Rfc3339) - .expect("Expected test timestamp to parse."); - let source_ref = serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "source_kind": "article", - "canonical_uri": "https://example.com/research/source-library", - "captured_at": "2026-02-25T12:10:00Z", - "trust_label": "public_web", - }); - let source_ref = source_ref.as_object().expect("Expected source_ref object."); - let content_hash = "doc-content-hash"; - let doc_id = super::source_record_id_for( - TENANT_ID, - PROJECT_ID, - "owner", - "project_shared", - DocType::Knowledge, - source_ref, - content_hash, - ); - let repeated_doc_id = super::source_record_id_for( - TENANT_ID, - PROJECT_ID, - "owner", - "project_shared", - DocType::Knowledge, - source_ref, - content_hash, - ); - let chunk_id = super::doc_chunk_id_for(doc_id, 0); - let chunk = DocChunk { - chunk_id, - doc_id, - chunk_index: 0, - start_offset: 0, - end_offset: 42, - chunk_text: "Source libraries preserve long-form evidence.".to_string(), - chunk_hash: "chunk-content-hash".to_string(), - created_at: now, - }; - let capture = super::build_source_capture_summary(super::SourceCaptureSummaryInput { - doc_id, - source_ref, - doc_type: DocType::Knowledge, - scope: "project_shared", - title: Some("Saved article"), - content_hash, - raw_content_hash: "raw-content-hash", - now, - chunks: &[chunk], - write_policy_audit: None, - }) - .expect("Expected source capture summary."); - - assert_eq!(doc_id, repeated_doc_id); - assert_eq!(capture.schema, "doc_source_capture/v1"); - assert_eq!(capture.source_record_id, doc_id); - assert_eq!(capture.origin, "https://example.com/research/source-library"); - assert_eq!(capture.captured_at, "2026-02-25T12:10:00Z"); - assert_eq!(capture.content_hash, content_hash); - assert_eq!(capture.visibility_scope, "project_shared"); - assert_eq!(capture.title.as_deref(), Some("Saved article")); - assert_eq!(capture.source_type, "article"); - assert_eq!(capture.source_spans.len(), 1); - assert_eq!(capture.source_spans[0].schema, "doc_source_span/v1"); - assert_eq!(capture.source_spans[0].chunk_id, Some(chunk_id)); - assert_eq!(capture.source_spans[0].status, "captured"); - assert_eq!(capture.source_spans[0].reason_code, None); - assert_eq!(capture.source_spans[0].start_offset, 0); - assert_eq!(capture.source_spans[0].end_offset, 42); - assert_eq!( - capture.source_spans[0].span_id, - super::source_span_id(content_hash, 0, 42, "captured") - ); - } - - #[test] - fn normalized_source_ref_records_policy_span_reasons() { - let now = OffsetDateTime::parse("2026-02-25T12:15:00Z", &Rfc3339) - .expect("Expected test timestamp to parse."); - let source_ref = serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "uri": "file:///tmp/source.txt", - }); - let source_ref_map = source_ref.as_object().expect("Expected source_ref object."); - let audit = WritePolicyAudit { - exclusions: vec![WriteSpan { start: 6, end: 12 }], - redactions: vec![WriteRedactionResult { - span: WriteSpan { start: 20, end: 30 }, - replacement: "[redacted]".to_string(), - }], - }; - let doc_id = super::source_record_id_for( - TENANT_ID, - PROJECT_ID, - "owner", - "project_shared", - DocType::Knowledge, - source_ref_map, - "stored-hash", - ); - let capture = super::build_source_capture_summary(super::SourceCaptureSummaryInput { - doc_id, - source_ref: source_ref_map, - doc_type: DocType::Knowledge, - scope: "project_shared", - title: None, - content_hash: "stored-hash", - raw_content_hash: "raw-hash", - now, - chunks: &[], - write_policy_audit: Some(&audit), - }) - .expect("Expected source capture summary."); - let normalized = super::normalize_source_ref_for_capture(source_ref, &capture) - .expect("Expected normalized source_ref"); - - assert_eq!(capture.policy_spans.len(), 2); - assert_eq!(capture.policy_spans[0].status, "excluded"); - assert_eq!(capture.policy_spans[0].reason_code.as_deref(), Some("WRITE_POLICY_EXCLUSION")); - assert_eq!(capture.policy_spans[1].status, "redacted"); - assert_eq!(capture.policy_spans[1].reason_code.as_deref(), Some("WRITE_POLICY_REDACTION")); - assert_eq!(normalized["source_record_id"], doc_id.to_string()); - assert_eq!(normalized["origin"], "file:///tmp/source.txt"); - assert_eq!(normalized["captured_at"], "2026-02-25T12:15:00Z"); - assert_eq!(normalized["content_hash"], "stored-hash"); - assert_eq!(normalized["visibility_scope"], "project_shared"); - assert_eq!(normalized["source_type"], "knowledge"); - assert_eq!(normalized["policy_spans"][0]["reason_code"], "WRITE_POLICY_EXCLUSION"); - assert_eq!(normalized["policy_spans"][1]["reason_code"], "WRITE_POLICY_REDACTION"); - } - - #[test] - fn validate_docs_put_rejects_incomplete_source_library_metadata() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Knowledge.as_str().to_string()), - title: Some("Saved article".to_string()), - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "source_kind": "article", - "captured_at": "2026-02-25T12:10:00Z", - "trust_label": "public_web" - }), - content: "Source libraries preserve long-form evidence.".to_string(), - }) - .expect_err("Expected canonical_uri to be required for source library metadata."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("canonical_uri")), - other => panic!("Unexpected error: {other:?}"), - } - - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Knowledge.as_str().to_string()), - title: Some("Saved thread".to_string()), - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "source_kind": "social_thread", - "canonical_uri": "https://example.com/thread/123", - "captured_at": "2026-02-25T12:10:00Z", - "trust_label": "public_web" - }), - content: "The thread says source libraries need social captures.".to_string(), - }) - .expect_err("Expected social_thread source_kind to require chat doc_type."); - - match err { - Error::InvalidRequest { message } => - assert!(message.contains("requires doc_type=chat")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn docs_l0_pointer_carries_hashes_and_position_locator() { - let now = OffsetDateTime::parse("2026-02-25T12:00:00Z", &Rfc3339) - .expect("Expected test timestamp to parse."); - let row = super::DocSearchRow { - chunk_id: Uuid::parse_str("11111111-1111-4111-8111-111111111111") - .expect("Expected chunk UUID."), - doc_id: Uuid::parse_str("22222222-2222-4222-8222-222222222222") - .expect("Expected doc UUID."), - scope: "project_shared".to_string(), - doc_type: "knowledge".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - updated_at: now, - content_hash: "doc-hash".to_string(), - chunk_hash: "chunk-hash".to_string(), - start_offset: 12, - end_offset: 64, - chunk_text: "Source libraries preserve long-form evidence.".to_string(), - }; - let pointer = super::build_docs_l0_pointer(&row, row.chunk_id); - - assert_eq!(pointer.schema, "source_ref/v1"); - assert_eq!(pointer.resolver, "elf_doc_ext/v1"); - assert_eq!(pointer.hashes.content_hash, "doc-hash"); - assert_eq!(pointer.hashes.chunk_hash, "chunk-hash"); - assert_eq!(pointer.reference.source_record_id, row.doc_id); - assert_eq!(pointer.reference.source_span_id, pointer.locator.span_id); - assert_eq!(pointer.locator.position.start, 12); - assert_eq!(pointer.locator.position.end, 64); - assert_eq!(pointer.locator.span_id, super::source_span_id("doc-hash", 12, 64, "captured")); - assert_eq!(pointer.state.content_hash, pointer.hashes.content_hash); - assert_eq!(pointer.state.chunk_hash, pointer.hashes.chunk_hash); - } - - #[test] - fn validate_docs_put_applies_write_policy_and_includes_audit() { - let validated = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Knowledge.as_str().to_string()), - title: None, - write_policy: Some(WritePolicy { - exclusions: vec![WriteSpan { start: 6, end: 35 }], - redactions: vec![], - }), - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - }), - content: "Hello sk-abcdefghijklmnopqrstuvwxyz!".to_string(), - }) - .expect("Expected valid write policy transformation."); - let expected_audit = elf_domain::writegate::WritePolicyAudit { - exclusions: vec![WriteSpan { start: 6, end: 35 }], - ..Default::default() - }; - - assert_eq!(validated.content, "Hello !".to_string()); - assert_eq!(validated.write_policy_audit.unwrap_or_default(), expected_audit); - } - - #[test] - fn validate_docs_put_rejects_secret_after_write_policy() { - let err = docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: Some(DocType::Knowledge.as_str().to_string()), - title: None, - write_policy: Some(WritePolicy { exclusions: vec![], redactions: vec![] }), - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - }), - content: "Hello sk-abcdefghijklmnopqrstuvwxyz!".to_string(), - }) - .expect_err("Expected secret-bearing content to be rejected."); - - match err { - Error::InvalidRequest { message } => assert!(message.contains("contains secrets")), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn validate_docs_put_allows_doc_source_ref_v1_and_rejects_free_text() { - docs::validate_docs_put(&DocsPutRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: Some("English title".to_string()), - write_policy: None, - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "notes": "English only." - }), - content: "English content.".to_string(), - }) - .expect("Expected doc_source_ref/v1 source_ref to be accepted."); - - let err = docs::validate_docs_put(&DocsPutRequest { - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "notes": "\u{4f60}\u{597d}\u{4e16}\u{754c}" - }), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: Some("English title".to_string()), - write_policy: None, - content: "English content.".to_string(), - }) - .expect_err("Expected non-English free-text in source_ref."); - - match err { - Error::NonEnglishInput { field } => assert_eq!(field, "$.source_ref[\"notes\"]"), - other => panic!("Unexpected error: {other:?}"), - } - - let err = docs::validate_docs_put(&DocsPutRequest { - source_ref: serde_json::json!({ - "schema": "doc_source_ref/v1", - "doc_type": "knowledge", - "ts": "2026-02-25T12:00:00Z", - "ref": "\u{4f60}\u{597d}\u{4e16}\u{754c}" - }), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "project_shared".to_string(), - doc_type: None, - title: Some("English title".to_string()), - write_policy: None, - content: "English content.".to_string(), - }) - .expect_err("Expected identifier lane with non-Latin text to be rejected."); - - match err { - Error::NonEnglishInput { field } => assert_eq!(field, "$.source_ref[\"ref\"]"), - other => panic!("Unexpected error: {other:?}"), - } - } - - #[test] - fn split_tokens_by_offsets_preserves_original_substring_offsets() { - let tokenizer = test_tokenizer(); - let chunks = - super::split_tokens_by_offsets("alpha bravo charlie delta", 2, 1, 10, &tokenizer) - .expect("Expected token chunking to succeed."); - - assert_eq!(chunks.len(), 3); - assert_eq!(chunks[0].start_offset, 0); - assert_eq!(chunks[0].end_offset, 11); - assert_eq!(chunks[1].start_offset, 6); - assert_eq!(chunks[1].end_offset, 19); - assert_eq!(chunks[2].start_offset, 12); - assert_eq!(chunks[2].end_offset, 25); - - for chunk in &chunks { - assert_eq!( - chunk.text, - "alpha bravo charlie delta"[chunk.start_offset..chunk.end_offset] - ); - } - } -} +#[path = "docs/tests.rs"] +mod tests; diff --git a/packages/elf-service/src/docs/api.rs b/packages/elf-service/src/docs/api.rs new file mode 100644 index 00000000..6842b954 --- /dev/null +++ b/packages/elf-service/src/docs/api.rs @@ -0,0 +1,23 @@ +mod doc_type; +mod excerpts; +mod put; +mod read; +mod search_l0; +mod selectors; +mod trajectory; + +pub use self::{ + doc_type::DocType, + excerpts::{ + DocsExcerptLocator, DocsExcerptResponse, DocsExcerptVerification, DocsExcerptsGetRequest, + }, + put::{DocsPutRequest, DocsPutResponse, DocsSourceCaptureSummary, DocsSourceSpanRef}, + read::{DocsDeleteRequest, DocsDeleteResponse, DocsGetRequest, DocsGetResponse}, + search_l0::{ + DocsSearchL0Item, DocsSearchL0ItemHashes, DocsSearchL0ItemLocator, DocsSearchL0ItemPointer, + DocsSearchL0ItemReference, DocsSearchL0ItemState, DocsSearchL0Request, + DocsSearchL0Response, + }, + selectors::{TextPositionSelector, TextQuoteSelector}, + trajectory::{DocRetrievalTrajectory, DocRetrievalTrajectoryStage}, +}; diff --git a/packages/elf-service/src/docs/api/doc_type.rs b/packages/elf-service/src/docs/api/doc_type.rs new file mode 100644 index 00000000..27bcc270 --- /dev/null +++ b/packages/elf-service/src/docs/api/doc_type.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// Document classification used for persistence and retrieval filters. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DocType { + /// Long-lived knowledge-base material. + Knowledge, + /// Chat transcripts or conversational context. + Chat, + /// Search-produced reference material. + Search, + /// Development-oriented artifacts such as code or plans. + Dev, +} +impl DocType { + /// Returns the canonical storage and API string for this document type. + pub fn as_str(self) -> &'static str { + match self { + Self::Knowledge => "knowledge", + Self::Chat => "chat", + Self::Search => "search", + Self::Dev => "dev", + } + } + + /// Parses a canonical document-type string. + pub fn parse(raw_doc_type: &str) -> Result { + match raw_doc_type { + "knowledge" => Ok(Self::Knowledge), + "chat" => Ok(Self::Chat), + "search" => Ok(Self::Search), + "dev" => Ok(Self::Dev), + _ => Err(Error::InvalidRequest { + message: "doc_type must be one of: knowledge, chat, search, dev.".to_string(), + }), + } + } +} diff --git a/packages/elf-service/src/docs/api/excerpts.rs b/packages/elf-service/src/docs/api/excerpts.rs new file mode 100644 index 00000000..e75fc2be --- /dev/null +++ b/packages/elf-service/src/docs/api/excerpts.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::docs::api::{ + selectors::{TextPositionSelector, TextQuoteSelector}, + trajectory::DocRetrievalTrajectory, +}; + +/// Request payload for excerpt retrieval. +#[derive(Clone, Debug, Deserialize)] +pub struct DocsExcerptsGetRequest { + /// Tenant that owns the document. + pub tenant_id: String, + /// Project that owns the document. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Identifier of the source document. + pub doc_id: Uuid, + /// Excerpt budget level: `L0`, `L1`, or `L2`. + pub level: String, // "L0" | "L1" | "L2" + /// Optional chunk identifier when the caller already knows the chunk. + pub chunk_id: Option, + /// Optional quote-based selector. + pub quote: Option, + /// Optional byte-position selector. + pub position: Option, + /// When true, includes retrieval trajectory output. + pub explain: Option, +} + +/// Verification metadata for one extracted excerpt. +#[derive(Clone, Debug, Serialize)] +pub struct DocsExcerptVerification { + /// Whether the excerpt selectors verified against current content. + pub verified: bool, + /// Verification failure codes. + pub verification_errors: Vec, + /// Whole-document BLAKE3 hash. + pub content_hash: String, + /// BLAKE3 hash of the returned excerpt. + pub excerpt_hash: String, +} + +/// Response payload for excerpt retrieval. +#[derive(Clone, Debug, Serialize)] +pub struct DocsExcerptResponse { + /// Excerpt trace identifier. + pub trace_id: Uuid, + /// Identifier of the source document. + pub doc_id: Uuid, + /// Returned excerpt text. + pub excerpt: String, + /// Inclusive start offset of the returned window. + pub start_offset: usize, + /// Exclusive end offset of the returned window. + pub end_offset: usize, + /// Concrete selector resolution result. + pub locator: DocsExcerptLocator, + /// Verification metadata for the returned excerpt. + pub verification: DocsExcerptVerification, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional retrieval trajectory emitted in explain mode. + pub trajectory: Option, +} + +/// Selector resolution metadata for an excerpt. +#[derive(Clone, Debug, Serialize)] +pub struct DocsExcerptLocator { + /// Stable source span identifier for the matched selector span. + pub span_id: Uuid, + /// Selector kind that produced the match. + pub selector_kind: String, + /// Inclusive start offset of the matched selector span. + pub match_start_offset: usize, + /// Exclusive end offset of the matched selector span. + pub match_end_offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + /// Matched chunk identifier, when known. + pub chunk_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Quote selector actually used for resolution. + pub quote: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Position selector actually used for resolution. + pub position: Option, +} diff --git a/packages/elf-service/src/docs/api/put.rs b/packages/elf-service/src/docs/api/put.rs new file mode 100644 index 00000000..6c593f3b --- /dev/null +++ b/packages/elf-service/src/docs/api/put.rs @@ -0,0 +1,100 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use elf_domain::writegate::{WritePolicy, WritePolicyAudit}; + +/// Request payload for document ingestion. +#[derive(Clone, Debug, Deserialize)] +pub struct DocsPutRequest { + /// Tenant that owns the document. + pub tenant_id: String, + /// Project that owns the document. + pub project_id: String, + /// Agent ingesting the document. + pub agent_id: String, + /// Scope to assign to the document. + pub scope: String, + /// Optional raw document-type string. + pub doc_type: Option, + /// Optional display title for the document. + pub title: Option, + /// Optional write policy applied before persistence. + pub write_policy: Option, + #[serde(default)] + /// Structured provenance metadata for the document. + pub source_ref: Value, + /// Full document body to store and chunk. + pub content: String, +} + +/// Response payload for document ingestion. +#[derive(Clone, Debug, Serialize)] +pub struct DocsPutResponse { + /// Identifier of the stored document. + pub doc_id: Uuid, + /// Normalized Source Library capture metadata for the stored document. + pub source_capture: DocsSourceCaptureSummary, + /// Number of persisted chunks generated from the content. + pub chunk_count: u32, + /// Byte length of the stored content. + pub content_bytes: u32, + /// Whole-document BLAKE3 hash. + pub content_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Write-policy audit emitted for the stored document, when applicable. + pub write_policy_audit: Option, +} + +/// Normalized Source Library capture metadata returned by `docs_put`. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSourceCaptureSummary { + /// Schema identifier for this capture summary. + pub schema: String, + /// Stable source record identifier. This is also the stored `doc_id`. + pub source_record_id: Uuid, + /// Canonical source origin used for operator inspection and deduplication. + pub origin: String, + /// RFC3339 timestamp when ELF captured the source. + pub captured_at: String, + /// Whole-document BLAKE3 hash for the persisted content. + pub content_hash: String, + /// Visibility scope assigned to the source record. + pub visibility_scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional display title associated with the source record. + pub title: Option, + /// Normalized source type, derived from `source_kind` when present. + pub source_type: String, + /// Stable span references for persisted source chunks. + pub source_spans: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + /// Typed audit records for redacted or excluded source spans. + pub policy_spans: Vec, +} + +/// Stable reference to one captured or policy-affected source span. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSourceSpanRef { + /// Schema identifier for this span reference. + pub schema: String, + /// Stable span identifier derived from content hash and byte offsets. + pub span_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + /// Chunk identifier when this span is backed by a persisted chunk. + pub chunk_id: Option, + /// Span lifecycle status such as `captured`, `excluded`, or `redacted`. + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Typed reason code for non-captured spans. + pub reason_code: Option, + /// Inclusive start byte offset in the relevant content hash. + pub start_offset: usize, + /// Exclusive end byte offset in the relevant content hash. + pub end_offset: usize, + /// Whole-content hash that makes the offsets replayable. + pub content_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Chunk hash when this span is backed by a persisted chunk. + pub chunk_hash: Option, +} diff --git a/packages/elf-service/src/docs/api/read.rs b/packages/elf-service/src/docs/api/read.rs new file mode 100644 index 00000000..4e05454f --- /dev/null +++ b/packages/elf-service/src/docs/api/read.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::NoteOp; + +/// Request payload for document metadata lookup. +#[derive(Clone, Debug, Deserialize)] +pub struct DocsGetRequest { + /// Tenant that owns the document. + pub tenant_id: String, + /// Project that owns the document. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Identifier of the document to fetch. + pub doc_id: Uuid, +} + +/// Response payload for document metadata lookup. +#[derive(Clone, Debug, Serialize)] +pub struct DocsGetResponse { + /// Document identifier. + pub doc_id: Uuid, + /// Tenant that owns the document. + pub tenant_id: String, + /// Project that owns the document. + pub project_id: String, + /// Agent that ingested the document. + pub agent_id: String, + /// Scope key for the document. + pub scope: String, + /// Stored document type. + pub doc_type: String, + /// Lifecycle status for the document. + pub status: String, + /// Optional document title. + pub title: Option, + /// Structured provenance metadata. + pub source_ref: Value, + /// Byte length of the stored content. + pub content_bytes: u32, + /// Whole-document BLAKE3 hash. + pub content_hash: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Request payload for Source Library document deletion. +#[derive(Clone, Debug, Deserialize)] +pub struct DocsDeleteRequest { + /// Tenant that owns the document. + pub tenant_id: String, + /// Project that owns the document. + pub project_id: String, + /// Agent requesting the deletion. + pub agent_id: String, + /// Identifier of the document to delete. + pub doc_id: Uuid, +} + +/// Response payload for Source Library document deletion. +#[derive(Clone, Debug, Serialize)] +pub struct DocsDeleteResponse { + /// Identifier of the affected document. + pub doc_id: Uuid, + /// Operation that was applied. + pub op: NoteOp, + /// Number of persisted chunks queued for derived-index deletion. + pub chunk_delete_count: u32, +} diff --git a/packages/elf-service/src/docs/api/search_l0.rs b/packages/elf-service/src/docs/api/search_l0.rs new file mode 100644 index 00000000..dff85e80 --- /dev/null +++ b/packages/elf-service/src/docs/api/search_l0.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::docs::api::{selectors::TextPositionSelector, trajectory::DocRetrievalTrajectory}; + +/// Request payload for L0 document retrieval. +#[derive(Clone, Debug, Deserialize)] +pub struct DocsSearchL0Request { + /// Tenant to search within. + pub tenant_id: String, + /// Project to search within. + pub project_id: String, + /// Agent used for access-control checks. + pub caller_agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Search query text. + pub query: String, + /// Optional scope filter. + pub scope: Option, + /// Optional status filter. + pub status: Option, + /// Optional document-type filter. + pub doc_type: Option, + /// Sparse-retrieval mode override. + pub sparse_mode: Option, + /// Optional domain filter from source metadata. + pub domain: Option, + /// Optional repository filter from source metadata. + pub repo: Option, + /// Optional agent filter. + pub agent_id: Option, + /// Optional thread filter. + pub thread_id: Option, + /// Optional lower bound for `updated_at`. + pub updated_after: Option, + /// Optional upper bound for `updated_at`. + pub updated_before: Option, + /// Optional lower bound for source timestamp metadata. + pub ts_gte: Option, + /// Optional upper bound for source timestamp metadata. + pub ts_lte: Option, + /// Maximum number of returned items. + pub top_k: Option, + /// Retrieval breadth before deduplication and projection. + pub candidate_k: Option, + /// When true, includes retrieval trajectory output. + pub explain: Option, +} + +/// One chunk-level hit returned by `docs_search_l0`. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0Item { + /// Document identifier. + pub doc_id: Uuid, + /// Chunk identifier. + pub chunk_id: Uuid, + /// Stable pointer bundle for later excerpt or resolution workflows. + pub pointer: DocsSearchL0ItemPointer, + /// Final score after retrieval and boosting. + pub score: f32, + /// Returned snippet text. + pub snippet: String, + /// Scope key for the document. + pub scope: String, + /// Stored document type. + pub doc_type: String, + /// Project that owns the document. + pub project_id: String, + /// Agent that ingested the document. + pub agent_id: String, + /// Last update timestamp for the document. + pub updated_at: OffsetDateTime, + /// Whole-document BLAKE3 hash. + pub content_hash: String, + /// Chunk-level BLAKE3 hash. + pub chunk_hash: String, +} + +/// Response payload for `docs_search_l0`. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0Response { + /// Retrieval trace identifier. + pub trace_id: Uuid, + /// Returned chunk hits. + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional retrieval trajectory emitted in explain mode. + pub trajectory: Option, +} + +/// Stable pointer for a chunk hit returned by document search. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0ItemPointer { + /// Pointer schema identifier. + pub schema: String, + /// Pointer resolver identifier. + pub resolver: String, + #[serde(rename = "ref")] + /// Logical identifiers used by the resolver. + pub reference: DocsSearchL0ItemReference, + /// Freshness guard for the pointer target. + pub state: DocsSearchL0ItemState, + /// Hash aliases for simpler pointer consumers. + pub hashes: DocsSearchL0ItemHashes, + /// Selector hints that can hydrate this chunk through `docs_excerpts_get`. + pub locator: DocsSearchL0ItemLocator, +} + +/// Logical identifiers for a document-search hit. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0ItemReference { + /// Document identifier. + pub doc_id: Uuid, + /// Chunk identifier. + pub chunk_id: Uuid, + /// Stable source record identifier. + pub source_record_id: Uuid, + /// Stable source span identifier for this chunk. + pub source_span_id: Uuid, +} + +/// Freshness guard for a document-search hit. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0ItemState { + /// Whole-document BLAKE3 hash. + pub content_hash: String, + /// Chunk-level BLAKE3 hash. + pub chunk_hash: String, + #[serde(with = "crate::time_serde")] + /// Last update timestamp for the document. + pub doc_updated_at: OffsetDateTime, +} + +/// Hash values carried with a document-search pointer. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0ItemHashes { + /// Whole-document BLAKE3 hash. + pub content_hash: String, + /// Chunk-level BLAKE3 hash. + pub chunk_hash: String, +} + +/// Locator hints carried with a document-search pointer. +#[derive(Clone, Debug, Serialize)] +pub struct DocsSearchL0ItemLocator { + /// Stable source span identifier for the locator. + pub span_id: Uuid, + /// Chunk byte position in the authoritative document content. + pub position: TextPositionSelector, +} diff --git a/packages/elf-service/src/docs/api/selectors.rs b/packages/elf-service/src/docs/api/selectors.rs new file mode 100644 index 00000000..e86c7b12 --- /dev/null +++ b/packages/elf-service/src/docs/api/selectors.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +/// Quote-based selector for excerpt extraction. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextQuoteSelector { + /// Exact quote text to resolve. + pub exact: String, + /// Optional leading context used to disambiguate repeated quotes. + pub prefix: Option, + /// Optional trailing context used to disambiguate repeated quotes. + pub suffix: Option, +} + +/// Byte-position selector for excerpt extraction. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextPositionSelector { + /// Inclusive start byte offset. + pub start: usize, + /// Exclusive end byte offset. + pub end: usize, +} diff --git a/packages/elf-service/src/docs/api/trajectory.rs b/packages/elf-service/src/docs/api/trajectory.rs new file mode 100644 index 00000000..02276a36 --- /dev/null +++ b/packages/elf-service/src/docs/api/trajectory.rs @@ -0,0 +1,22 @@ +use serde::Serialize; +use serde_json::Value; + +/// Explain payload for a document retrieval run. +#[derive(Clone, Debug, Serialize)] +pub struct DocRetrievalTrajectory { + /// Trajectory schema identifier. + pub schema: String, + /// Ordered retrieval stages. + pub stages: Vec, +} + +/// One stage in a document retrieval trajectory. +#[derive(Clone, Debug, Serialize)] +pub struct DocRetrievalTrajectoryStage { + /// Zero-based stage order. + pub stage_order: u32, + /// Stable stage name. + pub stage_name: String, + /// Free-form stage statistics. + pub stats: Value, +} diff --git a/packages/elf-service/src/docs/chunking.rs b/packages/elf-service/src/docs/chunking.rs new file mode 100644 index 00000000..60e215ce --- /dev/null +++ b/packages/elf-service/src/docs/chunking.rs @@ -0,0 +1,80 @@ +use crate::docs::{ByteChunk, Config, Error, Result, Tokenizer, Uuid}; + +pub(super) fn load_tokenizer(cfg: &Config) -> Result { + let tokenizer_repo = cfg.chunking.tokenizer_repo.trim(); + + if tokenizer_repo.is_empty() { + return Err(Error::InvalidRequest { + message: "chunking.tokenizer_repo must be set.".to_string(), + }); + } + + elf_chunking::load_tokenizer(tokenizer_repo).map_err(|err| Error::InvalidRequest { + message: format!("failed to load tokenizer: {err}"), + }) +} + +pub(super) fn split_tokens_by_offsets( + text: &str, + profile_max_tokens: usize, + profile_overlap_tokens: usize, + max_chunks: usize, + tokenizer: &Tokenizer, +) -> Result> { + if profile_max_tokens == 0 { + return Err(Error::InvalidRequest { + message: "max_tokens must be greater than zero.".to_string(), + }); + } + if profile_overlap_tokens >= profile_max_tokens { + return Err(Error::InvalidRequest { + message: "overlap_tokens must be less than max_tokens.".to_string(), + }); + } + + let encoding = tokenizer.encode(text, false).map_err(|err| Error::InvalidRequest { + message: format!("failed to tokenize content: {err}"), + })?; + let offsets = encoding.get_offsets(); + let mut chunks = Vec::new(); + + if offsets.is_empty() { + return Ok(Vec::new()); + } + + let mut chunk_start_token = 0_usize; + + while chunk_start_token < offsets.len() { + let chunk_end_token = (chunk_start_token + profile_max_tokens).min(offsets.len()); + let (start_offset, end_offset) = { + let (start, _) = offsets[chunk_start_token]; + let (_, end) = offsets[chunk_end_token.saturating_sub(1)]; + + (start, end) + }; + let chunk_text = + text.get(start_offset..end_offset).ok_or_else(|| Error::InvalidRequest { + message: "computed chunk offset is invalid UTF-8 boundary.".to_string(), + })?; + + chunks.push(ByteChunk { + chunk_id: Uuid::new_v4(), + start_offset, + end_offset, + text: chunk_text.to_string(), + }); + + if chunk_end_token >= offsets.len() { + break; + } + if chunks.len() >= max_chunks { + return Err(Error::InvalidRequest { + message: "doc exceeds max_chunks_per_doc.".to_string(), + }); + } + + chunk_start_token = chunk_end_token.saturating_sub(profile_overlap_tokens); + } + + Ok(chunks) +} diff --git a/packages/elf-service/src/docs/excerpts.rs b/packages/elf-service/src/docs/excerpts.rs new file mode 100644 index 00000000..e6dfa9b9 --- /dev/null +++ b/packages/elf-service/src/docs/excerpts.rs @@ -0,0 +1,14 @@ +mod access; +mod filter; +mod locator; +mod match_resolution; +mod text; + +pub(super) use self::{ + access::{doc_read_allowed, parse_scored_point_uuid_id}, + filter::build_doc_search_filter, + locator::{build_docs_l0_pointer, docs_excerpt_locator}, + match_resolution::{docs_excerpts_resolve_windowed_match, load_docs_excerpt_context}, +}; +#[cfg(test)] pub(super) use text::should_enable_sparse_auto; +pub(super) use text::{docs_search_sparse_enabled, truncate_bytes}; diff --git a/packages/elf-service/src/docs/excerpts/access.rs b/packages/elf-service/src/docs/excerpts/access.rs new file mode 100644 index 00000000..2805a475 --- /dev/null +++ b/packages/elf-service/src/docs/excerpts/access.rs @@ -0,0 +1,40 @@ +use crate::docs::{Error, HashSet, PointIdOptions, Result, ScoredPoint, SharedSpaceGrantKey, Uuid}; + +pub(in crate::docs) fn doc_read_allowed( + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, + owner_agent_id: &str, + scope: &str, +) -> bool { + if !allowed_scopes.iter().any(|s| s == scope) { + return false; + } + if scope == "agent_private" { + return owner_agent_id == requester_agent_id; + } + if owner_agent_id == requester_agent_id { + return true; + } + + shared_grants.contains(&SharedSpaceGrantKey { + scope: scope.to_string(), + space_owner_agent_id: owner_agent_id.to_string(), + }) +} + +pub(in crate::docs) fn parse_scored_point_uuid_id(point: &ScoredPoint) -> Result { + let id = point + .id + .as_ref() + .ok_or_else(|| Error::Qdrant { message: "Qdrant returned item without id.".to_string() })?; + + match id.point_id_options.as_ref() { + Some(PointIdOptions::Uuid(s)) => Uuid::parse_str(s.as_str()) + .map_err(|_| Error::Qdrant { message: "Qdrant returned invalid uuid id.".to_string() }), + Some(other) => Err(Error::Qdrant { + message: format!("Qdrant returned unsupported id type: {other:?}."), + }), + None => Err(Error::Qdrant { message: "Qdrant returned item with missing id.".to_string() }), + } +} diff --git a/packages/elf-service/src/docs/excerpts/filter.rs b/packages/elf-service/src/docs/excerpts/filter.rs new file mode 100644 index 00000000..f53baf3b --- /dev/null +++ b/packages/elf-service/src/docs/excerpts/filter.rs @@ -0,0 +1,134 @@ +use crate::docs::{ + Condition, DatetimeRange, DocsSearchL0Filters, Filter, MinShould, ORG_PROJECT_ID, + OffsetDateTime, Timestamp, +}; + +pub(in crate::docs) fn build_doc_search_filter( + tenant_id: &str, + project_id: &str, + caller_agent_id: &str, + allowed_scopes: &[String], + filters: &DocsSearchL0Filters, +) -> Filter { + let private_scope = "agent_private".to_string(); + let non_private_scopes: Vec = + allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); + let mut scope_should_conditions = Vec::new(); + + if allowed_scopes.iter().any(|scope| scope == "agent_private") { + let private_filter = Filter::all([ + Condition::matches("scope", private_scope), + Condition::matches("agent_id", caller_agent_id.to_string()), + ]); + + scope_should_conditions.push(Condition::from(private_filter)); + } + if !non_private_scopes.is_empty() { + scope_should_conditions.push(Condition::matches("scope", non_private_scopes)); + } + + let scope_min_should = if scope_should_conditions.is_empty() { + None + } else { + Some(MinShould { min_count: 1, conditions: scope_should_conditions }) + }; + let mut project_or_org_branches = vec![Condition::from(Filter { + must: vec![Condition::matches("project_id", project_id.to_string())], + should: Vec::new(), + must_not: Vec::new(), + min_should: scope_min_should, + })]; + + if allowed_scopes.iter().any(|scope| scope == "org_shared") { + let org_filter = Filter::all([ + Condition::matches("project_id", ORG_PROJECT_ID.to_string()), + Condition::matches("scope", "org_shared".to_string()), + ]); + + project_or_org_branches.push(Condition::from(org_filter)); + } + + Filter { + must: { + let mut must = vec![ + Condition::matches("tenant_id", tenant_id.to_string()), + Condition::matches("status", filters.status.clone()), + ]; + + if let Some(scope) = filters.scope.as_ref() { + must.push(Condition::matches("scope", scope.to_string())); + } + if let Some(doc_type) = filters.doc_type.as_ref() { + must.push(Condition::matches("doc_type", doc_type.as_str().to_string())); + } + if let Some(domain) = filters.domain.as_ref() { + must.push(Condition::matches("domain", domain.to_string())); + } + if let Some(repo) = filters.repo.as_ref() { + must.push(Condition::matches("repo", repo.to_string())); + } + if let Some(agent_id) = filters.agent_id.as_ref() { + must.push(Condition::matches("agent_id", agent_id.to_string())); + } + if let Some(thread_id) = filters.thread_id.as_ref() { + must.push(Condition::matches("thread_id", thread_id.to_string())); + } + if let Some(datetime_filter) = datetime_filter_range( + filters.updated_after.as_ref(), + filters.updated_before.as_ref(), + ) { + must.push(datetime_filter); + } + if let Some(datetime_filter) = + doc_ts_filter_range(filters.ts_gte.as_ref(), filters.ts_lte.as_ref()) + { + must.push(datetime_filter); + } + + must + }, + should: Vec::new(), + must_not: Vec::new(), + min_should: Some(MinShould { min_count: 1, conditions: project_or_org_branches }), + } +} + +pub(in crate::docs) fn datetime_filter_range( + updated_after: Option<&OffsetDateTime>, + updated_before: Option<&OffsetDateTime>, +) -> Option { + let gt = updated_after.map(|updated_after| Timestamp { + seconds: updated_after.unix_timestamp(), + nanos: updated_after.nanosecond() as i32, + }); + let lt = updated_before.map(|updated_before| Timestamp { + seconds: updated_before.unix_timestamp(), + nanos: updated_before.nanosecond() as i32, + }); + + if gt.is_none() && lt.is_none() { + return None; + } + + Some(Condition::datetime_range("updated_at", DatetimeRange { lt, gt, gte: None, lte: None })) +} + +pub(in crate::docs) fn doc_ts_filter_range( + ts_gte: Option<&OffsetDateTime>, + ts_lte: Option<&OffsetDateTime>, +) -> Option { + let gte = ts_gte.map(|ts_gte| Timestamp { + seconds: ts_gte.unix_timestamp(), + nanos: ts_gte.nanosecond() as i32, + }); + let lte = ts_lte.map(|ts_lte| Timestamp { + seconds: ts_lte.unix_timestamp(), + nanos: ts_lte.nanosecond() as i32, + }); + + if gte.is_none() && lte.is_none() { + return None; + } + + Some(Condition::datetime_range("doc_ts", DatetimeRange { lt: None, gt: None, gte, lte })) +} diff --git a/packages/elf-service/src/docs/excerpts/locator.rs b/packages/elf-service/src/docs/excerpts/locator.rs new file mode 100644 index 00000000..b5bb96c3 --- /dev/null +++ b/packages/elf-service/src/docs/excerpts/locator.rs @@ -0,0 +1,73 @@ +use crate::docs::{ + self, DOC_SOURCE_REF_RESOLVER_V1, DOC_SOURCE_REF_SCHEMA_V1, DocSearchRow, DocsExcerptLocator, + DocsExcerptsGetRequest, DocsSearchL0ItemHashes, DocsSearchL0ItemLocator, + DocsSearchL0ItemPointer, DocsSearchL0ItemReference, DocsSearchL0ItemState, + ExcerptsSelectorKind, TextPositionSelector, Uuid, +}; + +pub(in crate::docs) fn docs_excerpt_locator( + req: &DocsExcerptsGetRequest, + selector_kind: &ExcerptsSelectorKind, + match_start_offset: usize, + match_end_offset: usize, + content_hash: &str, +) -> DocsExcerptLocator { + DocsExcerptLocator { + span_id: docs::source_span_id( + content_hash, + match_start_offset, + match_end_offset, + selector_kind.span_kind(), + ), + selector_kind: selector_kind.as_str().to_string(), + match_start_offset, + match_end_offset, + chunk_id: req.chunk_id, + quote: req.quote.clone(), + position: req.position.clone(), + } +} + +pub(in crate::docs) fn build_docs_l0_pointer( + row: &DocSearchRow, + chunk_id: Uuid, +) -> DocsSearchL0ItemPointer { + let hashes = DocsSearchL0ItemHashes { + content_hash: row.content_hash.clone(), + chunk_hash: row.chunk_hash.clone(), + }; + + DocsSearchL0ItemPointer { + schema: DOC_SOURCE_REF_SCHEMA_V1.to_string(), + resolver: DOC_SOURCE_REF_RESOLVER_V1.to_string(), + reference: DocsSearchL0ItemReference { + doc_id: row.doc_id, + chunk_id, + source_record_id: row.doc_id, + source_span_id: docs::source_span_id( + row.content_hash.as_str(), + row.start_offset.max(0) as usize, + row.end_offset.max(0) as usize, + "captured", + ), + }, + state: DocsSearchL0ItemState { + content_hash: hashes.content_hash.clone(), + chunk_hash: hashes.chunk_hash.clone(), + doc_updated_at: row.updated_at, + }, + hashes, + locator: DocsSearchL0ItemLocator { + span_id: docs::source_span_id( + row.content_hash.as_str(), + row.start_offset.max(0) as usize, + row.end_offset.max(0) as usize, + "captured", + ), + position: TextPositionSelector { + start: row.start_offset.max(0) as usize, + end: row.end_offset.max(0) as usize, + }, + }, + } +} diff --git a/packages/elf-service/src/docs/excerpts/match_resolution.rs b/packages/elf-service/src/docs/excerpts/match_resolution.rs new file mode 100644 index 00000000..1a50de27 --- /dev/null +++ b/packages/elf-service/src/docs/excerpts/match_resolution.rs @@ -0,0 +1,201 @@ +use crate::{ + access, + docs::{ + Config, DocDocument, DocExcerptMatch, DocExcerptRange, DocTrajectoryBuilder, + DocsExcerptsGetRequest, Error, ExcerptsSelectorKind, ORG_PROJECT_ID, PgExecutor, PgPool, + Result, Uuid, docs, excerpts::text, + }, + search, +}; + +pub(in crate::docs) async fn load_docs_excerpt_context( + cfg: &Config, + pool: &PgPool, + tenant_id: &str, + project_id: &str, + agent_id: &str, + read_profile: &str, + doc_id: Uuid, +) -> Result { + let allowed_scopes = search::resolve_read_profile_scopes(cfg, read_profile)?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + pool, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await?; + let doc = load_doc_document_for_read(pool, doc_id, tenant_id, project_id) + .await? + .ok_or_else(|| Error::NotFound { message: "Doc not found.".to_string() })?; + + if doc.status != "active" + || !crate::docs::excerpts::access::doc_read_allowed( + agent_id, + &allowed_scopes, + &shared_grants, + doc.agent_id.as_str(), + doc.scope.as_str(), + ) { + return Err(Error::NotFound { message: "Doc not found.".to_string() }); + } + + Ok(doc) +} + +pub(in crate::docs) async fn docs_excerpts_resolve_windowed_match( + pool: &PgPool, + doc: &DocDocument, + req: &DocsExcerptsGetRequest, + level_max: usize, + trajectory: &mut DocTrajectoryBuilder, + verified: &mut bool, + verification_errors: &mut Vec, +) -> Result { + let DocExcerptMatch { selector_kind, match_start_offset, match_end_offset } = + docs_excerpts_resolve_match(pool, doc, req, verified, verification_errors).await?; + + trajectory.push( + "match_resolution", + serde_json::json!({ + "selector_kind": selector_kind.as_str(), + "match_start": match_start_offset, + "match_end": match_end_offset, + }), + ); + + let (start_offset, end_offset) = + text::bounded_window(match_start_offset, match_end_offset, doc.content.as_str(), level_max); + + trajectory.push( + "window_projection", + serde_json::json!({ + "window_start": start_offset, + "window_end": end_offset, + "content_len": doc.content.len(), + }), + ); + + Ok(DocExcerptRange { + selector_kind, + match_start_offset, + match_end_offset, + start_offset, + end_offset, + }) +} + +pub(in crate::docs) async fn docs_excerpts_resolve_match( + pool: &PgPool, + doc: &DocDocument, + req: &DocsExcerptsGetRequest, + verified: &mut bool, + verification_errors: &mut Vec, +) -> Result { + let (match_start_offset, match_end_offset, selector_kind) = + resolve_excerpts_match_range(pool, doc, req, verified, verification_errors).await?; + + Ok(DocExcerptMatch { selector_kind, match_start_offset, match_end_offset }) +} + +pub(in crate::docs) async fn load_doc_document_for_read( + executor: impl PgExecutor<'_>, + doc_id: Uuid, + tenant_id: &str, + project_id: &str, +) -> Result> { + let row: Option = sqlx::query_as::<_, DocDocument>( + "\ +SELECT + doc_id, + tenant_id, + project_id, + agent_id, + scope, + doc_type, + status, + title, + COALESCE(source_ref, '{}'::jsonb) AS source_ref, + content, + content_bytes, + content_hash, + created_at, + updated_at +FROM doc_documents +WHERE doc_id = $1 + AND tenant_id = $2 + AND ( + project_id = $3 + OR (project_id = $4 AND scope = 'org_shared') + ) +LIMIT 1", + ) + .bind(doc_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +pub(in crate::docs) async fn resolve_excerpts_match_range( + pool: &PgPool, + doc: &DocDocument, + req: &DocsExcerptsGetRequest, + verified: &mut bool, + verification_errors: &mut Vec, +) -> Result<(usize, usize, ExcerptsSelectorKind)> { + if let Some(chunk_id) = req.chunk_id { + let chunk = docs::get_doc_chunk(pool, chunk_id).await?; + let Some(chunk) = chunk else { + return Err(Error::NotFound { message: "Chunk not found.".to_string() }); + }; + + if chunk.doc_id != doc.doc_id { + return Err(Error::NotFound { message: "Chunk not found.".to_string() }); + } + + return Ok(( + chunk.start_offset.max(0) as usize, + chunk.end_offset.max(0) as usize, + ExcerptsSelectorKind::ChunkId, + )); + } + if let Some(quote) = req.quote.as_ref() { + return Ok(match text::locate_quote(&doc.content, quote) { + Some((s, e)) => (s, e, ExcerptsSelectorKind::Quote), + None => { + *verified = false; + + verification_errors.push("QUOTE_SELECTOR_NOT_FOUND".to_string()); + + if let Some(pos) = req.position.as_ref() { + ( + pos.start.min(doc.content.len()), + pos.end.min(doc.content.len()), + ExcerptsSelectorKind::Position, + ) + } else { + return Err(Error::NotFound { + message: "Selector did not match document.".to_string(), + }); + } + }, + }); + } + if let Some(pos) = req.position.as_ref() { + return Ok(( + pos.start.min(doc.content.len()), + pos.end.min(doc.content.len()), + ExcerptsSelectorKind::Position, + )); + } + + Err(Error::InvalidRequest { + message: "One of chunk_id, quote, or position is required.".to_string(), + }) +} diff --git a/packages/elf-service/src/docs/excerpts/text.rs b/packages/elf-service/src/docs/excerpts/text.rs new file mode 100644 index 00000000..3084c751 --- /dev/null +++ b/packages/elf-service/src/docs/excerpts/text.rs @@ -0,0 +1,103 @@ +use crate::docs::{DocsSparseMode, TextQuoteSelector}; + +pub(in crate::docs) fn truncate_bytes(text: &str, max: usize) -> String { + if text.len() <= max { + return text.to_string(); + } + + let mut cut = max; + + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + + text.get(0..cut).unwrap_or("").to_string() +} + +pub(in crate::docs) fn locate_quote( + text: &str, + quote: &TextQuoteSelector, +) -> Option<(usize, usize)> { + let prefix = quote.prefix.as_deref().unwrap_or(""); + let suffix = quote.suffix.as_deref().unwrap_or(""); + + for (start, _) in text.match_indices(quote.exact.as_str()) { + let end = start + quote.exact.len(); + + if !text[..start].ends_with(prefix) { + continue; + } + if !text[end..].starts_with(suffix) { + continue; + } + + return Some((start, end)); + } + + None +} + +pub(in crate::docs) fn bounded_window( + match_start: usize, + match_end: usize, + text: &str, + max_bytes: usize, +) -> (usize, usize) { + let len = text.len(); + let match_center = match_start.saturating_add(match_end.saturating_sub(match_start) / 2); + let half = max_bytes / 2; + let mut start = match_center.saturating_sub(half); + let mut end = (start + max_bytes).min(len); + + if end - start < max_bytes && start > 0 { + start = start.saturating_sub(max_bytes - (end - start)); + } + + while start < len && !text.is_char_boundary(start) { + start += 1; + } + while end > start && !text.is_char_boundary(end) { + end -= 1; + } + + (start, end) +} + +pub(in crate::docs) fn docs_search_sparse_enabled(mode: DocsSparseMode, query: &str) -> bool { + match mode { + DocsSparseMode::Auto => should_enable_sparse_auto(query), + DocsSparseMode::On => true, + DocsSparseMode::Off => false, + } +} + +pub(in crate::docs) fn should_enable_sparse_auto(query: &str) -> bool { + let trimmed = query.trim(); + + if trimmed.is_empty() { + return false; + } + if trimmed.contains("://") + || trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains('?') + { + return true; + } + + let has_mixed_alpha_num = trimmed.split_whitespace().any(|token| { + token.chars().any(|ch| ch.is_ascii_alphabetic()) + && token.chars().any(|ch| ch.is_ascii_digit()) + }); + let special_count = trimmed + .chars() + .filter(|ch| !(ch.is_ascii_alphanumeric() || ch.is_ascii_whitespace() || *ch == '_')) + .count(); + let compact_hex_like = { + let compact = trimmed.chars().filter(|ch| !ch.is_ascii_whitespace()).collect::(); + + compact.len() >= 12 && compact.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-') + }; + + special_count >= 2 || compact_hex_like || (has_mixed_alpha_num && trimmed.len() > 12) +} diff --git a/packages/elf-service/src/docs/queries.rs b/packages/elf-service/src/docs/queries.rs new file mode 100644 index 00000000..64af614c --- /dev/null +++ b/packages/elf-service/src/docs/queries.rs @@ -0,0 +1,93 @@ +use crate::docs::{ + self, BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME, DocSearchRow, DocsSparseMode, Document, + Error, Filter, Fusion, HashMap, ORG_PROJECT_ID, PgExecutor, PrefetchQueryBuilder, Qdrant, + Query, QueryPointsBuilder, Result, ScoredPoint, Uuid, +}; + +pub(super) async fn run_doc_fusion_query( + client: &Qdrant, + collection: &str, + query_text: &str, + vector: &[f32], + filter: &Filter, + sparse_mode: DocsSparseMode, + candidate_k: u32, +) -> Result> { + let sparse_enabled = docs::docs_search_sparse_enabled(sparse_mode, query_text); + let dense_prefetch = PrefetchQueryBuilder::default() + .query(Query::new_nearest(vector.to_vec())) + .using(DENSE_VECTOR_NAME) + .filter(filter.clone()) + .limit(candidate_k as u64); + let mut search = QueryPointsBuilder::new(collection.to_string()); + + search = search.add_prefetch(dense_prefetch); + + if sparse_enabled { + let bm25_prefetch = PrefetchQueryBuilder::default() + .query(Query::new_nearest(Document::new(query_text.to_string(), BM25_MODEL))) + .using(BM25_VECTOR_NAME) + .filter(filter.clone()) + .limit(candidate_k as u64); + + search = search.add_prefetch(bm25_prefetch); + } + + let search = search.with_payload(false).query(Fusion::Rrf).limit(candidate_k as u64); + let response = + client.query(search).await.map_err(|err| Error::Qdrant { message: err.to_string() })?; + + Ok(response.result) +} + +pub(super) async fn load_doc_search_rows( + executor: impl PgExecutor<'_>, + tenant_id: &str, + project_id: &str, + status: &str, + chunk_ids: &[Uuid], +) -> Result> { + if chunk_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec = sqlx::query_as( + "\ +SELECT + c.chunk_id, + c.doc_id, + d.scope, + d.doc_type, + d.project_id, + d.agent_id, + d.updated_at, + d.content_hash, + c.chunk_hash, + c.start_offset, + c.end_offset, + c.chunk_text +FROM doc_chunks c +JOIN doc_documents d ON d.doc_id = c.doc_id +WHERE c.chunk_id = ANY($1) + AND d.tenant_id = $2 + AND d.status = $4 + AND ( + d.project_id = $3 + OR (d.project_id = $5 AND d.scope = 'org_shared') + )", + ) + .bind(chunk_ids) + .bind(tenant_id) + .bind(project_id) + .bind(status) + .bind(ORG_PROJECT_ID) + .fetch_all(executor) + .await?; + let mut map = HashMap::with_capacity(rows.len()); + + for row in rows { + map.insert(row.chunk_id, row); + } + + Ok(map) +} diff --git a/packages/elf-service/src/docs/search_support.rs b/packages/elf-service/src/docs/search_support.rs new file mode 100644 index 00000000..52f43cc2 --- /dev/null +++ b/packages/elf-service/src/docs/search_support.rs @@ -0,0 +1,101 @@ +use crate::docs::{ + self, DEFAULT_L0_MAX_BYTES, DocSearchRow, DocTrajectoryBuilder, DocsSearchL0Item, HashMap, + HashSet, OffsetDateTime, Result, ScoredPoint, SharedSpaceGrantKey, Uuid, +}; + +pub(super) fn docs_search_l0_deduplicated_chunks( + scored: &[ScoredPoint], + candidate_k: usize, +) -> Result> { + let mut seen = HashSet::new(); + let mut chunks = Vec::new(); + + for point in scored.iter().take(candidate_k) { + let chunk_id = docs::parse_scored_point_uuid_id(point)?; + + if seen.insert(chunk_id) { + chunks.push((chunk_id, point.score)); + } + } + + Ok(chunks) +} + +pub(super) fn docs_search_l0_project_items( + scored_chunks: &[(Uuid, f32)], + rows: &HashMap, + caller_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, +) -> Vec { + let mut items = Vec::with_capacity(scored_chunks.len()); + + for (chunk_id, score) in scored_chunks { + let Some(row) = rows.get(chunk_id) else { continue }; + + if !docs::doc_read_allowed( + caller_agent_id, + allowed_scopes, + shared_grants, + row.agent_id.as_str(), + row.scope.as_str(), + ) { + continue; + } + + items.push(DocsSearchL0Item { + doc_id: row.doc_id, + chunk_id: *chunk_id, + pointer: docs::build_docs_l0_pointer(row, *chunk_id), + score: *score, + snippet: docs::truncate_bytes(row.chunk_text.as_str(), DEFAULT_L0_MAX_BYTES), + scope: row.scope.clone(), + doc_type: row.doc_type.clone(), + project_id: row.project_id.clone(), + agent_id: row.agent_id.clone(), + updated_at: row.updated_at, + content_hash: row.content_hash.clone(), + chunk_hash: row.chunk_hash.clone(), + }); + } + + items +} + +pub(super) fn apply_doc_recency_boost( + items: &mut [DocsSearchL0Item], + now: OffsetDateTime, + recency_tau_days: f32, + tie_breaker_weight: f32, +) { + if tie_breaker_weight <= 0.0 || items.is_empty() { + return; + } + + for item in items.iter_mut() { + let age_days = ((now - item.updated_at).as_seconds_f32() / 86_400.0).max(0.0); + let recency_decay = + if recency_tau_days > 0.0 { (-age_days / recency_tau_days).exp() } else { 1.0 }; + + item.score += tie_breaker_weight * recency_decay; + } +} + +pub(super) fn record_result_projection_stage( + trajectory: &mut DocTrajectoryBuilder, + pre_authorization_candidates: usize, + returned_items: usize, + recency_tau_days: f32, + tie_breaker_weight: f32, +) { + trajectory.push( + "result_projection", + serde_json::json!({ + "pre_authorization_candidates": pre_authorization_candidates, + "returned_items": returned_items, + "recency_tau_days": recency_tau_days, + "tie_breaker_weight": tie_breaker_weight, + "recency_boost_applied": tie_breaker_weight > 0.0 && !pre_authorization_candidates.eq(&0), + }), + ) +} diff --git a/packages/elf-service/src/docs/service.rs b/packages/elf-service/src/docs/service.rs new file mode 100644 index 00000000..7c241bc5 --- /dev/null +++ b/packages/elf-service/src/docs/service.rs @@ -0,0 +1,27 @@ +mod excerpt_get; +mod l0_search; +mod put; +mod read; + +use crate::{ + access, + docs::{ + DocDocument, DocExcerptRange, DocSearchRow, DocTrajectoryBuilder, DocsDeleteRequest, + DocsDeleteResponse, DocsExcerptResponse, DocsExcerptVerification, DocsExcerptsGetRequest, + DocsGetRequest, DocsGetResponse, DocsPutRequest, DocsPutResponse, DocsSearchL0Filters, + DocsSearchL0Item, DocsSearchL0Prepared, DocsSearchL0Request, DocsSearchL0Response, + DocsSparseMode, ElfService, Error, HashMap, HashSet, MAX_CANDIDATE_K, MAX_TOP_K, NoteOp, + ORG_PROJECT_ID, OffsetDateTime, Result, ScoredPoint, SharedSpaceGrantKey, + SourceCaptureSummaryInput, Uuid, ValidatedDocsPut, apply_doc_recency_boost, + build_doc_chunk_rows, build_doc_search_filter, build_source_capture_summary, + doc_chunk_id_for, doc_outbox, doc_read_allowed, docs, docs_excerpt_locator, + docs_excerpts_resolve_windowed_match, docs_search_l0_deduplicated_chunks, + docs_search_l0_project_items, docs_search_sparse_enabled, excerpt_level_max, + load_doc_search_rows, load_docs_excerpt_context, load_tokenizer, + normalize_source_ref_for_capture, record_result_projection_stage, + resolve_doc_chunking_profile, run_doc_fusion_query, slice, source_record_id_for, + split_tokens_by_offsets, validate_docs_excerpts_get, validate_docs_put, + validate_docs_search_l0, + }, + search, +}; diff --git a/packages/elf-service/src/docs/service/excerpt_get.rs b/packages/elf-service/src/docs/service/excerpt_get.rs new file mode 100644 index 00000000..b7678b26 --- /dev/null +++ b/packages/elf-service/src/docs/service/excerpt_get.rs @@ -0,0 +1,114 @@ +use crate::docs::service::{ + self, DocExcerptRange, DocTrajectoryBuilder, DocsExcerptResponse, DocsExcerptVerification, + DocsExcerptsGetRequest, ElfService, Result, Uuid, +}; + +impl ElfService { + /// Resolves and verifies an excerpt window from quote, position, or chunk selectors. + pub async fn docs_excerpts_get( + &self, + req: DocsExcerptsGetRequest, + ) -> Result { + let explain = req.explain.unwrap_or(false); + let trace_id = Uuid::new_v4(); + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + let read_profile = req.read_profile.trim(); + let mut trajectory = DocTrajectoryBuilder::new(explain); + + trajectory.push( + "request_validation", + serde_json::json!({ + "doc_id": req.doc_id, + "read_profile": read_profile, + }), + ); + + service::validate_docs_excerpts_get( + tenant_id, + project_id, + agent_id, + read_profile, + req.quote.as_ref(), + )?; + + let doc = service::load_docs_excerpt_context( + &self.cfg, + &self.db.pool, + tenant_id, + project_id, + agent_id, + read_profile, + req.doc_id, + ) + .await?; + let level_max = service::excerpt_level_max(req.level.as_str())?; + + trajectory.push( + "level_selection", + serde_json::json!({ + "level": req.level, + "max_bytes": level_max, + }), + ); + + let mut verified = true; + let mut verification_errors = Vec::new(); + let DocExcerptRange { + selector_kind, + match_start_offset, + match_end_offset, + start_offset, + end_offset, + } = service::docs_excerpts_resolve_windowed_match( + &self.db.pool, + &doc, + &req, + level_max, + &mut trajectory, + &mut verified, + &mut verification_errors, + ) + .await?; + let excerpt = doc.content.get(start_offset..end_offset).unwrap_or("").to_string(); + + if excerpt.is_empty() { + verified = false; + + verification_errors.push("EMPTY_EXCERPT".to_string()); + } + + let excerpt_hash = blake3::hash(excerpt.as_bytes()).to_hex().to_string(); + + trajectory.push( + "verification", + serde_json::json!({ + "verified": verified, + "error_count": verification_errors.len(), + }), + ); + + Ok(DocsExcerptResponse { + trace_id, + doc_id: doc.doc_id, + excerpt, + start_offset, + end_offset, + locator: service::docs_excerpt_locator( + &req, + &selector_kind, + match_start_offset, + match_end_offset, + doc.content_hash.as_str(), + ), + verification: DocsExcerptVerification { + verified, + verification_errors, + content_hash: doc.content_hash.clone(), + excerpt_hash, + }, + trajectory: trajectory.into_trajectory(), + }) + } +} diff --git a/packages/elf-service/src/docs/service/l0_search.rs b/packages/elf-service/src/docs/service/l0_search.rs new file mode 100644 index 00000000..e5f28e22 --- /dev/null +++ b/packages/elf-service/src/docs/service/l0_search.rs @@ -0,0 +1,233 @@ +use crate::docs::service::{ + self, DocSearchRow, DocTrajectoryBuilder, DocsSearchL0Filters, DocsSearchL0Item, + DocsSearchL0Prepared, DocsSearchL0Request, DocsSearchL0Response, DocsSparseMode, ElfService, + Error, HashMap, HashSet, MAX_CANDIDATE_K, MAX_TOP_K, OffsetDateTime, Result, ScoredPoint, + SharedSpaceGrantKey, Uuid, access, load_doc_search_rows, search, slice, +}; + +impl ElfService { + /// Runs L0 document retrieval with access filtering and optional explain output. + pub async fn docs_search_l0(&self, req: DocsSearchL0Request) -> Result { + let trace_id = Uuid::new_v4(); + let filters = service::validate_docs_search_l0(&req)?; + let mut prepared = self.prepare_docs_search_l0_request(&req, &filters).await?; + let scored = service::run_doc_fusion_query( + &self.qdrant.client, + self.cfg.storage.qdrant.docs_collection.as_str(), + req.query.as_str(), + &prepared.vector, + &prepared.filter, + prepared.sparse_mode, + prepared.candidate_k, + ) + .await?; + + self.record_docs_search_l0_vector_stats( + &mut prepared.trajectory, + &scored, + prepared.sparse_enabled, + prepared.sparse_mode, + ); + + let scored_chunks = + service::docs_search_l0_deduplicated_chunks(&scored, prepared.candidate_k as usize)?; + let chunk_ids: Vec = scored_chunks.iter().map(|(chunk_id, _)| *chunk_id).collect(); + let rows = self + .load_doc_search_rows(&req, &prepared.status, &chunk_ids, &mut prepared.trajectory) + .await?; + let mut items = self.build_docs_search_l0_items( + &req, + &scored_chunks, + &rows, + &prepared.allowed_scopes, + &prepared.shared_grants, + &mut prepared.trajectory, + ); + + service::apply_doc_recency_boost( + &mut items, + prepared.now, + self.cfg.ranking.recency_tau_days, + self.cfg.ranking.tie_breaker_weight, + ); + + items.sort_by(|a, b| b.score.total_cmp(&a.score)); + items.truncate(prepared.top_k as usize); + + service::record_result_projection_stage( + &mut prepared.trajectory, + rows.len(), + items.len(), + self.cfg.ranking.recency_tau_days, + self.cfg.ranking.tie_breaker_weight, + ); + + Ok(DocsSearchL0Response { + trace_id, + items, + trajectory: prepared.trajectory.into_trajectory(), + }) + } + + async fn load_doc_search_rows( + &self, + req: &DocsSearchL0Request, + status: &str, + chunk_ids: &[Uuid], + trajectory: &mut DocTrajectoryBuilder, + ) -> Result> { + let rows = load_doc_search_rows( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + status, + chunk_ids, + ) + .await?; + + trajectory.push( + "chunk_lookup", + serde_json::json!({ + "requested_chunks": chunk_ids.len(), + "loaded_chunks": rows.len(), + }), + ); + + Ok(rows) + } + + fn build_docs_search_l0_items( + &self, + req: &DocsSearchL0Request, + scored_chunks: &[(Uuid, f32)], + rows: &HashMap, + allowed_scopes: &[String], + shared_grants: &HashSet, + trajectory: &mut DocTrajectoryBuilder, + ) -> Vec { + let items = service::docs_search_l0_project_items( + scored_chunks, + rows, + req.caller_agent_id.as_str(), + allowed_scopes, + shared_grants, + ); + + trajectory.push( + "dedupe", + serde_json::json!({ + "raw_candidates": scored_chunks.len(), + "deduped_candidates": items.len(), + }), + ); + + items + } + + async fn prepare_docs_search_l0_request( + &self, + req: &DocsSearchL0Request, + filters: &DocsSearchL0Filters, + ) -> Result { + let explain = req.explain.unwrap_or(false); + let top_k = req.top_k.unwrap_or(12).min(MAX_TOP_K); + let candidate_k = req.candidate_k.unwrap_or(60).min(MAX_CANDIDATE_K); + let sparse_mode = filters.sparse_mode; + let sparse_enabled = service::docs_search_sparse_enabled(sparse_mode, req.query.as_str()); + let now = OffsetDateTime::now_utc(); + let mut trajectory = DocTrajectoryBuilder::new(explain); + + trajectory.push( + "request_validation", + serde_json::json!({ + "query_len": req.query.len(), + "top_k": top_k, + "candidate_k": candidate_k, + "sparse_mode": sparse_mode.as_str(), + "doc_type": filters + .doc_type + .as_ref() + .map(|doc_type| doc_type.as_str()) + .unwrap_or(""), + "status": &filters.status, + }), + ); + + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, req.read_profile.as_str())?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.caller_agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let filter = service::build_doc_search_filter( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.caller_agent_id.as_str(), + &allowed_scopes, + filters, + ); + let embedded = self + .providers + .embedding + .embed(&self.cfg.providers.embedding, slice::from_ref(&req.query)) + .await?; + + trajectory.push("query_embedding", serde_json::json!({ "provider": "embedding" })); + + let vector = embedded.first().ok_or_else(|| Error::Provider { + message: "Embedding provider returned no vectors.".to_string(), + })?; + + trajectory.push( + "vector_dimension_check", + serde_json::json!({ + "provided_dim": vector.len(), + "expected_dim": self.cfg.storage.qdrant.vector_dim as usize, + }), + ); + + if vector.len() != self.cfg.storage.qdrant.vector_dim as usize { + return Err(Error::Provider { + message: "Embedding vector dimension mismatch.".to_string(), + }); + } + + Ok(DocsSearchL0Prepared { + top_k, + candidate_k, + sparse_mode, + sparse_enabled, + now, + trajectory, + allowed_scopes, + shared_grants, + filter, + vector: vector.to_vec(), + status: filters.status.clone(), + }) + } + + fn record_docs_search_l0_vector_stats( + &self, + trajectory: &mut DocTrajectoryBuilder, + scored: &[ScoredPoint], + sparse_enabled: bool, + sparse_mode: DocsSparseMode, + ) { + let channels = if sparse_enabled { vec!["dense", "sparse"] } else { vec!["dense"] }; + + trajectory.push( + "vector_search", + serde_json::json!({ + "raw_points": scored.len(), + "sparse_mode": sparse_mode.as_str(), + "channels": channels, + }), + ); + } +} diff --git a/packages/elf-service/src/docs/service/put.rs b/packages/elf-service/src/docs/service/put.rs new file mode 100644 index 00000000..3be81c50 --- /dev/null +++ b/packages/elf-service/src/docs/service/put.rs @@ -0,0 +1,119 @@ +use crate::docs::service::{ + self, DocDocument, DocsPutRequest, DocsPutResponse, ElfService, Error, ORG_PROJECT_ID, + OffsetDateTime, Result, SourceCaptureSummaryInput, ValidatedDocsPut, access, doc_outbox, docs, +}; + +impl ElfService { + /// Validates, chunks, stores, and enqueues a document for indexing. + pub async fn docs_put(&self, req: DocsPutRequest) -> Result { + let ValidatedDocsPut { doc_type, content, write_policy_audit } = + service::validate_docs_put(&req)?; + let now = OffsetDateTime::now_utc(); + let embed_version = crate::embedding_version(&self.cfg); + let chunking_profile = service::resolve_doc_chunking_profile(doc_type); + let tokenizer = service::load_tokenizer(&self.cfg)?; + let tenant_id = req.tenant_id.clone(); + let project_id = req.project_id.clone(); + let agent_id = req.agent_id.clone(); + let scope = req.scope.clone(); + let title = req.title.clone(); + let source_ref = req.source_ref.clone(); + let source_ref_map = source_ref.as_object().ok_or_else(|| Error::InvalidRequest { + message: "source_ref must be a JSON object.".to_string(), + })?; + let effective_project_id = + if scope.trim() == "org_shared" { ORG_PROJECT_ID } else { project_id.as_str() }; + let content_bytes = content.len(); + let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string(); + let raw_content_hash = blake3::hash(req.content.as_bytes()).to_hex().to_string(); + let doc_id = service::source_record_id_for( + tenant_id.as_str(), + effective_project_id, + agent_id.as_str(), + scope.as_str(), + doc_type, + source_ref_map, + content_hash.as_str(), + ); + let mut chunks = service::split_tokens_by_offsets( + content.as_str(), + chunking_profile.max_tokens, + chunking_profile.overlap_tokens, + chunking_profile.max_chunks, + &tokenizer, + )?; + + for (chunk_index, chunk) in chunks.iter_mut().enumerate() { + chunk.chunk_id = service::doc_chunk_id_for(doc_id, chunk_index as i32); + } + + let chunk_rows = service::build_doc_chunk_rows(doc_id, &chunks, now); + let source_capture = service::build_source_capture_summary(SourceCaptureSummaryInput { + doc_id, + source_ref: source_ref_map, + doc_type, + scope: scope.as_str(), + title: title.as_deref(), + content_hash: content_hash.as_str(), + raw_content_hash: raw_content_hash.as_str(), + now, + chunks: &chunk_rows, + write_policy_audit: write_policy_audit.as_ref(), + })?; + let normalized_source_ref = + service::normalize_source_ref_for_capture(source_ref, &source_capture)?; + let doc_row = DocDocument { + doc_id, + tenant_id: tenant_id.clone(), + project_id: effective_project_id.to_string(), + agent_id: agent_id.clone(), + scope: scope.clone(), + doc_type: doc_type.as_str().to_string(), + status: "active".to_string(), + title, + source_ref: docs::normalize_source_ref(Some(normalized_source_ref)), + content, + content_bytes: content_bytes as i32, + content_hash: content_hash.clone(), + created_at: now, + updated_at: now, + }; + let mut tx = self.db.pool.begin().await?; + + docs::insert_doc_document(&mut *tx, &doc_row).await?; + + for chunk_row in &chunk_rows { + docs::insert_doc_chunk(&mut *tx, chunk_row).await?; + doc_outbox::enqueue_doc_outbox( + &mut *tx, + doc_id, + chunk_row.chunk_id, + "UPSERT", + embed_version.as_str(), + ) + .await?; + } + + if scope.trim() != "agent_private" { + access::ensure_active_project_scope_grant( + &mut *tx, + tenant_id.as_str(), + effective_project_id, + scope.as_str(), + agent_id.as_str(), + ) + .await?; + } + + tx.commit().await?; + + Ok(DocsPutResponse { + doc_id, + source_capture, + chunk_count: chunk_rows.len() as u32, + content_bytes: content_bytes as u32, + content_hash, + write_policy_audit, + }) + } +} diff --git a/packages/elf-service/src/docs/service/read.rs b/packages/elf-service/src/docs/service/read.rs new file mode 100644 index 00000000..dd65978f --- /dev/null +++ b/packages/elf-service/src/docs/service/read.rs @@ -0,0 +1,201 @@ +use crate::docs::service::{ + self, DocDocument, DocsDeleteRequest, DocsDeleteResponse, DocsGetRequest, DocsGetResponse, + ElfService, Error, HashSet, NoteOp, ORG_PROJECT_ID, OffsetDateTime, Result, access, doc_outbox, + docs, search, +}; + +impl ElfService { + /// Loads document metadata when the caller can read the requested scope. + pub async fn docs_get(&self, req: DocsGetRequest) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + let read_profile = req.read_profile.trim(); + + if tenant_id.is_empty() + || project_id.is_empty() + || agent_id.is_empty() + || read_profile.is_empty() + { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, agent_id, and read_profile are required." + .to_string(), + }); + } + + let allowed_scopes = search::resolve_read_profile_scopes(&self.cfg, read_profile)?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let row: Option = sqlx::query_as::<_, DocDocument>( + "\ +SELECT + doc_id, + tenant_id, + project_id, + agent_id, + scope, + doc_type, + status, + title, + COALESCE(source_ref, '{}'::jsonb) AS source_ref, + content, + content_bytes, + content_hash, + created_at, + updated_at +FROM doc_documents +WHERE doc_id = $1 + AND tenant_id = $2 + AND ( + project_id = $3 + OR (project_id = $4 AND scope = 'org_shared') + ) +LIMIT 1", + ) + .bind(req.doc_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(&self.db.pool) + .await?; + let Some(row) = row else { + return Err(Error::NotFound { message: "Doc not found.".to_string() }); + }; + let shared_grants = if row.scope == "agent_private" { + HashSet::new() + } else { + access::load_shared_read_grants_with_org_shared( + &self.db.pool, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await? + }; + + if row.status != "active" + || !service::doc_read_allowed( + agent_id, + &allowed_scopes, + &shared_grants, + row.agent_id.as_str(), + row.scope.as_str(), + ) { + return Err(Error::NotFound { message: "Doc not found.".to_string() }); + } + + Ok(DocsGetResponse { + doc_id: row.doc_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + scope: row.scope, + doc_type: row.doc_type, + status: row.status, + title: row.title, + source_ref: row.source_ref, + content_bytes: row.content_bytes.max(0) as u32, + content_hash: row.content_hash, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } + + /// Soft-deletes one Source Library document and enqueues doc-vector deletion. + pub async fn docs_delete(&self, req: DocsDeleteRequest) -> Result { + let now = OffsetDateTime::now_utc(); + let embed_version = crate::embedding_version(&self.cfg); + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let mut tx = self.db.pool.begin().await?; + let row: DocDocument = sqlx::query_as::<_, DocDocument>( + "\ +SELECT + doc_id, + tenant_id, + project_id, + agent_id, + scope, + doc_type, + status, + title, + COALESCE(source_ref, '{}'::jsonb) AS source_ref, + content, + content_bytes, + content_hash, + created_at, + updated_at +FROM doc_documents +WHERE doc_id = $1 + AND tenant_id = $2 + AND ( + project_id = $3 + OR (project_id = $4 AND scope = 'org_shared') + ) +FOR UPDATE", + ) + .bind(req.doc_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| Error::NotFound { message: "Doc not found.".to_string() })?; + + if row.agent_id != agent_id { + return Err(Error::NotFound { message: "Doc not found.".to_string() }); + } + + let scope_allowed = self.cfg.scopes.allowed.iter().any(|scope| scope == &row.scope); + let write_allowed = match row.scope.as_str() { + "agent_private" => self.cfg.scopes.write_allowed.agent_private, + "project_shared" => self.cfg.scopes.write_allowed.project_shared, + "org_shared" => self.cfg.scopes.write_allowed.org_shared, + _ => false, + }; + + if !scope_allowed || !write_allowed { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + if row.status == "deleted" { + tx.commit().await?; + + return Ok(DocsDeleteResponse { + doc_id: row.doc_id, + op: NoteOp::None, + chunk_delete_count: 0, + }); + } + + let chunks = docs::list_doc_chunks(&mut *tx, row.doc_id).await?; + + docs::mark_doc_deleted(&mut *tx, tenant_id, row.doc_id, now).await?; + + for chunk in &chunks { + doc_outbox::enqueue_doc_outbox( + &mut *tx, + row.doc_id, + chunk.chunk_id, + "DELETE", + embed_version.as_str(), + ) + .await?; + } + + tx.commit().await?; + + Ok(DocsDeleteResponse { + doc_id: row.doc_id, + op: NoteOp::Delete, + chunk_delete_count: chunks.len() as u32, + }) + } +} diff --git a/packages/elf-service/src/docs/source_capture.rs b/packages/elf-service/src/docs/source_capture.rs new file mode 100644 index 00000000..ac844798 --- /dev/null +++ b/packages/elf-service/src/docs/source_capture.rs @@ -0,0 +1,332 @@ +use crate::docs::{ + ByteChunk, DOC_SOURCE_CAPTURE_SCHEMA_V1, DOC_SOURCE_SPAN_SCHEMA_V1, DocChunk, DocType, + DocsSourceCaptureSummary, DocsSourceSpanRef, Error, Map, OffsetDateTime, Result, Rfc3339, + SourceCaptureSummaryInput, Uuid, Value, WritePolicyAudit, +}; + +pub(super) fn build_doc_chunk_rows( + doc_id: Uuid, + chunks: &[ByteChunk], + now: OffsetDateTime, +) -> Vec { + chunks + .iter() + .enumerate() + .map(|(chunk_index, chunk)| DocChunk { + chunk_id: doc_chunk_id_for(doc_id, chunk_index as i32), + doc_id, + chunk_index: chunk_index as i32, + start_offset: chunk.start_offset as i32, + end_offset: chunk.end_offset as i32, + chunk_text: chunk.text.clone(), + chunk_hash: blake3::hash(chunk.text.as_bytes()).to_hex().to_string(), + created_at: now, + }) + .collect() +} + +pub(super) fn doc_chunk_id_for(doc_id: Uuid, chunk_index: i32) -> Uuid { + let name = format!("elf-doc-chunk/v1:{doc_id}:{chunk_index}"); + + Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) +} + +pub(super) fn source_record_id_for( + tenant_id: &str, + project_id: &str, + agent_id: &str, + scope: &str, + doc_type: DocType, + source_ref: &Map, + content_hash: &str, +) -> Uuid { + let name = serde_json::json!([ + "elf-doc-source-record/v1", + tenant_id, + project_id, + agent_id, + scope, + doc_type.as_str(), + source_identity_value(source_ref, doc_type), + content_hash, + ]) + .to_string(); + + Uuid::new_v5(&Uuid::NAMESPACE_URL, name.as_bytes()) +} + +pub(super) fn source_span_id( + content_hash: &str, + start: usize, + end: usize, + span_kind: &str, +) -> Uuid { + let name = serde_json::json!(["elf-doc-source-span/v1", content_hash, start, end, span_kind,]) + .to_string(); + + Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) +} + +pub(super) fn build_source_capture_summary( + input: SourceCaptureSummaryInput<'_>, +) -> Result { + let SourceCaptureSummaryInput { + doc_id, + source_ref, + doc_type, + scope, + title, + content_hash, + raw_content_hash, + now, + chunks, + write_policy_audit, + } = input; + let captured_at = source_ref + .get("captured_at") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or(format_timestamp(now)?); + let source_spans = chunks + .iter() + .map(|chunk| DocsSourceSpanRef { + schema: DOC_SOURCE_SPAN_SCHEMA_V1.to_string(), + span_id: source_span_id( + content_hash, + chunk.start_offset.max(0) as usize, + chunk.end_offset.max(0) as usize, + "captured", + ), + chunk_id: Some(chunk.chunk_id), + status: "captured".to_string(), + reason_code: None, + start_offset: chunk.start_offset.max(0) as usize, + end_offset: chunk.end_offset.max(0) as usize, + content_hash: content_hash.to_string(), + chunk_hash: Some(chunk.chunk_hash.clone()), + }) + .collect(); + let policy_spans = source_policy_spans(raw_content_hash, write_policy_audit); + + Ok(DocsSourceCaptureSummary { + schema: DOC_SOURCE_CAPTURE_SCHEMA_V1.to_string(), + source_record_id: doc_id, + origin: source_origin(source_ref, doc_type), + captured_at, + content_hash: content_hash.to_string(), + visibility_scope: scope.to_string(), + title: title.map(ToString::to_string), + source_type: source_type(source_ref, doc_type), + source_spans, + policy_spans, + }) +} + +pub(super) fn source_policy_spans( + raw_content_hash: &str, + audit: Option<&WritePolicyAudit>, +) -> Vec { + let Some(audit) = audit else { + return Vec::new(); + }; + let mut spans = Vec::with_capacity(audit.exclusions.len() + audit.redactions.len()); + + for span in &audit.exclusions { + spans.push(policy_span_ref( + raw_content_hash, + span.start, + span.end, + "excluded", + "WRITE_POLICY_EXCLUSION", + )); + } + for redaction in &audit.redactions { + spans.push(policy_span_ref( + raw_content_hash, + redaction.span.start, + redaction.span.end, + "redacted", + "WRITE_POLICY_REDACTION", + )); + } + + spans +} + +pub(super) fn policy_span_ref( + content_hash: &str, + start: usize, + end: usize, + status: &str, + reason_code: &str, +) -> DocsSourceSpanRef { + DocsSourceSpanRef { + schema: DOC_SOURCE_SPAN_SCHEMA_V1.to_string(), + span_id: source_span_id(content_hash, start, end, reason_code), + chunk_id: None, + status: status.to_string(), + reason_code: Some(reason_code.to_string()), + start_offset: start, + end_offset: end, + content_hash: content_hash.to_string(), + chunk_hash: None, + } +} + +pub(super) fn normalize_source_ref_for_capture( + source_ref: Value, + source_capture: &DocsSourceCaptureSummary, +) -> Result { + let mut source_ref = source_ref.as_object().cloned().ok_or_else(|| Error::InvalidRequest { + message: "source_ref must be a JSON object.".to_string(), + })?; + + source_ref.insert( + "source_record_id".to_string(), + Value::String(source_capture.source_record_id.to_string()), + ); + source_ref.insert("origin".to_string(), Value::String(source_capture.origin.clone())); + source_ref.insert("captured_at".to_string(), Value::String(source_capture.captured_at.clone())); + source_ref + .insert("content_hash".to_string(), Value::String(source_capture.content_hash.clone())); + source_ref.insert( + "visibility_scope".to_string(), + Value::String(source_capture.visibility_scope.clone()), + ); + + if let Some(title) = source_capture.title.as_ref() { + source_ref.entry("title".to_string()).or_insert_with(|| Value::String(title.clone())); + } + + source_ref.insert("source_type".to_string(), Value::String(source_capture.source_type.clone())); + source_ref + .insert("source_spans".to_string(), source_spans_to_value(&source_capture.source_spans)?); + + if !source_capture.policy_spans.is_empty() { + source_ref.insert( + "policy_spans".to_string(), + source_spans_to_value(&source_capture.policy_spans)?, + ); + } + + Ok(Value::Object(source_ref)) +} + +pub(super) fn source_spans_to_value(spans: &[DocsSourceSpanRef]) -> Result { + serde_json::to_value(spans).map_err(|err| Error::InvalidRequest { + message: format!("failed to encode source span metadata: {err}"), + }) +} + +pub(super) fn source_type(source_ref: &Map, doc_type: DocType) -> String { + source_ref + .get("source_kind") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| doc_type.as_str()) + .to_string() +} + +pub(super) fn source_origin(source_ref: &Map, doc_type: DocType) -> String { + if let Some(origin) = source_ref_string(source_ref, "canonical_uri") + .or_else(|| source_ref_string(source_ref, "url")) + .or_else(|| source_ref_string(source_ref, "uri")) + { + return origin.to_string(); + } + + match doc_type { + DocType::Chat => source_ref_string(source_ref, "message_id") + .map(|message_id| { + format!( + "thread:{}#{}", + source_ref_string(source_ref, "thread_id").unwrap_or("unknown"), + message_id + ) + }) + .unwrap_or_else(|| { + format!( + "thread:{}", + source_ref_string(source_ref, "thread_id").unwrap_or("unknown") + ) + }), + DocType::Search => source_ref_string(source_ref, "domain") + .map(|domain| format!("search:{domain}")) + .unwrap_or_else(|| "search:unknown".to_string()), + DocType::Dev => dev_origin(source_ref), + DocType::Knowledge => source_ref_string(source_ref, "ts") + .map(|ts| format!("knowledge:{ts}")) + .unwrap_or_else(|| "knowledge:unknown".to_string()), + } +} + +pub(super) fn dev_origin(source_ref: &Map) -> String { + let repo = source_ref_string(source_ref, "repo").unwrap_or("unknown"); + let path = source_ref_string(source_ref, "path").unwrap_or(""); + let revision = source_ref_string(source_ref, "commit_sha") + .map(|commit| format!("@{commit}")) + .or_else(|| source_ref_i64(source_ref, "pr_number").map(|pr| format!("#pr-{pr}"))) + .or_else(|| { + source_ref_i64(source_ref, "issue_number").map(|issue| format!("#issue-{issue}")) + }) + .unwrap_or_default(); + + if path.is_empty() { + format!("repo:{repo}{revision}") + } else { + format!("repo:{repo}/{path}{revision}") + } +} + +pub(super) fn source_identity_value(source_ref: &Map, doc_type: DocType) -> Value { + if let Some(canonical_uri) = source_ref_string(source_ref, "canonical_uri") { + return serde_json::json!(["canonical_uri", canonical_uri]); + } + + match doc_type { + DocType::Chat => serde_json::json!([ + "chat", + source_ref_string(source_ref, "thread_id"), + source_ref_string(source_ref, "message_id"), + source_ref_string(source_ref, "role"), + source_ref_string(source_ref, "ts"), + ]), + DocType::Search => serde_json::json!([ + "search", + source_ref_string(source_ref, "url"), + source_ref_string(source_ref, "domain"), + source_ref_string(source_ref, "query"), + source_ref_string(source_ref, "ts"), + ]), + DocType::Dev => serde_json::json!([ + "dev", + source_ref_string(source_ref, "repo"), + source_ref_string(source_ref, "path"), + source_ref_string(source_ref, "commit_sha"), + source_ref_i64(source_ref, "pr_number"), + source_ref_i64(source_ref, "issue_number"), + ]), + DocType::Knowledge => serde_json::json!([ + "knowledge", + source_ref_string(source_ref, "uri"), + source_ref_string(source_ref, "ts"), + ]), + } +} + +pub(super) fn source_ref_string<'a>( + source_ref: &'a Map, + key: &str, +) -> Option<&'a str> { + source_ref.get(key).and_then(Value::as_str).filter(|value| !value.trim().is_empty()) +} + +pub(super) fn source_ref_i64(source_ref: &Map, key: &str) -> Option { + source_ref.get(key).and_then(Value::as_i64) +} + +pub(super) fn format_timestamp(ts: OffsetDateTime) -> Result { + ts.format(&Rfc3339).map_err(|err| Error::InvalidRequest { + message: format!("failed to format RFC3339 timestamp: {err}"), + }) +} diff --git a/packages/elf-service/src/docs/tests.rs b/packages/elf-service/src/docs/tests.rs new file mode 100644 index 00000000..b6b66950 --- /dev/null +++ b/packages/elf-service/src/docs/tests.rs @@ -0,0 +1,1140 @@ +use ahash::AHashMap; +use qdrant_client::qdrant::{ + DatetimeRange, Filter, condition::ConditionOneOf, r#match::MatchValue, +}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokenizers::{Tokenizer, models::wordlevel::WordLevel, pre_tokenizers::whitespace::Whitespace}; +use uuid::Uuid; + +use crate::docs::{ + self, DocSearchRow, DocType, DocsPutRequest, DocsSearchL0Filters, DocsSearchL0Request, + DocsSparseMode, Error, SourceCaptureSummaryInput, +}; +use elf_domain::writegate::{WritePolicy, WritePolicyAudit, WriteRedactionResult, WriteSpan}; +use elf_storage::models::DocChunk; + +const TENANT_ID: &str = "tenant"; +const PROJECT_ID: &str = "project"; + +fn test_request_with_query(query: &str) -> DocsSearchL0Request { + DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: query.to_string(), + scope: None, + status: None, + doc_type: None, + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + } +} + +fn first_datetime_range(filter: &Filter, key: &str) -> Option { + for condition in &filter.must { + if let Some(ConditionOneOf::Field(field)) = condition.condition_one_of.as_ref() { + if field.key != key { + continue; + } + + if let Some(range) = field.datetime_range.as_ref() { + return Some(*range); + } + } + } + + None +} + +fn first_match_value(filter: &Filter, key: &str) -> Option { + for condition in &filter.must { + if let Some(ConditionOneOf::Field(field)) = condition.condition_one_of.as_ref() { + if field.key != key { + continue; + } + + if let Some(r#match) = field.r#match.as_ref() { + let Some(match_value) = r#match.match_value.as_ref() else { + continue; + }; + + return match match_value { + MatchValue::Keyword(value) => Some(value.clone()), + _ => None, + }; + } + } + } + + None +} + +fn test_tokenizer() -> Tokenizer { + let mut vocab = AHashMap::new(); + + vocab.insert("alpha".to_string(), 1_u32); + vocab.insert("beta".to_string(), 2_u32); + vocab.insert("charlie".to_string(), 3_u32); + vocab.insert("delta".to_string(), 4_u32); + vocab.insert("".to_string(), 0_u32); + + let model = WordLevel::builder() + .vocab(vocab) + .unk_token("".to_string()) + .build() + .expect("Failed to build test tokenizer."); + let mut tokenizer = Tokenizer::new(model); + + tokenizer.with_pre_tokenizer(Some(Whitespace)); + + tokenizer +} + +#[test] +fn doc_type_parses_and_serializes() { + let encoded = + serde_json::to_string(&DocType::Knowledge).expect("Expected DocType serialization."); + let parsed = + serde_json::from_str::("\"knowledge\"").expect("Expected parse to succeed."); + let invalid: Result = serde_json::from_str("\"invalid\""); + + assert_eq!(encoded, "\"knowledge\""); + assert_eq!(parsed, DocType::Knowledge); + assert!(invalid.is_err()); +} + +#[test] +fn docs_search_l0_requires_chat_doc_type_for_thread_id() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "thread".to_string(), + scope: None, + status: None, + doc_type: Some("search".to_string()), + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: Some("thread-1".to_string()), + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected thread_id to require doc_type=chat."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("thread_id requires")), + other => panic!("Unexpected error: {other:?}"), + } + + docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "thread".to_string(), + scope: None, + status: None, + doc_type: Some("chat".to_string()), + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: Some("thread-1".to_string()), + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect("Expected thread_id filter to be accepted for chat."); +} + +#[test] +fn validate_docs_put_rejects_invalid_doc_type() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "invalid", + "ts": "2026-02-25T12:00:00Z", + }), + content: "Hello world.".to_string(), + }) + .expect_err("Expected invalid doc_type to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("doc_type")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn resolve_doc_chunking_profile_is_deterministic_by_doc_type() { + let small = docs::resolve_doc_chunking_profile(DocType::Chat); + + assert_eq!(small.max_tokens, 1_024); + assert_eq!(small.overlap_tokens, 128); + + let default = docs::resolve_doc_chunking_profile(DocType::Knowledge); + + assert_eq!(default.max_tokens, 2_048); + assert_eq!(default.overlap_tokens, 256); +} + +#[test] +fn validate_docs_search_l0_defaults_status_and_filters_dates() { + let filters = docs::validate_docs_search_l0(&test_request_with_query("hello world")) + .expect("valid request"); + + assert_eq!(filters.status, "active"); + + let bad_dates = DocsSearchL0Request { + updated_after: Some("2026-02-25T12:00:00Z".to_string()), + updated_before: Some("2026-02-25T11:00:00Z".to_string()), + sparse_mode: None, + domain: None, + repo: None, + ..test_request_with_query("status") + }; + let err = docs::validate_docs_search_l0(&bad_dates) + .expect_err("Expected bad date order to be rejected."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("earlier")); + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_search_l0_rejects_invalid_status() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "status".to_string(), + scope: None, + status: Some("archived".to_string()), + doc_type: None, + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected invalid status to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("status")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_search_l0_rejects_invalid_datetime_format() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "status".to_string(), + scope: None, + status: None, + doc_type: None, + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: None, + updated_after: Some("2026-02-25T12:00:00".to_string()), + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected invalid RFC3339 datetime to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("RFC3339")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn build_doc_search_filter_applies_status_and_requested_filters() { + let filters = DocsSearchL0Filters { + scope: Some("project_shared".to_string()), + status: "deleted".to_string(), + doc_type: Some(DocType::Chat), + sparse_mode: DocsSparseMode::Auto, + domain: None, + repo: None, + agent_id: Some("owner".to_string()), + thread_id: Some("thread-7".to_string()), + updated_after: Some( + OffsetDateTime::parse("2026-02-20T00:00:00Z", &Rfc3339).expect("Invalid timestamp."), + ), + updated_before: Some( + OffsetDateTime::parse("2026-02-28T00:00:00Z", &Rfc3339).expect("Invalid timestamp."), + ), + ts_gte: Some( + OffsetDateTime::parse("2026-01-01T00:00:00Z", &Rfc3339).expect("Invalid timestamp."), + ), + ts_lte: Some( + OffsetDateTime::parse("2026-12-31T00:00:00Z", &Rfc3339).expect("Invalid timestamp."), + ), + }; + let filter = super::build_doc_search_filter( + TENANT_ID, + PROJECT_ID, + "requester", + &["agent_private".to_string(), "project_shared".to_string()], + &filters, + ); + + assert_eq!(first_match_value(&filter, "tenant_id").as_deref(), Some("tenant")); + assert_eq!(first_match_value(&filter, "status").as_deref(), Some("deleted")); + assert_eq!(first_match_value(&filter, "scope").as_deref(), Some("project_shared")); + assert_eq!(first_match_value(&filter, "doc_type").as_deref(), Some("chat")); + assert_eq!(first_match_value(&filter, "agent_id").as_deref(), Some("owner")); + assert_eq!(first_match_value(&filter, "thread_id").as_deref(), Some("thread-7")); + assert_eq!(first_match_value(&filter, "domain").as_deref(), None); + assert_eq!(first_match_value(&filter, "repo").as_deref(), None); + + let datetime_range = first_datetime_range(&filter, "updated_at") + .expect("Expected datetime filter for updated_at."); + let after = + OffsetDateTime::parse("2026-02-20T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); + let before = + OffsetDateTime::parse("2026-02-28T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); + let lt = datetime_range.lt.as_ref().expect("Expected datetime filter .lt value."); + let gt = datetime_range.gt.as_ref().expect("Expected datetime filter .gt value."); + + assert_eq!(lt.seconds, before.unix_timestamp()); + assert_eq!(lt.nanos, before.nanosecond() as i32); + assert_eq!(gt.seconds, after.unix_timestamp()); + assert_eq!(gt.nanos, after.nanosecond() as i32); + assert!(datetime_range.gte.is_none()); + assert!(datetime_range.lte.is_none()); + + let doc_ts_range = + first_datetime_range(&filter, "doc_ts").expect("Expected datetime filter for doc_ts."); + let gte = doc_ts_range.gte.as_ref().expect("Expected datetime filter .gte value."); + let lte = doc_ts_range.lte.as_ref().expect("Expected datetime filter .lte value."); + let doc_ts_gte = + OffsetDateTime::parse("2026-01-01T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); + let doc_ts_lte = + OffsetDateTime::parse("2026-12-31T00:00:00Z", &Rfc3339).expect("Invalid timestamp."); + + assert_eq!(gte.seconds, doc_ts_gte.unix_timestamp()); + assert_eq!(gte.nanos, doc_ts_gte.nanosecond() as i32); + assert_eq!(lte.seconds, doc_ts_lte.unix_timestamp()); + assert_eq!(lte.nanos, doc_ts_lte.nanosecond() as i32); + assert!(doc_ts_range.gt.is_none()); + assert!(doc_ts_range.lt.is_none()); +} + +#[test] +fn validate_docs_search_l0_rejects_invalid_doc_ts_order() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "status".to_string(), + scope: None, + status: None, + doc_type: None, + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: Some("2026-02-25T12:00:00Z".to_string()), + ts_lte: Some("2026-02-25T11:00:00Z".to_string()), + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected bad doc_ts order to be rejected."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("earlier")); + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_search_l0_rejects_invalid_sparse_mode() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "status".to_string(), + scope: None, + status: None, + doc_type: None, + sparse_mode: Some("invalid".to_string()), + domain: None, + repo: None, + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected invalid sparse mode to be rejected."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("sparse_mode")); + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_search_l0_rejects_domain_without_doc_type_search() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "status".to_string(), + scope: None, + status: None, + doc_type: None, + sparse_mode: None, + domain: Some("example.com".to_string()), + repo: None, + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected domain without doc_type=search to be rejected."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("doc_type=search")); + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_search_l0_rejects_repo_without_doc_type_dev() { + let err = docs::validate_docs_search_l0(&DocsSearchL0Request { + tenant_id: TENANT_ID.to_string(), + project_id: PROJECT_ID.to_string(), + caller_agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "status".to_string(), + scope: None, + status: None, + doc_type: None, + sparse_mode: None, + domain: None, + repo: Some("hack-ink/ELF".to_string()), + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: None, + candidate_k: None, + explain: None, + }) + .expect_err("Expected repo without doc_type=dev to be rejected."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("doc_type=dev")); + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_search_l0_default_sparse_mode() { + let filters = + docs::validate_docs_search_l0(&test_request_with_query("status")).expect("valid request"); + + assert!(matches!(filters.sparse_mode, DocsSparseMode::Auto)); +} + +#[test] +fn should_enable_sparse_auto_uses_symbol_cues() { + assert!(super::should_enable_sparse_auto("https://example.com/search?q=abc")); + assert!(!super::should_enable_sparse_auto("how to debug a timeout")); +} + +#[test] +fn excerpt_level_max_supports_l0_and_rejects_unknown_level() { + assert_eq!( + super::excerpt_level_max("L0").expect("Expected L0 to be supported."), + super::DEFAULT_L0_MAX_BYTES + ); + assert!(super::excerpt_level_max("L3").is_err()); +} + +#[test] +fn validate_docs_put_rejects_missing_source_ref() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Knowledge.as_str().to_string()), + title: None, + write_policy: None, + source_ref: serde_json::json!({"schema":"doc_source_ref/v1", "doc_type":"knowledge"}), + content: "Hello world.".to_string(), + }) + .expect_err("Expected missing source_ref.ts to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("source_ref[\"ts\"]")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_rejects_non_object_source_ref() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: None, + write_policy: None, + source_ref: serde_json::json!("legacy-shape"), + content: "Hello world.".to_string(), + }) + .expect_err("Expected non-object source_ref to be rejected."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("source_ref must be a JSON object")) + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_rejects_mismatched_request_and_source_ref_doc_type() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Chat.as_str().to_string()), + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + }), + content: "Hello world.".to_string(), + }) + .expect_err("Expected mismatched doc_type to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("match")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_rejects_wrong_source_ref_schema() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "note_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + }), + content: "Hello world.".to_string(), + }) + .expect_err("Expected wrong source_ref.schema to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("doc_source_ref/v1")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_rejects_chat_source_ref_with_missing_thread_metadata() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Chat.as_str().to_string()), + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "chat", + "ts": "2026-02-25T12:00:00Z", + }), + content: "Hello world.".to_string(), + }) + .expect_err("Expected chat source_ref to require thread_id/role."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("thread_id")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_rejects_search_source_ref_with_missing_domain() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Search.as_str().to_string()), + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "search", + "ts": "2026-02-25T12:00:00Z", + "query": "test", + "url": "https://example.com", + }), + content: "Hello world.".to_string(), + }) + .expect_err("Expected search source_ref to require domain."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("domain")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_rejects_dev_source_ref_with_multiple_identifiers() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Dev.as_str().to_string()), + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "dev", + "ts": "2026-02-25T12:00:00Z", + "repo": "hack-ink/ELF", + "commit_sha": "9f0a3f4c4eb58bfcf4a5f4f9d0c7be0e13c2f8d19", + "issue_number": 123, + }), + content: "Hello world.".to_string(), + }) + .expect_err("Expected dev source_ref to enforce exactly one identifier field."); + + match err { + Error::InvalidRequest { message } => { + assert!(message.contains("exactly one of commit_sha, pr_number, or issue_number")) + }, + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_uses_source_ref_doc_type_when_request_doc_type_is_absent() { + let resolved_doc_type = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: None, + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "chat", + "ts": "2026-02-25T12:00:00Z", + "thread_id": "thread-1", + "role": "assistant" + }), + content: "Hello world.".to_string(), + }) + .expect("Expected valid source_ref to resolve doc_type."); + + assert_eq!(resolved_doc_type.doc_type, DocType::Chat); +} + +#[test] +fn validate_docs_put_accepts_source_library_article_metadata() { + let validated = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Knowledge.as_str().to_string()), + title: Some("Saved article".to_string()), + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "source_kind": "article", + "canonical_uri": "https://example.com/research/source-library", + "captured_at": "2026-02-25T12:10:00Z", + "source_created_at": "2026-02-24T09:00:00Z", + "trust_label": "public_web", + "author": "Example Author", + "handle": "example-author", + "excerpt_locator": { + "quote": { + "exact": "Source libraries preserve long-form evidence." + }, + "position": { + "start": 0, + "end": 48 + } + } + }), + content: + "Source libraries preserve long-form evidence. Agents can hydrate exact excerpts later." + .to_string(), + }) + .expect("Expected source library metadata to be accepted."); + + assert_eq!(validated.doc_type, DocType::Knowledge); +} + +#[test] +fn source_capture_metadata_uses_stable_record_and_span_ids() { + let now = OffsetDateTime::parse("2026-02-25T12:15:00Z", &Rfc3339) + .expect("Expected test timestamp to parse."); + let source_ref = serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "source_kind": "article", + "canonical_uri": "https://example.com/research/source-library", + "captured_at": "2026-02-25T12:10:00Z", + "trust_label": "public_web", + }); + let source_ref = source_ref.as_object().expect("Expected source_ref object."); + let content_hash = "doc-content-hash"; + let doc_id = super::source_record_id_for( + TENANT_ID, + PROJECT_ID, + "owner", + "project_shared", + DocType::Knowledge, + source_ref, + content_hash, + ); + let repeated_doc_id = super::source_record_id_for( + TENANT_ID, + PROJECT_ID, + "owner", + "project_shared", + DocType::Knowledge, + source_ref, + content_hash, + ); + let chunk_id = super::doc_chunk_id_for(doc_id, 0); + let chunk = DocChunk { + chunk_id, + doc_id, + chunk_index: 0, + start_offset: 0, + end_offset: 42, + chunk_text: "Source libraries preserve long-form evidence.".to_string(), + chunk_hash: "chunk-content-hash".to_string(), + created_at: now, + }; + let capture = super::build_source_capture_summary(SourceCaptureSummaryInput { + doc_id, + source_ref, + doc_type: DocType::Knowledge, + scope: "project_shared", + title: Some("Saved article"), + content_hash, + raw_content_hash: "raw-content-hash", + now, + chunks: &[chunk], + write_policy_audit: None, + }) + .expect("Expected source capture summary."); + + assert_eq!(doc_id, repeated_doc_id); + assert_eq!(capture.schema, "doc_source_capture/v1"); + assert_eq!(capture.source_record_id, doc_id); + assert_eq!(capture.origin, "https://example.com/research/source-library"); + assert_eq!(capture.captured_at, "2026-02-25T12:10:00Z"); + assert_eq!(capture.content_hash, content_hash); + assert_eq!(capture.visibility_scope, "project_shared"); + assert_eq!(capture.title.as_deref(), Some("Saved article")); + assert_eq!(capture.source_type, "article"); + assert_eq!(capture.source_spans.len(), 1); + assert_eq!(capture.source_spans[0].schema, "doc_source_span/v1"); + assert_eq!(capture.source_spans[0].chunk_id, Some(chunk_id)); + assert_eq!(capture.source_spans[0].status, "captured"); + assert_eq!(capture.source_spans[0].reason_code, None); + assert_eq!(capture.source_spans[0].start_offset, 0); + assert_eq!(capture.source_spans[0].end_offset, 42); + assert_eq!( + capture.source_spans[0].span_id, + super::source_span_id(content_hash, 0, 42, "captured") + ); +} + +#[test] +fn normalized_source_ref_records_policy_span_reasons() { + let now = OffsetDateTime::parse("2026-02-25T12:15:00Z", &Rfc3339) + .expect("Expected test timestamp to parse."); + let source_ref = serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "uri": "file:///tmp/source.txt", + }); + let source_ref_map = source_ref.as_object().expect("Expected source_ref object."); + let audit = WritePolicyAudit { + exclusions: vec![WriteSpan { start: 6, end: 12 }], + redactions: vec![WriteRedactionResult { + span: WriteSpan { start: 20, end: 30 }, + replacement: "[redacted]".to_string(), + }], + }; + let doc_id = super::source_record_id_for( + TENANT_ID, + PROJECT_ID, + "owner", + "project_shared", + DocType::Knowledge, + source_ref_map, + "stored-hash", + ); + let capture = super::build_source_capture_summary(SourceCaptureSummaryInput { + doc_id, + source_ref: source_ref_map, + doc_type: DocType::Knowledge, + scope: "project_shared", + title: None, + content_hash: "stored-hash", + raw_content_hash: "raw-hash", + now, + chunks: &[], + write_policy_audit: Some(&audit), + }) + .expect("Expected source capture summary."); + let normalized = super::normalize_source_ref_for_capture(source_ref, &capture) + .expect("Expected normalized source_ref"); + + assert_eq!(capture.policy_spans.len(), 2); + assert_eq!(capture.policy_spans[0].status, "excluded"); + assert_eq!(capture.policy_spans[0].reason_code.as_deref(), Some("WRITE_POLICY_EXCLUSION")); + assert_eq!(capture.policy_spans[1].status, "redacted"); + assert_eq!(capture.policy_spans[1].reason_code.as_deref(), Some("WRITE_POLICY_REDACTION")); + assert_eq!(normalized["source_record_id"], doc_id.to_string()); + assert_eq!(normalized["origin"], "file:///tmp/source.txt"); + assert_eq!(normalized["captured_at"], "2026-02-25T12:15:00Z"); + assert_eq!(normalized["content_hash"], "stored-hash"); + assert_eq!(normalized["visibility_scope"], "project_shared"); + assert_eq!(normalized["source_type"], "knowledge"); + assert_eq!(normalized["policy_spans"][0]["reason_code"], "WRITE_POLICY_EXCLUSION"); + assert_eq!(normalized["policy_spans"][1]["reason_code"], "WRITE_POLICY_REDACTION"); +} + +#[test] +fn validate_docs_put_rejects_incomplete_source_library_metadata() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Knowledge.as_str().to_string()), + title: Some("Saved article".to_string()), + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "source_kind": "article", + "captured_at": "2026-02-25T12:10:00Z", + "trust_label": "public_web" + }), + content: "Source libraries preserve long-form evidence.".to_string(), + }) + .expect_err("Expected canonical_uri to be required for source library metadata."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("canonical_uri")), + other => panic!("Unexpected error: {other:?}"), + } + + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Knowledge.as_str().to_string()), + title: Some("Saved thread".to_string()), + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "source_kind": "social_thread", + "canonical_uri": "https://example.com/thread/123", + "captured_at": "2026-02-25T12:10:00Z", + "trust_label": "public_web" + }), + content: "The thread says source libraries need social captures.".to_string(), + }) + .expect_err("Expected social_thread source_kind to require chat doc_type."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("requires doc_type=chat")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn docs_l0_pointer_carries_hashes_and_position_locator() { + let now = OffsetDateTime::parse("2026-02-25T12:00:00Z", &Rfc3339) + .expect("Expected test timestamp to parse."); + let row = DocSearchRow { + chunk_id: Uuid::parse_str("11111111-1111-4111-8111-111111111111") + .expect("Expected chunk UUID."), + doc_id: Uuid::parse_str("22222222-2222-4222-8222-222222222222") + .expect("Expected doc UUID."), + scope: "project_shared".to_string(), + doc_type: "knowledge".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + updated_at: now, + content_hash: "doc-hash".to_string(), + chunk_hash: "chunk-hash".to_string(), + start_offset: 12, + end_offset: 64, + chunk_text: "Source libraries preserve long-form evidence.".to_string(), + }; + let pointer = super::build_docs_l0_pointer(&row, row.chunk_id); + + assert_eq!(pointer.schema, "source_ref/v1"); + assert_eq!(pointer.resolver, "elf_doc_ext/v1"); + assert_eq!(pointer.hashes.content_hash, "doc-hash"); + assert_eq!(pointer.hashes.chunk_hash, "chunk-hash"); + assert_eq!(pointer.reference.source_record_id, row.doc_id); + assert_eq!(pointer.reference.source_span_id, pointer.locator.span_id); + assert_eq!(pointer.locator.position.start, 12); + assert_eq!(pointer.locator.position.end, 64); + assert_eq!(pointer.locator.span_id, super::source_span_id("doc-hash", 12, 64, "captured")); + assert_eq!(pointer.state.content_hash, pointer.hashes.content_hash); + assert_eq!(pointer.state.chunk_hash, pointer.hashes.chunk_hash); +} + +#[test] +fn validate_docs_put_applies_write_policy_and_includes_audit() { + let validated = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Knowledge.as_str().to_string()), + title: None, + write_policy: Some(WritePolicy { + exclusions: vec![WriteSpan { start: 6, end: 35 }], + redactions: vec![], + }), + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + }), + content: "Hello sk-abcdefghijklmnopqrstuvwxyz!".to_string(), + }) + .expect("Expected valid write policy transformation."); + let expected_audit = WritePolicyAudit { + exclusions: vec![WriteSpan { start: 6, end: 35 }], + ..Default::default() + }; + + assert_eq!(validated.content, "Hello !".to_string()); + assert_eq!(validated.write_policy_audit.unwrap_or_default(), expected_audit); +} + +#[test] +fn validate_docs_put_rejects_secret_after_write_policy() { + let err = docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: Some(DocType::Knowledge.as_str().to_string()), + title: None, + write_policy: Some(WritePolicy { exclusions: vec![], redactions: vec![] }), + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + }), + content: "Hello sk-abcdefghijklmnopqrstuvwxyz!".to_string(), + }) + .expect_err("Expected secret-bearing content to be rejected."); + + match err { + Error::InvalidRequest { message } => assert!(message.contains("contains secrets")), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn validate_docs_put_allows_doc_source_ref_v1_and_rejects_free_text() { + docs::validate_docs_put(&DocsPutRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: Some("English title".to_string()), + write_policy: None, + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "notes": "English only." + }), + content: "English content.".to_string(), + }) + .expect("Expected doc_source_ref/v1 source_ref to be accepted."); + + let err = docs::validate_docs_put(&DocsPutRequest { + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "notes": "\u{4f60}\u{597d}\u{4e16}\u{754c}" + }), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: Some("English title".to_string()), + write_policy: None, + content: "English content.".to_string(), + }) + .expect_err("Expected non-English free-text in source_ref."); + + match err { + Error::NonEnglishInput { field } => assert_eq!(field, "$.source_ref[\"notes\"]"), + other => panic!("Unexpected error: {other:?}"), + } + + let err = docs::validate_docs_put(&DocsPutRequest { + source_ref: serde_json::json!({ + "schema": "doc_source_ref/v1", + "doc_type": "knowledge", + "ts": "2026-02-25T12:00:00Z", + "ref": "\u{4f60}\u{597d}\u{4e16}\u{754c}" + }), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "project_shared".to_string(), + doc_type: None, + title: Some("English title".to_string()), + write_policy: None, + content: "English content.".to_string(), + }) + .expect_err("Expected identifier lane with non-Latin text to be rejected."); + + match err { + Error::NonEnglishInput { field } => assert_eq!(field, "$.source_ref[\"ref\"]"), + other => panic!("Unexpected error: {other:?}"), + } +} + +#[test] +fn split_tokens_by_offsets_preserves_original_substring_offsets() { + let tokenizer = test_tokenizer(); + let chunks = super::split_tokens_by_offsets("alpha bravo charlie delta", 2, 1, 10, &tokenizer) + .expect("Expected token chunking to succeed."); + + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0].start_offset, 0); + assert_eq!(chunks[0].end_offset, 11); + assert_eq!(chunks[1].start_offset, 6); + assert_eq!(chunks[1].end_offset, 19); + assert_eq!(chunks[2].start_offset, 12); + assert_eq!(chunks[2].end_offset, 25); + + for chunk in &chunks { + assert_eq!(chunk.text, "alpha bravo charlie delta"[chunk.start_offset..chunk.end_offset]); + } +} diff --git a/packages/elf-service/src/docs/types.rs b/packages/elf-service/src/docs/types.rs new file mode 100644 index 00000000..8916c50f --- /dev/null +++ b/packages/elf-service/src/docs/types.rs @@ -0,0 +1,225 @@ +use crate::docs::{ + DocChunk, DocRetrievalTrajectory, DocRetrievalTrajectoryStage, DocType, Filter, FromRow, + HashSet, Map, OffsetDateTime, SharedSpaceGrantKey, Uuid, Value, WritePolicyAudit, +}; + +pub(super) const MAX_TOP_K: u32 = 32; +pub(super) const MAX_CANDIDATE_K: u32 = 1_024; +pub(super) const DEFAULT_DOC_MAX_BYTES: usize = 4 * 1_024 * 1_024; +pub(super) const DEFAULT_MAX_CHUNKS_PER_DOC: usize = 4_096; +pub(super) const DEFAULT_L0_MAX_BYTES: usize = 256; +pub(super) const DEFAULT_L1_MAX_BYTES: usize = 8 * 1_024; +pub(super) const DEFAULT_L2_MAX_BYTES: usize = 32 * 1_024; +pub(super) const DOC_RETRIEVAL_TRAJECTORY_SCHEMA_V1: &str = "doc_retrieval_trajectory/v1"; +pub(super) const DOC_SOURCE_REF_SCHEMA_V1: &str = "source_ref/v1"; +pub(super) const DOC_SOURCE_REF_RESOLVER_V1: &str = "elf_doc_ext/v1"; +pub(super) const DOC_SOURCE_CAPTURE_SCHEMA_V1: &str = "doc_source_capture/v1"; +pub(super) const DOC_SOURCE_SPAN_SCHEMA_V1: &str = "doc_source_span/v1"; +pub(super) const DOC_STATUSES: [&str; 2] = ["active", "deleted"]; +pub(super) const SOURCE_LIBRARY_FIELD_KEYS: [&str; 9] = [ + "source_kind", + "canonical_uri", + "captured_at", + "source_created_at", + "trust_label", + "author", + "handle", + "excerpt_locator", + "source_content_hash", +]; +pub(super) const SOURCE_LIBRARY_KINDS: [&str; 7] = + ["article", "social_thread", "pdf", "text_export", "repo_file", "chat_excerpt", "web_page"]; +pub(super) const SOURCE_LIBRARY_TRUST_LABELS: [&str; 5] = + ["trusted", "user_captured", "public_web", "third_party", "unverified"]; + +pub(super) struct SourceCaptureSummaryInput<'a> { + pub(super) doc_id: Uuid, + pub(super) source_ref: &'a Map, + pub(super) doc_type: DocType, + pub(super) scope: &'a str, + pub(super) title: Option<&'a str>, + pub(super) content_hash: &'a str, + pub(super) raw_content_hash: &'a str, + pub(super) now: OffsetDateTime, + pub(super) chunks: &'a [DocChunk], + pub(super) write_policy_audit: Option<&'a WritePolicyAudit>, +} + +#[derive(Clone, Copy)] +pub(super) struct DocExcerptMatch { + pub(super) selector_kind: ExcerptsSelectorKind, + pub(super) match_start_offset: usize, + pub(super) match_end_offset: usize, +} + +pub(super) struct DocExcerptRange { + pub(super) selector_kind: ExcerptsSelectorKind, + pub(super) match_start_offset: usize, + pub(super) match_end_offset: usize, + pub(super) start_offset: usize, + pub(super) end_offset: usize, +} + +pub(super) struct DocTrajectoryBuilder { + pub(super) explain: bool, + pub(super) stages: Vec, + pub(super) stage_order: u32, +} +impl DocTrajectoryBuilder { + pub(super) fn new(explain: bool) -> Self { + Self { explain, stages: Vec::new(), stage_order: 0 } + } + + pub(super) fn push(&mut self, stage_name: &str, stats: Value) { + if !self.explain { + return; + } + + self.stages.push(DocRetrievalTrajectoryStage { + stage_order: self.stage_order, + stage_name: stage_name.to_string(), + stats, + }); + + self.stage_order += 1; + } + + pub(super) fn into_trajectory(self) -> Option { + if !self.explain { + return None; + } + + Some(DocRetrievalTrajectory { + schema: DOC_RETRIEVAL_TRAJECTORY_SCHEMA_V1.to_string(), + stages: self.stages, + }) + } +} + +#[derive(Clone, Debug)] +pub(super) struct DocsSearchL0Filters { + pub(super) scope: Option, + pub(super) status: String, + pub(super) doc_type: Option, + pub(super) sparse_mode: DocsSparseMode, + pub(super) domain: Option, + pub(super) repo: Option, + pub(super) agent_id: Option, + pub(super) thread_id: Option, + pub(super) updated_after: Option, + pub(super) updated_before: Option, + pub(super) ts_gte: Option, + pub(super) ts_lte: Option, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct DocChunkingProfile { + pub(super) max_tokens: usize, + pub(super) overlap_tokens: usize, + pub(super) max_chunks: usize, +} + +#[derive(Clone, Debug)] +pub(super) struct ByteChunk { + pub(super) chunk_id: Uuid, + pub(super) start_offset: usize, + pub(super) end_offset: usize, + pub(super) text: String, +} + +#[derive(Debug)] +pub(super) struct ValidatedDocsPut { + pub(super) doc_type: DocType, + pub(super) content: String, + pub(super) write_policy_audit: Option, +} + +#[derive(Clone, Debug, FromRow)] +pub(super) struct DocSearchRow { + pub(super) chunk_id: Uuid, + pub(super) doc_id: Uuid, + pub(super) scope: String, + pub(super) doc_type: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) updated_at: OffsetDateTime, + pub(super) content_hash: String, + pub(super) chunk_hash: String, + pub(super) start_offset: i32, + pub(super) end_offset: i32, + pub(super) chunk_text: String, +} + +pub(super) struct DocsSearchL0Prepared { + pub(super) top_k: u32, + pub(super) candidate_k: u32, + pub(super) sparse_mode: DocsSparseMode, + pub(super) sparse_enabled: bool, + pub(super) now: OffsetDateTime, + pub(super) trajectory: DocTrajectoryBuilder, + pub(super) allowed_scopes: Vec, + pub(super) shared_grants: HashSet, + pub(super) filter: Filter, + pub(super) vector: Vec, + pub(super) status: String, +} + +#[derive(Debug)] +pub(super) struct DocsSearchL0FiltersParsed { + pub(super) scope: Option, + pub(super) status: String, + pub(super) doc_type: Option, + pub(super) sparse_mode: DocsSparseMode, + pub(super) domain: Option, + pub(super) repo: Option, + pub(super) agent_id: Option, + pub(super) thread_id: Option, +} + +#[derive(Debug)] +pub(super) struct DocsSearchL0RangesParsed { + pub(super) updated_after: Option, + pub(super) updated_before: Option, + pub(super) ts_gte: Option, + pub(super) ts_lte: Option, +} + +#[derive(Clone, Copy, Debug)] +pub(super) enum DocsSparseMode { + Auto, + On, + Off, +} +impl DocsSparseMode { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::On => "on", + Self::Off => "off", + } + } +} + +#[derive(Clone, Copy)] +pub(super) enum ExcerptsSelectorKind { + ChunkId, + Quote, + Position, +} +impl ExcerptsSelectorKind { + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::ChunkId => "chunk_id", + Self::Quote => "quote", + Self::Position => "position", + } + } + + pub(super) fn span_kind(&self) -> &'static str { + match self { + Self::ChunkId => "captured", + Self::Quote => "quote", + Self::Position => "position", + } + } +} diff --git a/packages/elf-service/src/docs/validation.rs b/packages/elf-service/src/docs/validation.rs new file mode 100644 index 00000000..fcad73c3 --- /dev/null +++ b/packages/elf-service/src/docs/validation.rs @@ -0,0 +1,20 @@ +mod excerpts; +mod non_english; +mod put; +mod search; +mod source_ref; + +pub(in crate::docs) use self::{ + excerpts::{excerpt_level_max, resolve_doc_chunking_profile, validate_docs_excerpts_get}, + put::validate_docs_put, + search::validate_docs_search_l0, +}; + +use crate::docs::{ + DEFAULT_DOC_MAX_BYTES, DEFAULT_L0_MAX_BYTES, DEFAULT_L1_MAX_BYTES, DEFAULT_L2_MAX_BYTES, + DEFAULT_MAX_CHUNKS_PER_DOC, DOC_STATUSES, DocChunkingProfile, DocType, DocsPutRequest, + DocsSearchL0Filters, DocsSearchL0FiltersParsed, DocsSearchL0RangesParsed, DocsSearchL0Request, + DocsSparseMode, Error, Map, OffsetDateTime, Result, Rfc3339, SOURCE_LIBRARY_FIELD_KEYS, + SOURCE_LIBRARY_KINDS, SOURCE_LIBRARY_TRUST_LABELS, TextQuoteSelector, ValidatedDocsPut, Value, + english_gate, writegate, +}; diff --git a/packages/elf-service/src/docs/validation/excerpts.rs b/packages/elf-service/src/docs/validation/excerpts.rs new file mode 100644 index 00000000..2239e033 --- /dev/null +++ b/packages/elf-service/src/docs/validation/excerpts.rs @@ -0,0 +1,71 @@ +use crate::docs::validation::{ + DEFAULT_L0_MAX_BYTES, DEFAULT_L1_MAX_BYTES, DEFAULT_L2_MAX_BYTES, DEFAULT_MAX_CHUNKS_PER_DOC, + DocChunkingProfile, DocType, Error, Result, TextQuoteSelector, english_gate, +}; + +pub(in crate::docs) fn resolve_doc_chunking_profile(doc_type: DocType) -> DocChunkingProfile { + match doc_type { + DocType::Chat | DocType::Search => DocChunkingProfile { + max_tokens: 1_024, + overlap_tokens: 128, + max_chunks: DEFAULT_MAX_CHUNKS_PER_DOC, + }, + DocType::Knowledge | DocType::Dev => DocChunkingProfile { + max_tokens: 2_048, + overlap_tokens: 256, + max_chunks: DEFAULT_MAX_CHUNKS_PER_DOC, + }, + } +} + +pub(in crate::docs) fn validate_docs_excerpts_get( + tenant_id: &str, + project_id: &str, + agent_id: &str, + read_profile: &str, + quote: Option<&TextQuoteSelector>, +) -> Result<()> { + if tenant_id.is_empty() + || project_id.is_empty() + || agent_id.is_empty() + || read_profile.is_empty() + { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, agent_id, and read_profile are required.".to_string(), + }); + } + + if let Some(quote) = quote { + validate_quote_selector_english(quote)?; + } + + Ok(()) +} + +pub(in crate::docs) fn validate_quote_selector_english(quote: &TextQuoteSelector) -> Result<()> { + if !english_gate::is_english_natural_language(quote.exact.as_str()) { + return Err(Error::NonEnglishInput { field: "$.quote.exact".to_string() }); + } + + if let Some(prefix) = quote.prefix.as_ref() + && !english_gate::is_english_natural_language(prefix.as_str()) + { + return Err(Error::NonEnglishInput { field: "$.quote.prefix".to_string() }); + } + if let Some(suffix) = quote.suffix.as_ref() + && !english_gate::is_english_natural_language(suffix.as_str()) + { + return Err(Error::NonEnglishInput { field: "$.quote.suffix".to_string() }); + } + + Ok(()) +} + +pub(in crate::docs) fn excerpt_level_max(level: &str) -> Result { + match level { + "L0" => Ok(DEFAULT_L0_MAX_BYTES), + "L1" => Ok(DEFAULT_L1_MAX_BYTES), + "L2" => Ok(DEFAULT_L2_MAX_BYTES), + _ => Err(Error::InvalidRequest { message: "level must be L0, L1, or L2.".to_string() }), + } +} diff --git a/packages/elf-service/src/docs/validation/non_english.rs b/packages/elf-service/src/docs/validation/non_english.rs new file mode 100644 index 00000000..2739ba55 --- /dev/null +++ b/packages/elf-service/src/docs/validation/non_english.rs @@ -0,0 +1,61 @@ +use crate::docs::validation::{Value, english_gate}; + +pub(in crate::docs) fn find_non_english_path(value: &Value, path: &str) -> Option { + find_non_english_path_inner(value, path, false) +} + +pub(in crate::docs) fn find_non_english_path_inner( + value: &Value, + path: &str, + is_identifier_lane: bool, +) -> Option { + fn has_english_gate(text: &str, is_identifier_lane: bool) -> bool { + if is_identifier_lane { + return english_gate::is_english_identifier(text); + } + + english_gate::is_english_natural_language(text) + } + + match value { + Value::String(text) => + if !has_english_gate(text, is_identifier_lane) { + Some(path.to_string()) + } else { + None + }, + Value::Array(items) => { + for (idx, item) in items.iter().enumerate() { + let child_path = format!("{path}[{idx}]"); + + if let Some(found) = + find_non_english_path_inner(item, &child_path, is_identifier_lane) + { + return Some(found); + } + } + + None + }, + Value::Object(map) => { + for (key, value) in map.iter() { + let identifier_lane = is_identifier_lane + || matches!(key.as_str(), "ref" | "schema" | "resolver" | "hashes" | "state"); + let child_path = format!("{path}[\"{}\"]", escape_json_path_key(key)); + + if let Some(found) = + find_non_english_path_inner(value, &child_path, identifier_lane) + { + return Some(found); + } + } + + None + }, + _ => None, + } +} + +pub(in crate::docs) fn escape_json_path_key(key: &str) -> String { + key.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/packages/elf-service/src/docs/validation/put.rs b/packages/elf-service/src/docs/validation/put.rs new file mode 100644 index 00000000..327aaf65 --- /dev/null +++ b/packages/elf-service/src/docs/validation/put.rs @@ -0,0 +1,95 @@ +use crate::docs::validation::{ + DEFAULT_DOC_MAX_BYTES, DocType, DocsPutRequest, Error, OffsetDateTime, Result, Rfc3339, + ValidatedDocsPut, english_gate, non_english, + source_ref::{self}, + writegate, +}; + +pub(in crate::docs) fn validate_docs_put(req: &DocsPutRequest) -> Result { + if req.content.trim().is_empty() { + return Err(Error::InvalidRequest { message: "content must be non-empty.".to_string() }); + } + if req.scope.trim().is_empty() { + return Err(Error::InvalidRequest { message: "scope must be non-empty.".to_string() }); + } + if !matches!(req.scope.as_str(), "agent_private" | "project_shared" | "org_shared") { + return Err(Error::InvalidRequest { message: "Unknown scope.".to_string() }); + } + + let source_ref = req.source_ref.as_object().ok_or_else(|| Error::InvalidRequest { + message: "source_ref must be a JSON object.".to_string(), + })?; + let source_ref_doc_type = source_ref::extract_source_ref_string( + source_ref, + "doc_type", + "$.source_ref[\"doc_type\"]", + )?; + let source_ref_doc_type = DocType::parse(&source_ref_doc_type)?; + let source_ref_schema = + source_ref::extract_source_ref_string(source_ref, "schema", "$.source_ref[\"schema\"]")?; + + if source_ref_schema != "doc_source_ref/v1" { + return Err(Error::InvalidRequest { + message: "source_ref.schema must be 'doc_source_ref/v1'.".to_string(), + }); + } + + let ts = source_ref::extract_source_ref_string(source_ref, "ts", "$.source_ref[\"ts\"]")?; + + OffsetDateTime::parse(ts.as_str(), &Rfc3339).map_err(|_| Error::InvalidRequest { + message: "$.source_ref[\"ts\"] must be an RFC3339 datetime string.".to_string(), + })?; + + let doc_type = if let Some(doc_type) = req.doc_type.as_ref() { + let doc_type = DocType::parse(doc_type.as_str())?; + + if doc_type != source_ref_doc_type { + return Err(Error::InvalidRequest { + message: "doc_type must match source_ref.doc_type.".to_string(), + }); + } + + doc_type + } else { + source_ref_doc_type + }; + + source_ref::validate_doc_source_ref_requirements(source_ref_doc_type.as_str(), source_ref)?; + source_ref::validate_source_library_metadata(source_ref_doc_type.as_str(), source_ref)?; + + let write_policy = + writegate::apply_write_policy(req.content.as_str(), req.write_policy.as_ref()).map_err( + |err| Error::InvalidRequest { message: format!("write_policy is invalid: {err:?}") }, + )?; + let write_policy_audit = + if req.write_policy.is_some() { Some(write_policy.audit) } else { None }; + let content = write_policy.transformed; + + if content.trim().is_empty() { + return Err(Error::InvalidRequest { message: "content must be non-empty.".to_string() }); + } + if content.len() > DEFAULT_DOC_MAX_BYTES { + return Err(Error::InvalidRequest { + message: "content exceeds max_doc_bytes.".to_string(), + }); + } + if writegate::contains_secrets(content.as_str()) { + return Err(Error::InvalidRequest { message: "content contains secrets.".to_string() }); + } + + if let Some(found) = non_english::find_non_english_path(&req.source_ref, "$.source_ref") { + return Err(Error::NonEnglishInput { field: found }); + } + + if !english_gate::is_english_natural_language(content.as_str()) { + return Err(Error::NonEnglishInput { field: "$.content".to_string() }); + } + + if let Some(title) = req.title.as_ref() + && !english_gate::is_english_natural_language(title.as_str()) + { + return Err(Error::NonEnglishInput { field: "$.title".to_string() }); + } + + Ok(ValidatedDocsPut { doc_type, content, write_policy_audit }) +} diff --git a/packages/elf-service/src/docs/validation/search.rs b/packages/elf-service/src/docs/validation/search.rs new file mode 100644 index 00000000..87fcd50e --- /dev/null +++ b/packages/elf-service/src/docs/validation/search.rs @@ -0,0 +1,208 @@ +use crate::docs::validation::{ + DOC_STATUSES, DocType, DocsSearchL0Filters, DocsSearchL0FiltersParsed, + DocsSearchL0RangesParsed, DocsSearchL0Request, DocsSparseMode, Error, OffsetDateTime, Result, + Rfc3339, english_gate, +}; + +pub(in crate::docs) fn validate_docs_search_l0( + req: &DocsSearchL0Request, +) -> Result { + validate_docs_search_l0_query(req)?; + + let filters = parse_docs_search_l0_filters(req)?; + let ranges = parse_docs_search_l0_ranges(req)?; + + validate_docs_search_l0_temporal_ranges( + ranges.updated_after.as_ref(), + ranges.updated_before.as_ref(), + ranges.ts_gte.as_ref(), + ranges.ts_lte.as_ref(), + )?; + + Ok(DocsSearchL0Filters { + scope: filters.scope, + status: filters.status, + doc_type: filters.doc_type, + sparse_mode: filters.sparse_mode, + domain: filters.domain, + repo: filters.repo, + agent_id: filters.agent_id, + thread_id: filters.thread_id, + updated_after: ranges.updated_after, + updated_before: ranges.updated_before, + ts_gte: ranges.ts_gte, + ts_lte: ranges.ts_lte, + }) +} + +pub(in crate::docs) fn validate_docs_search_l0_query(req: &DocsSearchL0Request) -> Result<()> { + if req.query.trim().is_empty() { + return Err(Error::InvalidRequest { message: "query must be non-empty.".to_string() }); + } + if !english_gate::is_english_natural_language(req.query.as_str()) { + return Err(Error::NonEnglishInput { field: "$.query".to_string() }); + } + + Ok(()) +} + +pub(in crate::docs) fn parse_docs_search_l0_filters( + req: &DocsSearchL0Request, +) -> Result { + let scope = if let Some(scope) = req.scope.as_ref() { + let scope = scope.trim(); + + if scope.is_empty() { + return Err(Error::InvalidRequest { message: "scope must be non-empty.".to_string() }); + } + if !matches!(scope, "agent_private" | "project_shared" | "org_shared") { + return Err(Error::InvalidRequest { message: "Unknown scope.".to_string() }); + } + + Some(scope.to_string()) + } else { + None + }; + let status = req + .status + .as_ref() + .map(|status| status.trim().to_string()) + .filter(|status| !status.is_empty()) + .unwrap_or_else(|| "active".to_string()) + .to_lowercase(); + let status = if DOC_STATUSES.contains(&status.as_str()) { + status + } else { + return Err(Error::InvalidRequest { + message: "status must be one of: active|deleted.".to_string(), + }); + }; + let sparse_mode = parse_sparse_mode(req.sparse_mode.as_ref())?; + let doc_type = if let Some(doc_type) = req.doc_type.as_ref() { + let doc_type = doc_type.trim(); + + if doc_type.is_empty() { + return Err(Error::InvalidRequest { + message: "doc_type must be non-empty.".to_string(), + }); + } + + Some(DocType::parse(doc_type)?) + } else { + None + }; + let domain = req + .domain + .as_ref() + .map(|domain| domain.trim().to_string()) + .filter(|domain| !domain.is_empty()); + let repo = + req.repo.as_ref().map(|repo| repo.trim().to_string()).filter(|repo| !repo.is_empty()); + + if domain.is_some() && doc_type != Some(DocType::Search) { + return Err(Error::InvalidRequest { + message: "domain requires doc_type=search.".to_string(), + }); + } + if repo.is_some() && doc_type != Some(DocType::Dev) { + return Err(Error::InvalidRequest { message: "repo requires doc_type=dev.".to_string() }); + } + + let agent_id = req + .agent_id + .as_ref() + .map(|agent_id| agent_id.trim().to_string()) + .filter(|agent_id| !agent_id.is_empty()); + let thread_id = req + .thread_id + .as_ref() + .map(|thread_id| thread_id.trim().to_string()) + .filter(|thread_id| !thread_id.is_empty()); + + if thread_id.is_some() && doc_type != Some(DocType::Chat) { + return Err(Error::InvalidRequest { + message: "thread_id requires doc_type=chat.".to_string(), + }); + } + + Ok(DocsSearchL0FiltersParsed { + scope, + status, + doc_type, + sparse_mode, + domain, + repo, + agent_id, + thread_id, + }) +} + +pub(in crate::docs) fn parse_docs_search_l0_ranges( + req: &DocsSearchL0Request, +) -> Result { + let updated_after = parse_optional_rfc3339(req.updated_after.as_ref(), "$.updated_after")?; + let updated_before = parse_optional_rfc3339(req.updated_before.as_ref(), "$.updated_before")?; + let ts_gte = parse_optional_rfc3339(req.ts_gte.as_ref(), "$.ts_gte")?; + let ts_lte = parse_optional_rfc3339(req.ts_lte.as_ref(), "$.ts_lte")?; + + Ok(DocsSearchL0RangesParsed { updated_after, updated_before, ts_gte, ts_lte }) +} + +pub(in crate::docs) fn validate_docs_search_l0_temporal_ranges( + updated_after: Option<&OffsetDateTime>, + updated_before: Option<&OffsetDateTime>, + ts_gte: Option<&OffsetDateTime>, + ts_lte: Option<&OffsetDateTime>, +) -> Result<()> { + if let (Some(updated_after), Some(updated_before)) = (updated_after, updated_before) + && updated_after >= updated_before + { + return Err(Error::InvalidRequest { + message: "updated_after must be earlier than updated_before.".to_string(), + }); + } + if let (Some(ts_gte), Some(ts_lte)) = (ts_gte, ts_lte) + && ts_gte >= ts_lte + { + return Err(Error::InvalidRequest { + message: "ts_gte must be earlier than ts_lte.".to_string(), + }); + } + + Ok(()) +} + +pub(in crate::docs) fn parse_sparse_mode(raw: Option<&String>) -> Result { + let raw = raw.as_ref().map(|mode| mode.trim().to_lowercase()); + let Some(mode) = raw else { + return Ok(DocsSparseMode::Auto); + }; + let mode = mode.as_str(); + + match mode { + "auto" => Ok(DocsSparseMode::Auto), + "on" => Ok(DocsSparseMode::On), + "off" => Ok(DocsSparseMode::Off), + _ => Err(Error::InvalidRequest { + message: "sparse_mode must be one of: auto|on|off.".to_string(), + }), + } +} + +pub(in crate::docs) fn parse_optional_rfc3339( + raw: Option<&String>, + path: &str, +) -> Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + + if raw.is_empty() { + return Err(Error::InvalidRequest { message: format!("{path} must be non-empty.") }); + } + + OffsetDateTime::parse(raw, &Rfc3339).map(Some).map_err(|_| Error::InvalidRequest { + message: format!("{path} must be an RFC3339 datetime string."), + }) +} diff --git a/packages/elf-service/src/docs/validation/source_ref.rs b/packages/elf-service/src/docs/validation/source_ref.rs new file mode 100644 index 00000000..082e3e1c --- /dev/null +++ b/packages/elf-service/src/docs/validation/source_ref.rs @@ -0,0 +1,263 @@ +use crate::docs::validation::{ + Error, Map, OffsetDateTime, Result, Rfc3339, SOURCE_LIBRARY_FIELD_KEYS, SOURCE_LIBRARY_KINDS, + SOURCE_LIBRARY_TRUST_LABELS, Value, +}; + +pub(in crate::docs) fn extract_source_ref_string( + source_ref: &Map, + key: &str, + path: &str, +) -> Result { + source_ref + .get(key) + .and_then(Value::as_str) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .ok_or_else(|| Error::InvalidRequest { message: format!("{path} is required.") }) +} + +pub(in crate::docs) fn validate_doc_source_ref_requirements( + source_doc_type: &str, + source_ref: &Map, +) -> Result<()> { + match source_doc_type { + "chat" => { + extract_source_ref_string(source_ref, "thread_id", "$.source_ref[\"thread_id\"]")?; + extract_source_ref_string(source_ref, "role", "$.source_ref[\"role\"]")?; + }, + "search" => { + extract_source_ref_string(source_ref, "query", "$.source_ref[\"query\"]")?; + extract_source_ref_string(source_ref, "url", "$.source_ref[\"url\"]")?; + extract_source_ref_string(source_ref, "domain", "$.source_ref[\"domain\"]")?; + }, + "dev" => { + extract_source_ref_string(source_ref, "repo", "$.source_ref[\"repo\"]")?; + + let commit_sha_present = source_ref + .get("commit_sha") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let pr_number_present = source_ref + .get("pr_number") + .is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some()); + let issue_number_present = source_ref + .get("issue_number") + .is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some()); + let present_count = + commit_sha_present as u8 + pr_number_present as u8 + issue_number_present as u8; + + if present_count != 1 { + return Err(Error::InvalidRequest { + message: + "For doc_type=dev, exactly one of commit_sha, pr_number, or issue_number is required." + .to_string(), + }); + } + }, + "knowledge" => {}, + _ => unreachable!(), + } + + Ok(()) +} + +pub(in crate::docs) fn validate_source_library_metadata( + source_doc_type: &str, + source_ref: &Map, +) -> Result<()> { + if !source_library_metadata_present(source_ref) { + return Ok(()); + } + + let source_kind = + extract_source_ref_string(source_ref, "source_kind", "$.source_ref[\"source_kind\"]")?; + + if !SOURCE_LIBRARY_KINDS.contains(&source_kind.as_str()) { + return Err(Error::InvalidRequest { + message: format!( + "$.source_ref[\"source_kind\"] must be one of: {}.", + SOURCE_LIBRARY_KINDS.join("|") + ), + }); + } + + validate_source_kind_doc_type(source_kind.as_str(), source_doc_type)?; + extract_source_ref_string(source_ref, "canonical_uri", "$.source_ref[\"canonical_uri\"]")?; + validate_source_ref_rfc3339(source_ref, "captured_at")?; + + if source_ref.contains_key("source_created_at") { + validate_source_ref_rfc3339(source_ref, "source_created_at")?; + } + + let trust_label = + extract_source_ref_string(source_ref, "trust_label", "$.source_ref[\"trust_label\"]")?; + + if !SOURCE_LIBRARY_TRUST_LABELS.contains(&trust_label.as_str()) { + return Err(Error::InvalidRequest { + message: format!( + "$.source_ref[\"trust_label\"] must be one of: {}.", + SOURCE_LIBRARY_TRUST_LABELS.join("|") + ), + }); + } + + validate_optional_source_ref_string(source_ref, "author")?; + validate_optional_source_ref_string(source_ref, "handle")?; + validate_optional_source_ref_string(source_ref, "source_content_hash")?; + + if let Some(locator) = source_ref.get("excerpt_locator") { + validate_source_library_excerpt_locator(locator)?; + } + + Ok(()) +} + +pub(in crate::docs) fn source_library_metadata_present(source_ref: &Map) -> bool { + SOURCE_LIBRARY_FIELD_KEYS.iter().any(|key| source_ref.contains_key(*key)) +} + +pub(in crate::docs) fn validate_source_kind_doc_type( + source_kind: &str, + source_doc_type: &str, +) -> Result<()> { + let expected_doc_type = match source_kind { + "social_thread" | "chat_excerpt" => Some("chat"), + "repo_file" => Some("dev"), + _ => None, + }; + + if let Some(expected_doc_type) = expected_doc_type + && source_doc_type != expected_doc_type + { + return Err(Error::InvalidRequest { + message: format!( + "$.source_ref[\"source_kind\"]={source_kind} requires doc_type={expected_doc_type}." + ), + }); + } + + Ok(()) +} + +pub(in crate::docs) fn validate_source_ref_rfc3339( + source_ref: &Map, + key: &str, +) -> Result<()> { + let path = format!("$.source_ref[\"{key}\"]"); + let value = extract_source_ref_string(source_ref, key, path.as_str())?; + + OffsetDateTime::parse(value.as_str(), &Rfc3339).map_err(|_| Error::InvalidRequest { + message: format!("{path} must be an RFC3339 datetime string."), + })?; + + Ok(()) +} + +pub(in crate::docs) fn validate_optional_source_ref_string( + source_ref: &Map, + key: &str, +) -> Result<()> { + let path = format!("$.source_ref[\"{key}\"]"); + + validate_optional_source_ref_string_at(source_ref, key, path.as_str()) +} + +pub(in crate::docs) fn validate_optional_source_ref_string_at( + source_ref: &Map, + key: &str, + path: &str, +) -> Result<()> { + let Some(value) = source_ref.get(key) else { + return Ok(()); + }; + + value.as_str().map(str::trim).filter(|value| !value.is_empty()).ok_or_else(|| { + Error::InvalidRequest { message: format!("{path} must be a non-empty string.") } + })?; + + Ok(()) +} + +pub(in crate::docs) fn validate_source_library_excerpt_locator(locator: &Value) -> Result<()> { + let locator = locator.as_object().ok_or_else(|| Error::InvalidRequest { + message: "$.source_ref[\"excerpt_locator\"] must be a JSON object.".to_string(), + })?; + let has_quote = locator.contains_key("quote"); + let has_position = locator.contains_key("position"); + + if !has_quote && !has_position { + return Err(Error::InvalidRequest { + message: "$.source_ref[\"excerpt_locator\"] requires quote or position.".to_string(), + }); + } + + if let Some(quote) = locator.get("quote") { + validate_source_library_quote_locator(quote)?; + } + if let Some(position) = locator.get("position") { + validate_source_library_position_locator(position)?; + } + + Ok(()) +} + +pub(in crate::docs) fn validate_source_library_quote_locator(quote: &Value) -> Result<()> { + let quote = quote.as_object().ok_or_else(|| Error::InvalidRequest { + message: "$.source_ref[\"excerpt_locator\"][\"quote\"] must be a JSON object.".to_string(), + })?; + + extract_source_ref_string( + quote, + "exact", + "$.source_ref[\"excerpt_locator\"][\"quote\"][\"exact\"]", + )?; + validate_optional_source_ref_string_at( + quote, + "prefix", + "$.source_ref[\"excerpt_locator\"][\"quote\"][\"prefix\"]", + )?; + validate_optional_source_ref_string_at( + quote, + "suffix", + "$.source_ref[\"excerpt_locator\"][\"quote\"][\"suffix\"]", + )?; + + Ok(()) +} + +pub(in crate::docs) fn validate_source_library_position_locator(position: &Value) -> Result<()> { + let position = position.as_object().ok_or_else(|| Error::InvalidRequest { + message: "$.source_ref[\"excerpt_locator\"][\"position\"] must be a JSON object." + .to_string(), + })?; + let start = source_ref_u64( + position, + "start", + "$.source_ref[\"excerpt_locator\"][\"position\"][\"start\"]", + )?; + let end = source_ref_u64( + position, + "end", + "$.source_ref[\"excerpt_locator\"][\"position\"][\"end\"]", + )?; + + if start >= end { + return Err(Error::InvalidRequest { + message: "$.source_ref[\"excerpt_locator\"][\"position\"] start must be before end." + .to_string(), + }); + } + + Ok(()) +} + +pub(in crate::docs) fn source_ref_u64( + source_ref: &Map, + key: &str, + path: &str, +) -> Result { + source_ref + .get(key) + .and_then(Value::as_u64) + .ok_or_else(|| Error::InvalidRequest { message: format!("{path} must be an integer.") }) +} diff --git a/packages/elf-service/src/dreaming_review_queue.rs b/packages/elf-service/src/dreaming_review_queue.rs index 39f948c4..72f29c10 100644 --- a/packages/elf-service/src/dreaming_review_queue.rs +++ b/packages/elf-service/src/dreaming_review_queue.rs @@ -1,732 +1,17 @@ //! Dreaming review queue readback over consolidation proposals. -use std::collections::BTreeSet; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ - ConsolidationProposalResponse, ConsolidationProposalReviewEventResponse, ElfService, Result, +mod item; +mod policy; +mod service; +mod types; + +pub use self::{ + policy::ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1, + types::{ + DreamingReviewQueueAudit, DreamingReviewQueueItem, DreamingReviewQueueItemPolicy, + DreamingReviewQueuePolicy, DreamingReviewQueueRequest, DreamingReviewQueueResponse, + DreamingReviewQueueSummary, + }, }; -use elf_domain::consolidation::ConsolidationReviewState; -use elf_storage::consolidation; - -/// Schema identifier for Dreaming review queue responses. -pub const ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1: &str = "elf.dreaming_review_queue/v1"; - -const DEFAULT_QUEUE_LIMIT: u32 = 50; -const MAX_QUEUE_LIMIT: u32 = 200; -const HIGH_CONFIDENCE_AUTO_APPLY_FLOOR: f32 = 0.9; -const FORBIDDEN_SOURCE_MUTATION_KEYS: [&str; 8] = [ - "delete_source", - "delete_sources", - "overwrite_source", - "source_delete", - "source_mutation", - "source_mutations", - "source_note_updates", - "update_source", -]; - -/// Request payload for Dreaming review queue readback. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DreamingReviewQueueRequest { - /// Tenant that owns the review queue. - pub tenant_id: String, - /// Project that owns the review queue. - pub project_id: String, - /// Optional run filter. - pub run_id: Option, - /// Optional review-state filter. - pub review_state: Option, - /// Maximum number of queue items to return. - pub limit: Option, -} - -/// Dreaming review queue response. -#[derive(Clone, Debug, Serialize)] -pub struct DreamingReviewQueueResponse { - /// Response schema identifier. - pub schema: String, - /// Queue policy applied to every returned item. - pub policy: DreamingReviewQueuePolicy, - /// Aggregate queue summary. - pub summary: DreamingReviewQueueSummary, - /// Returned queue items. - pub items: Vec, -} - -/// Global review queue policy. -#[derive(Clone, Debug, Serialize)] -pub struct DreamingReviewQueuePolicy { - /// Authoritative source mutation is never allowed by this queue surface. - pub source_mutation_allowed: bool, - /// Whether high-impact proposals require explicit review. - pub high_impact_requires_review: bool, - /// Low-risk derived organization variants that may become auto-apply candidates. - pub low_risk_derived_organization_variants: Vec, - /// Review actions supported by the underlying consolidation proposal lifecycle. - pub review_actions: Vec, - /// Human-readable policy summary. - pub summary: String, -} -impl Default for DreamingReviewQueuePolicy { - fn default() -> Self { - Self { - source_mutation_allowed: false, - high_impact_requires_review: true, - low_risk_derived_organization_variants: vec![ - "tag".to_string(), - "duplicate_merge".to_string(), - ], - review_actions: vec![ - "approve".to_string(), - "apply".to_string(), - "defer".to_string(), - "discard".to_string(), - ], - summary: "Dreaming review queue proposals are source-backed derived outputs; authoritative source mutation is disallowed, and high-impact memory or graph changes remain review-gated.".to_string(), - } - } -} - -/// Aggregate queue summary. -#[derive(Clone, Debug, Default, Serialize)] -pub struct DreamingReviewQueueSummary { - /// Returned item count. - pub item_count: usize, - /// Items still waiting for review. - pub proposed_count: usize, - /// Items approved but not marked applied. - pub approved_count: usize, - /// Items marked applied to derived targets. - pub applied_count: usize, - /// Items discarded by review. - pub discarded_count: usize, - /// Items deferred for later audit. - pub deferred_count: usize, - /// Items classified as high impact. - pub high_impact_count: usize, - /// Items that request source mutation and therefore cannot be auto-applied. - pub source_mutation_requested_count: usize, - /// Items eligible for low-risk derived organization auto-apply after approval. - pub auto_apply_candidate_count: usize, - /// Items that currently satisfy the queue's auto-apply policy. - pub auto_apply_allowed_count: usize, - /// Number of distinct queue variants represented by the response. - pub variant_count: usize, -} - -/// One Dreaming review queue item. -#[derive(Clone, Debug, Serialize)] -pub struct DreamingReviewQueueItem { - /// Consolidation proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Consolidation proposal kind. - pub proposal_kind: String, - /// Dreaming queue variant inferred from proposal metadata. - pub queue_variant: String, - /// Derived-output apply intent. - pub apply_intent: String, - /// Current review state. - pub review_state: String, - /// Source references supporting the proposal. - pub source_refs: Value, - /// Aggregate immutable source snapshot. - pub source_snapshot: Value, - /// Target affected by the proposal, when supplied. - pub target_ref: Value, - /// Affected pages, memories, facts, or derived artifacts extracted for reviewer scan. - pub affected_refs: Vec, - /// Reviewable diff. - pub diff: Value, - /// Proposal confidence. - pub confidence: f32, - /// Unsupported-claim lint flags. - pub unsupported_claim_flags: Value, - /// Contradiction markers for review. - pub contradiction_markers: Value, - /// Staleness markers for review. - pub staleness_markers: Value, - /// Proposed derived payload. - pub proposed_payload: Value, - /// Per-item policy decision. - pub policy: DreamingReviewQueueItemPolicy, - /// Review audit readback. - pub review_audit: DreamingReviewQueueAudit, - #[serde(with = "crate::time_serde")] - /// Item creation timestamp. - pub created_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Item update timestamp. - pub updated_at: OffsetDateTime, -} -impl From for DreamingReviewQueueItem { - fn from(proposal: ConsolidationProposalResponse) -> Self { - let queue_variant = queue_variant_for( - proposal.proposal_kind.as_str(), - proposal.apply_intent.as_str(), - &proposal.proposed_payload, - ); - let source_mutation_requested = contains_forbidden_source_mutation_key(&proposal.diff) - || contains_forbidden_source_mutation_key(&proposal.proposed_payload) - || contains_forbidden_source_mutation_key(&proposal.target_ref); - let high_impact = high_impact_variant(queue_variant.as_str()); - let has_unsupported_claims = non_empty_json_array(&proposal.unsupported_claim_flags); - let has_review_markers = non_empty_json_array(&proposal.contradiction_markers) - || non_empty_json_array(&proposal.staleness_markers); - let auto_apply_candidate = low_risk_derived_organization(queue_variant.as_str()) - && proposal.confidence >= HIGH_CONFIDENCE_AUTO_APPLY_FLOOR - && !has_unsupported_claims - && !has_review_markers - && !source_mutation_requested; - let manual_apply_allowed = - proposal.review_state.as_str() == "approved" && !source_mutation_requested; - let auto_apply_allowed = auto_apply_candidate && manual_apply_allowed; - let requires_review = source_mutation_requested - || !matches!(proposal.review_state.as_str(), "approved" | "applied"); - let policy = DreamingReviewQueueItemPolicy { - source_mutation_requested, - high_impact, - requires_review, - auto_apply_candidate, - auto_apply_allowed, - reason: policy_reason( - source_mutation_requested, - high_impact, - has_unsupported_claims, - has_review_markers, - auto_apply_candidate, - auto_apply_allowed, - manual_apply_allowed, - ), - }; - let review_audit = DreamingReviewQueueAudit { - review_state: proposal.review_state.clone(), - available_actions: available_review_actions( - proposal.review_state.as_str(), - manual_apply_allowed, - ), - reviewer_agent_id: proposal.reviewer_agent_id.clone(), - review_comment: proposal.review_comment.clone(), - reviewed_at: proposal.reviewed_at, - review_events: proposal.review_events.clone(), - }; - - Self { - proposal_id: proposal.proposal_id, - run_id: proposal.run_id, - proposal_kind: proposal.proposal_kind, - queue_variant, - apply_intent: proposal.apply_intent, - review_state: proposal.review_state, - source_refs: proposal.source_refs, - source_snapshot: proposal.source_snapshot, - affected_refs: affected_refs(&proposal.target_ref, &proposal.proposed_payload), - target_ref: proposal.target_ref, - diff: proposal.diff, - confidence: proposal.confidence, - unsupported_claim_flags: proposal.unsupported_claim_flags, - contradiction_markers: proposal.contradiction_markers, - staleness_markers: proposal.staleness_markers, - proposed_payload: proposal.proposed_payload, - policy, - review_audit, - created_at: proposal.created_at, - updated_at: proposal.updated_at, - } - } -} - -/// Per-item policy readback. -#[derive(Clone, Debug, Serialize)] -pub struct DreamingReviewQueueItemPolicy { - /// Whether this proposal requests mutation of authoritative sources. - pub source_mutation_requested: bool, - /// Whether this item is considered high impact. - pub high_impact: bool, - /// Whether reviewer approval is required before downstream application. - pub requires_review: bool, - /// Whether this item is a low-risk derived organization auto-apply candidate. - pub auto_apply_candidate: bool, - /// Whether this item currently satisfies auto-apply policy. - pub auto_apply_allowed: bool, - /// Reason for the policy decision. - pub reason: String, -} - -/// Review audit readback for one queue item. -#[derive(Clone, Debug, Serialize)] -pub struct DreamingReviewQueueAudit { - /// Current review state. - pub review_state: String, - /// Actions currently accepted by the consolidation proposal lifecycle. - pub available_actions: Vec, - /// Agent that last reviewed the item. - pub reviewer_agent_id: Option, - /// Last reviewer comment. - pub review_comment: Option, - #[serde(with = "crate::time_serde::option")] - /// Last review timestamp. - pub reviewed_at: Option, - /// Append-only review events. - pub review_events: Vec, -} - -impl ElfService { - /// Lists consolidation proposals as a Dreaming review queue. - pub async fn dreaming_review_queue( - &self, - req: DreamingReviewQueueRequest, - ) -> Result { - let limit = bounded_queue_limit(req.limit); - let review_state = req.review_state.map(ConsolidationReviewState::as_str); - let proposals = consolidation::list_consolidation_proposals( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.run_id, - review_state, - limit, - ) - .await?; - let mut items = Vec::with_capacity(proposals.len()); - - for proposal in proposals { - let review_events = consolidation::list_consolidation_proposal_review_events( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - proposal.proposal_id, - ) - .await? - .into_iter() - .map(ConsolidationProposalReviewEventResponse::from) - .collect(); - let mut response = ConsolidationProposalResponse::from(proposal); - - response.review_events = review_events; - - items.push(DreamingReviewQueueItem::from(response)); - } - - Ok(DreamingReviewQueueResponse { - schema: ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1.to_string(), - policy: DreamingReviewQueuePolicy::default(), - summary: summarize_items(&items), - items, - }) - } -} - -fn summarize_items(items: &[DreamingReviewQueueItem]) -> DreamingReviewQueueSummary { - let mut summary = DreamingReviewQueueSummary { - item_count: items.len(), - ..DreamingReviewQueueSummary::default() - }; - let mut variants = BTreeSet::new(); - - for item in items { - match item.review_state.as_str() { - "proposed" => summary.proposed_count += 1, - "approved" => summary.approved_count += 1, - "applied" => summary.applied_count += 1, - "rejected" => summary.discarded_count += 1, - "archived" => summary.deferred_count += 1, - _ => {}, - } - - if item.policy.high_impact { - summary.high_impact_count += 1; - } - if item.policy.source_mutation_requested { - summary.source_mutation_requested_count += 1; - } - if item.policy.auto_apply_candidate { - summary.auto_apply_candidate_count += 1; - } - if item.policy.auto_apply_allowed { - summary.auto_apply_allowed_count += 1; - } - - variants.insert(item.queue_variant.as_str()); - } - - summary.variant_count = variants.len(); - - summary -} - -fn queue_variant_for(proposal_kind: &str, apply_intent: &str, proposed_payload: &Value) -> String { - for pointer in [ - "/queue_variant", - "/dreaming_variant", - "/proposal_variant", - "/variant", - "/artifact_kind", - "/metadata/queue_variant", - "/metadata/dreaming_variant", - "/metadata/artifact_kind", - ] { - if let Some(raw) = proposed_payload.pointer(pointer).and_then(Value::as_str) - && let Some(variant) = normalize_variant(raw) - { - return variant; - } - } - - if let Some(variant) = normalize_variant(proposal_kind) { - return variant; - } - - match apply_intent { - "create_derived_knowledge_page" | "update_derived_knowledge_page" => - "page_rebuild".to_string(), - "create_derived_graph_view" => "graph_fact".to_string(), - "create_derived_note" | "update_derived_note" => "memory_promotion".to_string(), - _ => "other".to_string(), - } -} - -fn normalize_variant(raw: &str) -> Option { - let token = raw.trim().to_ascii_lowercase().replace(['-', ' '], "_"); - - if token.is_empty() { - return None; - } - if token.contains("duplicate") || token.contains("dedupe") { - return Some("duplicate_merge".to_string()); - } - if token.contains("tag") || token.contains("taxonomy") { - return Some("tag".to_string()); - } - if token.contains("knowledge_page") || token.contains("page_rebuild") { - return Some("page_rebuild".to_string()); - } - if token.contains("graph_fact") || token.contains("graph_view") { - return Some("graph_fact".to_string()); - } - if token.contains("proactive_brief") || token.contains("daily_brief") { - return Some("proactive_brief".to_string()); - } - if token.contains("scheduled_memory") || token.contains("weekly_summary") { - return Some("scheduled_memory".to_string()); - } - if token.contains("memory_summary") || token.contains("summary") { - return Some("memory_summary".to_string()); - } - if token.contains("memory_promotion") || token.contains("derived_note") { - return Some("memory_promotion".to_string()); - } - if token.contains("correction") || token.contains("repair") { - return Some("correction".to_string()); - } - - Some(token) -} - -fn affected_refs(target_ref: &Value, proposed_payload: &Value) -> Vec { - let mut refs = Vec::new(); - - push_non_empty_object(&mut refs, target_ref); - - for pointer in [ - "/affected_refs", - "/affected_pages", - "/affected_memories", - "/affected_facts", - "/affected_notes", - ] { - match proposed_payload.pointer(pointer) { - Some(Value::Array(values)) => refs.extend(values.iter().cloned()), - Some(value) if non_empty_json_object(value) => refs.push(value.clone()), - _ => {}, - } - } - - refs -} - -fn push_non_empty_object(refs: &mut Vec, value: &Value) { - if non_empty_json_object(value) { - refs.push(value.clone()); - } -} - -fn non_empty_json_object(value: &Value) -> bool { - value.as_object().is_some_and(|object| !object.is_empty()) -} - -fn non_empty_json_array(value: &Value) -> bool { - value.as_array().is_some_and(|array| !array.is_empty()) -} - -fn contains_forbidden_source_mutation_key(value: &Value) -> bool { - match value { - Value::Object(map) => map.iter().any(|(key, nested)| { - FORBIDDEN_SOURCE_MUTATION_KEYS.contains(&key.as_str()) - || contains_forbidden_source_mutation_key(nested) - }), - Value::Array(items) => items.iter().any(contains_forbidden_source_mutation_key), - _ => false, - } -} - -fn low_risk_derived_organization(queue_variant: &str) -> bool { - matches!(queue_variant, "tag" | "duplicate_merge") -} - -fn high_impact_variant(queue_variant: &str) -> bool { - matches!(queue_variant, "memory_promotion" | "graph_fact" | "correction") -} - -fn available_review_actions(review_state: &str, manual_apply_allowed: bool) -> Vec { - let actions = match review_state { - "proposed" => &["approve", "defer", "discard"][..], - "approved" if manual_apply_allowed => &["apply", "defer", "discard"][..], - "approved" => &["defer", "discard"][..], - _ => &[][..], - }; - - actions.iter().map(|action| (*action).to_string()).collect() -} - -fn policy_reason( - source_mutation_requested: bool, - high_impact: bool, - has_unsupported_claims: bool, - has_review_markers: bool, - auto_apply_candidate: bool, - auto_apply_allowed: bool, - manual_apply_allowed: bool, -) -> String { - if source_mutation_requested { - return "source mutation is requested, so the proposal cannot be applied by the queue" - .to_string(); - } - if has_unsupported_claims || has_review_markers { - return "lint or review markers require explicit reviewer inspection".to_string(); - } - if auto_apply_allowed { - return "approved low-risk derived organization proposal satisfies auto-apply policy" - .to_string(); - } - if manual_apply_allowed { - return "approved review-gated proposal may be manually applied to a derived target" - .to_string(); - } - if high_impact { - return "high-impact memory, graph, or correction proposal requires approval before apply" - .to_string(); - } - - if auto_apply_candidate { - return "low-risk derived organization proposal is a candidate after reviewer approval" - .to_string(); - } - - "proposal remains reviewable derived output".to_string() -} - -fn bounded_queue_limit(limit: Option) -> i64 { - i64::from(limit.unwrap_or(DEFAULT_QUEUE_LIMIT).clamp(1, MAX_QUEUE_LIMIT)) -} - -#[cfg(test)] -mod tests { - use serde_json; - use time::OffsetDateTime; - use uuid::Uuid; - - use crate::{ConsolidationProposalResponse, dreaming_review_queue}; - - #[test] - fn queue_variant_prefers_payload_and_normalizes_future_variants() { - let payload = serde_json::json!({ - "metadata": { "queue_variant": "Duplicate Merge" } - }); - - assert_eq!( - dreaming_review_queue::queue_variant_for( - "derived_note", - "create_derived_note", - &payload - ), - "duplicate_merge" - ); - assert_eq!( - dreaming_review_queue::queue_variant_for( - "knowledge_page", - "update_derived_knowledge_page", - &serde_json::json!({}) - ), - "page_rebuild" - ); - assert_eq!( - dreaming_review_queue::queue_variant_for( - "correction", - "no_op", - &serde_json::json!({ "affected_notes": [] }) - ), - "correction" - ); - } - - #[test] - fn policy_detects_source_mutation_and_review_actions() { - assert!(dreaming_review_queue::contains_forbidden_source_mutation_key( - &serde_json::json!({ - "after": { "source_note_updates": [{ "note_id": "n1" }] } - }) - )); - assert_eq!( - dreaming_review_queue::available_review_actions("proposed", false), - vec!["approve", "defer", "discard"] - ); - assert_eq!( - dreaming_review_queue::available_review_actions("approved", true), - vec!["apply", "defer", "discard"] - ); - assert_eq!( - dreaming_review_queue::available_review_actions("approved", false), - vec!["defer", "discard"] - ); - assert!(dreaming_review_queue::available_review_actions("applied", false).is_empty()); - } - - #[test] - fn affected_refs_include_target_and_payload_refs() { - let refs = dreaming_review_queue::affected_refs( - &serde_json::json!({ "kind": "knowledge_page", "page_key": "architecture" }), - &serde_json::json!({ - "affected_pages": [{ "page_key": "architecture" }], - "affected_facts": [{ "fact_id": "f1" }] - }), - ); - - assert_eq!(refs.len(), 3); - } - - #[test] - fn queue_item_policy_separates_review_apply_and_auto_apply() { - let proposed_tag = dreaming_review_queue::DreamingReviewQueueItem::from(proposal( - "tag", - "no_op", - "proposed", - 0.95, - serde_json::json!({ "queue_variant": "tag" }), - serde_json::json!({ "summary": "tag", "before": {}, "after": {} }), - )); - - assert!(proposed_tag.policy.auto_apply_candidate); - assert!(!proposed_tag.policy.auto_apply_allowed); - assert!(proposed_tag.policy.requires_review); - assert_eq!( - proposed_tag.review_audit.available_actions, - vec!["approve", "defer", "discard"] - ); - - let approved_tag = dreaming_review_queue::DreamingReviewQueueItem::from(proposal( - "tag", - "no_op", - "approved", - 0.95, - serde_json::json!({ "queue_variant": "tag" }), - serde_json::json!({ "summary": "tag", "before": {}, "after": {} }), - )); - - assert!(approved_tag.policy.auto_apply_allowed); - assert!(!approved_tag.policy.requires_review); - assert_eq!(approved_tag.review_audit.available_actions, vec!["apply", "defer", "discard"]); - - let approved_graph = dreaming_review_queue::DreamingReviewQueueItem::from(proposal( - "graph_fact", - "create_derived_graph_view", - "approved", - 0.95, - serde_json::json!({ "queue_variant": "graph_fact" }), - serde_json::json!({ "summary": "graph", "before": {}, "after": {} }), - )); - - assert!(approved_graph.policy.high_impact); - assert!(!approved_graph.policy.auto_apply_allowed); - assert_eq!( - approved_graph.review_audit.available_actions, - vec!["apply", "defer", "discard"] - ); - - let source_mutation = dreaming_review_queue::DreamingReviewQueueItem::from(proposal( - "tag", - "no_op", - "approved", - 0.95, - serde_json::json!({ "queue_variant": "tag" }), - serde_json::json!({ - "summary": "source mutation", - "before": {}, - "after": { "source_mutation": true } - }), - )); - - assert!(source_mutation.policy.source_mutation_requested); - assert!(!source_mutation.policy.auto_apply_allowed); - assert!(source_mutation.policy.requires_review); - assert_eq!(source_mutation.review_audit.available_actions, vec!["defer", "discard"]); - - let memory_promotion = dreaming_review_queue::DreamingReviewQueueItem::from(proposal( - "derived_note", - "create_derived_note", - "proposed", - 0.95, - serde_json::json!({}), - serde_json::json!({ "summary": "promote", "before": {}, "after": {} }), - )); - - assert_eq!(memory_promotion.queue_variant, "memory_promotion"); - assert!(memory_promotion.policy.high_impact); - assert!(!memory_promotion.policy.auto_apply_candidate); - } - - fn proposal( - proposal_kind: &str, - apply_intent: &str, - review_state: &str, - confidence: f32, - proposed_payload: serde_json::Value, - diff: serde_json::Value, - ) -> ConsolidationProposalResponse { - let now = OffsetDateTime::UNIX_EPOCH; - ConsolidationProposalResponse { - proposal_id: Uuid::nil(), - run_id: Uuid::nil(), - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - contract_schema: "elf.consolidation/v1".to_string(), - proposal_kind: proposal_kind.to_string(), - apply_intent: apply_intent.to_string(), - review_state: review_state.to_string(), - source_refs: serde_json::json!([]), - source_snapshot: serde_json::json!({}), - lineage: serde_json::json!({}), - diff, - confidence, - unsupported_claim_flags: serde_json::json!([]), - contradiction_markers: serde_json::json!([]), - staleness_markers: serde_json::json!([]), - target_ref: serde_json::json!({}), - proposed_payload, - reviewer_agent_id: None, - review_comment: None, - reviewed_at: None, - created_at: now, - updated_at: now, - review_events: Vec::new(), - } - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/dreaming_review_queue/item.rs b/packages/elf-service/src/dreaming_review_queue/item.rs new file mode 100644 index 00000000..facd0efd --- /dev/null +++ b/packages/elf-service/src/dreaming_review_queue/item.rs @@ -0,0 +1,86 @@ +use crate::{ + ConsolidationProposalResponse, + dreaming_review_queue::{ + policy::{self, HIGH_CONFIDENCE_AUTO_APPLY_FLOOR}, + types::{DreamingReviewQueueAudit, DreamingReviewQueueItem, DreamingReviewQueueItemPolicy}, + }, +}; + +impl From for DreamingReviewQueueItem { + fn from(proposal: ConsolidationProposalResponse) -> Self { + let queue_variant = policy::queue_variant_for( + proposal.proposal_kind.as_str(), + proposal.apply_intent.as_str(), + &proposal.proposed_payload, + ); + let source_mutation_requested = + policy::contains_forbidden_source_mutation_key(&proposal.diff) + || policy::contains_forbidden_source_mutation_key(&proposal.proposed_payload) + || policy::contains_forbidden_source_mutation_key(&proposal.target_ref); + let high_impact = policy::high_impact_variant(queue_variant.as_str()); + let has_unsupported_claims = + policy::non_empty_json_array(&proposal.unsupported_claim_flags); + let has_review_markers = policy::non_empty_json_array(&proposal.contradiction_markers) + || policy::non_empty_json_array(&proposal.staleness_markers); + let auto_apply_candidate = policy::low_risk_derived_organization(queue_variant.as_str()) + && proposal.confidence >= HIGH_CONFIDENCE_AUTO_APPLY_FLOOR + && !has_unsupported_claims + && !has_review_markers + && !source_mutation_requested; + let manual_apply_allowed = + proposal.review_state.as_str() == "approved" && !source_mutation_requested; + let auto_apply_allowed = auto_apply_candidate && manual_apply_allowed; + let requires_review = source_mutation_requested + || !matches!(proposal.review_state.as_str(), "approved" | "applied"); + let policy = DreamingReviewQueueItemPolicy { + source_mutation_requested, + high_impact, + requires_review, + auto_apply_candidate, + auto_apply_allowed, + reason: policy::policy_reason( + source_mutation_requested, + high_impact, + has_unsupported_claims, + has_review_markers, + auto_apply_candidate, + auto_apply_allowed, + manual_apply_allowed, + ), + }; + let review_audit = DreamingReviewQueueAudit { + review_state: proposal.review_state.clone(), + available_actions: policy::available_review_actions( + proposal.review_state.as_str(), + manual_apply_allowed, + ), + reviewer_agent_id: proposal.reviewer_agent_id.clone(), + review_comment: proposal.review_comment.clone(), + reviewed_at: proposal.reviewed_at, + review_events: proposal.review_events.clone(), + }; + + Self { + proposal_id: proposal.proposal_id, + run_id: proposal.run_id, + proposal_kind: proposal.proposal_kind, + queue_variant, + apply_intent: proposal.apply_intent, + review_state: proposal.review_state, + source_refs: proposal.source_refs, + source_snapshot: proposal.source_snapshot, + affected_refs: policy::affected_refs(&proposal.target_ref, &proposal.proposed_payload), + target_ref: proposal.target_ref, + diff: proposal.diff, + confidence: proposal.confidence, + unsupported_claim_flags: proposal.unsupported_claim_flags, + contradiction_markers: proposal.contradiction_markers, + staleness_markers: proposal.staleness_markers, + proposed_payload: proposal.proposed_payload, + policy, + review_audit, + created_at: proposal.created_at, + updated_at: proposal.updated_at, + } + } +} diff --git a/packages/elf-service/src/dreaming_review_queue/policy.rs b/packages/elf-service/src/dreaming_review_queue/policy.rs new file mode 100644 index 00000000..0194af88 --- /dev/null +++ b/packages/elf-service/src/dreaming_review_queue/policy.rs @@ -0,0 +1,243 @@ +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::dreaming_review_queue::types::{DreamingReviewQueueItem, DreamingReviewQueueSummary}; + +/// Schema identifier for Dreaming review queue responses. +pub const ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1: &str = "elf.dreaming_review_queue/v1"; + +pub(super) const HIGH_CONFIDENCE_AUTO_APPLY_FLOOR: f32 = 0.9; + +const DEFAULT_QUEUE_LIMIT: u32 = 50; +const MAX_QUEUE_LIMIT: u32 = 200; +const FORBIDDEN_SOURCE_MUTATION_KEYS: [&str; 8] = [ + "delete_source", + "delete_sources", + "overwrite_source", + "source_delete", + "source_mutation", + "source_mutations", + "source_note_updates", + "update_source", +]; + +pub(super) fn summarize_items(items: &[DreamingReviewQueueItem]) -> DreamingReviewQueueSummary { + let mut summary = DreamingReviewQueueSummary { + item_count: items.len(), + ..DreamingReviewQueueSummary::default() + }; + let mut variants = BTreeSet::new(); + + for item in items { + match item.review_state.as_str() { + "proposed" => summary.proposed_count += 1, + "approved" => summary.approved_count += 1, + "applied" => summary.applied_count += 1, + "rejected" => summary.discarded_count += 1, + "archived" => summary.deferred_count += 1, + _ => {}, + } + + if item.policy.high_impact { + summary.high_impact_count += 1; + } + if item.policy.source_mutation_requested { + summary.source_mutation_requested_count += 1; + } + if item.policy.auto_apply_candidate { + summary.auto_apply_candidate_count += 1; + } + if item.policy.auto_apply_allowed { + summary.auto_apply_allowed_count += 1; + } + + variants.insert(item.queue_variant.as_str()); + } + + summary.variant_count = variants.len(); + + summary +} + +pub(super) fn queue_variant_for( + proposal_kind: &str, + apply_intent: &str, + proposed_payload: &Value, +) -> String { + for pointer in [ + "/queue_variant", + "/dreaming_variant", + "/proposal_variant", + "/variant", + "/artifact_kind", + "/metadata/queue_variant", + "/metadata/dreaming_variant", + "/metadata/artifact_kind", + ] { + if let Some(raw) = proposed_payload.pointer(pointer).and_then(Value::as_str) + && let Some(variant) = normalize_variant(raw) + { + return variant; + } + } + + if let Some(variant) = normalize_variant(proposal_kind) { + return variant; + } + + match apply_intent { + "create_derived_knowledge_page" | "update_derived_knowledge_page" => + "page_rebuild".to_string(), + "create_derived_graph_view" => "graph_fact".to_string(), + "create_derived_note" | "update_derived_note" => "memory_promotion".to_string(), + _ => "other".to_string(), + } +} + +pub(super) fn affected_refs(target_ref: &Value, proposed_payload: &Value) -> Vec { + let mut refs = Vec::new(); + + push_non_empty_object(&mut refs, target_ref); + + for pointer in [ + "/affected_refs", + "/affected_pages", + "/affected_memories", + "/affected_facts", + "/affected_notes", + ] { + match proposed_payload.pointer(pointer) { + Some(Value::Array(values)) => refs.extend(values.iter().cloned()), + Some(value) if non_empty_json_object(value) => refs.push(value.clone()), + _ => {}, + } + } + + refs +} + +pub(super) fn non_empty_json_array(value: &Value) -> bool { + value.as_array().is_some_and(|array| !array.is_empty()) +} + +pub(super) fn contains_forbidden_source_mutation_key(value: &Value) -> bool { + match value { + Value::Object(map) => map.iter().any(|(key, nested)| { + FORBIDDEN_SOURCE_MUTATION_KEYS.contains(&key.as_str()) + || contains_forbidden_source_mutation_key(nested) + }), + Value::Array(items) => items.iter().any(contains_forbidden_source_mutation_key), + _ => false, + } +} + +pub(super) fn low_risk_derived_organization(queue_variant: &str) -> bool { + matches!(queue_variant, "tag" | "duplicate_merge") +} + +pub(super) fn high_impact_variant(queue_variant: &str) -> bool { + matches!(queue_variant, "memory_promotion" | "graph_fact" | "correction") +} + +pub(super) fn available_review_actions( + review_state: &str, + manual_apply_allowed: bool, +) -> Vec { + let actions = match review_state { + "proposed" => &["approve", "defer", "discard"][..], + "approved" if manual_apply_allowed => &["apply", "defer", "discard"][..], + "approved" => &["defer", "discard"][..], + _ => &[][..], + }; + + actions.iter().map(|action| (*action).to_string()).collect() +} + +pub(super) fn policy_reason( + source_mutation_requested: bool, + high_impact: bool, + has_unsupported_claims: bool, + has_review_markers: bool, + auto_apply_candidate: bool, + auto_apply_allowed: bool, + manual_apply_allowed: bool, +) -> String { + if source_mutation_requested { + return "source mutation is requested, so the proposal cannot be applied by the queue" + .to_string(); + } + if has_unsupported_claims || has_review_markers { + return "lint or review markers require explicit reviewer inspection".to_string(); + } + if auto_apply_allowed { + return "approved low-risk derived organization proposal satisfies auto-apply policy" + .to_string(); + } + if manual_apply_allowed { + return "approved review-gated proposal may be manually applied to a derived target" + .to_string(); + } + if high_impact { + return "high-impact memory, graph, or correction proposal requires approval before apply" + .to_string(); + } + + if auto_apply_candidate { + return "low-risk derived organization proposal is a candidate after reviewer approval" + .to_string(); + } + + "proposal remains reviewable derived output".to_string() +} + +pub(super) fn bounded_queue_limit(limit: Option) -> i64 { + i64::from(limit.unwrap_or(DEFAULT_QUEUE_LIMIT).clamp(1, MAX_QUEUE_LIMIT)) +} + +fn normalize_variant(raw: &str) -> Option { + let token = raw.trim().to_ascii_lowercase().replace(['-', ' '], "_"); + + if token.is_empty() { + return None; + } + if token.contains("duplicate") || token.contains("dedupe") { + return Some("duplicate_merge".to_string()); + } + if token.contains("tag") || token.contains("taxonomy") { + return Some("tag".to_string()); + } + if token.contains("knowledge_page") || token.contains("page_rebuild") { + return Some("page_rebuild".to_string()); + } + if token.contains("graph_fact") || token.contains("graph_view") { + return Some("graph_fact".to_string()); + } + if token.contains("proactive_brief") || token.contains("daily_brief") { + return Some("proactive_brief".to_string()); + } + if token.contains("scheduled_memory") || token.contains("weekly_summary") { + return Some("scheduled_memory".to_string()); + } + if token.contains("memory_summary") || token.contains("summary") { + return Some("memory_summary".to_string()); + } + if token.contains("memory_promotion") || token.contains("derived_note") { + return Some("memory_promotion".to_string()); + } + if token.contains("correction") || token.contains("repair") { + return Some("correction".to_string()); + } + + Some(token) +} + +fn push_non_empty_object(refs: &mut Vec, value: &Value) { + if non_empty_json_object(value) { + refs.push(value.clone()); + } +} + +fn non_empty_json_object(value: &Value) -> bool { + value.as_object().is_some_and(|object| !object.is_empty()) +} diff --git a/packages/elf-service/src/dreaming_review_queue/service.rs b/packages/elf-service/src/dreaming_review_queue/service.rs new file mode 100644 index 00000000..6d7e58f6 --- /dev/null +++ b/packages/elf-service/src/dreaming_review_queue/service.rs @@ -0,0 +1,58 @@ +use crate::{ + ConsolidationProposalResponse, ConsolidationProposalReviewEventResponse, ElfService, Result, + dreaming_review_queue::{ + policy::{self, ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1}, + types::{ + DreamingReviewQueueItem, DreamingReviewQueuePolicy, DreamingReviewQueueRequest, + DreamingReviewQueueResponse, + }, + }, +}; +use elf_domain::consolidation::ConsolidationReviewState; +use elf_storage::consolidation; + +impl ElfService { + /// Lists consolidation proposals as a Dreaming review queue. + pub async fn dreaming_review_queue( + &self, + req: DreamingReviewQueueRequest, + ) -> Result { + let limit = policy::bounded_queue_limit(req.limit); + let review_state = req.review_state.map(ConsolidationReviewState::as_str); + let proposals = consolidation::list_consolidation_proposals( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.run_id, + review_state, + limit, + ) + .await?; + let mut items = Vec::with_capacity(proposals.len()); + + for proposal in proposals { + let review_events = consolidation::list_consolidation_proposal_review_events( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + proposal.proposal_id, + ) + .await? + .into_iter() + .map(ConsolidationProposalReviewEventResponse::from) + .collect(); + let mut response = ConsolidationProposalResponse::from(proposal); + + response.review_events = review_events; + + items.push(DreamingReviewQueueItem::from(response)); + } + + Ok(DreamingReviewQueueResponse { + schema: ELF_DREAMING_REVIEW_QUEUE_SCHEMA_V1.to_string(), + policy: DreamingReviewQueuePolicy::default(), + summary: policy::summarize_items(&items), + items, + }) + } +} diff --git a/packages/elf-service/src/dreaming_review_queue/tests.rs b/packages/elf-service/src/dreaming_review_queue/tests.rs new file mode 100644 index 00000000..904f5c0c --- /dev/null +++ b/packages/elf-service/src/dreaming_review_queue/tests.rs @@ -0,0 +1,179 @@ +use serde_json::{self, Value}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + ConsolidationProposalResponse, + dreaming_review_queue::{DreamingReviewQueueItem, policy}, +}; + +#[test] +fn queue_variant_prefers_payload_and_normalizes_future_variants() { + let payload = serde_json::json!({ + "metadata": { "queue_variant": "Duplicate Merge" } + }); + + assert_eq!( + policy::queue_variant_for("derived_note", "create_derived_note", &payload), + "duplicate_merge" + ); + assert_eq!( + policy::queue_variant_for( + "knowledge_page", + "update_derived_knowledge_page", + &serde_json::json!({}) + ), + "page_rebuild" + ); + assert_eq!( + policy::queue_variant_for( + "correction", + "no_op", + &serde_json::json!({ "affected_notes": [] }) + ), + "correction" + ); +} + +#[test] +fn policy_detects_source_mutation_and_review_actions() { + assert!(policy::contains_forbidden_source_mutation_key(&serde_json::json!({ + "after": { "source_note_updates": [{ "note_id": "n1" }] } + }))); + assert_eq!( + policy::available_review_actions("proposed", false), + vec!["approve", "defer", "discard"] + ); + assert_eq!( + policy::available_review_actions("approved", true), + vec!["apply", "defer", "discard"] + ); + assert_eq!(policy::available_review_actions("approved", false), vec!["defer", "discard"]); + assert!(policy::available_review_actions("applied", false).is_empty()); +} + +#[test] +fn affected_refs_include_target_and_payload_refs() { + let refs = policy::affected_refs( + &serde_json::json!({ "kind": "knowledge_page", "page_key": "architecture" }), + &serde_json::json!({ + "affected_pages": [{ "page_key": "architecture" }], + "affected_facts": [{ "fact_id": "f1" }] + }), + ); + + assert_eq!(refs.len(), 3); +} + +#[test] +fn queue_item_policy_separates_review_apply_and_auto_apply() { + let proposed_tag = DreamingReviewQueueItem::from(proposal( + "tag", + "no_op", + "proposed", + 0.95, + serde_json::json!({ "queue_variant": "tag" }), + serde_json::json!({ "summary": "tag", "before": {}, "after": {} }), + )); + + assert!(proposed_tag.policy.auto_apply_candidate); + assert!(!proposed_tag.policy.auto_apply_allowed); + assert!(proposed_tag.policy.requires_review); + assert_eq!(proposed_tag.review_audit.available_actions, vec!["approve", "defer", "discard"]); + + let approved_tag = DreamingReviewQueueItem::from(proposal( + "tag", + "no_op", + "approved", + 0.95, + serde_json::json!({ "queue_variant": "tag" }), + serde_json::json!({ "summary": "tag", "before": {}, "after": {} }), + )); + + assert!(approved_tag.policy.auto_apply_allowed); + assert!(!approved_tag.policy.requires_review); + assert_eq!(approved_tag.review_audit.available_actions, vec!["apply", "defer", "discard"]); + + let approved_graph = DreamingReviewQueueItem::from(proposal( + "graph_fact", + "create_derived_graph_view", + "approved", + 0.95, + serde_json::json!({ "queue_variant": "graph_fact" }), + serde_json::json!({ "summary": "graph", "before": {}, "after": {} }), + )); + + assert!(approved_graph.policy.high_impact); + assert!(!approved_graph.policy.auto_apply_allowed); + assert_eq!(approved_graph.review_audit.available_actions, vec!["apply", "defer", "discard"]); + + let source_mutation = DreamingReviewQueueItem::from(proposal( + "tag", + "no_op", + "approved", + 0.95, + serde_json::json!({ "queue_variant": "tag" }), + serde_json::json!({ + "summary": "source mutation", + "before": {}, + "after": { "source_mutation": true } + }), + )); + + assert!(source_mutation.policy.source_mutation_requested); + assert!(!source_mutation.policy.auto_apply_allowed); + assert!(source_mutation.policy.requires_review); + assert_eq!(source_mutation.review_audit.available_actions, vec!["defer", "discard"]); + + let memory_promotion = DreamingReviewQueueItem::from(proposal( + "derived_note", + "create_derived_note", + "proposed", + 0.95, + serde_json::json!({}), + serde_json::json!({ "summary": "promote", "before": {}, "after": {} }), + )); + + assert_eq!(memory_promotion.queue_variant, "memory_promotion"); + assert!(memory_promotion.policy.high_impact); + assert!(!memory_promotion.policy.auto_apply_candidate); +} + +fn proposal( + proposal_kind: &str, + apply_intent: &str, + review_state: &str, + confidence: f32, + proposed_payload: Value, + diff: Value, +) -> ConsolidationProposalResponse { + let now = OffsetDateTime::UNIX_EPOCH; + + ConsolidationProposalResponse { + proposal_id: Uuid::nil(), + run_id: Uuid::nil(), + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + contract_schema: "elf.consolidation/v1".to_string(), + proposal_kind: proposal_kind.to_string(), + apply_intent: apply_intent.to_string(), + review_state: review_state.to_string(), + source_refs: serde_json::json!([]), + source_snapshot: serde_json::json!({}), + lineage: serde_json::json!({}), + diff, + confidence, + unsupported_claim_flags: serde_json::json!([]), + contradiction_markers: serde_json::json!([]), + staleness_markers: serde_json::json!([]), + target_ref: serde_json::json!({}), + proposed_payload, + reviewer_agent_id: None, + review_comment: None, + reviewed_at: None, + created_at: now, + updated_at: now, + review_events: Vec::new(), + } +} diff --git a/packages/elf-service/src/dreaming_review_queue/types.rs b/packages/elf-service/src/dreaming_review_queue/types.rs new file mode 100644 index 00000000..9487e18c --- /dev/null +++ b/packages/elf-service/src/dreaming_review_queue/types.rs @@ -0,0 +1,178 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::ConsolidationProposalReviewEventResponse; +use elf_domain::consolidation::ConsolidationReviewState; + +/// Request payload for Dreaming review queue readback. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DreamingReviewQueueRequest { + /// Tenant that owns the review queue. + pub tenant_id: String, + /// Project that owns the review queue. + pub project_id: String, + /// Optional run filter. + pub run_id: Option, + /// Optional review-state filter. + pub review_state: Option, + /// Maximum number of queue items to return. + pub limit: Option, +} + +/// Dreaming review queue response. +#[derive(Clone, Debug, Serialize)] +pub struct DreamingReviewQueueResponse { + /// Response schema identifier. + pub schema: String, + /// Queue policy applied to every returned item. + pub policy: DreamingReviewQueuePolicy, + /// Aggregate queue summary. + pub summary: DreamingReviewQueueSummary, + /// Returned queue items. + pub items: Vec, +} + +/// Global review queue policy. +#[derive(Clone, Debug, Serialize)] +pub struct DreamingReviewQueuePolicy { + /// Authoritative source mutation is never allowed by this queue surface. + pub source_mutation_allowed: bool, + /// Whether high-impact proposals require explicit review. + pub high_impact_requires_review: bool, + /// Low-risk derived organization variants that may become auto-apply candidates. + pub low_risk_derived_organization_variants: Vec, + /// Review actions supported by the underlying consolidation proposal lifecycle. + pub review_actions: Vec, + /// Human-readable policy summary. + pub summary: String, +} +impl Default for DreamingReviewQueuePolicy { + fn default() -> Self { + Self { + source_mutation_allowed: false, + high_impact_requires_review: true, + low_risk_derived_organization_variants: vec![ + "tag".to_string(), + "duplicate_merge".to_string(), + ], + review_actions: vec![ + "approve".to_string(), + "apply".to_string(), + "defer".to_string(), + "discard".to_string(), + ], + summary: "Dreaming review queue proposals are source-backed derived outputs; authoritative source mutation is disallowed, and high-impact memory or graph changes remain review-gated.".to_string(), + } + } +} + +/// Aggregate queue summary. +#[derive(Clone, Debug, Default, Serialize)] +pub struct DreamingReviewQueueSummary { + /// Returned item count. + pub item_count: usize, + /// Items still waiting for review. + pub proposed_count: usize, + /// Items approved but not marked applied. + pub approved_count: usize, + /// Items marked applied to derived targets. + pub applied_count: usize, + /// Items discarded by review. + pub discarded_count: usize, + /// Items deferred for later audit. + pub deferred_count: usize, + /// Items classified as high impact. + pub high_impact_count: usize, + /// Items that request source mutation and therefore cannot be auto-applied. + pub source_mutation_requested_count: usize, + /// Items eligible for low-risk derived organization auto-apply after approval. + pub auto_apply_candidate_count: usize, + /// Items that currently satisfy the queue's auto-apply policy. + pub auto_apply_allowed_count: usize, + /// Number of distinct queue variants represented by the response. + pub variant_count: usize, +} + +/// One Dreaming review queue item. +#[derive(Clone, Debug, Serialize)] +pub struct DreamingReviewQueueItem { + /// Consolidation proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Consolidation proposal kind. + pub proposal_kind: String, + /// Dreaming queue variant inferred from proposal metadata. + pub queue_variant: String, + /// Derived-output apply intent. + pub apply_intent: String, + /// Current review state. + pub review_state: String, + /// Source references supporting the proposal. + pub source_refs: Value, + /// Aggregate immutable source snapshot. + pub source_snapshot: Value, + /// Target affected by the proposal, when supplied. + pub target_ref: Value, + /// Affected pages, memories, facts, or derived artifacts extracted for reviewer scan. + pub affected_refs: Vec, + /// Reviewable diff. + pub diff: Value, + /// Proposal confidence. + pub confidence: f32, + /// Unsupported-claim lint flags. + pub unsupported_claim_flags: Value, + /// Contradiction markers for review. + pub contradiction_markers: Value, + /// Staleness markers for review. + pub staleness_markers: Value, + /// Proposed derived payload. + pub proposed_payload: Value, + /// Per-item policy decision. + pub policy: DreamingReviewQueueItemPolicy, + /// Review audit readback. + pub review_audit: DreamingReviewQueueAudit, + #[serde(with = "crate::time_serde")] + /// Item creation timestamp. + pub created_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Item update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Per-item policy readback. +#[derive(Clone, Debug, Serialize)] +pub struct DreamingReviewQueueItemPolicy { + /// Whether this proposal requests mutation of authoritative sources. + pub source_mutation_requested: bool, + /// Whether this item is considered high impact. + pub high_impact: bool, + /// Whether reviewer approval is required before downstream application. + pub requires_review: bool, + /// Whether this item is a low-risk derived organization auto-apply candidate. + pub auto_apply_candidate: bool, + /// Whether this item currently satisfies auto-apply policy. + pub auto_apply_allowed: bool, + /// Reason for the policy decision. + pub reason: String, +} + +/// Review audit readback for one queue item. +#[derive(Clone, Debug, Serialize)] +pub struct DreamingReviewQueueAudit { + /// Current review state. + pub review_state: String, + /// Actions currently accepted by the consolidation proposal lifecycle. + pub available_actions: Vec, + /// Agent that last reviewed the item. + pub reviewer_agent_id: Option, + /// Last reviewer comment. + pub review_comment: Option, + #[serde(with = "crate::time_serde::option")] + /// Last review timestamp. + pub reviewed_at: Option, + /// Append-only review events. + pub review_events: Vec, +} diff --git a/packages/elf-service/src/entity_memory.rs b/packages/elf-service/src/entity_memory.rs index ae045d1d..0a08d44b 100644 --- a/packages/elf-service/src/entity_memory.rs +++ b/packages/elf-service/src/entity_memory.rs @@ -1,836 +1,19 @@ //! Entity-scoped memory authority readback. -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{FromRow, PgConnection, PgExecutor}; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ - ElfService, Error, Result, - access::{self, ORG_PROJECT_ID}, - graph::RelationTemporalStatus, - search, +mod build; +mod service; +mod storage; +mod types; +mod validation; + +pub use types::{ + EntityMemoryEntity, EntityMemoryItem, EntityMemoryRelation, EntityMemorySummary, + EntityMemoryViewRequest, EntityMemoryViewResponse, }; -use elf_storage::{graph, models::GraphEntity}; /// Entity memory view response schema identifier. pub const ELF_ENTITY_MEMORY_VIEW_SCHEMA_V1: &str = "elf.entity_memory_view/v1"; const TOP_OF_MIND_IMPORTANCE_THRESHOLD: f32 = 0.8; -/// Request payload for an entity-scoped memory view. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct EntityMemoryViewRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Exact graph entity id to resolve. - pub entity_id: Option, - /// Canonical or alias surface to resolve when entity_id is omitted. - pub entity_surface: Option, -} - -/// Response payload for an entity-scoped memory view. -#[derive(Clone, Debug, Serialize)] -pub struct EntityMemoryViewResponse { - /// Response schema identifier. - pub schema: String, - /// Tenant used for the read. - pub tenant_id: String, - /// Project used for the read. - pub project_id: String, - /// Agent that requested the read. - pub agent_id: String, - /// Read profile used for access control. - pub read_profile: String, - #[serde(with = "crate::time_serde")] - /// Timestamp used for lifecycle classification. - pub as_of: OffsetDateTime, - /// Resolved graph entity. - pub entity: EntityMemoryEntity, - /// Aggregate counters for the returned items. - pub summary: EntityMemorySummary, - /// Entity-relevant core blocks and archival notes. - pub items: Vec, -} - -/// Resolved graph entity reference. -#[derive(Clone, Debug, Serialize)] -pub struct EntityMemoryEntity { - /// Entity identifier. - pub entity_id: Uuid, - /// Canonical entity surface. - pub canonical: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional entity kind. - pub kind: Option, - /// Canonical plus alias surfaces used for matching core blocks. - pub surfaces: Vec, -} - -/// Aggregate counters for an entity memory view. -#[derive(Clone, Debug, Default, Serialize)] -pub struct EntityMemorySummary { - /// Number of current items. - pub current_count: usize, - /// Number of stale items. - pub stale_count: usize, - /// Number of superseded items. - pub superseded_count: usize, - /// Number of tombstoned items. - pub tombstoned_count: usize, - /// Number of top-of-mind items. - pub top_of_mind_count: usize, - /// Number of background items. - pub background_count: usize, - /// Number of core memory block items. - pub core_block_count: usize, - /// Number of graph evidence note items. - pub archival_note_count: usize, -} - -/// One item in an entity memory view. -#[derive(Clone, Debug, Serialize)] -pub struct EntityMemoryItem { - /// Source family for the item. - pub source: String, - /// Lifecycle bucket. - pub lifecycle: String, - /// Read bucket used by agents to decide whether to treat this as always-loaded context. - pub read_bucket: String, - /// Scope key for access explanation. - pub scope: String, - /// Agent that owns the source record. - pub agent_id: String, - /// Note identifier for archival_note items. - #[serde(skip_serializing_if = "Option::is_none")] - pub note_id: Option, - /// Core block identifier for core_block items. - #[serde(skip_serializing_if = "Option::is_none")] - pub block_id: Option, - /// Active core block attachment identifier for core_block items. - #[serde(skip_serializing_if = "Option::is_none")] - pub attachment_id: Option, - /// Optional note type discriminator. - #[serde(skip_serializing_if = "Option::is_none")] - pub note_type: Option, - /// Optional stable source key. - #[serde(skip_serializing_if = "Option::is_none")] - pub key: Option, - /// Human-readable title for core blocks. - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - /// Text payload. - pub text: String, - /// Importance score when available. - #[serde(skip_serializing_if = "Option::is_none")] - pub importance: Option, - /// Confidence score when available. - #[serde(skip_serializing_if = "Option::is_none")] - pub confidence: Option, - /// Structured source/provenance metadata. - pub source_ref: Value, - #[serde(with = "crate::time_serde")] - /// Last source update timestamp. - pub updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// Optional expiry timestamp for archival notes. - pub expires_at: Option, - /// Relations that connect this item to the entity. - pub relations: Vec, -} - -/// Graph relation that made an item relevant to the entity. -#[derive(Clone, Debug, Serialize)] -pub struct EntityMemoryRelation { - /// Graph fact identifier. - pub fact_id: Uuid, - /// Predicate surface recorded on the fact. - pub predicate: String, - /// Scope of the graph fact. - pub scope: String, - /// Agent that emitted the graph fact. - pub actor: String, - #[serde(with = "crate::time_serde")] - /// Start of fact validity window. - pub valid_from: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// End of fact validity window, when superseded. - pub valid_to: Option, - /// Temporal state for the fact relative to the view timestamp. - pub temporal_status: RelationTemporalStatus, -} - -#[derive(Debug)] -struct PreparedEntityMemoryRequest { - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - entity_id: Option, - entity_surface: Option, -} - -#[derive(Clone, Debug, FromRow)] -struct EntityAliasRow { - alias: String, -} - -#[derive(Clone, Debug, FromRow)] -struct EntityNoteRow { - note_id: Uuid, - agent_id: String, - scope: String, - r#type: String, - key: Option, - text: String, - importance: f32, - confidence: f32, - status: String, - updated_at: OffsetDateTime, - expires_at: Option, - source_ref: Value, - fact_id: Uuid, - fact_scope: String, - fact_agent_id: String, - predicate: String, - valid_from: OffsetDateTime, - valid_to: Option, -} - -#[derive(Clone, Debug, FromRow)] -struct EntityCoreBlockRow { - attachment_id: Uuid, - block_id: Uuid, - agent_id: String, - scope: String, - key: String, - title: String, - content: String, - source_ref: Value, - updated_at: OffsetDateTime, -} - -impl ElfService { - /// Returns an entity-scoped view across attached core blocks and graph-linked notes. - pub async fn entity_memory_view( - &self, - req: EntityMemoryViewRequest, - ) -> Result { - let prepared = validate_entity_memory_request(req)?; - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let as_of = OffsetDateTime::now_utc(); - let mut conn = self.db.pool.acquire().await?; - let entity = resolve_entity(&mut conn, &prepared).await?; - let aliases = fetch_aliases(conn.as_mut(), entity.entity_id).await?; - let mut surfaces = vec![entity.canonical.clone()]; - - for alias in aliases { - if !surfaces.iter().any(|surface| surface.eq_ignore_ascii_case(&alias)) { - surfaces.push(alias); - } - } - - let shared_grants = access::load_shared_read_grants_with_org_shared( - conn.as_mut(), - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.agent_id.as_str(), - org_shared_allowed, - ) - .await?; - let note_rows = fetch_entity_note_rows( - conn.as_mut(), - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - entity.entity_id, - &allowed_scopes, - ) - .await?; - let block_rows = fetch_entity_core_block_rows( - conn.as_mut(), - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.agent_id.as_str(), - prepared.read_profile.as_str(), - ) - .await?; - let mut items = build_note_items( - note_rows, - prepared.agent_id.as_str(), - &allowed_scopes, - &shared_grants, - as_of, - ); - - items.extend(build_core_block_items( - block_rows, - prepared.agent_id.as_str(), - &allowed_scopes, - &shared_grants, - &surfaces, - )); - - sort_entity_memory_items(&mut items); - - let summary = summarize_items(&items); - - Ok(EntityMemoryViewResponse { - schema: ELF_ENTITY_MEMORY_VIEW_SCHEMA_V1.to_string(), - tenant_id: prepared.tenant_id, - project_id: prepared.project_id, - agent_id: prepared.agent_id, - read_profile: prepared.read_profile, - as_of, - entity: EntityMemoryEntity { - entity_id: entity.entity_id, - canonical: entity.canonical, - kind: entity.kind, - surfaces, - }, - summary, - items, - }) - } -} - -fn validate_entity_memory_request( - req: EntityMemoryViewRequest, -) -> Result { - let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; - let project_id = normalize_required(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; - let read_profile = normalize_required(req.read_profile.as_str(), "read_profile")?; - let entity_surface = req - .entity_surface - .as_deref() - .map(|surface| normalize_required(surface, "entity_surface")) - .transpose()?; - - if req.entity_id.is_some() == entity_surface.is_some() { - return Err(Error::InvalidRequest { - message: "Exactly one of entity_id or entity_surface is required.".to_string(), - }); - } - - Ok(PreparedEntityMemoryRequest { - tenant_id, - project_id, - agent_id, - read_profile, - entity_id: req.entity_id, - entity_surface, - }) -} - -fn build_note_items( - rows: Vec, - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, - as_of: OffsetDateTime, -) -> Vec { - let mut items = Vec::new(); - - for row in rows { - if !row_read_allowed( - row.agent_id.as_str(), - row.scope.as_str(), - requester_agent_id, - allowed_scopes, - shared_grants, - ) || !row_read_allowed( - row.fact_agent_id.as_str(), - row.fact_scope.as_str(), - requester_agent_id, - allowed_scopes, - shared_grants, - ) { - continue; - } - - let lifecycle = note_lifecycle(row.status.as_str(), row.expires_at, as_of); - let read_bucket = note_read_bucket(lifecycle.as_str(), row.importance); - let relation = relation_from_note_row(&row, as_of); - - if let Some(item) = items.iter_mut().find(|item: &&mut EntityMemoryItem| { - item.source == "archival_note" && item.note_id == Some(row.note_id) - }) { - item.relations.push(relation); - - continue; - } - - items.push(EntityMemoryItem { - source: "archival_note".to_string(), - lifecycle, - read_bucket, - scope: row.scope, - agent_id: row.agent_id, - note_id: Some(row.note_id), - block_id: None, - attachment_id: None, - note_type: Some(row.r#type), - key: row.key, - title: None, - text: row.text, - importance: Some(row.importance), - confidence: Some(row.confidence), - source_ref: row.source_ref, - updated_at: row.updated_at, - expires_at: row.expires_at, - relations: vec![relation], - }); - } - - items -} - -fn build_core_block_items( - rows: Vec, - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, - surfaces: &[String], -) -> Vec { - rows.into_iter() - .filter(|row| { - row_read_allowed( - row.agent_id.as_str(), - row.scope.as_str(), - requester_agent_id, - allowed_scopes, - shared_grants, - ) && core_block_mentions_entity(row, surfaces) - }) - .map(|row| EntityMemoryItem { - source: "core_block".to_string(), - lifecycle: "current".to_string(), - read_bucket: "top_of_mind".to_string(), - scope: row.scope, - agent_id: row.agent_id, - note_id: None, - block_id: Some(row.block_id), - attachment_id: Some(row.attachment_id), - note_type: None, - key: Some(row.key), - title: Some(row.title), - text: row.content, - importance: None, - confidence: None, - source_ref: row.source_ref, - updated_at: row.updated_at, - expires_at: None, - relations: Vec::new(), - }) - .collect() -} - -fn row_read_allowed( - owner_agent_id: &str, - scope: &str, - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, -) -> bool { - if !allowed_scopes.iter().any(|allowed| allowed == scope) { - return false; - } - if scope == "agent_private" { - return owner_agent_id == requester_agent_id; - } - if !matches!(scope, "project_shared" | "org_shared") { - return false; - } - if owner_agent_id == requester_agent_id { - return true; - } - - shared_grants.contains(&access::SharedSpaceGrantKey { - scope: scope.to_string(), - space_owner_agent_id: owner_agent_id.to_string(), - }) -} - -fn note_lifecycle( - status: &str, - expires_at: Option, - as_of: OffsetDateTime, -) -> String { - match status { - "active" if expires_at.is_some_and(|expires_at| expires_at <= as_of) => "stale".to_string(), - "active" => "current".to_string(), - "deprecated" => "superseded".to_string(), - "deleted" => "tombstoned".to_string(), - other => other.to_string(), - } -} - -fn note_read_bucket(lifecycle: &str, importance: f32) -> String { - if lifecycle == "current" && importance >= TOP_OF_MIND_IMPORTANCE_THRESHOLD { - "top_of_mind".to_string() - } else { - "background".to_string() - } -} - -fn relation_from_note_row(row: &EntityNoteRow, as_of: OffsetDateTime) -> EntityMemoryRelation { - EntityMemoryRelation { - fact_id: row.fact_id, - predicate: row.predicate.clone(), - scope: row.fact_scope.clone(), - actor: row.fact_agent_id.clone(), - valid_from: row.valid_from, - valid_to: row.valid_to, - temporal_status: crate::graph::relation_temporal_status( - row.valid_from, - row.valid_to, - as_of, - ), - } -} - -fn core_block_mentions_entity(row: &EntityCoreBlockRow, surfaces: &[String]) -> bool { - let haystack = - format!("{} {} {} {}", row.key, row.title, row.content, row.source_ref).to_lowercase(); - - surfaces - .iter() - .map(|surface| surface.trim().to_lowercase()) - .filter(|surface| !surface.is_empty()) - .any(|surface| haystack.contains(surface.as_str())) -} - -fn summarize_items(items: &[EntityMemoryItem]) -> EntityMemorySummary { - let mut summary = EntityMemorySummary::default(); - - for item in items { - match item.lifecycle.as_str() { - "current" => summary.current_count += 1, - "stale" => summary.stale_count += 1, - "superseded" => summary.superseded_count += 1, - "tombstoned" => summary.tombstoned_count += 1, - _ => {}, - } - match item.read_bucket.as_str() { - "top_of_mind" => summary.top_of_mind_count += 1, - "background" => summary.background_count += 1, - _ => {}, - } - match item.source.as_str() { - "core_block" => summary.core_block_count += 1, - "archival_note" => summary.archival_note_count += 1, - _ => {}, - } - } - - summary -} - -fn sort_entity_memory_items(items: &mut [EntityMemoryItem]) { - items.sort_by(|left, right| { - read_bucket_rank(right.read_bucket.as_str()) - .cmp(&read_bucket_rank(left.read_bucket.as_str())) - .then_with(|| { - lifecycle_rank(right.lifecycle.as_str()) - .cmp(&lifecycle_rank(left.lifecycle.as_str())) - }) - .then_with(|| right.updated_at.cmp(&left.updated_at)) - .then_with(|| left.source.cmp(&right.source)) - }); -} - -fn read_bucket_rank(bucket: &str) -> u8 { - match bucket { - "top_of_mind" => 1, - _ => 0, - } -} - -fn lifecycle_rank(lifecycle: &str) -> u8 { - match lifecycle { - "current" => 3, - "stale" => 2, - "superseded" => 1, - _ => 0, - } -} - -fn normalize_required(raw: &str, field: &str) -> Result { - let trimmed = raw.trim(); - - if trimmed.is_empty() { - return Err(Error::InvalidRequest { message: format!("{field} is required.") }); - } - - Ok(trimmed.to_string()) -} - -async fn resolve_entity( - conn: &mut PgConnection, - req: &PreparedEntityMemoryRequest, -) -> Result { - if let Some(entity_id) = req.entity_id { - return sqlx::query_as::<_, GraphEntity>( - "\ -SELECT - entity_id, - tenant_id, - project_id, - canonical, - canonical_norm, - kind, - created_at, - updated_at -FROM graph_entities -WHERE tenant_id = $1 - AND project_id = $2 - AND entity_id = $3", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(entity_id) - .fetch_optional(conn) - .await? - .ok_or_else(|| Error::NotFound { - message: format!("graph entity not found: {entity_id}"), - }); - } - - let surface = req.entity_surface.as_deref().expect("surface was validated"); - - graph::resolve_entity_by_surface(conn, req.tenant_id.as_str(), req.project_id.as_str(), surface) - .await - .map_err(|err| Error::Storage { message: err.to_string() })? - .ok_or_else(|| Error::NotFound { - message: format!("graph entity not found for surface={surface}"), - }) -} - -async fn fetch_aliases<'e, E>(executor: E, entity_id: Uuid) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, EntityAliasRow>( - "\ -SELECT alias -FROM graph_entity_aliases -WHERE entity_id = $1 -ORDER BY alias ASC", - ) - .bind(entity_id) - .fetch_all(executor) - .await?; - - Ok(rows.into_iter().map(|row| row.alias).collect()) -} - -async fn fetch_entity_note_rows<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - entity_id: Uuid, - allowed_scopes: &[String], -) -> Result> -where - E: PgExecutor<'e>, -{ - sqlx::query_as::<_, EntityNoteRow>( - "\ -SELECT - n.note_id, - n.agent_id, - n.scope, - n.type, - n.key, - n.text, - n.importance, - n.confidence, - n.status, - n.updated_at, - n.expires_at, - n.source_ref, - gf.fact_id, - gf.scope AS fact_scope, - gf.agent_id AS fact_agent_id, - gf.predicate, - gf.valid_from, - gf.valid_to -FROM graph_facts gf -JOIN graph_fact_evidence gfe ON gfe.fact_id = gf.fact_id -JOIN memory_notes n ON n.note_id = gfe.note_id -WHERE gf.tenant_id = $1 - AND (gf.project_id = $2 OR (gf.project_id = $5 AND gf.scope = 'org_shared')) - AND (gf.subject_entity_id = $3 OR gf.object_entity_id = $3) - AND gf.scope = ANY($4::text[]) - AND n.tenant_id = $1 - AND (n.project_id = $2 OR (n.project_id = $5 AND n.scope = 'org_shared')) - AND n.scope = ANY($4::text[]) -ORDER BY n.updated_at DESC, n.note_id ASC, gf.valid_from DESC, gf.fact_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(entity_id) - .bind(allowed_scopes) - .bind(ORG_PROJECT_ID) - .fetch_all(executor) - .await - .map_err(Into::into) -} - -async fn fetch_entity_core_block_rows<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - agent_id: &str, - read_profile: &str, -) -> Result> -where - E: PgExecutor<'e>, -{ - sqlx::query_as::<_, EntityCoreBlockRow>( - "\ -SELECT - a.attachment_id, - b.block_id, - b.agent_id, - b.scope, - b.key, - b.title, - b.content, - b.source_ref, - b.updated_at -FROM core_memory_block_attachments a -JOIN core_memory_blocks b ON b.block_id = a.block_id -WHERE a.tenant_id = $1 - AND a.project_id = $2 - AND a.agent_id = $3 - AND a.read_profile = $4 - AND a.detached_at IS NULL - AND b.status = 'active' -ORDER BY a.attached_at ASC, b.key ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(read_profile) - .fetch_all(executor) - .await - .map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use serde_json; - use time::OffsetDateTime; - use uuid::Uuid; - - use crate::{ - EntityMemoryItem, - entity_memory::{self, EntityCoreBlockRow}, - }; - - #[test] - fn entity_memory_note_lifecycle_classifies_current_stale_superseded_and_tombstoned() { - let as_of = OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"); - let expired = OffsetDateTime::from_unix_timestamp(90).expect("valid timestamp"); - - assert_eq!(entity_memory::note_lifecycle("active", None, as_of), "current"); - assert_eq!(entity_memory::note_lifecycle("active", Some(expired), as_of), "stale"); - assert_eq!(entity_memory::note_lifecycle("deprecated", None, as_of), "superseded"); - assert_eq!(entity_memory::note_lifecycle("deleted", None, as_of), "tombstoned"); - } - - #[test] - fn entity_memory_read_bucket_keeps_only_current_high_importance_top_of_mind() { - assert_eq!(entity_memory::note_read_bucket("current", 0.8), "top_of_mind"); - assert_eq!(entity_memory::note_read_bucket("current", 0.79), "background"); - assert_eq!(entity_memory::note_read_bucket("stale", 0.99), "background"); - } - - #[test] - fn entity_memory_core_block_mentions_canonical_or_alias_surface() { - let row = EntityCoreBlockRow { - attachment_id: Uuid::from_u128(1), - block_id: Uuid::from_u128(2), - agent_id: "agent".to_string(), - scope: "agent_private".to_string(), - key: "preferences".to_string(), - title: "Profile".to_string(), - content: "Alicia prefers precise architecture notes.".to_string(), - source_ref: serde_json::json!({ "source": "core" }), - updated_at: OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"), - }; - - assert!(entity_memory::core_block_mentions_entity( - &row, - &["Alice".to_string(), "Alicia".to_string()] - )); - assert!(!entity_memory::core_block_mentions_entity(&row, &["Bob".to_string()])); - } - - #[test] - fn entity_memory_summary_counts_lifecycle_and_read_buckets() { - let now = OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"); - let items = vec![ - EntityMemoryItem { - source: "core_block".to_string(), - lifecycle: "current".to_string(), - read_bucket: "top_of_mind".to_string(), - scope: "agent_private".to_string(), - agent_id: "agent".to_string(), - note_id: None, - block_id: Some(Uuid::from_u128(1)), - attachment_id: Some(Uuid::from_u128(2)), - note_type: None, - key: Some("profile".to_string()), - title: Some("Profile".to_string()), - text: "Alice prefers concise updates.".to_string(), - importance: None, - confidence: None, - source_ref: serde_json::json!({}), - updated_at: now, - expires_at: None, - relations: Vec::new(), - }, - EntityMemoryItem { - source: "archival_note".to_string(), - lifecycle: "stale".to_string(), - read_bucket: "background".to_string(), - scope: "project_shared".to_string(), - agent_id: "agent".to_string(), - note_id: Some(Uuid::from_u128(3)), - block_id: None, - attachment_id: None, - note_type: Some("preference".to_string()), - key: None, - title: None, - text: "Alice once preferred verbose updates.".to_string(), - importance: Some(0.7), - confidence: Some(0.9), - source_ref: serde_json::json!({}), - updated_at: now, - expires_at: Some(now), - relations: Vec::new(), - }, - ]; - let summary = entity_memory::summarize_items(&items); - - assert_eq!(summary.current_count, 1); - assert_eq!(summary.stale_count, 1); - assert_eq!(summary.top_of_mind_count, 1); - assert_eq!(summary.background_count, 1); - assert_eq!(summary.core_block_count, 1); - assert_eq!(summary.archival_note_count, 1); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/entity_memory/build.rs b/packages/elf-service/src/entity_memory/build.rs new file mode 100644 index 00000000..1a56c858 --- /dev/null +++ b/packages/elf-service/src/entity_memory/build.rs @@ -0,0 +1,242 @@ +use std::collections::HashSet; + +use time::OffsetDateTime; + +use crate::{ + access, + entity_memory::{ + TOP_OF_MIND_IMPORTANCE_THRESHOLD, + storage::{EntityCoreBlockRow, EntityNoteRow}, + types::{EntityMemoryItem, EntityMemoryRelation, EntityMemorySummary}, + }, + graph, +}; + +pub(super) fn build_note_items( + rows: Vec, + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, + as_of: OffsetDateTime, +) -> Vec { + let mut items = Vec::new(); + + for row in rows { + if !row_read_allowed( + row.agent_id.as_str(), + row.scope.as_str(), + requester_agent_id, + allowed_scopes, + shared_grants, + ) || !row_read_allowed( + row.fact_agent_id.as_str(), + row.fact_scope.as_str(), + requester_agent_id, + allowed_scopes, + shared_grants, + ) { + continue; + } + + let lifecycle = note_lifecycle(row.status.as_str(), row.expires_at, as_of); + let read_bucket = note_read_bucket(lifecycle.as_str(), row.importance); + let relation = relation_from_note_row(&row, as_of); + + if let Some(item) = items.iter_mut().find(|item: &&mut EntityMemoryItem| { + item.source == "archival_note" && item.note_id == Some(row.note_id) + }) { + item.relations.push(relation); + + continue; + } + + items.push(EntityMemoryItem { + source: "archival_note".to_string(), + lifecycle, + read_bucket, + scope: row.scope, + agent_id: row.agent_id, + note_id: Some(row.note_id), + block_id: None, + attachment_id: None, + note_type: Some(row.r#type), + key: row.key, + title: None, + text: row.text, + importance: Some(row.importance), + confidence: Some(row.confidence), + source_ref: row.source_ref, + updated_at: row.updated_at, + expires_at: row.expires_at, + relations: vec![relation], + }); + } + + items +} + +pub(super) fn build_core_block_items( + rows: Vec, + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, + surfaces: &[String], +) -> Vec { + rows.into_iter() + .filter(|row| { + row_read_allowed( + row.agent_id.as_str(), + row.scope.as_str(), + requester_agent_id, + allowed_scopes, + shared_grants, + ) && core_block_mentions_entity(row, surfaces) + }) + .map(|row| EntityMemoryItem { + source: "core_block".to_string(), + lifecycle: "current".to_string(), + read_bucket: "top_of_mind".to_string(), + scope: row.scope, + agent_id: row.agent_id, + note_id: None, + block_id: Some(row.block_id), + attachment_id: Some(row.attachment_id), + note_type: None, + key: Some(row.key), + title: Some(row.title), + text: row.content, + importance: None, + confidence: None, + source_ref: row.source_ref, + updated_at: row.updated_at, + expires_at: None, + relations: Vec::new(), + }) + .collect() +} + +pub(super) fn note_lifecycle( + status: &str, + expires_at: Option, + as_of: OffsetDateTime, +) -> String { + match status { + "active" if expires_at.is_some_and(|expires_at| expires_at <= as_of) => "stale".to_string(), + "active" => "current".to_string(), + "deprecated" => "superseded".to_string(), + "deleted" => "tombstoned".to_string(), + other => other.to_string(), + } +} + +pub(super) fn note_read_bucket(lifecycle: &str, importance: f32) -> String { + if lifecycle == "current" && importance >= TOP_OF_MIND_IMPORTANCE_THRESHOLD { + "top_of_mind".to_string() + } else { + "background".to_string() + } +} + +pub(super) fn core_block_mentions_entity(row: &EntityCoreBlockRow, surfaces: &[String]) -> bool { + let haystack = + format!("{} {} {} {}", row.key, row.title, row.content, row.source_ref).to_lowercase(); + + surfaces + .iter() + .map(|surface| surface.trim().to_lowercase()) + .filter(|surface| !surface.is_empty()) + .any(|surface| haystack.contains(surface.as_str())) +} + +pub(super) fn summarize_items(items: &[EntityMemoryItem]) -> EntityMemorySummary { + let mut summary = EntityMemorySummary::default(); + + for item in items { + match item.lifecycle.as_str() { + "current" => summary.current_count += 1, + "stale" => summary.stale_count += 1, + "superseded" => summary.superseded_count += 1, + "tombstoned" => summary.tombstoned_count += 1, + _ => {}, + } + match item.read_bucket.as_str() { + "top_of_mind" => summary.top_of_mind_count += 1, + "background" => summary.background_count += 1, + _ => {}, + } + match item.source.as_str() { + "core_block" => summary.core_block_count += 1, + "archival_note" => summary.archival_note_count += 1, + _ => {}, + } + } + + summary +} + +pub(super) fn sort_entity_memory_items(items: &mut [EntityMemoryItem]) { + items.sort_by(|left, right| { + read_bucket_rank(right.read_bucket.as_str()) + .cmp(&read_bucket_rank(left.read_bucket.as_str())) + .then_with(|| { + lifecycle_rank(right.lifecycle.as_str()) + .cmp(&lifecycle_rank(left.lifecycle.as_str())) + }) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.source.cmp(&right.source)) + }); +} + +fn row_read_allowed( + owner_agent_id: &str, + scope: &str, + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, +) -> bool { + if !allowed_scopes.iter().any(|allowed| allowed == scope) { + return false; + } + if scope == "agent_private" { + return owner_agent_id == requester_agent_id; + } + if !matches!(scope, "project_shared" | "org_shared") { + return false; + } + if owner_agent_id == requester_agent_id { + return true; + } + + shared_grants.contains(&access::SharedSpaceGrantKey { + scope: scope.to_string(), + space_owner_agent_id: owner_agent_id.to_string(), + }) +} + +fn relation_from_note_row(row: &EntityNoteRow, as_of: OffsetDateTime) -> EntityMemoryRelation { + EntityMemoryRelation { + fact_id: row.fact_id, + predicate: row.predicate.clone(), + scope: row.fact_scope.clone(), + actor: row.fact_agent_id.clone(), + valid_from: row.valid_from, + valid_to: row.valid_to, + temporal_status: graph::relation_temporal_status(row.valid_from, row.valid_to, as_of), + } +} + +fn read_bucket_rank(bucket: &str) -> u8 { + match bucket { + "top_of_mind" => 1, + _ => 0, + } +} + +fn lifecycle_rank(lifecycle: &str) -> u8 { + match lifecycle { + "current" => 3, + "stale" => 2, + "superseded" => 1, + _ => 0, + } +} diff --git a/packages/elf-service/src/entity_memory/service.rs b/packages/elf-service/src/entity_memory/service.rs new file mode 100644 index 00000000..c2bf4a82 --- /dev/null +++ b/packages/elf-service/src/entity_memory/service.rs @@ -0,0 +1,98 @@ +use time::OffsetDateTime; + +use crate::{ + ElfService, Result, access, + entity_memory::{ + ELF_ENTITY_MEMORY_VIEW_SCHEMA_V1, + build::{self}, + storage::{self}, + types::{EntityMemoryEntity, EntityMemoryViewRequest, EntityMemoryViewResponse}, + validation, + }, + search, +}; + +impl ElfService { + /// Returns an entity-scoped view across attached core blocks and graph-linked notes. + pub async fn entity_memory_view( + &self, + req: EntityMemoryViewRequest, + ) -> Result { + let prepared = validation::validate_entity_memory_request(req)?; + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let as_of = OffsetDateTime::now_utc(); + let mut conn = self.db.pool.acquire().await?; + let entity = storage::resolve_entity(&mut conn, &prepared).await?; + let aliases = storage::fetch_aliases(conn.as_mut(), entity.entity_id).await?; + let mut surfaces = vec![entity.canonical.clone()]; + + for alias in aliases { + if !surfaces.iter().any(|surface| surface.eq_ignore_ascii_case(&alias)) { + surfaces.push(alias); + } + } + + let shared_grants = access::load_shared_read_grants_with_org_shared( + conn.as_mut(), + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let note_rows = storage::fetch_entity_note_rows( + conn.as_mut(), + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + entity.entity_id, + &allowed_scopes, + ) + .await?; + let block_rows = storage::fetch_entity_core_block_rows( + conn.as_mut(), + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + prepared.read_profile.as_str(), + ) + .await?; + let mut items = build::build_note_items( + note_rows, + prepared.agent_id.as_str(), + &allowed_scopes, + &shared_grants, + as_of, + ); + + items.extend(build::build_core_block_items( + block_rows, + prepared.agent_id.as_str(), + &allowed_scopes, + &shared_grants, + &surfaces, + )); + + build::sort_entity_memory_items(&mut items); + + let summary = build::summarize_items(&items); + + Ok(EntityMemoryViewResponse { + schema: ELF_ENTITY_MEMORY_VIEW_SCHEMA_V1.to_string(), + tenant_id: prepared.tenant_id, + project_id: prepared.project_id, + agent_id: prepared.agent_id, + read_profile: prepared.read_profile, + as_of, + entity: EntityMemoryEntity { + entity_id: entity.entity_id, + canonical: entity.canonical, + kind: entity.kind, + surfaces, + }, + summary, + items, + }) + } +} diff --git a/packages/elf-service/src/entity_memory/storage.rs b/packages/elf-service/src/entity_memory/storage.rs new file mode 100644 index 00000000..c7ff3c72 --- /dev/null +++ b/packages/elf-service/src/entity_memory/storage.rs @@ -0,0 +1,202 @@ +use serde_json::Value; +use sqlx::{FromRow, PgConnection, PgExecutor}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Error, Result, access::ORG_PROJECT_ID, entity_memory::types::PreparedEntityMemoryRequest, +}; +use elf_storage::{graph, models::GraphEntity}; + +#[derive(Clone, Debug, FromRow)] +pub(super) struct EntityNoteRow { + pub(super) note_id: Uuid, + pub(super) agent_id: String, + pub(super) scope: String, + pub(super) r#type: String, + pub(super) key: Option, + pub(super) text: String, + pub(super) importance: f32, + pub(super) confidence: f32, + pub(super) status: String, + pub(super) updated_at: OffsetDateTime, + pub(super) expires_at: Option, + pub(super) source_ref: Value, + pub(super) fact_id: Uuid, + pub(super) fact_scope: String, + pub(super) fact_agent_id: String, + pub(super) predicate: String, + pub(super) valid_from: OffsetDateTime, + pub(super) valid_to: Option, +} + +#[derive(Clone, Debug, FromRow)] +pub(super) struct EntityCoreBlockRow { + pub(super) attachment_id: Uuid, + pub(super) block_id: Uuid, + pub(super) agent_id: String, + pub(super) scope: String, + pub(super) key: String, + pub(super) title: String, + pub(super) content: String, + pub(super) source_ref: Value, + pub(super) updated_at: OffsetDateTime, +} + +#[derive(Clone, Debug, FromRow)] +struct EntityAliasRow { + alias: String, +} + +pub(super) async fn resolve_entity( + conn: &mut PgConnection, + req: &PreparedEntityMemoryRequest, +) -> Result { + if let Some(entity_id) = req.entity_id { + return sqlx::query_as::<_, GraphEntity>( + "\ +SELECT + entity_id, + tenant_id, + project_id, + canonical, + canonical_norm, + kind, + created_at, + updated_at +FROM graph_entities +WHERE tenant_id = $1 + AND project_id = $2 + AND entity_id = $3", + ) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(entity_id) + .fetch_optional(conn) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("graph entity not found: {entity_id}"), + }); + } + + let surface = req.entity_surface.as_deref().expect("surface was validated"); + + graph::resolve_entity_by_surface(conn, req.tenant_id.as_str(), req.project_id.as_str(), surface) + .await + .map_err(|err| Error::Storage { message: err.to_string() })? + .ok_or_else(|| Error::NotFound { + message: format!("graph entity not found for surface={surface}"), + }) +} + +pub(super) async fn fetch_aliases<'e, E>(executor: E, entity_id: Uuid) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, EntityAliasRow>( + "\ +SELECT alias +FROM graph_entity_aliases +WHERE entity_id = $1 +ORDER BY alias ASC", + ) + .bind(entity_id) + .fetch_all(executor) + .await?; + + Ok(rows.into_iter().map(|row| row.alias).collect()) +} + +pub(super) async fn fetch_entity_note_rows<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + entity_id: Uuid, + allowed_scopes: &[String], +) -> Result> +where + E: PgExecutor<'e>, +{ + sqlx::query_as::<_, EntityNoteRow>( + "\ +SELECT + n.note_id, + n.agent_id, + n.scope, + n.type, + n.key, + n.text, + n.importance, + n.confidence, + n.status, + n.updated_at, + n.expires_at, + n.source_ref, + gf.fact_id, + gf.scope AS fact_scope, + gf.agent_id AS fact_agent_id, + gf.predicate, + gf.valid_from, + gf.valid_to +FROM graph_facts gf +JOIN graph_fact_evidence gfe ON gfe.fact_id = gf.fact_id +JOIN memory_notes n ON n.note_id = gfe.note_id +WHERE gf.tenant_id = $1 + AND (gf.project_id = $2 OR (gf.project_id = $5 AND gf.scope = 'org_shared')) + AND (gf.subject_entity_id = $3 OR gf.object_entity_id = $3) + AND gf.scope = ANY($4::text[]) + AND n.tenant_id = $1 + AND (n.project_id = $2 OR (n.project_id = $5 AND n.scope = 'org_shared')) + AND n.scope = ANY($4::text[]) +ORDER BY n.updated_at DESC, n.note_id ASC, gf.valid_from DESC, gf.fact_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(entity_id) + .bind(allowed_scopes) + .bind(ORG_PROJECT_ID) + .fetch_all(executor) + .await + .map_err(Into::into) +} + +pub(super) async fn fetch_entity_core_block_rows<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + agent_id: &str, + read_profile: &str, +) -> Result> +where + E: PgExecutor<'e>, +{ + sqlx::query_as::<_, EntityCoreBlockRow>( + "\ +SELECT + a.attachment_id, + b.block_id, + b.agent_id, + b.scope, + b.key, + b.title, + b.content, + b.source_ref, + b.updated_at +FROM core_memory_block_attachments a +JOIN core_memory_blocks b ON b.block_id = a.block_id +WHERE a.tenant_id = $1 + AND a.project_id = $2 + AND a.agent_id = $3 + AND a.read_profile = $4 + AND a.detached_at IS NULL + AND b.status = 'active' +ORDER BY a.attached_at ASC, b.key ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(read_profile) + .fetch_all(executor) + .await + .map_err(Into::into) +} diff --git a/packages/elf-service/src/entity_memory/tests.rs b/packages/elf-service/src/entity_memory/tests.rs new file mode 100644 index 00000000..860033c0 --- /dev/null +++ b/packages/elf-service/src/entity_memory/tests.rs @@ -0,0 +1,98 @@ +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + EntityMemoryItem, + entity_memory::{build, storage::EntityCoreBlockRow}, +}; + +#[test] +fn entity_memory_note_lifecycle_classifies_current_stale_superseded_and_tombstoned() { + let as_of = OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"); + let expired = OffsetDateTime::from_unix_timestamp(90).expect("valid timestamp"); + + assert_eq!(build::note_lifecycle("active", None, as_of), "current"); + assert_eq!(build::note_lifecycle("active", Some(expired), as_of), "stale"); + assert_eq!(build::note_lifecycle("deprecated", None, as_of), "superseded"); + assert_eq!(build::note_lifecycle("deleted", None, as_of), "tombstoned"); +} + +#[test] +fn entity_memory_read_bucket_keeps_only_current_high_importance_top_of_mind() { + assert_eq!(build::note_read_bucket("current", 0.8), "top_of_mind"); + assert_eq!(build::note_read_bucket("current", 0.79), "background"); + assert_eq!(build::note_read_bucket("stale", 0.99), "background"); +} + +#[test] +fn entity_memory_core_block_mentions_canonical_or_alias_surface() { + let row = EntityCoreBlockRow { + attachment_id: Uuid::from_u128(1), + block_id: Uuid::from_u128(2), + agent_id: "agent".to_string(), + scope: "agent_private".to_string(), + key: "preferences".to_string(), + title: "Profile".to_string(), + content: "Alicia prefers precise architecture notes.".to_string(), + source_ref: serde_json::json!({ "source": "core" }), + updated_at: OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"), + }; + + assert!(build::core_block_mentions_entity(&row, &["Alice".to_string(), "Alicia".to_string()])); + assert!(!build::core_block_mentions_entity(&row, &["Bob".to_string()])); +} + +#[test] +fn entity_memory_summary_counts_lifecycle_and_read_buckets() { + let now = OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"); + let items = vec![ + EntityMemoryItem { + source: "core_block".to_string(), + lifecycle: "current".to_string(), + read_bucket: "top_of_mind".to_string(), + scope: "agent_private".to_string(), + agent_id: "agent".to_string(), + note_id: None, + block_id: Some(Uuid::from_u128(1)), + attachment_id: Some(Uuid::from_u128(2)), + note_type: None, + key: Some("profile".to_string()), + title: Some("Profile".to_string()), + text: "Alice prefers concise updates.".to_string(), + importance: None, + confidence: None, + source_ref: serde_json::json!({}), + updated_at: now, + expires_at: None, + relations: Vec::new(), + }, + EntityMemoryItem { + source: "archival_note".to_string(), + lifecycle: "stale".to_string(), + read_bucket: "background".to_string(), + scope: "project_shared".to_string(), + agent_id: "agent".to_string(), + note_id: Some(Uuid::from_u128(3)), + block_id: None, + attachment_id: None, + note_type: Some("preference".to_string()), + key: None, + title: None, + text: "Alice once preferred verbose updates.".to_string(), + importance: Some(0.7), + confidence: Some(0.9), + source_ref: serde_json::json!({}), + updated_at: now, + expires_at: Some(now), + relations: Vec::new(), + }, + ]; + let summary = build::summarize_items(&items); + + assert_eq!(summary.current_count, 1); + assert_eq!(summary.stale_count, 1); + assert_eq!(summary.top_of_mind_count, 1); + assert_eq!(summary.background_count, 1); + assert_eq!(summary.core_block_count, 1); + assert_eq!(summary.archival_note_count, 1); +} diff --git a/packages/elf-service/src/entity_memory/types.rs b/packages/elf-service/src/entity_memory/types.rs new file mode 100644 index 00000000..98ce36be --- /dev/null +++ b/packages/elf-service/src/entity_memory/types.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::graph::RelationTemporalStatus; + +/// Request payload for an entity-scoped memory view. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EntityMemoryViewRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Exact graph entity id to resolve. + pub entity_id: Option, + /// Canonical or alias surface to resolve when entity_id is omitted. + pub entity_surface: Option, +} + +/// Response payload for an entity-scoped memory view. +#[derive(Clone, Debug, Serialize)] +pub struct EntityMemoryViewResponse { + /// Response schema identifier. + pub schema: String, + /// Tenant used for the read. + pub tenant_id: String, + /// Project used for the read. + pub project_id: String, + /// Agent that requested the read. + pub agent_id: String, + /// Read profile used for access control. + pub read_profile: String, + #[serde(with = "crate::time_serde")] + /// Timestamp used for lifecycle classification. + pub as_of: OffsetDateTime, + /// Resolved graph entity. + pub entity: EntityMemoryEntity, + /// Aggregate counters for the returned items. + pub summary: EntityMemorySummary, + /// Entity-relevant core blocks and archival notes. + pub items: Vec, +} + +/// Resolved graph entity reference. +#[derive(Clone, Debug, Serialize)] +pub struct EntityMemoryEntity { + /// Entity identifier. + pub entity_id: Uuid, + /// Canonical entity surface. + pub canonical: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, + /// Canonical plus alias surfaces used for matching core blocks. + pub surfaces: Vec, +} + +/// Aggregate counters for an entity memory view. +#[derive(Clone, Debug, Default, Serialize)] +pub struct EntityMemorySummary { + /// Number of current items. + pub current_count: usize, + /// Number of stale items. + pub stale_count: usize, + /// Number of superseded items. + pub superseded_count: usize, + /// Number of tombstoned items. + pub tombstoned_count: usize, + /// Number of top-of-mind items. + pub top_of_mind_count: usize, + /// Number of background items. + pub background_count: usize, + /// Number of core memory block items. + pub core_block_count: usize, + /// Number of graph evidence note items. + pub archival_note_count: usize, +} + +/// One item in an entity memory view. +#[derive(Clone, Debug, Serialize)] +pub struct EntityMemoryItem { + /// Source family for the item. + pub source: String, + /// Lifecycle bucket. + pub lifecycle: String, + /// Read bucket used by agents to decide whether to treat this as always-loaded context. + pub read_bucket: String, + /// Scope key for access explanation. + pub scope: String, + /// Agent that owns the source record. + pub agent_id: String, + /// Note identifier for archival_note items. + #[serde(skip_serializing_if = "Option::is_none")] + pub note_id: Option, + /// Core block identifier for core_block items. + #[serde(skip_serializing_if = "Option::is_none")] + pub block_id: Option, + /// Active core block attachment identifier for core_block items. + #[serde(skip_serializing_if = "Option::is_none")] + pub attachment_id: Option, + /// Optional note type discriminator. + #[serde(skip_serializing_if = "Option::is_none")] + pub note_type: Option, + /// Optional stable source key. + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + /// Human-readable title for core blocks. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Text payload. + pub text: String, + /// Importance score when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub importance: Option, + /// Confidence score when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// Structured source/provenance metadata. + pub source_ref: Value, + #[serde(with = "crate::time_serde")] + /// Last source update timestamp. + pub updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// Optional expiry timestamp for archival notes. + pub expires_at: Option, + /// Relations that connect this item to the entity. + pub relations: Vec, +} + +/// Graph relation that made an item relevant to the entity. +#[derive(Clone, Debug, Serialize)] +pub struct EntityMemoryRelation { + /// Graph fact identifier. + pub fact_id: Uuid, + /// Predicate surface recorded on the fact. + pub predicate: String, + /// Scope of the graph fact. + pub scope: String, + /// Agent that emitted the graph fact. + pub actor: String, + #[serde(with = "crate::time_serde")] + /// Start of fact validity window. + pub valid_from: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// End of fact validity window, when superseded. + pub valid_to: Option, + /// Temporal state for the fact relative to the view timestamp. + pub temporal_status: RelationTemporalStatus, +} + +#[derive(Debug)] +pub(super) struct PreparedEntityMemoryRequest { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) entity_id: Option, + pub(super) entity_surface: Option, +} diff --git a/packages/elf-service/src/entity_memory/validation.rs b/packages/elf-service/src/entity_memory/validation.rs new file mode 100644 index 00000000..19a00793 --- /dev/null +++ b/packages/elf-service/src/entity_memory/validation.rs @@ -0,0 +1,43 @@ +use crate::{ + Error, Result, + entity_memory::types::{EntityMemoryViewRequest, PreparedEntityMemoryRequest}, +}; + +pub(super) fn validate_entity_memory_request( + req: EntityMemoryViewRequest, +) -> Result { + let tenant_id = normalize_required(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required(req.agent_id.as_str(), "agent_id")?; + let read_profile = normalize_required(req.read_profile.as_str(), "read_profile")?; + let entity_surface = req + .entity_surface + .as_deref() + .map(|surface| normalize_required(surface, "entity_surface")) + .transpose()?; + + if req.entity_id.is_some() == entity_surface.is_some() { + return Err(Error::InvalidRequest { + message: "Exactly one of entity_id or entity_surface is required.".to_string(), + }); + } + + Ok(PreparedEntityMemoryRequest { + tenant_id, + project_id, + agent_id, + read_profile, + entity_id: req.entity_id, + entity_surface, + }) +} + +fn normalize_required(raw: &str, field: &str) -> Result { + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} is required.") }); + } + + Ok(trimmed.to_string()) +} diff --git a/packages/elf-service/src/graph_query.rs b/packages/elf-service/src/graph_query.rs index 9207fcaa..fa02ac08 100644 --- a/packages/elf-service/src/graph_query.rs +++ b/packages/elf-service/src/graph_query.rs @@ -1,5 +1,19 @@ //! Structured graph query APIs. +mod build; +mod resolution; +mod service; +mod state; +mod storage; +mod types; +mod validation; + +pub use types::{ + GraphQueryEntity, GraphQueryEntityRef, GraphQueryExplain, GraphQueryFact, GraphQueryObject, + GraphQueryObjectEntity, GraphQueryPredicate, GraphQueryPredicateRef, GraphQueryRequest, + GraphQueryResponse, +}; + use std::collections::HashSet; use serde::{Deserialize, Serialize}; @@ -7,13 +21,16 @@ use sqlx::{FromRow, PgConnection}; use time::OffsetDateTime; use uuid::Uuid; -use crate::{ - ElfService, Error, Result, - access::{self, ORG_PROJECT_ID}, - graph::RelationTemporalStatus, - search, -}; +use crate::{ElfService, Error, Result, access::ORG_PROJECT_ID, graph::RelationTemporalStatus}; +use build::graph_query_facts_from_rows; use elf_storage::{graph, models::GraphEntity}; +use resolution::{resolve_predicate, resolve_subject}; +use state::{ + GraphQueryFactRow, GraphQueryRowsFetchParams, PreparedGraphQuery, ResolvedGraphQueryPredicate, + ResolvedGraphQuerySubject, +}; +use storage::fetch_graph_query_rows; +use validation::validate_graph_query_request; /// Schema identifier for graph-query responses. pub const ELF_GRAPH_QUERY_SCHEMA_V1: &str = "elf.graph_query/v1"; @@ -92,380 +109,21 @@ WHERE gf.tenant_id = $1 )) ) ) -ORDER BY gf.valid_from DESC, gf.fact_id ASC -LIMIT $8"; - -/// Subject selector used by graph-query APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(untagged)] -pub enum GraphQueryEntityRef { - /// Resolve the subject by entity identifier. - EntityId { - /// Entity identifier to resolve. - entity_id: Uuid, - }, - /// Resolve the subject by canonical or alias surface. - Surface { - /// Canonical or alias surface to resolve. - surface: String, - }, -} - -/// Predicate selector used by graph-query APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(untagged)] -pub enum GraphQueryPredicateRef { - /// Resolve the predicate by predicate identifier. - PredicateId { - /// Predicate identifier to resolve. - predicate_id: Uuid, - }, - /// Resolve the predicate by canonical or alias surface. - Surface { - /// Canonical or alias surface to resolve. - surface: String, - }, -} - -/// Request payload for graph-query lookups. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct GraphQueryRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Subject entity selector. - pub subject: GraphQueryEntityRef, - - /// Optional predicate selector used to narrow the results. - pub predicate: Option, - - /// Optional requested scopes. - pub scopes: Option>, - #[serde(with = "crate::time_serde::option")] - /// Point-in-time view for temporal facts. - pub as_of: Option, - /// Optional maximum number of returned facts. - pub limit: Option, - /// When true, includes explain metadata. - pub explain: Option, -} - -/// Response payload for graph-query lookups. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryResponse { - #[serde(with = "crate::time_serde")] - /// Effective point-in-time view used for the query. - pub as_of: OffsetDateTime, - /// Resolved subject entity. - pub subject: GraphQueryEntity, - #[serde(skip_serializing_if = "Option::is_none")] - /// Resolved predicate, when the request filtered by predicate. - pub predicate: Option, - /// Effective scopes used for the query. - pub scopes: Vec, - /// Whether the result set was truncated by the limit. - pub truncated: bool, - /// Returned fact rows. - pub facts: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional explain metadata. - pub explain: Option, -} - -/// Resolved graph entity reference. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryEntity { - /// Entity identifier. - pub entity_id: Uuid, - /// Canonical entity surface. - pub canonical: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional entity kind. - pub kind: Option, -} - -/// Resolved graph predicate reference. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryPredicate { - /// Predicate identifier. - pub predicate_id: Uuid, - /// Canonical predicate surface. - pub canonical: String, -} - -/// One graph fact returned by the query. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryFact { - /// Fact identifier. - pub fact_id: Uuid, - /// Scope key for the fact. - pub scope: String, - /// Agent that emitted the fact. - pub actor: String, - /// Predicate surface recorded on the fact. - pub predicate: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Resolved predicate identifier, when available. - pub predicate_id: Option, - #[serde(with = "crate::time_serde")] - /// Start of the fact validity window. - pub valid_from: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// End of the fact validity window, if superseded. - pub valid_to: Option, - /// Temporal state for the fact relative to the service read timestamp. - pub temporal_status: RelationTemporalStatus, - /// Object payload for the fact. - pub object: GraphQueryObject, - /// Evidence note identifiers supporting the fact. - pub evidence_note_ids: Vec, -} - -/// Object payload returned for a graph fact. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryObject { - #[serde(skip_serializing_if = "Option::is_none")] - /// Entity-shaped object value. - pub entity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Scalar object value. - pub value: Option, -} - -/// Resolved entity payload for a graph-fact object. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryObjectEntity { - /// Entity identifier. - pub entity_id: Uuid, - /// Canonical entity surface. - pub canonical: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional entity kind. - pub kind: Option, -} - -/// Explain metadata for a graph-query response. -#[derive(Clone, Debug, Serialize)] -pub struct GraphQueryExplain { - /// Explain schema identifier. - pub schema: String, - #[serde(with = "crate::time_serde")] - /// Effective point-in-time view used for the query. - pub as_of: OffsetDateTime, - /// Requested result limit. - pub requested_limit: u32, - /// Scopes allowed by the read profile. - pub allowed_scopes: Vec, - /// Scopes effectively queried after request filtering. - pub effective_scopes: Vec, - /// Number of rows read from storage. - pub queried_rows: usize, - /// Number of rows returned to the caller. - pub returned_rows: usize, - /// Whether the result set was truncated by the limit. - pub truncated: bool, -} - -#[derive(Debug)] -struct PreparedGraphQuery { - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - subject: GraphQueryEntityRef, - predicate: Option, - requested_scopes: Vec, - as_of: OffsetDateTime, - limit: usize, - explain: bool, -} - -#[derive(Debug)] -struct ResolvedGraphQuerySubject { - entity_id: Uuid, - canonical: String, - kind: Option, -} - -#[derive(Debug)] -struct ResolvedGraphQueryPredicate { - id: Uuid, - canonical: String, -} - -#[derive(Debug)] -struct GraphQueryRowsFetchParams<'a> { - tenant_id: &'a str, - project_id: &'a str, - subject_entity_id: Uuid, - scopes: &'a [String], - as_of: OffsetDateTime, - actor: &'a str, - shared_scope_keys: &'a [String], - predicate_id: Option, - limit_plus_one: i64, -} - -#[derive(Debug, FromRow)] -struct GraphQueryFactRow { - fact_id: Uuid, - scope: String, - actor: String, - predicate: String, - predicate_id: Option, - object_entity_id: Option, - object_canonical: Option, - object_kind: Option, - object_value: Option, - valid_from: OffsetDateTime, - valid_to: Option, - evidence_note_ids: Vec, -} - -impl ElfService { - /// Resolves a subject and returns active graph facts visible to the caller. - pub async fn graph_query(&self, req: GraphQueryRequest) -> Result { - let prepared = validate_graph_query_request(req)?; - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; - let effective_scopes = - resolve_effective_scopes(&allowed_scopes, prepared.requested_scopes.as_slice())?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope.trim() == "org_shared"); - let mut conn = self.db.pool.acquire().await?; - let subject = - resolve_subject(&mut conn, &prepared.tenant_id, &prepared.project_id, prepared.subject) - .await?; - let predicate = resolve_predicate( - &mut conn, - &prepared.tenant_id, - &prepared.project_id, - prepared.predicate, - ) - .await?; - let shared_grants = access::load_shared_read_grants_with_org_shared( - conn.as_mut(), - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.agent_id.as_str(), - org_shared_allowed, - ) - .await?; - let shared_scope_keys: Vec = shared_grants - .into_iter() - .map(|item| format!("{}:{}", item.scope, item.space_owner_agent_id)) - .collect(); - let predicate_id = predicate.as_ref().map(|predicate| predicate.id); - let read_at = OffsetDateTime::now_utc(); - let rows = fetch_graph_query_rows( - &mut conn, - GraphQueryRowsFetchParams { - tenant_id: prepared.tenant_id.as_str(), - project_id: prepared.project_id.as_str(), - subject_entity_id: subject.entity_id, - scopes: effective_scopes.as_slice(), - as_of: prepared.as_of, - actor: prepared.agent_id.as_str(), - shared_scope_keys: shared_scope_keys.as_slice(), - predicate_id, - limit_plus_one: (prepared.limit as i64) + 1, - }, - ) - .await?; - let facts = graph_query_facts_from_rows(rows, read_at); - let queried_rows = facts.len(); - let (facts, truncated) = truncate_graph_query_facts(facts, prepared.limit); - let explain = if prepared.explain { - Some(build_graph_query_explain( - prepared.as_of, - &allowed_scopes, - &effective_scopes, - prepared.limit, - queried_rows, - facts.len(), - truncated, - )) - } else { - None - }; - - Ok(GraphQueryResponse { - as_of: prepared.as_of, - subject: GraphQueryEntity { - entity_id: subject.entity_id, - canonical: subject.canonical, - kind: subject.kind, - }, - predicate: predicate.map(|resolved| GraphQueryPredicate { - predicate_id: resolved.id, - canonical: resolved.canonical, - }), - scopes: effective_scopes, - truncated, - facts, - explain, - }) - } -} + ORDER BY gf.valid_from DESC, gf.fact_id ASC + LIMIT $8"; pub(crate) fn resolve_effective_scopes( allowed_scopes: &[String], requested_scopes: &[String], ) -> Result> { - let allowed = allowed_scopes - .iter() - .map(|scope| scope.trim()) - .filter(|scope| !scope.is_empty()) - .collect::>(); - - if allowed.is_empty() { - return Err(Error::InvalidRequest { - message: "read_profile resolves to no readable scopes.".to_string(), - }); - } - if requested_scopes.is_empty() { - let mut deduped = Vec::with_capacity(allowed.len()); - - for scope in allowed { - if !deduped.iter().any(|value| value == scope) { - deduped.push(scope.to_string()); - } - } - - return Ok(deduped); - } - - let mut effective = Vec::new(); - - for requested_scope in requested_scopes { - if !allowed.iter().any(|scope| scope == requested_scope) { - return Err(Error::InvalidRequest { - message: format!("scope is not readable under read_profile: {}", requested_scope), - }); - } - if !effective.iter().any(|scope| scope == requested_scope) { - effective.push(requested_scope.to_string()); - } - } - - Ok(effective) + build::resolve_effective_scopes(allowed_scopes, requested_scopes) } pub(crate) fn truncate_graph_query_facts( - mut facts: Vec, + facts: Vec, limit: usize, ) -> (Vec, bool) { - let truncated = facts.len() > limit; - - if truncated { - facts.truncate(limit); - } - - (facts, truncated) + build::truncate_graph_query_facts(facts, limit) } pub(crate) fn build_graph_query_explain( @@ -477,430 +135,15 @@ pub(crate) fn build_graph_query_explain( returned_rows: usize, truncated: bool, ) -> GraphQueryExplain { - GraphQueryExplain { - schema: ELF_GRAPH_QUERY_SCHEMA_V1.to_string(), + build::build_graph_query_explain( as_of, - requested_limit: requested_limit as u32, - allowed_scopes: allowed_scopes.to_vec(), - effective_scopes: effective_scopes.to_vec(), + allowed_scopes, + effective_scopes, + requested_limit, queried_rows, returned_rows, truncated, - } -} - -fn graph_query_facts_from_rows( - rows: Vec, - read_at: OffsetDateTime, -) -> Vec { - rows.into_iter() - .filter(|row| !row.evidence_note_ids.is_empty()) - .map(|row| { - let object = if let Some(entity_id) = row.object_entity_id { - GraphQueryObject { - entity: Some(GraphQueryObjectEntity { - entity_id, - canonical: row.object_canonical.unwrap_or_else(|| "".to_string()), - kind: row.object_kind, - }), - value: None, - } - } else { - GraphQueryObject { entity: None, value: row.object_value } - }; - - GraphQueryFact { - fact_id: row.fact_id, - scope: row.scope, - actor: row.actor, - predicate: row.predicate, - predicate_id: row.predicate_id, - valid_from: row.valid_from, - valid_to: row.valid_to, - temporal_status: crate::graph::relation_temporal_status( - row.valid_from, - row.valid_to, - read_at, - ), - object, - evidence_note_ids: row.evidence_note_ids, - } - }) - .collect() -} - -fn validate_graph_query_request(req: GraphQueryRequest) -> Result { - let tenant_id = normalize_required_field(req.tenant_id.as_str(), "tenant_id")?; - let project_id = normalize_required_field(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required_field(req.agent_id.as_str(), "agent_id")?; - let read_profile = normalize_required_field(req.read_profile.as_str(), "read_profile")?; - let subject = match req.subject { - GraphQueryEntityRef::EntityId { entity_id } => GraphQueryEntityRef::EntityId { entity_id }, - GraphQueryEntityRef::Surface { surface } => { - let surface = normalize_required_field(surface.as_str(), "subject.surface")?; - - GraphQueryEntityRef::Surface { surface } - }, - }; - let predicate = match req.predicate { - Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => - Some(GraphQueryPredicateRef::PredicateId { predicate_id }), - Some(GraphQueryPredicateRef::Surface { surface }) => { - let surface = normalize_required_field(surface.as_str(), "predicate.surface")?; - - Some(GraphQueryPredicateRef::Surface { surface }) - }, - None => None, - }; - let requested_scopes = normalize_scopes(req.scopes)?; - let limit = req.limit.unwrap_or(DEFAULT_GRAPH_QUERY_LIMIT); - - if !matches!(limit, 1..=MAX_GRAPH_QUERY_LIMIT) { - return Err(Error::InvalidRequest { - message: format!("limit must be between 1 and {MAX_GRAPH_QUERY_LIMIT}."), - }); - } - - Ok(PreparedGraphQuery { - tenant_id, - project_id, - agent_id, - read_profile, - subject, - predicate, - requested_scopes, - as_of: req.as_of.unwrap_or_else(OffsetDateTime::now_utc), - limit: limit as usize, - explain: req.explain.unwrap_or(false), - }) -} - -fn normalize_required_field(value: &str, field: &str) -> Result { - let trimmed = value.trim(); - - if trimmed.is_empty() { - return Err(Error::InvalidRequest { message: format!("{field} is required.") }); - } - - Ok(trimmed.to_string()) -} - -fn normalize_scopes(scopes: Option>) -> Result> { - let scopes = scopes.unwrap_or_default(); - let mut seen = HashSet::new(); - let mut normalized = Vec::new(); - - for scope in scopes { - let scope = scope.trim().to_string(); - - if scope.is_empty() { - return Err(Error::InvalidRequest { - message: "scopes entries must be non-empty strings.".to_string(), - }); - } - if seen.insert(scope.clone()) { - normalized.push(scope); - } - } - - Ok(normalized) -} - -async fn resolve_subject( - conn: &mut PgConnection, - tenant_id: &str, - project_id: &str, - subject: GraphQueryEntityRef, -) -> Result { - match subject { - GraphQueryEntityRef::EntityId { entity_id } => { - let row = sqlx::query_as::<_, GraphEntity>( - "\ -SELECT - entity_id, - tenant_id, - project_id, - canonical, - canonical_norm, - kind, - created_at, - updated_at -FROM graph_entities -WHERE tenant_id = $1 - AND project_id = $2 - AND entity_id = $3", - ) - .bind(tenant_id) - .bind(project_id) - .bind(entity_id) - .fetch_optional(conn) - .await?; - let Some(row) = row else { - return Err(Error::NotFound { - message: format!("graph entity not found for subject entity_id={entity_id}"), - }); - }; - - Ok(ResolvedGraphQuerySubject { - entity_id: row.entity_id, - canonical: row.canonical, - kind: row.kind, - }) - }, - GraphQueryEntityRef::Surface { surface } => { - let Some(row) = - graph::resolve_entity_by_surface(conn, tenant_id, project_id, &surface).await? - else { - return Err(Error::NotFound { - message: format!("graph entity not found for subject surface={surface}"), - }); - }; - - Ok(ResolvedGraphQuerySubject { - entity_id: row.entity_id, - canonical: row.canonical, - kind: row.kind, - }) - }, - } -} - -async fn resolve_predicate( - conn: &mut PgConnection, - tenant_id: &str, - project_id: &str, - predicate: Option, -) -> Result> { - let Some(predicate) = predicate else { - return Ok(None); - }; - - match predicate { - GraphQueryPredicateRef::PredicateId { predicate_id } => { - let row = graph::get_predicate_by_id(conn, predicate_id).await?; - let Some(row) = row else { - return Err(Error::NotFound { - message: format!("graph predicate not found: {predicate_id}"), - }); - }; - - Ok(Some(ResolvedGraphQueryPredicate { id: row.predicate_id, canonical: row.canonical })) - }, - GraphQueryPredicateRef::Surface { surface } => { - let Some(row) = - graph::resolve_predicate_no_register(conn, tenant_id, project_id, &surface).await? - else { - return Err(Error::NotFound { - message: format!("graph predicate not found for surface={surface}"), - }); - }; - - Ok(Some(ResolvedGraphQueryPredicate { id: row.predicate_id, canonical: row.canonical })) - }, - } -} - -async fn fetch_graph_query_rows( - conn: &mut PgConnection, - params: GraphQueryRowsFetchParams<'_>, -) -> Result> { - let GraphQueryRowsFetchParams { - tenant_id, - project_id, - subject_entity_id, - scopes, - as_of, - actor, - shared_scope_keys, - predicate_id, - limit_plus_one, - } = params; - let rows = sqlx::query_as::<_, GraphQueryFactRow>(GRAPH_QUERY_FACTS_SQL) - .bind(tenant_id) - .bind(project_id) - .bind(subject_entity_id) - .bind(scopes) - .bind(as_of) - .bind(actor) - .bind(shared_scope_keys) - .bind(limit_plus_one) - .bind(GRAPH_QUERY_EVIDENCE_LIMIT) - .bind(ORG_PROJECT_ID) - .bind(predicate_id) - .fetch_all(conn) - .await?; - - Ok(rows) + ) } -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use uuid::Uuid; - - use crate::{ - ELF_GRAPH_QUERY_SCHEMA_V1, Error, GraphQueryFact, GraphQueryObject, GraphQueryObjectEntity, - graph::RelationTemporalStatus, - graph_query::{self, GraphQueryEntityRef, GraphQueryRequest, OffsetDateTime}, - }; - - fn base_request() -> GraphQueryRequest { - GraphQueryRequest { - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - subject: GraphQueryEntityRef::Surface { surface: "Alice".to_string() }, - predicate: None, - scopes: None, - as_of: None, - limit: Some(10), - explain: Some(true), - } - } - - #[test] - fn test_validate_graph_query_request_rejects_invalid_fields() { - let mut request = base_request(); - - request.subject = GraphQueryEntityRef::Surface { surface: " ".to_string() }; - - let err = graph_query::validate_graph_query_request(request) - .expect_err("invalid subject should fail"); - - assert!(matches!(err, Error::InvalidRequest { .. }), "expected invalid request error"); - } - - #[test] - fn test_truncate_graph_query_facts_and_explain_shaping() { - let facts = vec![ - GraphQueryFact { - fact_id: Uuid::from_u128(1), - scope: "project_shared".to_string(), - actor: "agent1".to_string(), - predicate: "knows".to_string(), - predicate_id: None, - valid_from: OffsetDateTime::from_unix_timestamp(1).expect("valid timestamp"), - valid_to: None, - temporal_status: RelationTemporalStatus::Current, - object: GraphQueryObject { - entity: Some(GraphQueryObjectEntity { - entity_id: Uuid::from_u128(100), - canonical: "Bob".to_string(), - kind: Some("person".to_string()), - }), - value: None, - }, - evidence_note_ids: vec![], - }, - GraphQueryFact { - fact_id: Uuid::from_u128(2), - scope: "project_shared".to_string(), - actor: "agent1".to_string(), - predicate: "likes".to_string(), - predicate_id: None, - valid_from: OffsetDateTime::from_unix_timestamp(2).expect("valid timestamp"), - valid_to: None, - temporal_status: RelationTemporalStatus::Current, - object: GraphQueryObject { - entity: Some(GraphQueryObjectEntity { - entity_id: Uuid::from_u128(101), - canonical: "Carol".to_string(), - kind: Some("person".to_string()), - }), - value: None, - }, - evidence_note_ids: vec![], - }, - GraphQueryFact { - fact_id: Uuid::from_u128(3), - scope: "project_shared".to_string(), - actor: "agent2".to_string(), - predicate: "located_in".to_string(), - predicate_id: None, - valid_from: OffsetDateTime::from_unix_timestamp(3).expect("valid timestamp"), - valid_to: None, - temporal_status: RelationTemporalStatus::Current, - object: GraphQueryObject { entity: None, value: Some("office".to_string()) }, - evidence_note_ids: vec![], - }, - ]; - let (trimmed, truncated) = graph_query::truncate_graph_query_facts(facts, 2); - - assert!(truncated); - assert_eq!(trimmed.len(), 2); - - let explain = graph_query::build_graph_query_explain( - OffsetDateTime::from_unix_timestamp(4).expect("valid timestamp"), - &["private_plus_project".to_string()], - &["private_plus_project".to_string()], - 2, - 3, - trimmed.len(), - truncated, - ); - - assert_eq!(explain.queried_rows, 3); - assert_eq!(explain.returned_rows, 2); - assert!(explain.truncated); - assert_eq!(explain.schema, ELF_GRAPH_QUERY_SCHEMA_V1); - } - - #[test] - fn test_resolve_effective_scopes_validates_requested_scopes() { - let allowed = vec![ - "agent_private".to_string(), - "project_shared".to_string(), - "org_shared".to_string(), - ]; - let requested = vec!["project_shared".to_string(), "project_shared".to_string()]; - let resolved = - graph_query::resolve_effective_scopes(&allowed, &requested).expect("valid scopes"); - let deduped: HashSet<_> = resolved.iter().collect(); - - assert_eq!(resolved, vec!["project_shared".to_string()]); - assert_eq!(deduped.len(), 1); - } - - #[test] - fn graph_query_rows_without_readable_evidence_are_suppressed() { - let read_at = OffsetDateTime::from_unix_timestamp(30).expect("valid timestamp"); - let rows = vec![ - super::GraphQueryFactRow { - fact_id: Uuid::from_u128(1), - scope: "agent_private".to_string(), - actor: "agent".to_string(), - predicate: "works at".to_string(), - predicate_id: None, - object_entity_id: None, - object_canonical: None, - object_kind: None, - object_value: Some("Deleted Source Inc.".to_string()), - valid_from: OffsetDateTime::from_unix_timestamp(10).expect("valid timestamp"), - valid_to: None, - evidence_note_ids: vec![], - }, - super::GraphQueryFactRow { - fact_id: Uuid::from_u128(2), - scope: "agent_private".to_string(), - actor: "agent".to_string(), - predicate: "works at".to_string(), - predicate_id: None, - object_entity_id: None, - object_canonical: None, - object_kind: None, - object_value: Some("Active Source Inc.".to_string()), - valid_from: OffsetDateTime::from_unix_timestamp(20).expect("valid timestamp"), - valid_to: None, - evidence_note_ids: vec![Uuid::from_u128(200)], - }, - ]; - let facts = super::graph_query_facts_from_rows(rows, read_at); - - assert_eq!(facts.len(), 1); - assert_eq!(facts[0].fact_id, Uuid::from_u128(2)); - assert_eq!(facts[0].object.value.as_deref(), Some("Active Source Inc.")); - assert_eq!(facts[0].evidence_note_ids, vec![Uuid::from_u128(200)]); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/graph_query/build.rs b/packages/elf-service/src/graph_query/build.rs new file mode 100644 index 00000000..9727d2bc --- /dev/null +++ b/packages/elf-service/src/graph_query/build.rs @@ -0,0 +1,124 @@ +use crate::{ + graph, + graph_query::{ + ELF_GRAPH_QUERY_SCHEMA_V1, Error, GraphQueryExplain, GraphQueryFact, GraphQueryFactRow, + GraphQueryObject, GraphQueryObjectEntity, OffsetDateTime, Result, + }, +}; + +pub(crate) fn resolve_effective_scopes( + allowed_scopes: &[String], + requested_scopes: &[String], +) -> Result> { + let allowed = allowed_scopes + .iter() + .map(|scope| scope.trim()) + .filter(|scope| !scope.is_empty()) + .collect::>(); + + if allowed.is_empty() { + return Err(Error::InvalidRequest { + message: "read_profile resolves to no readable scopes.".to_string(), + }); + } + if requested_scopes.is_empty() { + let mut deduped = Vec::with_capacity(allowed.len()); + + for scope in allowed { + if !deduped.iter().any(|value| value == scope) { + deduped.push(scope.to_string()); + } + } + + return Ok(deduped); + } + + let mut effective = Vec::new(); + + for requested_scope in requested_scopes { + if !allowed.iter().any(|scope| scope == requested_scope) { + return Err(Error::InvalidRequest { + message: format!("scope is not readable under read_profile: {}", requested_scope), + }); + } + if !effective.iter().any(|scope| scope == requested_scope) { + effective.push(requested_scope.to_string()); + } + } + + Ok(effective) +} + +pub(crate) fn truncate_graph_query_facts( + mut facts: Vec, + limit: usize, +) -> (Vec, bool) { + let truncated = facts.len() > limit; + + if truncated { + facts.truncate(limit); + } + + (facts, truncated) +} + +pub(crate) fn build_graph_query_explain( + as_of: OffsetDateTime, + allowed_scopes: &[String], + effective_scopes: &[String], + requested_limit: usize, + queried_rows: usize, + returned_rows: usize, + truncated: bool, +) -> GraphQueryExplain { + GraphQueryExplain { + schema: ELF_GRAPH_QUERY_SCHEMA_V1.to_string(), + as_of, + requested_limit: requested_limit as u32, + allowed_scopes: allowed_scopes.to_vec(), + effective_scopes: effective_scopes.to_vec(), + queried_rows, + returned_rows, + truncated, + } +} + +pub(super) fn graph_query_facts_from_rows( + rows: Vec, + read_at: OffsetDateTime, +) -> Vec { + rows.into_iter() + .filter(|row| !row.evidence_note_ids.is_empty()) + .map(|row| { + let object = if let Some(entity_id) = row.object_entity_id { + GraphQueryObject { + entity: Some(GraphQueryObjectEntity { + entity_id, + canonical: row.object_canonical.unwrap_or_else(|| "".to_string()), + kind: row.object_kind, + }), + value: None, + } + } else { + GraphQueryObject { entity: None, value: row.object_value } + }; + + GraphQueryFact { + fact_id: row.fact_id, + scope: row.scope, + actor: row.actor, + predicate: row.predicate, + predicate_id: row.predicate_id, + valid_from: row.valid_from, + valid_to: row.valid_to, + temporal_status: graph::relation_temporal_status( + row.valid_from, + row.valid_to, + read_at, + ), + object, + evidence_note_ids: row.evidence_note_ids, + } + }) + .collect() +} diff --git a/packages/elf-service/src/graph_query/resolution.rs b/packages/elf-service/src/graph_query/resolution.rs new file mode 100644 index 00000000..17cc6540 --- /dev/null +++ b/packages/elf-service/src/graph_query/resolution.rs @@ -0,0 +1,98 @@ +use crate::graph_query::{ + Error, GraphEntity, GraphQueryEntityRef, GraphQueryPredicateRef, PgConnection, + ResolvedGraphQueryPredicate, ResolvedGraphQuerySubject, Result, graph, +}; + +pub(super) async fn resolve_subject( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + subject: GraphQueryEntityRef, +) -> Result { + match subject { + GraphQueryEntityRef::EntityId { entity_id } => { + let row = sqlx::query_as::<_, GraphEntity>( + "\ +SELECT + entity_id, + tenant_id, + project_id, + canonical, + canonical_norm, + kind, + created_at, + updated_at +FROM graph_entities +WHERE tenant_id = $1 + AND project_id = $2 + AND entity_id = $3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(entity_id) + .fetch_optional(conn) + .await?; + let Some(row) = row else { + return Err(Error::NotFound { + message: format!("graph entity not found for subject entity_id={entity_id}"), + }); + }; + + Ok(ResolvedGraphQuerySubject { + entity_id: row.entity_id, + canonical: row.canonical, + kind: row.kind, + }) + }, + GraphQueryEntityRef::Surface { surface } => { + let Some(row) = + graph::resolve_entity_by_surface(conn, tenant_id, project_id, &surface).await? + else { + return Err(Error::NotFound { + message: format!("graph entity not found for subject surface={surface}"), + }); + }; + + Ok(ResolvedGraphQuerySubject { + entity_id: row.entity_id, + canonical: row.canonical, + kind: row.kind, + }) + }, + } +} + +pub(super) async fn resolve_predicate( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + predicate: Option, +) -> Result> { + let Some(predicate) = predicate else { + return Ok(None); + }; + + match predicate { + GraphQueryPredicateRef::PredicateId { predicate_id } => { + let row = graph::get_predicate_by_id(conn, predicate_id).await?; + let Some(row) = row else { + return Err(Error::NotFound { + message: format!("graph predicate not found: {predicate_id}"), + }); + }; + + Ok(Some(ResolvedGraphQueryPredicate { id: row.predicate_id, canonical: row.canonical })) + }, + GraphQueryPredicateRef::Surface { surface } => { + let Some(row) = + graph::resolve_predicate_no_register(conn, tenant_id, project_id, &surface).await? + else { + return Err(Error::NotFound { + message: format!("graph predicate not found for surface={surface}"), + }); + }; + + Ok(Some(ResolvedGraphQueryPredicate { id: row.predicate_id, canonical: row.canonical })) + }, + } +} diff --git a/packages/elf-service/src/graph_query/service.rs b/packages/elf-service/src/graph_query/service.rs new file mode 100644 index 00000000..b57011f6 --- /dev/null +++ b/packages/elf-service/src/graph_query/service.rs @@ -0,0 +1,99 @@ +use crate::{ + access, + graph_query::{ + self, ElfService, GraphQueryEntity, GraphQueryPredicate, GraphQueryRequest, + GraphQueryResponse, GraphQueryRowsFetchParams, OffsetDateTime, Result, + }, + search, +}; + +impl ElfService { + /// Resolves a subject and returns active graph facts visible to the caller. + pub async fn graph_query(&self, req: GraphQueryRequest) -> Result { + let prepared = graph_query::validate_graph_query_request(req)?; + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; + let effective_scopes = graph_query::resolve_effective_scopes( + &allowed_scopes, + prepared.requested_scopes.as_slice(), + )?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope.trim() == "org_shared"); + let mut conn = self.db.pool.acquire().await?; + let subject = graph_query::resolve_subject( + &mut conn, + &prepared.tenant_id, + &prepared.project_id, + prepared.subject, + ) + .await?; + let predicate = graph_query::resolve_predicate( + &mut conn, + &prepared.tenant_id, + &prepared.project_id, + prepared.predicate, + ) + .await?; + let shared_grants = access::load_shared_read_grants_with_org_shared( + conn.as_mut(), + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let shared_scope_keys: Vec = shared_grants + .into_iter() + .map(|item| format!("{}:{}", item.scope, item.space_owner_agent_id)) + .collect(); + let predicate_id = predicate.as_ref().map(|predicate| predicate.id); + let read_at = OffsetDateTime::now_utc(); + let rows = graph_query::fetch_graph_query_rows( + &mut conn, + GraphQueryRowsFetchParams { + tenant_id: prepared.tenant_id.as_str(), + project_id: prepared.project_id.as_str(), + subject_entity_id: subject.entity_id, + scopes: effective_scopes.as_slice(), + as_of: prepared.as_of, + actor: prepared.agent_id.as_str(), + shared_scope_keys: shared_scope_keys.as_slice(), + predicate_id, + limit_plus_one: (prepared.limit as i64) + 1, + }, + ) + .await?; + let facts = graph_query::graph_query_facts_from_rows(rows, read_at); + let queried_rows = facts.len(); + let (facts, truncated) = graph_query::truncate_graph_query_facts(facts, prepared.limit); + let explain = if prepared.explain { + Some(graph_query::build_graph_query_explain( + prepared.as_of, + &allowed_scopes, + &effective_scopes, + prepared.limit, + queried_rows, + facts.len(), + truncated, + )) + } else { + None + }; + + Ok(GraphQueryResponse { + as_of: prepared.as_of, + subject: GraphQueryEntity { + entity_id: subject.entity_id, + canonical: subject.canonical, + kind: subject.kind, + }, + predicate: predicate.map(|resolved| GraphQueryPredicate { + predicate_id: resolved.id, + canonical: resolved.canonical, + }), + scopes: effective_scopes, + truncated, + facts, + explain, + }) + } +} diff --git a/packages/elf-service/src/graph_query/state.rs b/packages/elf-service/src/graph_query/state.rs new file mode 100644 index 00000000..5b946c7c --- /dev/null +++ b/packages/elf-service/src/graph_query/state.rs @@ -0,0 +1,59 @@ +use crate::graph_query::{ + FromRow, GraphQueryEntityRef, GraphQueryPredicateRef, OffsetDateTime, Uuid, +}; + +#[derive(Debug)] +pub(super) struct PreparedGraphQuery { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) subject: GraphQueryEntityRef, + pub(super) predicate: Option, + pub(super) requested_scopes: Vec, + pub(super) as_of: OffsetDateTime, + pub(super) limit: usize, + pub(super) explain: bool, +} + +#[derive(Debug)] +pub(super) struct ResolvedGraphQuerySubject { + pub(super) entity_id: Uuid, + pub(super) canonical: String, + pub(super) kind: Option, +} + +#[derive(Debug)] +pub(super) struct ResolvedGraphQueryPredicate { + pub(super) id: Uuid, + pub(super) canonical: String, +} + +#[derive(Debug)] +pub(super) struct GraphQueryRowsFetchParams<'a> { + pub(super) tenant_id: &'a str, + pub(super) project_id: &'a str, + pub(super) subject_entity_id: Uuid, + pub(super) scopes: &'a [String], + pub(super) as_of: OffsetDateTime, + pub(super) actor: &'a str, + pub(super) shared_scope_keys: &'a [String], + pub(super) predicate_id: Option, + pub(super) limit_plus_one: i64, +} + +#[derive(Debug, FromRow)] +pub(super) struct GraphQueryFactRow { + pub(super) fact_id: Uuid, + pub(super) scope: String, + pub(super) actor: String, + pub(super) predicate: String, + pub(super) predicate_id: Option, + pub(super) object_entity_id: Option, + pub(super) object_canonical: Option, + pub(super) object_kind: Option, + pub(super) object_value: Option, + pub(super) valid_from: OffsetDateTime, + pub(super) valid_to: Option, + pub(super) evidence_note_ids: Vec, +} diff --git a/packages/elf-service/src/graph_query/storage.rs b/packages/elf-service/src/graph_query/storage.rs new file mode 100644 index 00000000..c28fe54a --- /dev/null +++ b/packages/elf-service/src/graph_query/storage.rs @@ -0,0 +1,37 @@ +use crate::graph_query::{ + GRAPH_QUERY_EVIDENCE_LIMIT, GRAPH_QUERY_FACTS_SQL, GraphQueryFactRow, + GraphQueryRowsFetchParams, ORG_PROJECT_ID, PgConnection, Result, +}; + +pub(super) async fn fetch_graph_query_rows( + conn: &mut PgConnection, + params: GraphQueryRowsFetchParams<'_>, +) -> Result> { + let GraphQueryRowsFetchParams { + tenant_id, + project_id, + subject_entity_id, + scopes, + as_of, + actor, + shared_scope_keys, + predicate_id, + limit_plus_one, + } = params; + let rows = sqlx::query_as::<_, GraphQueryFactRow>(GRAPH_QUERY_FACTS_SQL) + .bind(tenant_id) + .bind(project_id) + .bind(subject_entity_id) + .bind(scopes) + .bind(as_of) + .bind(actor) + .bind(shared_scope_keys) + .bind(limit_plus_one) + .bind(GRAPH_QUERY_EVIDENCE_LIMIT) + .bind(ORG_PROJECT_ID) + .bind(predicate_id) + .fetch_all(conn) + .await?; + + Ok(rows) +} diff --git a/packages/elf-service/src/graph_query/tests.rs b/packages/elf-service/src/graph_query/tests.rs new file mode 100644 index 00000000..f47c5a28 --- /dev/null +++ b/packages/elf-service/src/graph_query/tests.rs @@ -0,0 +1,165 @@ +use std::collections::HashSet; + +use uuid::Uuid; + +use crate::{ + ELF_GRAPH_QUERY_SCHEMA_V1, Error, GraphQueryFact, GraphQueryObject, GraphQueryObjectEntity, + graph::RelationTemporalStatus, + graph_query::{self, GraphQueryEntityRef, GraphQueryRequest, OffsetDateTime}, +}; + +fn base_request() -> GraphQueryRequest { + GraphQueryRequest { + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + subject: GraphQueryEntityRef::Surface { surface: "Alice".to_string() }, + predicate: None, + scopes: None, + as_of: None, + limit: Some(10), + explain: Some(true), + } +} + +#[test] +fn test_validate_graph_query_request_rejects_invalid_fields() { + let mut request = base_request(); + + request.subject = GraphQueryEntityRef::Surface { surface: " ".to_string() }; + + let err = graph_query::validate_graph_query_request(request) + .expect_err("invalid subject should fail"); + + assert!(matches!(err, Error::InvalidRequest { .. }), "expected invalid request error"); +} + +#[test] +fn test_truncate_graph_query_facts_and_explain_shaping() { + let facts = vec![ + GraphQueryFact { + fact_id: Uuid::from_u128(1), + scope: "project_shared".to_string(), + actor: "agent1".to_string(), + predicate: "knows".to_string(), + predicate_id: None, + valid_from: OffsetDateTime::from_unix_timestamp(1).expect("valid timestamp"), + valid_to: None, + temporal_status: RelationTemporalStatus::Current, + object: GraphQueryObject { + entity: Some(GraphQueryObjectEntity { + entity_id: Uuid::from_u128(100), + canonical: "Bob".to_string(), + kind: Some("person".to_string()), + }), + value: None, + }, + evidence_note_ids: vec![], + }, + GraphQueryFact { + fact_id: Uuid::from_u128(2), + scope: "project_shared".to_string(), + actor: "agent1".to_string(), + predicate: "likes".to_string(), + predicate_id: None, + valid_from: OffsetDateTime::from_unix_timestamp(2).expect("valid timestamp"), + valid_to: None, + temporal_status: RelationTemporalStatus::Current, + object: GraphQueryObject { + entity: Some(GraphQueryObjectEntity { + entity_id: Uuid::from_u128(101), + canonical: "Carol".to_string(), + kind: Some("person".to_string()), + }), + value: None, + }, + evidence_note_ids: vec![], + }, + GraphQueryFact { + fact_id: Uuid::from_u128(3), + scope: "project_shared".to_string(), + actor: "agent2".to_string(), + predicate: "located_in".to_string(), + predicate_id: None, + valid_from: OffsetDateTime::from_unix_timestamp(3).expect("valid timestamp"), + valid_to: None, + temporal_status: RelationTemporalStatus::Current, + object: GraphQueryObject { entity: None, value: Some("office".to_string()) }, + evidence_note_ids: vec![], + }, + ]; + let (trimmed, truncated) = graph_query::truncate_graph_query_facts(facts, 2); + + assert!(truncated); + assert_eq!(trimmed.len(), 2); + + let explain = graph_query::build_graph_query_explain( + OffsetDateTime::from_unix_timestamp(4).expect("valid timestamp"), + &["private_plus_project".to_string()], + &["private_plus_project".to_string()], + 2, + 3, + trimmed.len(), + truncated, + ); + + assert_eq!(explain.queried_rows, 3); + assert_eq!(explain.returned_rows, 2); + assert!(explain.truncated); + assert_eq!(explain.schema, ELF_GRAPH_QUERY_SCHEMA_V1); +} + +#[test] +fn test_resolve_effective_scopes_validates_requested_scopes() { + let allowed = + vec!["agent_private".to_string(), "project_shared".to_string(), "org_shared".to_string()]; + let requested = vec!["project_shared".to_string(), "project_shared".to_string()]; + let resolved = + graph_query::resolve_effective_scopes(&allowed, &requested).expect("valid scopes"); + let deduped: HashSet<_> = resolved.iter().collect(); + + assert_eq!(resolved, vec!["project_shared".to_string()]); + assert_eq!(deduped.len(), 1); +} + +#[test] +fn graph_query_rows_without_readable_evidence_are_suppressed() { + let read_at = OffsetDateTime::from_unix_timestamp(30).expect("valid timestamp"); + let rows = vec![ + super::GraphQueryFactRow { + fact_id: Uuid::from_u128(1), + scope: "agent_private".to_string(), + actor: "agent".to_string(), + predicate: "works at".to_string(), + predicate_id: None, + object_entity_id: None, + object_canonical: None, + object_kind: None, + object_value: Some("Deleted Source Inc.".to_string()), + valid_from: OffsetDateTime::from_unix_timestamp(10).expect("valid timestamp"), + valid_to: None, + evidence_note_ids: vec![], + }, + super::GraphQueryFactRow { + fact_id: Uuid::from_u128(2), + scope: "agent_private".to_string(), + actor: "agent".to_string(), + predicate: "works at".to_string(), + predicate_id: None, + object_entity_id: None, + object_canonical: None, + object_kind: None, + object_value: Some("Active Source Inc.".to_string()), + valid_from: OffsetDateTime::from_unix_timestamp(20).expect("valid timestamp"), + valid_to: None, + evidence_note_ids: vec![Uuid::from_u128(200)], + }, + ]; + let facts = super::graph_query_facts_from_rows(rows, read_at); + + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_id, Uuid::from_u128(2)); + assert_eq!(facts[0].object.value.as_deref(), Some("Active Source Inc.")); + assert_eq!(facts[0].evidence_note_ids, vec![Uuid::from_u128(200)]); +} diff --git a/packages/elf-service/src/graph_query/types.rs b/packages/elf-service/src/graph_query/types.rs new file mode 100644 index 00000000..89e61757 --- /dev/null +++ b/packages/elf-service/src/graph_query/types.rs @@ -0,0 +1,177 @@ +use crate::graph_query::{Deserialize, OffsetDateTime, RelationTemporalStatus, Serialize, Uuid}; + +/// Subject selector used by graph-query APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum GraphQueryEntityRef { + /// Resolve the subject by entity identifier. + EntityId { + /// Entity identifier to resolve. + entity_id: Uuid, + }, + /// Resolve the subject by canonical or alias surface. + Surface { + /// Canonical or alias surface to resolve. + surface: String, + }, +} + +/// Predicate selector used by graph-query APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum GraphQueryPredicateRef { + /// Resolve the predicate by predicate identifier. + PredicateId { + /// Predicate identifier to resolve. + predicate_id: Uuid, + }, + /// Resolve the predicate by canonical or alias surface. + Surface { + /// Canonical or alias surface to resolve. + surface: String, + }, +} + +/// Request payload for graph-query lookups. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GraphQueryRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Subject entity selector. + pub subject: GraphQueryEntityRef, + + /// Optional predicate selector used to narrow the results. + pub predicate: Option, + + /// Optional requested scopes. + pub scopes: Option>, + #[serde(with = "crate::time_serde::option")] + /// Point-in-time view for temporal facts. + pub as_of: Option, + /// Optional maximum number of returned facts. + pub limit: Option, + /// When true, includes explain metadata. + pub explain: Option, +} + +/// Response payload for graph-query lookups. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryResponse { + #[serde(with = "crate::time_serde")] + /// Effective point-in-time view used for the query. + pub as_of: OffsetDateTime, + /// Resolved subject entity. + pub subject: GraphQueryEntity, + #[serde(skip_serializing_if = "Option::is_none")] + /// Resolved predicate, when the request filtered by predicate. + pub predicate: Option, + /// Effective scopes used for the query. + pub scopes: Vec, + /// Whether the result set was truncated by the limit. + pub truncated: bool, + /// Returned fact rows. + pub facts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional explain metadata. + pub explain: Option, +} + +/// Resolved graph entity reference. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryEntity { + /// Entity identifier. + pub entity_id: Uuid, + /// Canonical entity surface. + pub canonical: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Resolved graph predicate reference. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryPredicate { + /// Predicate identifier. + pub predicate_id: Uuid, + /// Canonical predicate surface. + pub canonical: String, +} + +/// One graph fact returned by the query. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryFact { + /// Fact identifier. + pub fact_id: Uuid, + /// Scope key for the fact. + pub scope: String, + /// Agent that emitted the fact. + pub actor: String, + /// Predicate surface recorded on the fact. + pub predicate: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Resolved predicate identifier, when available. + pub predicate_id: Option, + #[serde(with = "crate::time_serde")] + /// Start of the fact validity window. + pub valid_from: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// End of the fact validity window, if superseded. + pub valid_to: Option, + /// Temporal state for the fact relative to the service read timestamp. + pub temporal_status: RelationTemporalStatus, + /// Object payload for the fact. + pub object: GraphQueryObject, + /// Evidence note identifiers supporting the fact. + pub evidence_note_ids: Vec, +} + +/// Object payload returned for a graph fact. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryObject { + #[serde(skip_serializing_if = "Option::is_none")] + /// Entity-shaped object value. + pub entity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Scalar object value. + pub value: Option, +} + +/// Resolved entity payload for a graph-fact object. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryObjectEntity { + /// Entity identifier. + pub entity_id: Uuid, + /// Canonical entity surface. + pub canonical: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Explain metadata for a graph-query response. +#[derive(Clone, Debug, Serialize)] +pub struct GraphQueryExplain { + /// Explain schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Effective point-in-time view used for the query. + pub as_of: OffsetDateTime, + /// Requested result limit. + pub requested_limit: u32, + /// Scopes allowed by the read profile. + pub allowed_scopes: Vec, + /// Scopes effectively queried after request filtering. + pub effective_scopes: Vec, + /// Number of rows read from storage. + pub queried_rows: usize, + /// Number of rows returned to the caller. + pub returned_rows: usize, + /// Whether the result set was truncated by the limit. + pub truncated: bool, +} diff --git a/packages/elf-service/src/graph_query/validation.rs b/packages/elf-service/src/graph_query/validation.rs new file mode 100644 index 00000000..b80a90f1 --- /dev/null +++ b/packages/elf-service/src/graph_query/validation.rs @@ -0,0 +1,81 @@ +use crate::graph_query::{ + DEFAULT_GRAPH_QUERY_LIMIT, Error, GraphQueryEntityRef, GraphQueryPredicateRef, + GraphQueryRequest, HashSet, MAX_GRAPH_QUERY_LIMIT, OffsetDateTime, PreparedGraphQuery, Result, +}; + +pub(super) fn validate_graph_query_request(req: GraphQueryRequest) -> Result { + let tenant_id = normalize_required_field(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required_field(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required_field(req.agent_id.as_str(), "agent_id")?; + let read_profile = normalize_required_field(req.read_profile.as_str(), "read_profile")?; + let subject = match req.subject { + GraphQueryEntityRef::EntityId { entity_id } => GraphQueryEntityRef::EntityId { entity_id }, + GraphQueryEntityRef::Surface { surface } => { + let surface = normalize_required_field(surface.as_str(), "subject.surface")?; + + GraphQueryEntityRef::Surface { surface } + }, + }; + let predicate = match req.predicate { + Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => + Some(GraphQueryPredicateRef::PredicateId { predicate_id }), + Some(GraphQueryPredicateRef::Surface { surface }) => { + let surface = normalize_required_field(surface.as_str(), "predicate.surface")?; + + Some(GraphQueryPredicateRef::Surface { surface }) + }, + None => None, + }; + let requested_scopes = normalize_scopes(req.scopes)?; + let limit = req.limit.unwrap_or(DEFAULT_GRAPH_QUERY_LIMIT); + + if !matches!(limit, 1..=MAX_GRAPH_QUERY_LIMIT) { + return Err(Error::InvalidRequest { + message: format!("limit must be between 1 and {MAX_GRAPH_QUERY_LIMIT}."), + }); + } + + Ok(PreparedGraphQuery { + tenant_id, + project_id, + agent_id, + read_profile, + subject, + predicate, + requested_scopes, + as_of: req.as_of.unwrap_or_else(OffsetDateTime::now_utc), + limit: limit as usize, + explain: req.explain.unwrap_or(false), + }) +} + +fn normalize_required_field(value: &str, field: &str) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} is required.") }); + } + + Ok(trimmed.to_string()) +} + +fn normalize_scopes(scopes: Option>) -> Result> { + let scopes = scopes.unwrap_or_default(); + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for scope in scopes { + let scope = scope.trim().to_string(); + + if scope.is_empty() { + return Err(Error::InvalidRequest { + message: "scopes entries must be non-empty strings.".to_string(), + }); + } + if seen.insert(scope.clone()) { + normalized.push(scope); + } + } + + Ok(normalized) +} diff --git a/packages/elf-service/src/graph_report.rs b/packages/elf-service/src/graph_report.rs index 97cc52c7..1a118148 100644 --- a/packages/elf-service/src/graph_report.rs +++ b/packages/elf-service/src/graph_report.rs @@ -1,5 +1,19 @@ //! Source-backed graph topic-map reports. +mod build; +mod resolution; +mod service; +mod state; +mod storage; +mod types; +mod validation; + +pub use types::{ + GraphReportEntity, GraphReportExplain, GraphReportFact, GraphReportPredicate, + GraphReportRequest, GraphReportResponse, GraphReportSummary, GraphTopicEdge, GraphTopicMap, + GraphTopicNode, +}; + use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -9,14 +23,21 @@ use uuid::Uuid; use crate::{ ElfService, Error, Result, - access::{self, ORG_PROJECT_ID}, + access::ORG_PROJECT_ID, graph::RelationTemporalStatus, graph_query::{ - self, GraphQueryEntityRef, GraphQueryObject, GraphQueryObjectEntity, GraphQueryPredicateRef, + GraphQueryEntityRef, GraphQueryObject, GraphQueryObjectEntity, GraphQueryPredicateRef, }, - search, }; +use build::{build_report_facts, build_topic_map, summarize_report_facts, truncate_report_rows}; use elf_storage::{graph, models::GraphEntity}; +use resolution::{resolve_predicate, resolve_subject}; +use state::{ + GraphReportFactRow, GraphReportRowsFetchParams, PreparedGraphReport, + ResolvedGraphReportPredicate, ResolvedGraphReportSubject, +}; +use storage::fetch_graph_report_rows; +use validation::validate_graph_report_request; /// Schema identifier for graph report responses. pub const ELF_GRAPH_REPORT_SCHEMA_V1: &str = "elf.graph_report/v1"; @@ -112,871 +133,4 @@ WHERE gf.tenant_id = $1 ORDER BY gf.valid_from DESC, gf.fact_id ASC LIMIT $8"; -/// Request payload for a graph topic-map report. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct GraphReportRequest { - /// Tenant to query within. - pub tenant_id: String, - /// Project to query within. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Subject entity selector. - pub subject: GraphQueryEntityRef, - /// Optional predicate selector used to narrow the report. - pub predicate: Option, - /// Optional requested scopes. - pub scopes: Option>, - #[serde(with = "crate::time_serde::option")] - /// Point-in-time used for current, historical, and future classification. - pub as_of: Option, - /// Optional maximum number of returned facts. - pub limit: Option, - /// When true, includes explain metadata. - pub explain: Option, -} - -/// Response payload for a graph topic-map report. -#[derive(Clone, Debug, Serialize)] -pub struct GraphReportResponse { - /// Report schema identifier. - pub schema: String, - #[serde(with = "crate::time_serde")] - /// Effective point-in-time view used for temporal classification. - pub as_of: OffsetDateTime, - /// Resolved subject entity. - pub subject: GraphReportEntity, - #[serde(skip_serializing_if = "Option::is_none")] - /// Resolved predicate, when the request filtered by predicate. - pub predicate: Option, - /// Effective scopes used for the report. - pub scopes: Vec, - /// Aggregate report counters. - pub summary: GraphReportSummary, - /// Topic map projection of the graph facts. - pub topic_map: GraphTopicMap, - /// Returned fact rows. - pub facts: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional explain metadata. - pub explain: Option, -} - -/// Resolved graph entity reference. -#[derive(Clone, Debug, Serialize)] -pub struct GraphReportEntity { - /// Entity identifier. - pub entity_id: Uuid, - /// Canonical entity surface. - pub canonical: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional entity kind. - pub kind: Option, -} - -/// Resolved graph predicate reference. -#[derive(Clone, Debug, Serialize)] -pub struct GraphReportPredicate { - /// Predicate identifier. - pub predicate_id: Uuid, - /// Canonical predicate surface. - pub canonical: String, -} - -/// Aggregate counters for graph reports. -#[derive(Clone, Debug, Default, Serialize)] -pub struct GraphReportSummary { - /// Number of returned facts. - pub fact_count: usize, - /// Number of facts current at `as_of`. - pub current_count: usize, - /// Number of facts historical at `as_of`. - pub historical_count: usize, - /// Number of facts whose validity starts after `as_of`. - pub future_count: usize, - /// Number of facts with at least one evidence note link. - pub sourced_count: usize, - /// Number of facts still backed by pending or unresolved predicate vocabulary. - pub inferred_count: usize, - /// Number of facts that conflict under a single-cardinality predicate. - pub ambiguous_count: usize, - /// Number of stale facts, currently equivalent to historical facts. - pub stale_count: usize, - /// Number of facts linked to a superseding replacement. - pub superseded_count: usize, - /// Total evidence note links returned with the facts. - pub evidence_link_count: usize, -} - -/// One graph fact returned by a graph report. -#[derive(Clone, Debug, Serialize)] -pub struct GraphReportFact { - /// Fact identifier. - pub fact_id: Uuid, - /// Scope key for the fact. - pub scope: String, - /// Agent that emitted the fact. - pub actor: String, - /// Predicate surface recorded on the fact. - pub predicate: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Resolved predicate identifier, when available. - pub predicate_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Predicate registry status, when available. - pub predicate_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Predicate registry cardinality, when available. - pub predicate_cardinality: Option, - #[serde(with = "crate::time_serde")] - /// Start of the fact validity window. - pub valid_from: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// End of the fact validity window, if superseded or explicitly bounded. - pub valid_to: Option, - /// Temporal state for the fact relative to report `as_of`. - pub temporal_status: RelationTemporalStatus, - /// Object payload for the fact. - pub object: GraphQueryObject, - /// Evidence note identifiers supporting the fact. - pub evidence_note_ids: Vec, - /// Replacement fact ids that supersede this fact. - pub superseded_by_fact_ids: Vec, - /// Older fact ids superseded by this fact. - pub supersedes_fact_ids: Vec, - /// Source-backed report status markers. - pub status_markers: Vec, -} - -/// Topic-map projection for graph reports. -#[derive(Clone, Debug, Serialize)] -pub struct GraphTopicMap { - /// Topic-map nodes. - pub nodes: Vec, - /// Topic-map edges, one per returned fact. - pub edges: Vec, -} - -/// Topic-map node. -#[derive(Clone, Debug, Serialize)] -pub struct GraphTopicNode { - /// Stable node identifier. - pub node_id: String, - /// Human-readable node label. - pub label: String, - /// Node type such as subject, entity, or value. - pub node_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional entity kind. - pub kind: Option, -} - -/// Topic-map edge. -#[derive(Clone, Debug, Serialize)] -pub struct GraphTopicEdge { - /// Backing fact identifier. - pub fact_id: Uuid, - /// Source topic node identifier. - pub source_node_id: String, - /// Target topic node identifier. - pub target_node_id: String, - /// Predicate label. - pub predicate: String, - /// Temporal state for the edge. - pub temporal_status: RelationTemporalStatus, - /// Source-backed report status markers. - pub status_markers: Vec, - /// Evidence note identifiers supporting the edge. - pub evidence_note_ids: Vec, -} - -/// Explain metadata for graph reports. -#[derive(Clone, Debug, Serialize)] -pub struct GraphReportExplain { - /// Explain schema identifier. - pub schema: String, - #[serde(with = "crate::time_serde")] - /// Effective point-in-time used for classification. - pub as_of: OffsetDateTime, - /// Requested result limit. - pub requested_limit: u32, - /// Scopes allowed by the read profile. - pub allowed_scopes: Vec, - /// Scopes effectively queried after request filtering. - pub effective_scopes: Vec, - /// Number of rows read from storage. - pub queried_rows: usize, - /// Number of rows returned to the caller. - pub returned_rows: usize, - /// Whether the result set was truncated by the limit. - pub truncated: bool, -} - -#[derive(Debug)] -struct PreparedGraphReport { - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - subject: GraphQueryEntityRef, - predicate: Option, - requested_scopes: Vec, - as_of: OffsetDateTime, - limit: usize, - explain: bool, -} - -#[derive(Debug)] -struct ResolvedGraphReportSubject { - entity_id: Uuid, - canonical: String, - kind: Option, -} - -#[derive(Debug)] -struct ResolvedGraphReportPredicate { - id: Uuid, - canonical: String, -} - -#[derive(Debug)] -struct GraphReportRowsFetchParams<'a> { - tenant_id: &'a str, - project_id: &'a str, - subject_entity_id: Uuid, - scopes: &'a [String], - actor: &'a str, - shared_scope_keys: &'a [String], - predicate_id: Option, - limit_plus_one: i64, -} - -#[derive(Debug, FromRow)] -struct GraphReportFactRow { - fact_id: Uuid, - scope: String, - actor: String, - predicate: String, - predicate_id: Option, - predicate_status: Option, - predicate_cardinality: Option, - object_entity_id: Option, - object_canonical: Option, - object_kind: Option, - object_value: Option, - valid_from: OffsetDateTime, - valid_to: Option, - evidence_note_ids: Vec, - superseded_by_fact_ids: Vec, - supersedes_fact_ids: Vec, -} - -impl ElfService { - /// Builds a source-backed graph report for one subject entity. - pub async fn graph_report(&self, req: GraphReportRequest) -> Result { - let prepared = validate_graph_report_request(req)?; - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; - let effective_scopes = graph_query::resolve_effective_scopes( - &allowed_scopes, - prepared.requested_scopes.as_slice(), - )?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope.trim() == "org_shared"); - let mut conn = self.db.pool.acquire().await?; - let subject = - resolve_subject(&mut conn, &prepared.tenant_id, &prepared.project_id, prepared.subject) - .await?; - let predicate = resolve_predicate( - &mut conn, - &prepared.tenant_id, - &prepared.project_id, - prepared.predicate, - ) - .await?; - let shared_grants = access::load_shared_read_grants_with_org_shared( - conn.as_mut(), - prepared.tenant_id.as_str(), - prepared.project_id.as_str(), - prepared.agent_id.as_str(), - org_shared_allowed, - ) - .await?; - let shared_scope_keys: Vec = shared_grants - .into_iter() - .map(|item| format!("{}:{}", item.scope, item.space_owner_agent_id)) - .collect(); - let predicate_id = predicate.as_ref().map(|predicate| predicate.id); - let rows = fetch_graph_report_rows( - &mut conn, - GraphReportRowsFetchParams { - tenant_id: prepared.tenant_id.as_str(), - project_id: prepared.project_id.as_str(), - subject_entity_id: subject.entity_id, - scopes: effective_scopes.as_slice(), - actor: prepared.agent_id.as_str(), - shared_scope_keys: shared_scope_keys.as_slice(), - predicate_id, - limit_plus_one: (prepared.limit as i64) + 1, - }, - ) - .await?; - let queried_rows = rows.len(); - let (rows, truncated) = truncate_report_rows(rows, prepared.limit); - let facts = build_report_facts(rows, prepared.as_of); - let summary = summarize_report_facts(&facts); - let topic_map = build_topic_map(&subject, &facts); - let explain = if prepared.explain { - Some(GraphReportExplain { - schema: ELF_GRAPH_REPORT_SCHEMA_V1.to_string(), - as_of: prepared.as_of, - requested_limit: prepared.limit as u32, - allowed_scopes, - effective_scopes: effective_scopes.clone(), - queried_rows, - returned_rows: facts.len(), - truncated, - }) - } else { - None - }; - - Ok(GraphReportResponse { - schema: ELF_GRAPH_REPORT_SCHEMA_V1.to_string(), - as_of: prepared.as_of, - subject: GraphReportEntity { - entity_id: subject.entity_id, - canonical: subject.canonical, - kind: subject.kind, - }, - predicate: predicate.map(|resolved| GraphReportPredicate { - predicate_id: resolved.id, - canonical: resolved.canonical, - }), - scopes: effective_scopes, - summary, - topic_map, - facts, - explain, - }) - } -} - -fn validate_graph_report_request(req: GraphReportRequest) -> Result { - let tenant_id = normalize_required_field(req.tenant_id.as_str(), "tenant_id")?; - let project_id = normalize_required_field(req.project_id.as_str(), "project_id")?; - let agent_id = normalize_required_field(req.agent_id.as_str(), "agent_id")?; - let read_profile = normalize_required_field(req.read_profile.as_str(), "read_profile")?; - let subject = match req.subject { - GraphQueryEntityRef::EntityId { entity_id } => GraphQueryEntityRef::EntityId { entity_id }, - GraphQueryEntityRef::Surface { surface } => { - let surface = normalize_required_field(surface.as_str(), "subject.surface")?; - - GraphQueryEntityRef::Surface { surface } - }, - }; - let predicate = match req.predicate { - Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => - Some(GraphQueryPredicateRef::PredicateId { predicate_id }), - Some(GraphQueryPredicateRef::Surface { surface }) => { - let surface = normalize_required_field(surface.as_str(), "predicate.surface")?; - - Some(GraphQueryPredicateRef::Surface { surface }) - }, - None => None, - }; - let requested_scopes = normalize_scopes(req.scopes)?; - let limit = req.limit.unwrap_or(DEFAULT_GRAPH_REPORT_LIMIT); - - if !matches!(limit, 1..=MAX_GRAPH_REPORT_LIMIT) { - return Err(Error::InvalidRequest { - message: format!("limit must be between 1 and {MAX_GRAPH_REPORT_LIMIT}."), - }); - } - - Ok(PreparedGraphReport { - tenant_id, - project_id, - agent_id, - read_profile, - subject, - predicate, - requested_scopes, - as_of: req.as_of.unwrap_or_else(OffsetDateTime::now_utc), - limit: limit as usize, - explain: req.explain.unwrap_or(false), - }) -} - -fn normalize_required_field(value: &str, field: &str) -> Result { - let trimmed = value.trim(); - - if trimmed.is_empty() { - return Err(Error::InvalidRequest { message: format!("{field} is required.") }); - } - - Ok(trimmed.to_string()) -} - -fn normalize_scopes(scopes: Option>) -> Result> { - let scopes = scopes.unwrap_or_default(); - let mut seen = BTreeSet::new(); - let mut normalized = Vec::new(); - - for scope in scopes { - let scope = scope.trim().to_string(); - - if scope.is_empty() { - return Err(Error::InvalidRequest { - message: "scopes entries must be non-empty strings.".to_string(), - }); - } - if seen.insert(scope.clone()) { - normalized.push(scope); - } - } - - Ok(normalized) -} - -fn truncate_report_rows( - mut rows: Vec, - limit: usize, -) -> (Vec, bool) { - let truncated = rows.len() > limit; - - if truncated { - rows.truncate(limit); - } - - (rows, truncated) -} - -fn build_report_facts( - rows: Vec, - as_of: OffsetDateTime, -) -> Vec { - let rows: Vec = - rows.into_iter().filter(|row| !row.evidence_note_ids.is_empty()).collect(); - let current_single_counts = current_single_predicate_counts(&rows, as_of); - - rows.into_iter() - .map(|row| { - let temporal_status = - crate::graph::relation_temporal_status(row.valid_from, row.valid_to, as_of); - let object = graph_object(&row); - let predicate_key = predicate_group_key(&row); - let ambiguous = temporal_status == RelationTemporalStatus::Current - && row.predicate_cardinality.as_deref() == Some("single") - && current_single_counts.get(&predicate_key).copied().unwrap_or(0) > 1; - let status_markers = report_status_markers(&row, temporal_status, ambiguous); - - GraphReportFact { - fact_id: row.fact_id, - scope: row.scope, - actor: row.actor, - predicate: row.predicate, - predicate_id: row.predicate_id, - predicate_status: row.predicate_status, - predicate_cardinality: row.predicate_cardinality, - valid_from: row.valid_from, - valid_to: row.valid_to, - temporal_status, - object, - evidence_note_ids: row.evidence_note_ids, - superseded_by_fact_ids: row.superseded_by_fact_ids, - supersedes_fact_ids: row.supersedes_fact_ids, - status_markers, - } - }) - .collect() -} - -fn current_single_predicate_counts( - rows: &[GraphReportFactRow], - as_of: OffsetDateTime, -) -> BTreeMap { - let mut counts = BTreeMap::new(); - - for row in rows { - if row.predicate_cardinality.as_deref() != Some("single") { - continue; - } - if crate::graph::relation_temporal_status(row.valid_from, row.valid_to, as_of) - != RelationTemporalStatus::Current - { - continue; - } - - *counts.entry(predicate_group_key(row)).or_insert(0) += 1; - } - - counts -} - -fn predicate_group_key(row: &GraphReportFactRow) -> String { - row.predicate_id - .map(|id| id.to_string()) - .unwrap_or_else(|| format!("surface:{}", row.predicate)) -} - -fn graph_object(row: &GraphReportFactRow) -> GraphQueryObject { - if let Some(entity_id) = row.object_entity_id { - return GraphQueryObject { - entity: Some(GraphQueryObjectEntity { - entity_id, - canonical: row.object_canonical.clone().unwrap_or_default(), - kind: row.object_kind.clone(), - }), - value: None, - }; - } - - GraphQueryObject { entity: None, value: row.object_value.clone() } -} - -fn report_status_markers( - row: &GraphReportFactRow, - temporal_status: RelationTemporalStatus, - ambiguous: bool, -) -> Vec { - let mut markers = Vec::new(); - - if row.evidence_note_ids.is_empty() { - markers.push("unsupported".to_string()); - } else { - markers.push("sourced".to_string()); - } - if row.predicate_status.as_deref() != Some("active") { - markers.push("inferred".to_string()); - } - if temporal_status == RelationTemporalStatus::Historical { - markers.push("stale".to_string()); - } - if !row.superseded_by_fact_ids.is_empty() { - markers.push("superseded".to_string()); - } - if ambiguous { - markers.push("ambiguous".to_string()); - } - - markers -} - -fn summarize_report_facts(facts: &[GraphReportFact]) -> GraphReportSummary { - let mut summary = GraphReportSummary { fact_count: facts.len(), ..Default::default() }; - - for fact in facts { - match fact.temporal_status { - RelationTemporalStatus::Current => summary.current_count += 1, - RelationTemporalStatus::Historical => summary.historical_count += 1, - RelationTemporalStatus::Future => summary.future_count += 1, - } - - if !fact.evidence_note_ids.is_empty() { - summary.sourced_count += 1; - } - if fact.status_markers.iter().any(|marker| marker == "inferred") { - summary.inferred_count += 1; - } - if fact.status_markers.iter().any(|marker| marker == "ambiguous") { - summary.ambiguous_count += 1; - } - if fact.status_markers.iter().any(|marker| marker == "stale") { - summary.stale_count += 1; - } - if fact.status_markers.iter().any(|marker| marker == "superseded") { - summary.superseded_count += 1; - } - - summary.evidence_link_count += fact.evidence_note_ids.len(); - } - - summary -} - -fn build_topic_map( - subject: &ResolvedGraphReportSubject, - facts: &[GraphReportFact], -) -> GraphTopicMap { - let subject_node_id = format!("entity:{}", subject.entity_id); - let mut nodes = BTreeMap::new(); - - nodes.insert( - subject_node_id.clone(), - GraphTopicNode { - node_id: subject_node_id.clone(), - label: subject.canonical.clone(), - node_type: "subject".to_string(), - kind: subject.kind.clone(), - }, - ); - - let edges = facts - .iter() - .map(|fact| { - let (target_node_id, label, kind, node_type) = match &fact.object.entity { - Some(entity) => ( - format!("entity:{}", entity.entity_id), - entity.canonical.clone(), - entity.kind.clone(), - "entity".to_string(), - ), - None => ( - format!("value:{}", fact.object.value.as_deref().unwrap_or_default()), - fact.object.value.clone().unwrap_or_default(), - None, - "value".to_string(), - ), - }; - - nodes.entry(target_node_id.clone()).or_insert_with(|| GraphTopicNode { - node_id: target_node_id.clone(), - label, - node_type, - kind, - }); - GraphTopicEdge { - fact_id: fact.fact_id, - source_node_id: subject_node_id.clone(), - target_node_id, - predicate: fact.predicate.clone(), - temporal_status: fact.temporal_status, - status_markers: fact.status_markers.clone(), - evidence_note_ids: fact.evidence_note_ids.clone(), - } - }) - .collect(); - - GraphTopicMap { nodes: nodes.into_values().collect(), edges } -} - -async fn resolve_subject( - conn: &mut PgConnection, - tenant_id: &str, - project_id: &str, - subject: GraphQueryEntityRef, -) -> Result { - match subject { - GraphQueryEntityRef::EntityId { entity_id } => { - let row = sqlx::query_as::<_, GraphEntity>( - "\ -SELECT - entity_id, - tenant_id, - project_id, - canonical, - canonical_norm, - kind, - created_at, - updated_at -FROM graph_entities -WHERE tenant_id = $1 - AND project_id = $2 - AND entity_id = $3", - ) - .bind(tenant_id) - .bind(project_id) - .bind(entity_id) - .fetch_optional(conn) - .await?; - let Some(row) = row else { - return Err(Error::NotFound { - message: format!("graph entity not found for subject entity_id={entity_id}"), - }); - }; - - Ok(ResolvedGraphReportSubject { - entity_id: row.entity_id, - canonical: row.canonical, - kind: row.kind, - }) - }, - GraphQueryEntityRef::Surface { surface } => { - let Some(row) = - graph::resolve_entity_by_surface(conn, tenant_id, project_id, &surface).await? - else { - return Err(Error::NotFound { - message: format!("graph entity not found for subject surface={surface}"), - }); - }; - - Ok(ResolvedGraphReportSubject { - entity_id: row.entity_id, - canonical: row.canonical, - kind: row.kind, - }) - }, - } -} - -async fn resolve_predicate( - conn: &mut PgConnection, - tenant_id: &str, - project_id: &str, - predicate: Option, -) -> Result> { - match predicate { - Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => { - let Some(row) = graph::get_predicate_by_id(conn, predicate_id).await? else { - return Err(Error::NotFound { - message: format!("graph predicate not found; predicate_id={predicate_id}"), - }); - }; - - Ok(Some(ResolvedGraphReportPredicate { - id: row.predicate_id, - canonical: row.canonical, - })) - }, - Some(GraphQueryPredicateRef::Surface { surface }) => { - let Some(row) = - graph::resolve_predicate_no_register(conn, tenant_id, project_id, &surface).await? - else { - return Err(Error::NotFound { - message: format!("graph predicate not found for surface={surface}"), - }); - }; - - Ok(Some(ResolvedGraphReportPredicate { - id: row.predicate_id, - canonical: row.canonical, - })) - }, - None => Ok(None), - } -} - -async fn fetch_graph_report_rows( - conn: &mut PgConnection, - params: GraphReportRowsFetchParams<'_>, -) -> Result> { - let rows = sqlx::query_as::<_, GraphReportFactRow>(GRAPH_REPORT_FACTS_SQL) - .bind(params.tenant_id) - .bind(params.project_id) - .bind(params.subject_entity_id) - .bind(params.scopes) - .bind(OffsetDateTime::now_utc()) - .bind(params.actor) - .bind(params.shared_scope_keys) - .bind(params.limit_plus_one) - .bind(GRAPH_REPORT_EVIDENCE_LIMIT) - .bind(ORG_PROJECT_ID) - .bind(params.predicate_id) - .fetch_all(conn) - .await?; - - Ok(rows) -} - -#[cfg(test)] -mod tests { - use time::OffsetDateTime; - use uuid::Uuid; - - use crate::{ - RelationTemporalStatus, - graph_report::{self, GraphReportFactRow}, - }; - - fn ts(value: i64) -> OffsetDateTime { - OffsetDateTime::from_unix_timestamp(value).expect("valid timestamp") - } - - fn row( - raw_id: u128, - object_value: &str, - valid_from: i64, - valid_to: Option, - predicate_status: &str, - cardinality: &str, - superseded_by: Vec, - ) -> GraphReportFactRow { - GraphReportFactRow { - fact_id: Uuid::from_u128(raw_id), - scope: "agent_private".to_string(), - actor: "agent".to_string(), - predicate: "works at".to_string(), - predicate_id: Some(Uuid::from_u128(999)), - predicate_status: Some(predicate_status.to_string()), - predicate_cardinality: Some(cardinality.to_string()), - object_entity_id: None, - object_canonical: None, - object_kind: None, - object_value: Some(object_value.to_string()), - valid_from: ts(valid_from), - valid_to: valid_to.map(ts), - evidence_note_ids: vec![Uuid::from_u128(raw_id + 10_000)], - superseded_by_fact_ids: superseded_by, - supersedes_fact_ids: vec![], - } - } - - #[test] - fn graph_report_classifies_temporal_source_and_supersession_markers() { - let replacement_id = Uuid::from_u128(2); - let facts = graph_report::build_report_facts( - vec![ - row(1, "Initech", 10, Some(20), "active", "single", vec![replacement_id]), - row(2, "Globex", 20, None, "active", "single", vec![]), - row(3, "Umbrella", 30, None, "pending", "single", vec![]), - ], - ts(25), - ); - let summary = graph_report::summarize_report_facts(&facts); - - assert_eq!(summary.fact_count, 3); - assert_eq!(summary.current_count, 1); - assert_eq!(summary.historical_count, 1); - assert_eq!(summary.future_count, 1); - assert_eq!(summary.sourced_count, 3); - assert_eq!(summary.inferred_count, 1); - assert_eq!(summary.stale_count, 1); - assert_eq!(summary.superseded_count, 1); - assert_eq!(summary.evidence_link_count, 3); - assert_eq!(facts[0].temporal_status, RelationTemporalStatus::Historical); - assert!(facts[0].status_markers.iter().any(|marker| marker == "superseded")); - assert!(facts[2].status_markers.iter().any(|marker| marker == "inferred")); - } - - #[test] - fn graph_report_suppresses_facts_without_readable_evidence() { - let mut deleted_source = - row(1, "Deleted Source Inc.", 10, None, "active", "single", vec![]); - - deleted_source.evidence_note_ids = vec![]; - - let facts = graph_report::build_report_facts( - vec![ - deleted_source, - row(2, "Active Source Inc.", 20, None, "active", "single", vec![]), - ], - ts(25), - ); - - assert_eq!(facts.len(), 1); - assert_eq!(facts[0].fact_id, Uuid::from_u128(2)); - assert_eq!(facts[0].object.value.as_deref(), Some("Active Source Inc.")); - } - - #[test] - fn graph_topic_map_preserves_fact_edges_and_source_markers() { - let subject = super::ResolvedGraphReportSubject { - entity_id: Uuid::from_u128(42), - canonical: "Alice".to_string(), - kind: Some("person".to_string()), - }; - let facts = graph_report::build_report_facts( - vec![row(1, "Globex", 20, None, "active", "single", vec![])], - ts(25), - ); - let topic_map = graph_report::build_topic_map(&subject, &facts); - - assert_eq!(topic_map.nodes.len(), 2); - assert_eq!(topic_map.edges.len(), 1); - assert_eq!(topic_map.edges[0].predicate, "works at"); - assert_eq!(topic_map.edges[0].temporal_status, RelationTemporalStatus::Current); - assert!(topic_map.edges[0].status_markers.iter().any(|marker| marker == "sourced")); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/graph_report/build.rs b/packages/elf-service/src/graph_report/build.rs new file mode 100644 index 00000000..a8edc31e --- /dev/null +++ b/packages/elf-service/src/graph_report/build.rs @@ -0,0 +1,220 @@ +use crate::{ + graph, + graph_report::{ + BTreeMap, GraphQueryObject, GraphQueryObjectEntity, GraphReportFact, GraphReportFactRow, + GraphReportSummary, GraphTopicEdge, GraphTopicMap, GraphTopicNode, OffsetDateTime, + RelationTemporalStatus, ResolvedGraphReportSubject, + }, +}; + +pub(super) fn truncate_report_rows( + mut rows: Vec, + limit: usize, +) -> (Vec, bool) { + let truncated = rows.len() > limit; + + if truncated { + rows.truncate(limit); + } + + (rows, truncated) +} + +pub(super) fn build_report_facts( + rows: Vec, + as_of: OffsetDateTime, +) -> Vec { + let rows: Vec = + rows.into_iter().filter(|row| !row.evidence_note_ids.is_empty()).collect(); + let current_single_counts = current_single_predicate_counts(&rows, as_of); + + rows.into_iter() + .map(|row| { + let temporal_status = + graph::relation_temporal_status(row.valid_from, row.valid_to, as_of); + let object = graph_object(&row); + let predicate_key = predicate_group_key(&row); + let ambiguous = temporal_status == RelationTemporalStatus::Current + && row.predicate_cardinality.as_deref() == Some("single") + && current_single_counts.get(&predicate_key).copied().unwrap_or(0) > 1; + let status_markers = report_status_markers(&row, temporal_status, ambiguous); + + GraphReportFact { + fact_id: row.fact_id, + scope: row.scope, + actor: row.actor, + predicate: row.predicate, + predicate_id: row.predicate_id, + predicate_status: row.predicate_status, + predicate_cardinality: row.predicate_cardinality, + valid_from: row.valid_from, + valid_to: row.valid_to, + temporal_status, + object, + evidence_note_ids: row.evidence_note_ids, + superseded_by_fact_ids: row.superseded_by_fact_ids, + supersedes_fact_ids: row.supersedes_fact_ids, + status_markers, + } + }) + .collect() +} + +pub(super) fn summarize_report_facts(facts: &[GraphReportFact]) -> GraphReportSummary { + let mut summary = GraphReportSummary { fact_count: facts.len(), ..Default::default() }; + + for fact in facts { + match fact.temporal_status { + RelationTemporalStatus::Current => summary.current_count += 1, + RelationTemporalStatus::Historical => summary.historical_count += 1, + RelationTemporalStatus::Future => summary.future_count += 1, + } + + if !fact.evidence_note_ids.is_empty() { + summary.sourced_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "inferred") { + summary.inferred_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "ambiguous") { + summary.ambiguous_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "stale") { + summary.stale_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "superseded") { + summary.superseded_count += 1; + } + + summary.evidence_link_count += fact.evidence_note_ids.len(); + } + + summary +} + +pub(super) fn build_topic_map( + subject: &ResolvedGraphReportSubject, + facts: &[GraphReportFact], +) -> GraphTopicMap { + let subject_node_id = format!("entity:{}", subject.entity_id); + let mut nodes = BTreeMap::new(); + + nodes.insert( + subject_node_id.clone(), + GraphTopicNode { + node_id: subject_node_id.clone(), + label: subject.canonical.clone(), + node_type: "subject".to_string(), + kind: subject.kind.clone(), + }, + ); + + let edges = facts + .iter() + .map(|fact| { + let (target_node_id, label, kind, node_type) = match &fact.object.entity { + Some(entity) => ( + format!("entity:{}", entity.entity_id), + entity.canonical.clone(), + entity.kind.clone(), + "entity".to_string(), + ), + None => ( + format!("value:{}", fact.object.value.as_deref().unwrap_or_default()), + fact.object.value.clone().unwrap_or_default(), + None, + "value".to_string(), + ), + }; + + nodes.entry(target_node_id.clone()).or_insert_with(|| GraphTopicNode { + node_id: target_node_id.clone(), + label, + node_type, + kind, + }); + GraphTopicEdge { + fact_id: fact.fact_id, + source_node_id: subject_node_id.clone(), + target_node_id, + predicate: fact.predicate.clone(), + temporal_status: fact.temporal_status, + status_markers: fact.status_markers.clone(), + evidence_note_ids: fact.evidence_note_ids.clone(), + } + }) + .collect(); + + GraphTopicMap { nodes: nodes.into_values().collect(), edges } +} + +fn current_single_predicate_counts( + rows: &[GraphReportFactRow], + as_of: OffsetDateTime, +) -> BTreeMap { + let mut counts = BTreeMap::new(); + + for row in rows { + if row.predicate_cardinality.as_deref() != Some("single") { + continue; + } + if graph::relation_temporal_status(row.valid_from, row.valid_to, as_of) + != RelationTemporalStatus::Current + { + continue; + } + + *counts.entry(predicate_group_key(row)).or_insert(0) += 1; + } + + counts +} + +fn predicate_group_key(row: &GraphReportFactRow) -> String { + row.predicate_id + .map(|id| id.to_string()) + .unwrap_or_else(|| format!("surface:{}", row.predicate)) +} + +fn graph_object(row: &GraphReportFactRow) -> GraphQueryObject { + if let Some(entity_id) = row.object_entity_id { + return GraphQueryObject { + entity: Some(GraphQueryObjectEntity { + entity_id, + canonical: row.object_canonical.clone().unwrap_or_default(), + kind: row.object_kind.clone(), + }), + value: None, + }; + } + + GraphQueryObject { entity: None, value: row.object_value.clone() } +} + +fn report_status_markers( + row: &GraphReportFactRow, + temporal_status: RelationTemporalStatus, + ambiguous: bool, +) -> Vec { + let mut markers = Vec::new(); + + if row.evidence_note_ids.is_empty() { + markers.push("unsupported".to_string()); + } else { + markers.push("sourced".to_string()); + } + if row.predicate_status.as_deref() != Some("active") { + markers.push("inferred".to_string()); + } + if temporal_status == RelationTemporalStatus::Historical { + markers.push("stale".to_string()); + } + if !row.superseded_by_fact_ids.is_empty() { + markers.push("superseded".to_string()); + } + if ambiguous { + markers.push("ambiguous".to_string()); + } + + markers +} diff --git a/packages/elf-service/src/graph_report/resolution.rs b/packages/elf-service/src/graph_report/resolution.rs new file mode 100644 index 00000000..e073a4c1 --- /dev/null +++ b/packages/elf-service/src/graph_report/resolution.rs @@ -0,0 +1,100 @@ +use crate::graph_report::{ + Error, GraphEntity, GraphQueryEntityRef, GraphQueryPredicateRef, PgConnection, + ResolvedGraphReportPredicate, ResolvedGraphReportSubject, Result, graph, +}; + +pub(super) async fn resolve_subject( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + subject: GraphQueryEntityRef, +) -> Result { + match subject { + GraphQueryEntityRef::EntityId { entity_id } => { + let row = sqlx::query_as::<_, GraphEntity>( + "\ +SELECT + entity_id, + tenant_id, + project_id, + canonical, + canonical_norm, + kind, + created_at, + updated_at +FROM graph_entities +WHERE tenant_id = $1 + AND project_id = $2 + AND entity_id = $3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(entity_id) + .fetch_optional(conn) + .await?; + let Some(row) = row else { + return Err(Error::NotFound { + message: format!("graph entity not found for subject entity_id={entity_id}"), + }); + }; + + Ok(ResolvedGraphReportSubject { + entity_id: row.entity_id, + canonical: row.canonical, + kind: row.kind, + }) + }, + GraphQueryEntityRef::Surface { surface } => { + let Some(row) = + graph::resolve_entity_by_surface(conn, tenant_id, project_id, &surface).await? + else { + return Err(Error::NotFound { + message: format!("graph entity not found for subject surface={surface}"), + }); + }; + + Ok(ResolvedGraphReportSubject { + entity_id: row.entity_id, + canonical: row.canonical, + kind: row.kind, + }) + }, + } +} + +pub(super) async fn resolve_predicate( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + predicate: Option, +) -> Result> { + match predicate { + Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => { + let Some(row) = graph::get_predicate_by_id(conn, predicate_id).await? else { + return Err(Error::NotFound { + message: format!("graph predicate not found; predicate_id={predicate_id}"), + }); + }; + + Ok(Some(ResolvedGraphReportPredicate { + id: row.predicate_id, + canonical: row.canonical, + })) + }, + Some(GraphQueryPredicateRef::Surface { surface }) => { + let Some(row) = + graph::resolve_predicate_no_register(conn, tenant_id, project_id, &surface).await? + else { + return Err(Error::NotFound { + message: format!("graph predicate not found for surface={surface}"), + }); + }; + + Ok(Some(ResolvedGraphReportPredicate { + id: row.predicate_id, + canonical: row.canonical, + })) + }, + None => Ok(None), + } +} diff --git a/packages/elf-service/src/graph_report/service.rs b/packages/elf-service/src/graph_report/service.rs new file mode 100644 index 00000000..873008b0 --- /dev/null +++ b/packages/elf-service/src/graph_report/service.rs @@ -0,0 +1,103 @@ +use crate::{ + access, graph_query, + graph_report::{ + self, ELF_GRAPH_REPORT_SCHEMA_V1, ElfService, GraphReportEntity, GraphReportExplain, + GraphReportPredicate, GraphReportRequest, GraphReportResponse, GraphReportRowsFetchParams, + Result, + }, + search, +}; + +impl ElfService { + /// Builds a source-backed graph report for one subject entity. + pub async fn graph_report(&self, req: GraphReportRequest) -> Result { + let prepared = graph_report::validate_graph_report_request(req)?; + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; + let effective_scopes = graph_query::resolve_effective_scopes( + &allowed_scopes, + prepared.requested_scopes.as_slice(), + )?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope.trim() == "org_shared"); + let mut conn = self.db.pool.acquire().await?; + let subject = graph_report::resolve_subject( + &mut conn, + &prepared.tenant_id, + &prepared.project_id, + prepared.subject, + ) + .await?; + let predicate = graph_report::resolve_predicate( + &mut conn, + &prepared.tenant_id, + &prepared.project_id, + prepared.predicate, + ) + .await?; + let shared_grants = access::load_shared_read_grants_with_org_shared( + conn.as_mut(), + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let shared_scope_keys: Vec = shared_grants + .into_iter() + .map(|item| format!("{}:{}", item.scope, item.space_owner_agent_id)) + .collect(); + let predicate_id = predicate.as_ref().map(|predicate| predicate.id); + let rows = graph_report::fetch_graph_report_rows( + &mut conn, + GraphReportRowsFetchParams { + tenant_id: prepared.tenant_id.as_str(), + project_id: prepared.project_id.as_str(), + subject_entity_id: subject.entity_id, + scopes: effective_scopes.as_slice(), + actor: prepared.agent_id.as_str(), + shared_scope_keys: shared_scope_keys.as_slice(), + predicate_id, + limit_plus_one: (prepared.limit as i64) + 1, + }, + ) + .await?; + let queried_rows = rows.len(); + let (rows, truncated) = graph_report::truncate_report_rows(rows, prepared.limit); + let facts = graph_report::build_report_facts(rows, prepared.as_of); + let summary = graph_report::summarize_report_facts(&facts); + let topic_map = graph_report::build_topic_map(&subject, &facts); + let explain = if prepared.explain { + Some(GraphReportExplain { + schema: ELF_GRAPH_REPORT_SCHEMA_V1.to_string(), + as_of: prepared.as_of, + requested_limit: prepared.limit as u32, + allowed_scopes, + effective_scopes: effective_scopes.clone(), + queried_rows, + returned_rows: facts.len(), + truncated, + }) + } else { + None + }; + + Ok(GraphReportResponse { + schema: ELF_GRAPH_REPORT_SCHEMA_V1.to_string(), + as_of: prepared.as_of, + subject: GraphReportEntity { + entity_id: subject.entity_id, + canonical: subject.canonical, + kind: subject.kind, + }, + predicate: predicate.map(|resolved| GraphReportPredicate { + predicate_id: resolved.id, + canonical: resolved.canonical, + }), + scopes: effective_scopes, + summary, + topic_map, + facts, + explain, + }) + } +} diff --git a/packages/elf-service/src/graph_report/state.rs b/packages/elf-service/src/graph_report/state.rs new file mode 100644 index 00000000..4b545e66 --- /dev/null +++ b/packages/elf-service/src/graph_report/state.rs @@ -0,0 +1,62 @@ +use crate::graph_report::{ + FromRow, GraphQueryEntityRef, GraphQueryPredicateRef, OffsetDateTime, Uuid, +}; + +#[derive(Debug)] +pub(super) struct PreparedGraphReport { + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) subject: GraphQueryEntityRef, + pub(super) predicate: Option, + pub(super) requested_scopes: Vec, + pub(super) as_of: OffsetDateTime, + pub(super) limit: usize, + pub(super) explain: bool, +} + +#[derive(Debug)] +pub(super) struct ResolvedGraphReportSubject { + pub(super) entity_id: Uuid, + pub(super) canonical: String, + pub(super) kind: Option, +} + +#[derive(Debug)] +pub(super) struct ResolvedGraphReportPredicate { + pub(super) id: Uuid, + pub(super) canonical: String, +} + +#[derive(Debug)] +pub(super) struct GraphReportRowsFetchParams<'a> { + pub(super) tenant_id: &'a str, + pub(super) project_id: &'a str, + pub(super) subject_entity_id: Uuid, + pub(super) scopes: &'a [String], + pub(super) actor: &'a str, + pub(super) shared_scope_keys: &'a [String], + pub(super) predicate_id: Option, + pub(super) limit_plus_one: i64, +} + +#[derive(Debug, FromRow)] +pub(super) struct GraphReportFactRow { + pub(super) fact_id: Uuid, + pub(super) scope: String, + pub(super) actor: String, + pub(super) predicate: String, + pub(super) predicate_id: Option, + pub(super) predicate_status: Option, + pub(super) predicate_cardinality: Option, + pub(super) object_entity_id: Option, + pub(super) object_canonical: Option, + pub(super) object_kind: Option, + pub(super) object_value: Option, + pub(super) valid_from: OffsetDateTime, + pub(super) valid_to: Option, + pub(super) evidence_note_ids: Vec, + pub(super) superseded_by_fact_ids: Vec, + pub(super) supersedes_fact_ids: Vec, +} diff --git a/packages/elf-service/src/graph_report/storage.rs b/packages/elf-service/src/graph_report/storage.rs new file mode 100644 index 00000000..4e4d0841 --- /dev/null +++ b/packages/elf-service/src/graph_report/storage.rs @@ -0,0 +1,26 @@ +use crate::graph_report::{ + GRAPH_REPORT_EVIDENCE_LIMIT, GRAPH_REPORT_FACTS_SQL, GraphReportFactRow, + GraphReportRowsFetchParams, ORG_PROJECT_ID, OffsetDateTime, PgConnection, Result, +}; + +pub(super) async fn fetch_graph_report_rows( + conn: &mut PgConnection, + params: GraphReportRowsFetchParams<'_>, +) -> Result> { + let rows = sqlx::query_as::<_, GraphReportFactRow>(GRAPH_REPORT_FACTS_SQL) + .bind(params.tenant_id) + .bind(params.project_id) + .bind(params.subject_entity_id) + .bind(params.scopes) + .bind(OffsetDateTime::now_utc()) + .bind(params.actor) + .bind(params.shared_scope_keys) + .bind(params.limit_plus_one) + .bind(GRAPH_REPORT_EVIDENCE_LIMIT) + .bind(ORG_PROJECT_ID) + .bind(params.predicate_id) + .fetch_all(conn) + .await?; + + Ok(rows) +} diff --git a/packages/elf-service/src/graph_report/tests.rs b/packages/elf-service/src/graph_report/tests.rs new file mode 100644 index 00000000..6e4651e6 --- /dev/null +++ b/packages/elf-service/src/graph_report/tests.rs @@ -0,0 +1,103 @@ +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + RelationTemporalStatus, + graph_report::{self, GraphReportFactRow, ResolvedGraphReportSubject}, +}; + +fn ts(value: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(value).expect("valid timestamp") +} + +fn row( + raw_id: u128, + object_value: &str, + valid_from: i64, + valid_to: Option, + predicate_status: &str, + cardinality: &str, + superseded_by: Vec, +) -> GraphReportFactRow { + GraphReportFactRow { + fact_id: Uuid::from_u128(raw_id), + scope: "agent_private".to_string(), + actor: "agent".to_string(), + predicate: "works at".to_string(), + predicate_id: Some(Uuid::from_u128(999)), + predicate_status: Some(predicate_status.to_string()), + predicate_cardinality: Some(cardinality.to_string()), + object_entity_id: None, + object_canonical: None, + object_kind: None, + object_value: Some(object_value.to_string()), + valid_from: ts(valid_from), + valid_to: valid_to.map(ts), + evidence_note_ids: vec![Uuid::from_u128(raw_id + 10_000)], + superseded_by_fact_ids: superseded_by, + supersedes_fact_ids: vec![], + } +} + +#[test] +fn graph_report_classifies_temporal_source_and_supersession_markers() { + let replacement_id = Uuid::from_u128(2); + let facts = graph_report::build_report_facts( + vec![ + row(1, "Initech", 10, Some(20), "active", "single", vec![replacement_id]), + row(2, "Globex", 20, None, "active", "single", vec![]), + row(3, "Umbrella", 30, None, "pending", "single", vec![]), + ], + ts(25), + ); + let summary = graph_report::summarize_report_facts(&facts); + + assert_eq!(summary.fact_count, 3); + assert_eq!(summary.current_count, 1); + assert_eq!(summary.historical_count, 1); + assert_eq!(summary.future_count, 1); + assert_eq!(summary.sourced_count, 3); + assert_eq!(summary.inferred_count, 1); + assert_eq!(summary.stale_count, 1); + assert_eq!(summary.superseded_count, 1); + assert_eq!(summary.evidence_link_count, 3); + assert_eq!(facts[0].temporal_status, RelationTemporalStatus::Historical); + assert!(facts[0].status_markers.iter().any(|marker| marker == "superseded")); + assert!(facts[2].status_markers.iter().any(|marker| marker == "inferred")); +} + +#[test] +fn graph_report_suppresses_facts_without_readable_evidence() { + let mut deleted_source = row(1, "Deleted Source Inc.", 10, None, "active", "single", vec![]); + + deleted_source.evidence_note_ids = vec![]; + + let facts = graph_report::build_report_facts( + vec![deleted_source, row(2, "Active Source Inc.", 20, None, "active", "single", vec![])], + ts(25), + ); + + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_id, Uuid::from_u128(2)); + assert_eq!(facts[0].object.value.as_deref(), Some("Active Source Inc.")); +} + +#[test] +fn graph_topic_map_preserves_fact_edges_and_source_markers() { + let subject = ResolvedGraphReportSubject { + entity_id: Uuid::from_u128(42), + canonical: "Alice".to_string(), + kind: Some("person".to_string()), + }; + let facts = graph_report::build_report_facts( + vec![row(1, "Globex", 20, None, "active", "single", vec![])], + ts(25), + ); + let topic_map = graph_report::build_topic_map(&subject, &facts); + + assert_eq!(topic_map.nodes.len(), 2); + assert_eq!(topic_map.edges.len(), 1); + assert_eq!(topic_map.edges[0].predicate, "works at"); + assert_eq!(topic_map.edges[0].temporal_status, RelationTemporalStatus::Current); + assert!(topic_map.edges[0].status_markers.iter().any(|marker| marker == "sourced")); +} diff --git a/packages/elf-service/src/graph_report/types.rs b/packages/elf-service/src/graph_report/types.rs new file mode 100644 index 00000000..ea9a5995 --- /dev/null +++ b/packages/elf-service/src/graph_report/types.rs @@ -0,0 +1,206 @@ +use crate::graph_report::{ + Deserialize, GraphQueryEntityRef, GraphQueryObject, GraphQueryPredicateRef, OffsetDateTime, + RelationTemporalStatus, Serialize, Uuid, +}; + +/// Request payload for a graph topic-map report. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GraphReportRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Subject entity selector. + pub subject: GraphQueryEntityRef, + /// Optional predicate selector used to narrow the report. + pub predicate: Option, + /// Optional requested scopes. + pub scopes: Option>, + #[serde(with = "crate::time_serde::option")] + /// Point-in-time used for current, historical, and future classification. + pub as_of: Option, + /// Optional maximum number of returned facts. + pub limit: Option, + /// When true, includes explain metadata. + pub explain: Option, +} + +/// Response payload for a graph topic-map report. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportResponse { + /// Report schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Effective point-in-time view used for temporal classification. + pub as_of: OffsetDateTime, + /// Resolved subject entity. + pub subject: GraphReportEntity, + #[serde(skip_serializing_if = "Option::is_none")] + /// Resolved predicate, when the request filtered by predicate. + pub predicate: Option, + /// Effective scopes used for the report. + pub scopes: Vec, + /// Aggregate report counters. + pub summary: GraphReportSummary, + /// Topic map projection of the graph facts. + pub topic_map: GraphTopicMap, + /// Returned fact rows. + pub facts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional explain metadata. + pub explain: Option, +} + +/// Resolved graph entity reference. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportEntity { + /// Entity identifier. + pub entity_id: Uuid, + /// Canonical entity surface. + pub canonical: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Resolved graph predicate reference. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportPredicate { + /// Predicate identifier. + pub predicate_id: Uuid, + /// Canonical predicate surface. + pub canonical: String, +} + +/// Aggregate counters for graph reports. +#[derive(Clone, Debug, Default, Serialize)] +pub struct GraphReportSummary { + /// Number of returned facts. + pub fact_count: usize, + /// Number of facts current at `as_of`. + pub current_count: usize, + /// Number of facts historical at `as_of`. + pub historical_count: usize, + /// Number of facts whose validity starts after `as_of`. + pub future_count: usize, + /// Number of facts with at least one evidence note link. + pub sourced_count: usize, + /// Number of facts still backed by pending or unresolved predicate vocabulary. + pub inferred_count: usize, + /// Number of facts that conflict under a single-cardinality predicate. + pub ambiguous_count: usize, + /// Number of stale facts, currently equivalent to historical facts. + pub stale_count: usize, + /// Number of facts linked to a superseding replacement. + pub superseded_count: usize, + /// Total evidence note links returned with the facts. + pub evidence_link_count: usize, +} + +/// One graph fact returned by a graph report. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportFact { + /// Fact identifier. + pub fact_id: Uuid, + /// Scope key for the fact. + pub scope: String, + /// Agent that emitted the fact. + pub actor: String, + /// Predicate surface recorded on the fact. + pub predicate: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Resolved predicate identifier, when available. + pub predicate_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Predicate registry status, when available. + pub predicate_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Predicate registry cardinality, when available. + pub predicate_cardinality: Option, + #[serde(with = "crate::time_serde")] + /// Start of the fact validity window. + pub valid_from: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// End of the fact validity window, if superseded or explicitly bounded. + pub valid_to: Option, + /// Temporal state for the fact relative to report `as_of`. + pub temporal_status: RelationTemporalStatus, + /// Object payload for the fact. + pub object: GraphQueryObject, + /// Evidence note identifiers supporting the fact. + pub evidence_note_ids: Vec, + /// Replacement fact ids that supersede this fact. + pub superseded_by_fact_ids: Vec, + /// Older fact ids superseded by this fact. + pub supersedes_fact_ids: Vec, + /// Source-backed report status markers. + pub status_markers: Vec, +} + +/// Topic-map projection for graph reports. +#[derive(Clone, Debug, Serialize)] +pub struct GraphTopicMap { + /// Topic-map nodes. + pub nodes: Vec, + /// Topic-map edges, one per returned fact. + pub edges: Vec, +} + +/// Topic-map node. +#[derive(Clone, Debug, Serialize)] +pub struct GraphTopicNode { + /// Stable node identifier. + pub node_id: String, + /// Human-readable node label. + pub label: String, + /// Node type such as subject, entity, or value. + pub node_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Topic-map edge. +#[derive(Clone, Debug, Serialize)] +pub struct GraphTopicEdge { + /// Backing fact identifier. + pub fact_id: Uuid, + /// Source topic node identifier. + pub source_node_id: String, + /// Target topic node identifier. + pub target_node_id: String, + /// Predicate label. + pub predicate: String, + /// Temporal state for the edge. + pub temporal_status: RelationTemporalStatus, + /// Source-backed report status markers. + pub status_markers: Vec, + /// Evidence note identifiers supporting the edge. + pub evidence_note_ids: Vec, +} + +/// Explain metadata for graph reports. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportExplain { + /// Explain schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Effective point-in-time used for classification. + pub as_of: OffsetDateTime, + /// Requested result limit. + pub requested_limit: u32, + /// Scopes allowed by the read profile. + pub allowed_scopes: Vec, + /// Scopes effectively queried after request filtering. + pub effective_scopes: Vec, + /// Number of rows read from storage. + pub queried_rows: usize, + /// Number of rows returned to the caller. + pub returned_rows: usize, + /// Whether the result set was truncated by the limit. + pub truncated: bool, +} diff --git a/packages/elf-service/src/graph_report/validation.rs b/packages/elf-service/src/graph_report/validation.rs new file mode 100644 index 00000000..4b95b596 --- /dev/null +++ b/packages/elf-service/src/graph_report/validation.rs @@ -0,0 +1,83 @@ +use crate::graph_report::{ + BTreeSet, DEFAULT_GRAPH_REPORT_LIMIT, Error, GraphQueryEntityRef, GraphQueryPredicateRef, + GraphReportRequest, MAX_GRAPH_REPORT_LIMIT, OffsetDateTime, PreparedGraphReport, Result, +}; + +pub(super) fn validate_graph_report_request( + req: GraphReportRequest, +) -> Result { + let tenant_id = normalize_required_field(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required_field(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required_field(req.agent_id.as_str(), "agent_id")?; + let read_profile = normalize_required_field(req.read_profile.as_str(), "read_profile")?; + let subject = match req.subject { + GraphQueryEntityRef::EntityId { entity_id } => GraphQueryEntityRef::EntityId { entity_id }, + GraphQueryEntityRef::Surface { surface } => { + let surface = normalize_required_field(surface.as_str(), "subject.surface")?; + + GraphQueryEntityRef::Surface { surface } + }, + }; + let predicate = match req.predicate { + Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => + Some(GraphQueryPredicateRef::PredicateId { predicate_id }), + Some(GraphQueryPredicateRef::Surface { surface }) => { + let surface = normalize_required_field(surface.as_str(), "predicate.surface")?; + + Some(GraphQueryPredicateRef::Surface { surface }) + }, + None => None, + }; + let requested_scopes = normalize_scopes(req.scopes)?; + let limit = req.limit.unwrap_or(DEFAULT_GRAPH_REPORT_LIMIT); + + if !matches!(limit, 1..=MAX_GRAPH_REPORT_LIMIT) { + return Err(Error::InvalidRequest { + message: format!("limit must be between 1 and {MAX_GRAPH_REPORT_LIMIT}."), + }); + } + + Ok(PreparedGraphReport { + tenant_id, + project_id, + agent_id, + read_profile, + subject, + predicate, + requested_scopes, + as_of: req.as_of.unwrap_or_else(OffsetDateTime::now_utc), + limit: limit as usize, + explain: req.explain.unwrap_or(false), + }) +} + +fn normalize_required_field(value: &str, field: &str) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} is required.") }); + } + + Ok(trimmed.to_string()) +} + +fn normalize_scopes(scopes: Option>) -> Result> { + let scopes = scopes.unwrap_or_default(); + let mut seen = BTreeSet::new(); + let mut normalized = Vec::new(); + + for scope in scopes { + let scope = scope.trim().to_string(); + + if scope.is_empty() { + return Err(Error::InvalidRequest { + message: "scopes entries must be non-empty strings.".to_string(), + }); + } + if seen.insert(scope.clone()) { + normalized.push(scope); + } + } + + Ok(normalized) +} diff --git a/packages/elf-service/src/history.rs b/packages/elf-service/src/history.rs new file mode 100644 index 00000000..4691d392 --- /dev/null +++ b/packages/elf-service/src/history.rs @@ -0,0 +1,112 @@ +use serde_json::Value; +use sqlx::PgExecutor; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::Result; +use elf_storage::models::MemoryNote; + +pub(crate) struct InsertVersionArgs<'a> { + pub(crate) note_id: Uuid, + pub(crate) op: &'a str, + pub(crate) prev_snapshot: Option, + pub(crate) new_snapshot: Option, + pub(crate) reason: &'a str, + pub(crate) actor: &'a str, + pub(crate) ts: OffsetDateTime, +} + +pub(crate) fn note_snapshot(note: &MemoryNote) -> Value { + serde_json::json!({ + "note_id": note.note_id, + "tenant_id": note.tenant_id, + "project_id": note.project_id, + "agent_id": note.agent_id, + "scope": note.scope, + "type": note.r#type, + "key": note.key, + "text": note.text, + "importance": note.importance, + "confidence": note.confidence, + "status": note.status, + "created_at": note.created_at, + "updated_at": note.updated_at, + "expires_at": note.expires_at, + "embedding_version": note.embedding_version, + "source_ref": note.source_ref, + "hit_count": note.hit_count, + "last_hit_at": note.last_hit_at, + }) +} + +pub(crate) async fn insert_version<'e, E>(executor: E, args: InsertVersionArgs<'_>) -> Result +where + E: PgExecutor<'e>, +{ + let InsertVersionArgs { note_id, op, prev_snapshot, new_snapshot, reason, actor, ts } = args; + let version_id = Uuid::new_v4(); + + sqlx::query( + "\ +INSERT INTO memory_note_versions ( + version_id, + note_id, + op, + prev_snapshot, + new_snapshot, + reason, + actor, + ts +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + ) + .bind(version_id) + .bind(note_id) + .bind(op) + .bind(prev_snapshot) + .bind(new_snapshot) + .bind(reason) + .bind(actor) + .bind(ts) + .execute(executor) + .await?; + + Ok(version_id) +} + +pub(crate) async fn enqueue_outbox_tx<'e, E>( + executor: E, + note_id: Uuid, + op: &str, + embedding_version: &str, + now: OffsetDateTime, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO indexing_outbox ( + outbox_id, + note_id, + op, + embedding_version, + status, + created_at, + updated_at, + available_at +) +VALUES ($1,$2,$3,$4,'PENDING',$5,$6,$7)", + ) + .bind(Uuid::new_v4()) + .bind(note_id) + .bind(op) + .bind(embedding_version) + .bind(now) + .bind(now) + .bind(now) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/packages/elf-service/src/ingestion_profiles.rs b/packages/elf-service/src/ingestion_profiles.rs index 3955c856..c038e278 100644 --- a/packages/elf-service/src/ingestion_profiles.rs +++ b/packages/elf-service/src/ingestion_profiles.rs @@ -1,887 +1,32 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{FromRow, PgPool}; -use time::OffsetDateTime; - -use crate::{ElfService, Error, Result}; -use elf_config::LlmProviderConfig; +mod admin; +mod profile; +mod resolution; +mod storage; +mod types; + +pub use types::{ + AdminIngestionProfileCreateRequest, AdminIngestionProfileDefaultGetRequest, + AdminIngestionProfileDefaultResponse, AdminIngestionProfileDefaultSetRequest, + AdminIngestionProfileGetRequest, AdminIngestionProfileListRequest, + AdminIngestionProfileResponse, AdminIngestionProfileSummary, + AdminIngestionProfileVersionsListRequest, AdminIngestionProfileVersionsListResponse, + AdminIngestionProfilesListResponse, IngestionProfileRef, IngestionProfileSelector, +}; + +use sqlx::PgPool; + +use crate::Result; +use types::ResolvedIngestionProfile; const ADD_EVENT_PIPELINE: &str = "add_event"; const DEFAULT_PROFILE_ID: &str = "default"; const DEFAULT_PROFILE_VERSION: i32 = 1; -/// Selector for an ingestion profile and optional version. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct IngestionProfileSelector { - /// Profile identifier. - pub id: String, - /// Optional explicit version. - pub version: Option, -} - -/// Resolved ingestion-profile reference. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct IngestionProfileRef { - /// Profile identifier. - pub id: String, - /// Resolved version. - pub version: i32, -} - -/// Request payload for creating an ingestion profile version. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AdminIngestionProfileCreateRequest { - /// Tenant that owns the profile. - pub tenant_id: String, - /// Project that owns the profile. - pub project_id: String, - /// Profile identifier. - pub profile_id: String, - /// Optional explicit version number. - pub version: Option, - /// JSON profile payload. - pub profile: Value, - /// Actor creating the profile version. - pub created_by: String, -} - -/// Request payload for listing ingestion profiles. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AdminIngestionProfileListRequest { - /// Tenant that owns the profiles. - pub tenant_id: String, - /// Project that owns the profiles. - pub project_id: String, -} - -/// Request payload for fetching one ingestion profile. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AdminIngestionProfileGetRequest { - /// Tenant that owns the profile. - pub tenant_id: String, - /// Project that owns the profile. - pub project_id: String, - /// Profile identifier. - pub profile_id: String, - /// Optional explicit version. - pub version: Option, -} - -/// Request payload for listing all versions of one ingestion profile. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AdminIngestionProfileVersionsListRequest { - /// Tenant that owns the profile. - pub tenant_id: String, - /// Project that owns the profile. - pub project_id: String, - /// Profile identifier. - pub profile_id: String, -} - -/// Request payload for reading the default ingestion profile pointer. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AdminIngestionProfileDefaultGetRequest { - /// Tenant that owns the default pointer. - pub tenant_id: String, - /// Project that owns the default pointer. - pub project_id: String, -} - -/// Request payload for updating the default ingestion profile pointer. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct AdminIngestionProfileDefaultSetRequest { - /// Tenant that owns the default pointer. - pub tenant_id: String, - /// Project that owns the default pointer. - pub project_id: String, - /// Profile identifier to make default. - pub profile_id: String, - /// Optional explicit version to make default. - pub version: Option, -} - -/// Response payload for one ingestion profile version. -#[derive(Clone, Debug, Serialize)] -pub struct AdminIngestionProfileResponse { - /// Profile identifier. - pub profile_id: String, - /// Profile version. - pub version: i32, - /// JSON profile payload. - pub profile: Value, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Actor that created the version. - pub created_by: String, -} - -/// Summary row for an ingestion profile version. -#[derive(Clone, Debug, Serialize)] -pub struct AdminIngestionProfileSummary { - /// Profile identifier. - pub profile_id: String, - /// Profile version. - pub version: i32, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Actor that created the version. - pub created_by: String, -} - -/// Response payload for listing ingestion profiles. -#[derive(Clone, Debug, Serialize)] -pub struct AdminIngestionProfilesListResponse { - /// Returned profile summaries. - pub profiles: Vec, -} - -/// Response payload for listing versions of one ingestion profile. -#[derive(Clone, Debug, Serialize)] -pub struct AdminIngestionProfileVersionsListResponse { - /// Returned profile-version summaries. - pub profiles: Vec, -} - -/// Response payload for reading the default ingestion profile pointer. -#[derive(Clone, Debug, Serialize)] -pub struct AdminIngestionProfileDefaultResponse { - /// Default profile identifier. - pub profile_id: String, - /// Default profile version, when pinned. - pub version: Option, - #[serde(with = "crate::time_serde")] - /// Last update timestamp for the default pointer. - pub updated_at: OffsetDateTime, -} - -#[derive(Clone, Debug)] -pub(crate) struct ResolvedIngestionProfile { - pub profile_ref: IngestionProfileRef, - pub prompt_schema: Value, - pub prompt_system: String, - pub prompt_user_template: String, - pub model: Option, - pub temperature: Option, - pub timeout_ms: Option, -} -impl ResolvedIngestionProfile { - pub(crate) fn build_extractor_messages( - &self, - messages_json: &str, - max_notes: u32, - max_note_chars: u32, - ) -> Result> { - let schema = - serde_json::to_string(&self.prompt_schema).map_err(|_| Error::InvalidRequest { - message: "Failed to serialize ingestion profile schema.".to_string(), - })?; - let user_prompt = self - .prompt_user_template - .replace("{SCHEMA}", &schema) - .replace("{MAX_NOTES}", max_notes.to_string().as_str()) - .replace("{MAX_NOTE_CHARS}", max_note_chars.to_string().as_str()) - .replace("{MESSAGES_JSON}", messages_json); - - Ok(vec![ - serde_json::json!({ "role": "system", "content": self.prompt_system.clone() }), - serde_json::json!({ "role": "user", "content": user_prompt }), - ]) - } - - pub(crate) fn resolved_llm_config(&self, base: &LlmProviderConfig) -> LlmProviderConfig { - LlmProviderConfig { - provider_id: base.provider_id.clone(), - api_base: base.api_base.clone(), - api_key: base.api_key.clone(), - path: base.path.clone(), - model: self.model.clone().unwrap_or_else(|| base.model.clone()), - temperature: self.temperature.unwrap_or(base.temperature), - timeout_ms: self.timeout_ms.unwrap_or(base.timeout_ms), - default_headers: base.default_headers.clone(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct IngestionProfileV1 { - #[serde(default = "default_schema_version")] - schema_version: i32, - - prompt_schema: Option, - - prompt_system_template: Option, - - prompt_user_template: Option, - - model: Option, - - temperature: Option, - - timeout_ms: Option, -} -impl IngestionProfileV1 { - fn with_defaults(self) -> Self { - let defaults = builtin_profile_v1(); - let mut merged = defaults; - - if self.schema_version != 0 { - merged.schema_version = self.schema_version; - } - - merged.prompt_schema = self.prompt_schema.or(merged.prompt_schema); - merged.prompt_system_template = - self.prompt_system_template.or(merged.prompt_system_template); - merged.prompt_user_template = self.prompt_user_template.or(merged.prompt_user_template); - merged.model = self.model.or(merged.model); - merged.temperature = self.temperature.or(merged.temperature); - merged.timeout_ms = self.timeout_ms.or(merged.timeout_ms); - - merged - } -} - -#[derive(FromRow)] -struct ProfileRow { - profile_id: String, - version: i32, - profile: Value, -} - -#[derive(FromRow)] -struct ProfileMetadataRow { - profile_id: String, - version: i32, - profile: Value, - created_at: OffsetDateTime, - created_by: String, -} - -#[derive(FromRow)] -struct ProfileSummaryRow { - profile_id: String, - version: i32, - created_at: OffsetDateTime, - created_by: String, -} - -#[derive(FromRow)] -struct ProfileDefaultRow { - profile_id: String, - version: Option, - updated_at: OffsetDateTime, -} - -impl ElfService { - /// Creates a new ingestion profile version. - pub async fn admin_ingestion_profile_create( - &self, - req: AdminIngestionProfileCreateRequest, - ) -> Result { - let profile_id = req.profile_id.trim().to_string(); - let created_by = req.created_by.trim().to_string(); - - if profile_id.is_empty() { - return Err(Error::InvalidRequest { - message: "profile_id must be non-empty.".to_string(), - }); - } - if created_by.is_empty() { - return Err(Error::InvalidRequest { - message: "created_by must be non-empty.".to_string(), - }); - } - if !req.profile.is_object() { - return Err(Error::InvalidRequest { - message: "profile must be a JSON object.".to_string(), - }); - } - - let _ = parse_profile(req.profile.clone())?; - let version = match req.version { - Some(version) if version > 0 => version, - Some(_) => { - return Err(Error::InvalidRequest { - message: "version must be greater than 0.".to_string(), - }); - }, - None => { - sqlx::query_scalar::<_, i32>( - "SELECT COALESCE(MAX(version), 0) + 1 FROM memory_ingestion_profiles WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ADD_EVENT_PIPELINE) - .bind(profile_id.as_str()) - .fetch_one(&self.db.pool) - .await? - } - }; - let row = sqlx::query_as::<_, ProfileMetadataRow>( - "\ -INSERT INTO memory_ingestion_profiles ( - tenant_id, - project_id, - pipeline, - profile_id, - version, - profile, - created_by -) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7) -ON CONFLICT DO NOTHING -RETURNING profile_id, version, profile, created_at, created_by", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ADD_EVENT_PIPELINE) - .bind(profile_id.as_str()) - .bind(version) - .bind(req.profile) - .bind(created_by.as_str()) - .fetch_optional(&self.db.pool) - .await?; - let row = row.ok_or_else(|| Error::Conflict { - message: format!( - "Ingestion profile '{}' version {} already exists for tenant '{}' project '{}' pipeline '{}'.", - profile_id, version, req.tenant_id, req.project_id, ADD_EVENT_PIPELINE, - ), - })?; - - Ok(AdminIngestionProfileResponse { - profile_id: row.profile_id, - version: row.version, - profile: row.profile, - created_at: row.created_at, - created_by: row.created_by, - }) - } - - /// Lists the latest visible ingestion profile versions. - pub async fn admin_ingestion_profiles_list( - &self, - req: AdminIngestionProfileListRequest, - ) -> Result { - let rows = sqlx::query_as::<_, ProfileSummaryRow>( - "\ -SELECT DISTINCT ON (profile_id) - profile_id, version, created_at, created_by -FROM memory_ingestion_profiles -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 -ORDER BY profile_id, version DESC", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ADD_EVENT_PIPELINE) - .fetch_all(&self.db.pool) - .await?; - let profiles = rows - .into_iter() - .map(|row| AdminIngestionProfileSummary { - profile_id: row.profile_id, - version: row.version, - created_at: row.created_at, - created_by: row.created_by, - }) - .collect(); - - Ok(AdminIngestionProfilesListResponse { profiles }) - } - - /// Fetches one ingestion profile version. - pub async fn admin_ingestion_profile_get( - &self, - req: AdminIngestionProfileGetRequest, - ) -> Result { - let selector = IngestionProfileSelector { - id: req.profile_id.trim().to_string(), - version: req.version, - }; - - if selector.id.is_empty() { - return Err(Error::InvalidRequest { - message: "profile_id must be non-empty.".to_string(), - }); - } - - if let Some(version) = selector.version - && version <= 0 - { - return Err(Error::InvalidRequest { - message: "version must be greater than 0.".to_string(), - }); - } - - let row = select_profile_metadata( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - &selector, - ) - .await?; - - Ok(AdminIngestionProfileResponse { - profile_id: row.profile_id, - version: row.version, - profile: row.profile, - created_at: row.created_at, - created_by: row.created_by, - }) - } - - /// Lists all versions for one ingestion profile. - pub async fn admin_ingestion_profile_versions_list( - &self, - req: AdminIngestionProfileVersionsListRequest, - ) -> Result { - let profile_id = req.profile_id.trim().to_string(); - - if profile_id.is_empty() { - return Err(Error::InvalidRequest { - message: "profile_id must be non-empty.".to_string(), - }); - } - - let rows = sqlx::query_as::<_, ProfileSummaryRow>( - "\ -SELECT profile_id, version, created_at, created_by -FROM memory_ingestion_profiles -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 -ORDER BY version DESC", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ADD_EVENT_PIPELINE) - .bind(profile_id) - .fetch_all(&self.db.pool) - .await?; - let profiles = rows - .into_iter() - .map(|row| AdminIngestionProfileSummary { - profile_id: row.profile_id, - version: row.version, - created_at: row.created_at, - created_by: row.created_by, - }) - .collect(); - - Ok(AdminIngestionProfileVersionsListResponse { profiles }) - } - - /// Reads the default ingestion profile pointer. - pub async fn admin_ingestion_profile_default_get( - &self, - req: AdminIngestionProfileDefaultGetRequest, - ) -> Result { - seed_default_profile(&self.db.pool, req.tenant_id.as_str(), req.project_id.as_str()) - .await?; - - let row = sqlx::query_as::<_, ProfileDefaultRow>( - "\ -SELECT profile_id, version, updated_at -FROM memory_ingestion_profile_defaults -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ADD_EVENT_PIPELINE) - .fetch_optional(&self.db.pool) - .await?; - let row = match row { - Some(row) => row, - None => { - let selector = select_default_selector( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - ) - .await?; - - ProfileDefaultRow { - profile_id: selector.id, - version: selector.version, - updated_at: OffsetDateTime::now_utc(), - } - }, - }; - - Ok(AdminIngestionProfileDefaultResponse { - profile_id: row.profile_id, - version: row.version, - updated_at: row.updated_at, - }) - } - - /// Updates the default ingestion profile pointer. - pub async fn admin_ingestion_profile_default_set( - &self, - req: AdminIngestionProfileDefaultSetRequest, - ) -> Result { - let profile_id = req.profile_id.trim().to_string(); - - if profile_id.is_empty() { - return Err(Error::InvalidRequest { - message: "profile_id must be non-empty.".to_string(), - }); - } - - if let Some(version) = req.version - && version <= 0 - { - return Err(Error::InvalidRequest { - message: "version must be greater than 0.".to_string(), - }); - } - - let selector = IngestionProfileSelector { id: profile_id.clone(), version: req.version }; - let row = select_profile_metadata( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - &selector, - ) - .await?; - let version = row.version; - let row = sqlx::query_as::<_, ProfileDefaultRow>( - "\ -INSERT INTO memory_ingestion_profile_defaults ( - tenant_id, - project_id, - pipeline, - profile_id, - version -) VALUES ($1,$2,$3,$4,$5) -ON CONFLICT (tenant_id, project_id, pipeline) DO UPDATE -SET profile_id = EXCLUDED.profile_id, - version = EXCLUDED.version, - updated_at = now() -RETURNING profile_id, version, updated_at", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(ADD_EVENT_PIPELINE) - .bind(row.profile_id) - .bind(version) - .fetch_one(&self.db.pool) - .await?; - - Ok(AdminIngestionProfileDefaultResponse { - profile_id: row.profile_id, - version: row.version, - updated_at: row.updated_at, - }) - } -} - pub(crate) async fn resolve_add_event_profile( pool: &PgPool, tenant_id: &str, project_id: &str, selector: Option<&IngestionProfileSelector>, ) -> Result { - seed_default_profile(pool, tenant_id, project_id).await?; - - let selector = if let Some(selector) = selector { - selector.clone() - } else { - select_default_selector(pool, tenant_id, project_id).await? - }; - let row = select_profile(pool, tenant_id, project_id, &selector).await?; - let parsed = parse_profile(row.profile)?; - let merged = parsed.with_defaults(); - - if merged.schema_version != 1 { - return Err(Error::InvalidRequest { - message: "Unsupported ingestion profile schema version.".to_string(), - }); - } - - let prompt_schema = merged.prompt_schema.ok_or_else(|| Error::InvalidRequest { - message: "Missing prompt schema in ingestion profile.".to_string(), - })?; - let prompt_system_template = - merged.prompt_system_template.ok_or_else(|| Error::InvalidRequest { - message: "Missing system prompt template in ingestion profile.".to_string(), - })?; - let prompt_user_template = - merged.prompt_user_template.ok_or_else(|| Error::InvalidRequest { - message: "Missing user prompt template in ingestion profile.".to_string(), - })?; - - Ok(ResolvedIngestionProfile { - profile_ref: IngestionProfileRef { id: row.profile_id, version: row.version }, - prompt_schema, - prompt_system: prompt_system_template, - prompt_user_template, - model: merged.model, - temperature: merged.temperature, - timeout_ms: merged.timeout_ms, - }) -} - -fn default_schema_version() -> i32 { - 1 -} - -fn parse_profile(profile: Value) -> Result { - let parsed = serde_json::from_value::(profile.clone()).or_else(|_| { - if profile.is_object() { - Ok(IngestionProfileV1 { - schema_version: 1, - prompt_schema: Some(profile), - prompt_system_template: None, - prompt_user_template: None, - model: None, - temperature: None, - timeout_ms: None, - }) - } else { - Err(Error::InvalidRequest { - message: "Ingestion profile JSON has unsupported format.".to_string(), - }) - } - })?; - - Ok(parsed) -} - -fn builtin_profile_v1() -> IngestionProfileV1 { - IngestionProfileV1 { - schema_version: 1, - prompt_schema: Some(builtin_profile_schema()), - prompt_system_template: Some( - "You are a memory extraction engine for an agent memory system. Output must be valid JSON only and must match the provided schema exactly. \ -Extract at most MAX_NOTES high-signal, cross-session reusable memory notes from the given messages. \ -Each note must be one English sentence and must not contain any non-English text. \ -The structured field is optional. If present, summary must be short, facts must be short sentences supported by the evidence quotes, and concepts must be short phrases. \ -structured.entities and structured.relations should mirror the structured schema with optional entity and relation metadata and relation timestamps. \ -Preserve numbers, dates, percentages, currency amounts, tickers, URLs, and code snippets exactly. \ -Never store secrets or PII: API keys, tokens, private keys, seed phrases, passwords, bank IDs, personal addresses. \ -For every note, provide 1 to 2 evidence quotes copied verbatim from the input messages and include the message_index. \ -If you cannot provide verbatim evidence, omit the note. \ -If content is ephemeral or not useful long-term, return an empty notes array." - .to_string(), - ), - prompt_user_template: Some( - "Return JSON matching this exact schema:\n{SCHEMA}\nConstraints:\n- MAX_NOTES = {MAX_NOTES}\n- MAX_NOTE_CHARS = {MAX_NOTE_CHARS}\nHere are the messages as JSON:\n{MESSAGES_JSON}" - .to_string(), - ), - model: None, - temperature: None, - timeout_ms: None, - } -} - -fn builtin_profile_schema() -> Value { - serde_json::json!({ - "notes": [ - { - "type": "preference|constraint|decision|profile|fact|plan", - "key": "string|null", - "text": "English-only sentence <= MAX_NOTE_CHARS", - "structured": { - "summary": "string|null", - "facts": "string[]|null", - "concepts": "string[]|null", - "entities": [ - { - "canonical": "string|null", - "kind": "string|null", - "aliases": "string[]|null" - } - ], - "relations": [ - { - "subject": { - "canonical": "string|null", - "kind": "string|null", - "aliases": "string[]|null" - }, - "predicate": "string", - "object": { - "entity": { - "canonical": "string|null", - "kind": "string|null", - "aliases": "string[]|null" - }, - "value": "string|null" - }, - "valid_from": "string|null", - "valid_to": "string|null" - } - ] - }, - "importance": 0.0, - "confidence": 0.0, - "ttl_days": "number|null", - "scope_suggestion": "agent_private|project_shared|org_shared|null", - "evidence": [ - { "message_index": "number", "quote": "string" } - ], - "reason": "string" - } - ] - }) -} - -async fn select_profile_metadata( - pool: &PgPool, - tenant_id: &str, - project_id: &str, - selector: &IngestionProfileSelector, -) -> Result { - let row = if let Some(version) = selector.version { - sqlx::query_as::<_, ProfileMetadataRow>( - "\ -SELECT profile_id, version, profile, created_at, created_by -FROM memory_ingestion_profiles -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 AND version=$5", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .bind(selector.id.as_str()) - .bind(version) - .fetch_optional(pool) - .await? - } else { - sqlx::query_as::<_, ProfileMetadataRow>( - "\ -SELECT profile_id, version, profile, created_at, created_by -FROM memory_ingestion_profiles -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 -ORDER BY version DESC -LIMIT 1", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .bind(selector.id.as_str()) - .fetch_optional(pool) - .await? - }; - - row.ok_or_else(|| Error::InvalidRequest { - message: format!( - "Ingestion profile '{}' not found for tenant '{}' project '{}' pipeline '{}'.", - selector.id, tenant_id, project_id, ADD_EVENT_PIPELINE, - ), - }) -} - -async fn select_profile( - pool: &PgPool, - tenant_id: &str, - project_id: &str, - selector: &IngestionProfileSelector, -) -> Result { - let row = if let Some(version) = selector.version { - sqlx::query_as::<_, ProfileRow>( - "\ -SELECT profile_id, version, profile -FROM memory_ingestion_profiles -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 AND version=$5", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .bind(selector.id.as_str()) - .bind(version) - .fetch_optional(pool) - .await? - } else { - sqlx::query_as::<_, ProfileRow>( - "\ -SELECT profile_id, version, profile -FROM memory_ingestion_profiles -WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 -ORDER BY version DESC -LIMIT 1", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .bind(selector.id.as_str()) - .fetch_optional(pool) - .await? - }; - - row.ok_or_else(|| Error::InvalidRequest { - message: format!( - "Ingestion profile '{}' not found for tenant '{}' project '{}' pipeline '{}'.", - selector.id, tenant_id, project_id, ADD_EVENT_PIPELINE - ), - }) -} - -async fn select_default_selector( - pool: &PgPool, - tenant_id: &str, - project_id: &str, -) -> Result { - let row = sqlx::query_as::<_, (String, Option)>( - "SELECT profile_id, version FROM memory_ingestion_profile_defaults WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .fetch_optional(pool) - .await?; - let row = match row { - Some((profile_id, version)) => IngestionProfileSelector { id: profile_id, version }, - None => IngestionProfileSelector { - id: DEFAULT_PROFILE_ID.to_string(), - version: Some(DEFAULT_PROFILE_VERSION), - }, - }; - - Ok(row) -} - -async fn seed_default_profile(pool: &PgPool, tenant_id: &str, project_id: &str) -> Result<()> { - let profile = - serde_json::to_value(builtin_profile_v1()).map_err(|_| Error::InvalidRequest { - message: "Failed to serialize default ingestion profile.".to_string(), - })?; - - sqlx::query( - "\ -INSERT INTO memory_ingestion_profiles ( - tenant_id, - project_id, - pipeline, - profile_id, - version, - profile -) VALUES ($1,$2,$3,$4,$5,$6::jsonb) -ON CONFLICT DO NOTHING", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .bind(DEFAULT_PROFILE_ID) - .bind(DEFAULT_PROFILE_VERSION) - .bind(profile) - .execute(pool) - .await?; - sqlx::query( - "\ -INSERT INTO memory_ingestion_profile_defaults ( - tenant_id, - project_id, - pipeline, - profile_id, - version -) VALUES ($1,$2,$3,$4,$5) -ON CONFLICT DO NOTHING", - ) - .bind(tenant_id) - .bind(project_id) - .bind(ADD_EVENT_PIPELINE) - .bind(DEFAULT_PROFILE_ID) - .bind(DEFAULT_PROFILE_VERSION) - .execute(pool) - .await?; - - Ok(()) + resolution::resolve_add_event_profile(pool, tenant_id, project_id, selector).await } diff --git a/packages/elf-service/src/ingestion_profiles/admin.rs b/packages/elf-service/src/ingestion_profiles/admin.rs new file mode 100644 index 00000000..f9aea896 --- /dev/null +++ b/packages/elf-service/src/ingestion_profiles/admin.rs @@ -0,0 +1,273 @@ +use time::OffsetDateTime; + +use crate::{ + ElfService, Error, Result, + ingestion_profiles::{ + ADD_EVENT_PIPELINE, profile, + storage::{self}, + types::{ + AdminIngestionProfileCreateRequest, AdminIngestionProfileDefaultGetRequest, + AdminIngestionProfileDefaultResponse, AdminIngestionProfileDefaultSetRequest, + AdminIngestionProfileGetRequest, AdminIngestionProfileListRequest, + AdminIngestionProfileResponse, AdminIngestionProfileSummary, + AdminIngestionProfileVersionsListRequest, AdminIngestionProfileVersionsListResponse, + AdminIngestionProfilesListResponse, IngestionProfileSelector, + }, + }, +}; + +impl ElfService { + /// Creates a new ingestion profile version. + pub async fn admin_ingestion_profile_create( + &self, + req: AdminIngestionProfileCreateRequest, + ) -> Result { + let profile_id = req.profile_id.trim().to_string(); + let created_by = req.created_by.trim().to_string(); + + if profile_id.is_empty() { + return Err(Error::InvalidRequest { + message: "profile_id must be non-empty.".to_string(), + }); + } + if created_by.is_empty() { + return Err(Error::InvalidRequest { + message: "created_by must be non-empty.".to_string(), + }); + } + if !req.profile.is_object() { + return Err(Error::InvalidRequest { + message: "profile must be a JSON object.".to_string(), + }); + } + + let _ = profile::parse_profile(req.profile.clone())?; + let version = match req.version { + Some(version) if version > 0 => version, + Some(_) => { + return Err(Error::InvalidRequest { + message: "version must be greater than 0.".to_string(), + }); + }, + None => + storage::next_profile_version( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + profile_id.as_str(), + ) + .await?, + }; + let row = storage::insert_profile_metadata( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + profile_id.as_str(), + version, + req.profile, + created_by.as_str(), + ) + .await?; + let row = row.ok_or_else(|| Error::Conflict { + message: format!( + "Ingestion profile '{}' version {} already exists for tenant '{}' project '{}' pipeline '{}'.", + profile_id, version, req.tenant_id, req.project_id, ADD_EVENT_PIPELINE, + ), + })?; + + Ok(AdminIngestionProfileResponse { + profile_id: row.profile_id, + version: row.version, + profile: row.profile, + created_at: row.created_at, + created_by: row.created_by, + }) + } + + /// Lists the latest visible ingestion profile versions. + pub async fn admin_ingestion_profiles_list( + &self, + req: AdminIngestionProfileListRequest, + ) -> Result { + let rows = storage::list_latest_profile_summaries( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + ) + .await?; + let profiles = rows + .into_iter() + .map(|row| AdminIngestionProfileSummary { + profile_id: row.profile_id, + version: row.version, + created_at: row.created_at, + created_by: row.created_by, + }) + .collect(); + + Ok(AdminIngestionProfilesListResponse { profiles }) + } + + /// Fetches one ingestion profile version. + pub async fn admin_ingestion_profile_get( + &self, + req: AdminIngestionProfileGetRequest, + ) -> Result { + let selector = IngestionProfileSelector { + id: req.profile_id.trim().to_string(), + version: req.version, + }; + + if selector.id.is_empty() { + return Err(Error::InvalidRequest { + message: "profile_id must be non-empty.".to_string(), + }); + } + + if let Some(version) = selector.version + && version <= 0 + { + return Err(Error::InvalidRequest { + message: "version must be greater than 0.".to_string(), + }); + } + + let row = storage::select_profile_metadata( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + &selector, + ) + .await?; + + Ok(AdminIngestionProfileResponse { + profile_id: row.profile_id, + version: row.version, + profile: row.profile, + created_at: row.created_at, + created_by: row.created_by, + }) + } + + /// Lists all versions for one ingestion profile. + pub async fn admin_ingestion_profile_versions_list( + &self, + req: AdminIngestionProfileVersionsListRequest, + ) -> Result { + let profile_id = req.profile_id.trim().to_string(); + + if profile_id.is_empty() { + return Err(Error::InvalidRequest { + message: "profile_id must be non-empty.".to_string(), + }); + } + + let rows = storage::list_profile_version_summaries( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + profile_id.as_str(), + ) + .await?; + let profiles = rows + .into_iter() + .map(|row| AdminIngestionProfileSummary { + profile_id: row.profile_id, + version: row.version, + created_at: row.created_at, + created_by: row.created_by, + }) + .collect(); + + Ok(AdminIngestionProfileVersionsListResponse { profiles }) + } + + /// Reads the default ingestion profile pointer. + pub async fn admin_ingestion_profile_default_get( + &self, + req: AdminIngestionProfileDefaultGetRequest, + ) -> Result { + storage::seed_default_profile( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + ) + .await?; + + let row = storage::select_default_row( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + ) + .await?; + let row = match row { + Some(row) => row, + None => { + let selector = storage::select_default_selector( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + ) + .await?; + + return Ok(AdminIngestionProfileDefaultResponse { + profile_id: selector.id, + version: selector.version, + updated_at: OffsetDateTime::now_utc(), + }); + }, + }; + + Ok(AdminIngestionProfileDefaultResponse { + profile_id: row.profile_id, + version: row.version, + updated_at: row.updated_at, + }) + } + + /// Updates the default ingestion profile pointer. + pub async fn admin_ingestion_profile_default_set( + &self, + req: AdminIngestionProfileDefaultSetRequest, + ) -> Result { + let profile_id = req.profile_id.trim().to_string(); + + if profile_id.is_empty() { + return Err(Error::InvalidRequest { + message: "profile_id must be non-empty.".to_string(), + }); + } + + if let Some(version) = req.version + && version <= 0 + { + return Err(Error::InvalidRequest { + message: "version must be greater than 0.".to_string(), + }); + } + + let selector = IngestionProfileSelector { id: profile_id.clone(), version: req.version }; + let row = storage::select_profile_metadata( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + &selector, + ) + .await?; + let version = row.version; + let row = storage::upsert_default_row( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + row.profile_id, + version, + ) + .await?; + + Ok(AdminIngestionProfileDefaultResponse { + profile_id: row.profile_id, + version: row.version, + updated_at: row.updated_at, + }) + } +} diff --git a/packages/elf-service/src/ingestion_profiles/profile.rs b/packages/elf-service/src/ingestion_profiles/profile.rs new file mode 100644 index 00000000..f67315d4 --- /dev/null +++ b/packages/elf-service/src/ingestion_profiles/profile.rs @@ -0,0 +1,147 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{Error, Result}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct IngestionProfileV1 { + #[serde(default = "default_schema_version")] + pub(super) schema_version: i32, + + pub(super) prompt_schema: Option, + + pub(super) prompt_system_template: Option, + + pub(super) prompt_user_template: Option, + + pub(super) model: Option, + + pub(super) temperature: Option, + + pub(super) timeout_ms: Option, +} +impl IngestionProfileV1 { + pub(super) fn with_defaults(self) -> Self { + let defaults = builtin_profile_v1(); + let mut merged = defaults; + + if self.schema_version != 0 { + merged.schema_version = self.schema_version; + } + + merged.prompt_schema = self.prompt_schema.or(merged.prompt_schema); + merged.prompt_system_template = + self.prompt_system_template.or(merged.prompt_system_template); + merged.prompt_user_template = self.prompt_user_template.or(merged.prompt_user_template); + merged.model = self.model.or(merged.model); + merged.temperature = self.temperature.or(merged.temperature); + merged.timeout_ms = self.timeout_ms.or(merged.timeout_ms); + + merged + } +} + +pub(super) fn parse_profile(profile: Value) -> Result { + let parsed = serde_json::from_value::(profile.clone()).or_else(|_| { + if profile.is_object() { + Ok(IngestionProfileV1 { + schema_version: 1, + prompt_schema: Some(profile), + prompt_system_template: None, + prompt_user_template: None, + model: None, + temperature: None, + timeout_ms: None, + }) + } else { + Err(Error::InvalidRequest { + message: "Ingestion profile JSON has unsupported format.".to_string(), + }) + } + })?; + + Ok(parsed) +} + +pub(super) fn builtin_profile_v1() -> IngestionProfileV1 { + IngestionProfileV1 { + schema_version: 1, + prompt_schema: Some(builtin_profile_schema()), + prompt_system_template: Some( + "You are a memory extraction engine for an agent memory system. Output must be valid JSON only and must match the provided schema exactly. \ +Extract at most MAX_NOTES high-signal, cross-session reusable memory notes from the given messages. \ +Each note must be one English sentence and must not contain any non-English text. \ +The structured field is optional. If present, summary must be short, facts must be short sentences supported by the evidence quotes, and concepts must be short phrases. \ +structured.entities and structured.relations should mirror the structured schema with optional entity and relation metadata and relation timestamps. \ +Preserve numbers, dates, percentages, currency amounts, tickers, URLs, and code snippets exactly. \ +Never store secrets or PII: API keys, tokens, private keys, seed phrases, passwords, bank IDs, personal addresses. \ +For every note, provide 1 to 2 evidence quotes copied verbatim from the input messages and include the message_index. \ +If you cannot provide verbatim evidence, omit the note. \ +If content is ephemeral or not useful long-term, return an empty notes array." + .to_string(), + ), + prompt_user_template: Some( + "Return JSON matching this exact schema:\n{SCHEMA}\nConstraints:\n- MAX_NOTES = {MAX_NOTES}\n- MAX_NOTE_CHARS = {MAX_NOTE_CHARS}\nHere are the messages as JSON:\n{MESSAGES_JSON}" + .to_string(), + ), + model: None, + temperature: None, + timeout_ms: None, + } +} + +fn default_schema_version() -> i32 { + 1 +} + +fn builtin_profile_schema() -> Value { + serde_json::json!({ + "notes": [ + { + "type": "preference|constraint|decision|profile|fact|plan", + "key": "string|null", + "text": "English-only sentence <= MAX_NOTE_CHARS", + "structured": { + "summary": "string|null", + "facts": "string[]|null", + "concepts": "string[]|null", + "entities": [ + { + "canonical": "string|null", + "kind": "string|null", + "aliases": "string[]|null" + } + ], + "relations": [ + { + "subject": { + "canonical": "string|null", + "kind": "string|null", + "aliases": "string[]|null" + }, + "predicate": "string", + "object": { + "entity": { + "canonical": "string|null", + "kind": "string|null", + "aliases": "string[]|null" + }, + "value": "string|null" + }, + "valid_from": "string|null", + "valid_to": "string|null" + } + ] + }, + "importance": 0.0, + "confidence": 0.0, + "ttl_days": "number|null", + "scope_suggestion": "agent_private|project_shared|org_shared|null", + "evidence": [ + { "message_index": "number", "quote": "string" } + ], + "reason": "string" + } + ] + }) +} diff --git a/packages/elf-service/src/ingestion_profiles/resolution.rs b/packages/elf-service/src/ingestion_profiles/resolution.rs new file mode 100644 index 00000000..606a8e00 --- /dev/null +++ b/packages/elf-service/src/ingestion_profiles/resolution.rs @@ -0,0 +1,56 @@ +use sqlx::PgPool; + +use crate::{ + Error, Result, + ingestion_profiles::{ + profile, + storage::{self}, + types::{IngestionProfileRef, IngestionProfileSelector, ResolvedIngestionProfile}, + }, +}; + +pub(crate) async fn resolve_add_event_profile( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + selector: Option<&IngestionProfileSelector>, +) -> Result { + storage::seed_default_profile(pool, tenant_id, project_id).await?; + + let selector = if let Some(selector) = selector { + selector.clone() + } else { + storage::select_default_selector(pool, tenant_id, project_id).await? + }; + let row = storage::select_profile(pool, tenant_id, project_id, &selector).await?; + let parsed = profile::parse_profile(row.profile)?; + let merged = parsed.with_defaults(); + + if merged.schema_version != 1 { + return Err(Error::InvalidRequest { + message: "Unsupported ingestion profile schema version.".to_string(), + }); + } + + let prompt_schema = merged.prompt_schema.ok_or_else(|| Error::InvalidRequest { + message: "Missing prompt schema in ingestion profile.".to_string(), + })?; + let prompt_system_template = + merged.prompt_system_template.ok_or_else(|| Error::InvalidRequest { + message: "Missing system prompt template in ingestion profile.".to_string(), + })?; + let prompt_user_template = + merged.prompt_user_template.ok_or_else(|| Error::InvalidRequest { + message: "Missing user prompt template in ingestion profile.".to_string(), + })?; + + Ok(ResolvedIngestionProfile { + profile_ref: IngestionProfileRef { id: row.profile_id, version: row.version }, + prompt_schema, + prompt_system: prompt_system_template, + prompt_user_template, + model: merged.model, + temperature: merged.temperature, + timeout_ms: merged.timeout_ms, + }) +} diff --git a/packages/elf-service/src/ingestion_profiles/storage.rs b/packages/elf-service/src/ingestion_profiles/storage.rs new file mode 100644 index 00000000..c2c4edfc --- /dev/null +++ b/packages/elf-service/src/ingestion_profiles/storage.rs @@ -0,0 +1,361 @@ +use serde_json::Value; +use sqlx::{FromRow, PgPool}; +use time::OffsetDateTime; + +use crate::{ + Error, Result, + ingestion_profiles::{ + ADD_EVENT_PIPELINE, DEFAULT_PROFILE_ID, DEFAULT_PROFILE_VERSION, profile, + types::IngestionProfileSelector, + }, +}; + +#[derive(FromRow)] +pub(super) struct ProfileRow { + pub(super) profile_id: String, + pub(super) version: i32, + pub(super) profile: Value, +} + +#[derive(FromRow)] +pub(super) struct ProfileMetadataRow { + pub(super) profile_id: String, + pub(super) version: i32, + pub(super) profile: Value, + pub(super) created_at: OffsetDateTime, + pub(super) created_by: String, +} + +#[derive(FromRow)] +pub(super) struct ProfileSummaryRow { + pub(super) profile_id: String, + pub(super) version: i32, + pub(super) created_at: OffsetDateTime, + pub(super) created_by: String, +} + +#[derive(FromRow)] +pub(super) struct ProfileDefaultRow { + pub(super) profile_id: String, + pub(super) version: Option, + pub(super) updated_at: OffsetDateTime, +} + +pub(super) async fn next_profile_version( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + profile_id: &str, +) -> Result { + let version = sqlx::query_scalar::<_, i32>( + "SELECT COALESCE(MAX(version), 0) + 1 FROM memory_ingestion_profiles WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(profile_id) + .fetch_one(pool) + .await?; + + Ok(version) +} + +pub(super) async fn insert_profile_metadata( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + profile_id: &str, + version: i32, + profile: Value, + created_by: &str, +) -> Result> { + let row = sqlx::query_as::<_, ProfileMetadataRow>( + "\ +INSERT INTO memory_ingestion_profiles ( + tenant_id, + project_id, + pipeline, + profile_id, + version, + profile, + created_by +) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7) +ON CONFLICT DO NOTHING +RETURNING profile_id, version, profile, created_at, created_by", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(profile_id) + .bind(version) + .bind(profile) + .bind(created_by) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub(super) async fn list_latest_profile_summaries( + pool: &PgPool, + tenant_id: &str, + project_id: &str, +) -> Result> { + let rows = sqlx::query_as::<_, ProfileSummaryRow>( + "\ +SELECT DISTINCT ON (profile_id) + profile_id, version, created_at, created_by +FROM memory_ingestion_profiles +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 +ORDER BY profile_id, version DESC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub(super) async fn select_profile_metadata( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + selector: &IngestionProfileSelector, +) -> Result { + let row = if let Some(version) = selector.version { + sqlx::query_as::<_, ProfileMetadataRow>( + "\ +SELECT profile_id, version, profile, created_at, created_by +FROM memory_ingestion_profiles +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 AND version=$5", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(selector.id.as_str()) + .bind(version) + .fetch_optional(pool) + .await? + } else { + sqlx::query_as::<_, ProfileMetadataRow>( + "\ +SELECT profile_id, version, profile, created_at, created_by +FROM memory_ingestion_profiles +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 +ORDER BY version DESC +LIMIT 1", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(selector.id.as_str()) + .fetch_optional(pool) + .await? + }; + + row.ok_or_else(|| Error::InvalidRequest { + message: format!( + "Ingestion profile '{}' not found for tenant '{}' project '{}' pipeline '{}'.", + selector.id, tenant_id, project_id, ADD_EVENT_PIPELINE, + ), + }) +} + +pub(super) async fn list_profile_version_summaries( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + profile_id: &str, +) -> Result> { + let rows = sqlx::query_as::<_, ProfileSummaryRow>( + "\ +SELECT profile_id, version, created_at, created_by +FROM memory_ingestion_profiles +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 +ORDER BY version DESC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(profile_id) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub(super) async fn select_default_row( + pool: &PgPool, + tenant_id: &str, + project_id: &str, +) -> Result> { + let row = sqlx::query_as::<_, ProfileDefaultRow>( + "\ +SELECT profile_id, version, updated_at +FROM memory_ingestion_profile_defaults +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub(super) async fn upsert_default_row( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + profile_id: String, + version: i32, +) -> Result { + let row = sqlx::query_as::<_, ProfileDefaultRow>( + "\ +INSERT INTO memory_ingestion_profile_defaults ( + tenant_id, + project_id, + pipeline, + profile_id, + version +) VALUES ($1,$2,$3,$4,$5) +ON CONFLICT (tenant_id, project_id, pipeline) DO UPDATE +SET profile_id = EXCLUDED.profile_id, + version = EXCLUDED.version, + updated_at = now() +RETURNING profile_id, version, updated_at", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(profile_id) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(row) +} + +pub(super) async fn select_profile( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + selector: &IngestionProfileSelector, +) -> Result { + let row = if let Some(version) = selector.version { + sqlx::query_as::<_, ProfileRow>( + "\ +SELECT profile_id, version, profile +FROM memory_ingestion_profiles +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 AND version=$5", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(selector.id.as_str()) + .bind(version) + .fetch_optional(pool) + .await? + } else { + sqlx::query_as::<_, ProfileRow>( + "\ +SELECT profile_id, version, profile +FROM memory_ingestion_profiles +WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3 AND profile_id=$4 +ORDER BY version DESC +LIMIT 1", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(selector.id.as_str()) + .fetch_optional(pool) + .await? + }; + + row.ok_or_else(|| Error::InvalidRequest { + message: format!( + "Ingestion profile '{}' not found for tenant '{}' project '{}' pipeline '{}'.", + selector.id, tenant_id, project_id, ADD_EVENT_PIPELINE + ), + }) +} + +pub(super) async fn select_default_selector( + pool: &PgPool, + tenant_id: &str, + project_id: &str, +) -> Result { + let row = sqlx::query_as::<_, (String, Option)>( + "SELECT profile_id, version FROM memory_ingestion_profile_defaults WHERE tenant_id=$1 AND project_id=$2 AND pipeline=$3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .fetch_optional(pool) + .await?; + let row = match row { + Some((profile_id, version)) => IngestionProfileSelector { id: profile_id, version }, + None => IngestionProfileSelector { + id: DEFAULT_PROFILE_ID.to_string(), + version: Some(DEFAULT_PROFILE_VERSION), + }, + }; + + Ok(row) +} + +pub(super) async fn seed_default_profile( + pool: &PgPool, + tenant_id: &str, + project_id: &str, +) -> Result<()> { + let profile = + serde_json::to_value(profile::builtin_profile_v1()).map_err(|_| Error::InvalidRequest { + message: "Failed to serialize default ingestion profile.".to_string(), + })?; + + sqlx::query( + "\ +INSERT INTO memory_ingestion_profiles ( + tenant_id, + project_id, + pipeline, + profile_id, + version, + profile +) VALUES ($1,$2,$3,$4,$5,$6::jsonb) +ON CONFLICT DO NOTHING", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(DEFAULT_PROFILE_ID) + .bind(DEFAULT_PROFILE_VERSION) + .bind(profile) + .execute(pool) + .await?; + sqlx::query( + "\ +INSERT INTO memory_ingestion_profile_defaults ( + tenant_id, + project_id, + pipeline, + profile_id, + version +) VALUES ($1,$2,$3,$4,$5) +ON CONFLICT DO NOTHING", + ) + .bind(tenant_id) + .bind(project_id) + .bind(ADD_EVENT_PIPELINE) + .bind(DEFAULT_PROFILE_ID) + .bind(DEFAULT_PROFILE_VERSION) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/packages/elf-service/src/ingestion_profiles/types.rs b/packages/elf-service/src/ingestion_profiles/types.rs new file mode 100644 index 00000000..f9ef3f9b --- /dev/null +++ b/packages/elf-service/src/ingestion_profiles/types.rs @@ -0,0 +1,200 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; + +use crate::{Error, Result}; +use elf_config::LlmProviderConfig; + +/// Selector for an ingestion profile and optional version. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct IngestionProfileSelector { + /// Profile identifier. + pub id: String, + /// Optional explicit version. + pub version: Option, +} + +/// Resolved ingestion-profile reference. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct IngestionProfileRef { + /// Profile identifier. + pub id: String, + /// Resolved version. + pub version: i32, +} + +/// Request payload for creating an ingestion profile version. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminIngestionProfileCreateRequest { + /// Tenant that owns the profile. + pub tenant_id: String, + /// Project that owns the profile. + pub project_id: String, + /// Profile identifier. + pub profile_id: String, + /// Optional explicit version number. + pub version: Option, + /// JSON profile payload. + pub profile: Value, + /// Actor creating the profile version. + pub created_by: String, +} + +/// Request payload for listing ingestion profiles. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminIngestionProfileListRequest { + /// Tenant that owns the profiles. + pub tenant_id: String, + /// Project that owns the profiles. + pub project_id: String, +} + +/// Request payload for fetching one ingestion profile. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminIngestionProfileGetRequest { + /// Tenant that owns the profile. + pub tenant_id: String, + /// Project that owns the profile. + pub project_id: String, + /// Profile identifier. + pub profile_id: String, + /// Optional explicit version. + pub version: Option, +} + +/// Request payload for listing all versions of one ingestion profile. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminIngestionProfileVersionsListRequest { + /// Tenant that owns the profile. + pub tenant_id: String, + /// Project that owns the profile. + pub project_id: String, + /// Profile identifier. + pub profile_id: String, +} + +/// Request payload for reading the default ingestion profile pointer. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminIngestionProfileDefaultGetRequest { + /// Tenant that owns the default pointer. + pub tenant_id: String, + /// Project that owns the default pointer. + pub project_id: String, +} + +/// Request payload for updating the default ingestion profile pointer. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminIngestionProfileDefaultSetRequest { + /// Tenant that owns the default pointer. + pub tenant_id: String, + /// Project that owns the default pointer. + pub project_id: String, + /// Profile identifier to make default. + pub profile_id: String, + /// Optional explicit version to make default. + pub version: Option, +} + +/// Response payload for one ingestion profile version. +#[derive(Clone, Debug, Serialize)] +pub struct AdminIngestionProfileResponse { + /// Profile identifier. + pub profile_id: String, + /// Profile version. + pub version: i32, + /// JSON profile payload. + pub profile: Value, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Actor that created the version. + pub created_by: String, +} + +/// Summary row for an ingestion profile version. +#[derive(Clone, Debug, Serialize)] +pub struct AdminIngestionProfileSummary { + /// Profile identifier. + pub profile_id: String, + /// Profile version. + pub version: i32, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Actor that created the version. + pub created_by: String, +} + +/// Response payload for listing ingestion profiles. +#[derive(Clone, Debug, Serialize)] +pub struct AdminIngestionProfilesListResponse { + /// Returned profile summaries. + pub profiles: Vec, +} + +/// Response payload for listing versions of one ingestion profile. +#[derive(Clone, Debug, Serialize)] +pub struct AdminIngestionProfileVersionsListResponse { + /// Returned profile-version summaries. + pub profiles: Vec, +} + +/// Response payload for reading the default ingestion profile pointer. +#[derive(Clone, Debug, Serialize)] +pub struct AdminIngestionProfileDefaultResponse { + /// Default profile identifier. + pub profile_id: String, + /// Default profile version, when pinned. + pub version: Option, + #[serde(with = "crate::time_serde")] + /// Last update timestamp for the default pointer. + pub updated_at: OffsetDateTime, +} + +#[derive(Clone, Debug)] +pub(crate) struct ResolvedIngestionProfile { + pub profile_ref: IngestionProfileRef, + pub prompt_schema: Value, + pub prompt_system: String, + pub prompt_user_template: String, + pub model: Option, + pub temperature: Option, + pub timeout_ms: Option, +} +impl ResolvedIngestionProfile { + pub(crate) fn build_extractor_messages( + &self, + messages_json: &str, + max_notes: u32, + max_note_chars: u32, + ) -> Result> { + let schema = + serde_json::to_string(&self.prompt_schema).map_err(|_| Error::InvalidRequest { + message: "Failed to serialize ingestion profile schema.".to_string(), + })?; + let user_prompt = self + .prompt_user_template + .replace("{SCHEMA}", &schema) + .replace("{MAX_NOTES}", max_notes.to_string().as_str()) + .replace("{MAX_NOTE_CHARS}", max_note_chars.to_string().as_str()) + .replace("{MESSAGES_JSON}", messages_json); + + Ok(vec![ + serde_json::json!({ "role": "system", "content": self.prompt_system.clone() }), + serde_json::json!({ "role": "user", "content": user_prompt }), + ]) + } + + pub(crate) fn resolved_llm_config(&self, base: &LlmProviderConfig) -> LlmProviderConfig { + LlmProviderConfig { + provider_id: base.provider_id.clone(), + api_base: base.api_base.clone(), + api_key: base.api_key.clone(), + path: base.path.clone(), + model: self.model.clone().unwrap_or_else(|| base.model.clone()), + temperature: self.temperature.unwrap_or(base.temperature), + timeout_ms: self.timeout_ms.unwrap_or(base.timeout_ms), + default_headers: base.default_headers.clone(), + } + } +} diff --git a/packages/elf-service/src/knowledge.rs b/packages/elf-service/src/knowledge.rs index 75e35334..944a975e 100644 --- a/packages/elf-service/src/knowledge.rs +++ b/packages/elf-service/src/knowledge.rs @@ -1,8 +1,34 @@ //! Deterministic derived knowledge page rebuild and readback service APIs. +mod api; +mod lint; +mod persistence; +mod read; +mod rebuild; +mod resolve; +mod responses; +mod sections; +mod sources; +mod support; +mod types; +mod watch; +mod watch_service; + +pub use api::{ + KnowledgeDeltaMemoryCandidate, KnowledgePageChangedSource, KnowledgePageGetRequest, + KnowledgePageLintFindingResponse, KnowledgePageLintRequest, KnowledgePageLintResponse, + KnowledgePageLintSummary, KnowledgePageProposalRunSummary, KnowledgePageRebuildOutput, + KnowledgePageRebuildRequest, KnowledgePageRebuildResponse, KnowledgePageResponse, + KnowledgePageSearchItem, KnowledgePageSearchRequest, KnowledgePageSearchResponse, + KnowledgePageSectionRebuildState, KnowledgePageSectionResponse, + KnowledgePageSectionSourceBacklink, KnowledgePageSourceRefResponse, KnowledgePageSummary, + KnowledgePageWatchRebuildItem, KnowledgePageWatchRebuildRequest, + KnowledgePageWatchRebuildResponse, KnowledgePageWatchRebuildSummary, KnowledgePagesListRequest, + KnowledgePagesListResponse, +}; + use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use serde::{Deserialize, Serialize}; use serde_json::{self, Map, Number, Value}; use sqlx::{Postgres, Transaction}; use time::OffsetDateTime; @@ -35,4413 +61,42 @@ use elf_storage::{ KnowledgePageSectionInsert, KnowledgePageSourceRefInsert, KnowledgePageUpsert, KnowledgeProposalSource, KnowledgeRelationSource, KnowledgeRelationSourcesFetch, }, - models::{ - KnowledgePage, KnowledgePageLintFinding, KnowledgePageSection, KnowledgePageSourceRef, - }, + models::{KnowledgePage, KnowledgePageSection, KnowledgePageSourceRef}, +}; +use persistence::{insert_lint_finding, replace_page_children}; +use responses::{knowledge_page_search_item, section_response}; +use sections::{build_sections, lint_page_sections, lint_unsupported_sections}; +use sources::{ + cloned_source_refs, recallable_source_refs, source_refs_by_section, source_row_read_allowed, + source_snapshots, }; +#[cfg(test)] use support::hash_text; +use support::{ + bounded_limit, citation_count, citations_value, coverage_complete, current_key, + doc_chunk_source_snapshot, doc_source_snapshot, empty_object, event_source_snapshot, + generated_title, hash_json, low_source_coverage_finding, missing_source_finding, + note_source_snapshot, page_content_hash, previous_version_diff_from_metadata, + previous_version_diff_value, proposal_source_snapshot, rebuild_metadata, + rebuild_metadata_with_previous_version_diff, relation_source_snapshot, + repair_guidance_for_finding_type, sanitize_proposal_snapshot, section_hash_payload, + snippet_for_query, sorted_unique, source_changed, source_coverage_value, source_indexes, + source_key, source_snapshot_value, source_sort_key, stale_source_finding, truncate_chars, + validate_context, validate_non_empty, validate_object, version_identity_value, + with_repair_guidance, +}; +use types::{DraftSection, LintDraft, SourceIds, SourceSnapshot, WatchRebuildOutcome}; +use watch::{ + blocked_watch_rebuild, candidate_proposal_input, candidate_run_input_refs, + changed_source_arrays, default_generate_memory_candidates, knowledge_delta_source_snapshot, + normalized_changed_sources, proposal_run_summary, rebuild_request_from_page, + successful_watch_rebuild, watch_operator_summary, watch_rebuild_summary, +}; +#[cfg(test)] use watch::{memory_candidates_for_page, rebuild_outputs}; const DEFAULT_LIST_LIMIT: i64 = 50; const MAX_LIST_LIMIT: i64 = 200; const SEARCH_SNIPPET_CHARS: usize = 280; const PREVIOUS_VERSION_DIFF_KEY: &str = "previous_version_diff"; - -/// Request to rebuild one derived knowledge page from explicit source ids. -#[derive(Clone, Debug, Deserialize)] -pub struct KnowledgePageRebuildRequest { - /// Tenant that owns the page and source records. - pub tenant_id: String, - /// Project that owns the page and source records. - pub project_id: String, - /// Agent requesting the rebuild. - pub agent_id: String, - /// Page kind. - pub page_kind: KnowledgePageKind, - /// Stable page key within the tenant/project/kind namespace. - pub page_key: String, - /// Optional display title; a deterministic title is generated when omitted. - pub title: Option, - #[serde(default)] - /// Source Library documents to compile into the page. - pub doc_ids: Vec, - #[serde(default)] - /// Source Library document chunks or spans to compile into the page. - pub doc_chunk_ids: Vec, - #[serde(default)] - /// Memory note sources to compile into the page. - pub note_ids: Vec, - #[serde(default)] - /// Durable add_event audit source ids to compile into the page. - pub event_ids: Vec, - #[serde(default)] - /// Graph relation fact ids to compile into the page. - pub relation_ids: Vec, - #[serde(default)] - /// Applied consolidation proposal ids to compile into the page. - pub proposal_ids: Vec, - #[serde(default = "empty_object")] - /// Provider metadata for nondeterministic or future LLM-derived rebuilds. - pub provider_metadata: Value, -} - -/// Response returned after rebuilding a derived knowledge page. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageRebuildResponse { - /// Rebuilt page with sections, source refs, and lint findings. - pub page: KnowledgePageResponse, -} - -/// Request to get one derived knowledge page. -#[derive(Clone, Debug, Deserialize)] -pub struct KnowledgePageGetRequest { - /// Tenant that owns the page. - pub tenant_id: String, - /// Project that owns the page. - pub project_id: String, - /// Page identifier. - pub page_id: Uuid, -} - -/// Request to list derived knowledge pages. -#[derive(Clone, Debug, Deserialize)] -pub struct KnowledgePagesListRequest { - /// Tenant that owns the pages. - pub tenant_id: String, - /// Project that owns the pages. - pub project_id: String, - /// Optional page-kind filter. - pub page_kind: Option, - /// Maximum number of pages to return. - pub limit: Option, -} - -/// Response returned by derived knowledge page listing. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePagesListResponse { - /// Returned pages. - pub pages: Vec, -} - -/// Request to lint one derived knowledge page against current source snapshots. -#[derive(Clone, Debug, Deserialize)] -pub struct KnowledgePageLintRequest { - /// Tenant that owns the page. - pub tenant_id: String, - /// Project that owns the page. - pub project_id: String, - /// Page identifier. - pub page_id: Uuid, -} - -/// Request to search derived knowledge page sections. -#[derive(Clone, Debug, Deserialize)] -pub struct KnowledgePageSearchRequest { - /// Tenant that owns the pages. - pub tenant_id: String, - /// Project that owns the pages. - pub project_id: String, - /// Agent requesting the page search. - pub agent_id: String, - /// Read profile controlling source visibility. - pub read_profile: String, - /// English-only query for page title, key, heading, or section content. - pub query: String, - /// Optional page-kind filter. - pub page_kind: Option, - /// Maximum number of section snippets to return. - pub limit: Option, -} - -/// Request to rebuild pages affected by changed authoritative sources. -#[derive(Clone, Debug, Deserialize)] -pub struct KnowledgePageWatchRebuildRequest { - /// Tenant that owns the pages and changed sources. - pub tenant_id: String, - /// Project that owns the pages and changed sources. - pub project_id: String, - /// Agent requesting the watch/rebuild operation. - pub agent_id: String, - /// Changed source references observed by a watcher or operator. - pub changed_sources: Vec, - /// Optional page-kind filter for the affected-page lookup. - pub page_kind: Option, - /// Maximum number of affected pages to rebuild. - pub limit: Option, - #[serde(default = "default_generate_memory_candidates")] - /// Whether changed knowledge deltas should queue reviewable memory proposals. - pub generate_memory_candidates: bool, -} - -/// Changed authoritative source reference for the watch/rebuild loop. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct KnowledgePageChangedSource { - /// Changed source kind. - pub source_kind: KnowledgeSourceKind, - /// Changed source identifier. - pub source_id: Uuid, -} - -/// Response returned after linting one knowledge page. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageLintResponse { - /// Page identifier. - pub page_id: Uuid, - /// Current lint findings. - pub findings: Vec, -} - -/// Response returned by derived knowledge page section search. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSearchResponse { - /// Matching derived page snippets. - pub items: Vec, -} - -/// Response returned after rebuilding pages affected by changed sources. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageWatchRebuildResponse { - /// Versioned response schema. - pub schema: String, - /// Operator-readable aggregate summary. - pub summary: KnowledgePageWatchRebuildSummary, - /// Per-page rebuild results. - pub pages: Vec, - /// Reviewable memory candidates derived from knowledge deltas. - pub memory_candidates: Vec, - /// Queued consolidation run, when memory candidates were generated. - pub proposal_run: Option, - /// One-line operator summary messages. - pub operator_summary: Vec, -} - -/// Aggregate watch/rebuild outcome counters. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageWatchRebuildSummary { - /// Changed source count after de-duplication. - pub changed_source_count: usize, - /// Knowledge pages that cited one of the changed sources. - pub affected_page_count: usize, - /// Pages rebuilt with changed derived output. - pub changed_page_count: usize, - /// Pages rebuilt with unchanged derived output. - pub unchanged_page_count: usize, - /// Pages that had stale lint findings before rebuild. - pub stale_page_count: usize, - /// Pages that could not be rebuilt. - pub blocked_page_count: usize, - /// Memory candidates generated for review. - pub memory_candidate_count: usize, -} - -/// Per-page changed-source rebuild result. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageWatchRebuildItem { - /// Knowledge page identifier. - pub page_id: Uuid, - /// Page kind. - pub page_kind: String, - /// Stable page key. - pub page_key: String, - /// Page title. - pub title: String, - /// Page rebuild state: changed, unchanged, stale, or blocked. - pub rebuild_state: String, - /// Per-section rebuild states. - pub sections: Vec, - /// Classified rebuild/lint outputs. - pub outputs: Vec, - /// Rebuilt page readback, omitted when blocked. - pub rebuilt_page: Option, - /// Blocking error text, when rebuild failed. - pub blocked_reason: Option, - /// Previous-version diff metadata, when available. - pub previous_version_diff: Option, - /// Operator-readable page summary. - pub operator_summary: String, -} - -/// Per-section rebuild state for changed-source rebuild output. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSectionRebuildState { - /// Stable section key. - pub section_key: String, - /// Section heading. - pub heading: String, - /// Section state: changed, unchanged, stale, or blocked. - pub state: String, - /// Output types attached to the section. - pub output_types: Vec, - /// Lint finding types attached to the section before rebuild. - pub lint_finding_types: Vec, -} - -/// Classified output emitted by the watch/rebuild loop. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageRebuildOutput { - /// Output type, such as stale_section, changed_claim, missing_citation, conflict, - /// changed_source, or blocked. - pub output_type: String, - /// Severity for operator triage. - pub severity: String, - /// Associated section key, when section-scoped. - pub section_key: Option, - /// Associated source kind, when source-scoped. - pub source_kind: Option, - /// Associated source id, when source-scoped. - pub source_id: Option, - /// Human-readable output message. - pub message: String, - /// Structured reason and evidence details. - pub details: Value, -} - -/// Reviewable memory candidate produced from a knowledge delta. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgeDeltaMemoryCandidate { - /// Candidate reason, such as changed_claim or conflict. - pub reason: String, - /// Knowledge page identifier. - pub page_id: Uuid, - /// Section identifier that produced the candidate. - pub section_id: Uuid, - /// Stable section key. - pub section_key: String, - /// Source refs copied into the reviewable proposal. - pub source_refs: Vec, - /// Source snapshot summary for reviewer inspection. - pub source_snapshot: Value, - /// Reviewable proposal diff. - pub diff: ConsolidationProposalDiff, - /// Proposed memory note payload. - pub proposed_payload: Value, -} - -/// Queued reviewable proposal run produced by changed-source rebuild. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageProposalRunSummary { - /// Consolidation run identifier. - pub run_id: Uuid, - /// Queued worker job identifier. - pub job_id: Uuid, - /// Number of memory candidate proposals queued in the run payload. - pub proposal_count: usize, - /// Review surface for the queued candidates. - pub review_surface: String, -} - -/// Summary DTO for one derived knowledge page. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSummary { - /// Page identifier. - pub page_id: Uuid, - /// Tenant that owns the page. - pub tenant_id: String, - /// Project that owns the page. - pub project_id: String, - /// Page kind. - pub page_kind: String, - /// Stable page key. - pub page_key: String, - /// Page title. - pub title: String, - /// Versioned page contract schema. - pub contract_schema: String, - /// Page lifecycle status. - pub status: String, - /// Canonical source snapshot hash. - pub rebuild_source_hash: String, - /// Canonical page content hash. - pub content_hash: String, - /// Source coverage metadata. - pub source_coverage: Value, - /// Rebuild metadata. - pub rebuild_metadata: Value, - /// Previous-version diff metadata, when present. - pub previous_version_diff: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, - /// Last rebuild timestamp. - pub rebuilt_at: OffsetDateTime, -} -impl From for KnowledgePageSummary { - fn from(page: KnowledgePage) -> Self { - Self { - page_id: page.page_id, - tenant_id: page.tenant_id, - project_id: page.project_id, - page_kind: page.page_kind, - page_key: page.page_key, - title: page.title, - contract_schema: page.contract_schema, - status: page.status, - rebuild_source_hash: page.rebuild_source_hash, - content_hash: page.content_hash, - source_coverage: page.source_coverage, - previous_version_diff: previous_version_diff_from_metadata(&page.rebuild_metadata), - rebuild_metadata: page.rebuild_metadata, - created_at: page.created_at, - updated_at: page.updated_at, - rebuilt_at: page.rebuilt_at, - } - } -} - -/// Full readback DTO for one derived knowledge page. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageResponse { - /// Page summary. - pub page: KnowledgePageSummary, - /// Page sections. - pub sections: Vec, - /// Normalized source refs. - pub source_refs: Vec, - /// Lint findings. - pub lint_findings: Vec, -} - -/// Readback DTO for one page section. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSectionResponse { - /// Section identifier. - pub section_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Stable section key. - pub section_key: String, - /// Section heading. - pub heading: String, - /// Section role. - pub role: String, - /// Section content. - pub content: String, - /// Display order. - pub ordinal: i32, - /// Serialized citation array. - pub citations: Value, - /// Reason this section is intentionally unsupported, when present. - pub unsupported_reason: Option, - /// Count of section-local citations. - pub citation_count: usize, - /// Count of normalized source refs attached to this section. - pub source_ref_count: usize, - /// True when the section has both citations and normalized source backlinks. - pub coverage_complete: bool, - /// Section-local normalized source backlinks. - pub source_backlinks: Vec, - /// Section content hash. - pub content_hash: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} -impl From for KnowledgePageSectionResponse { - fn from(section: KnowledgePageSection) -> Self { - Self { - section_id: section.section_id, - page_id: section.page_id, - section_key: section.section_key, - heading: section.heading, - role: section.role, - content: section.content, - ordinal: section.ordinal, - citations: section.citations, - unsupported_reason: section.unsupported_reason, - citation_count: 0, - source_ref_count: 0, - coverage_complete: false, - source_backlinks: Vec::new(), - content_hash: section.content_hash, - created_at: section.created_at, - updated_at: section.updated_at, - } - } -} - -/// Section-local source backlink used by page readback and viewer provenance. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSectionSourceBacklink { - /// Source kind. - pub source_kind: String, - /// Authoritative source identifier. - pub source_id: Uuid, - /// Captured source status. - pub source_status: Option, - /// Captured source update timestamp. - pub source_updated_at: Option, - /// Captured source content hash. - pub source_content_hash: Option, -} -impl From<&KnowledgePageSourceRef> for KnowledgePageSectionSourceBacklink { - fn from(source_ref: &KnowledgePageSourceRef) -> Self { - Self { - source_kind: source_ref.source_kind.clone(), - source_id: source_ref.source_id, - source_status: source_ref.source_status.clone(), - source_updated_at: source_ref.source_updated_at, - source_content_hash: source_ref.source_content_hash.clone(), - } - } -} - -/// Readback DTO for one normalized source reference. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSourceRefResponse { - /// Source-reference row identifier. - pub ref_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Citing section, when section-scoped. - pub section_id: Option, - /// Source kind. - pub source_kind: String, - /// Authoritative source identifier. - pub source_id: Uuid, - /// Captured source status. - pub source_status: Option, - /// Captured source update timestamp. - pub source_updated_at: Option, - /// Captured source content hash. - pub source_content_hash: Option, - /// Captured source snapshot. - pub source_snapshot: Value, - /// Citation-local metadata. - pub citation_metadata: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} -impl From for KnowledgePageSourceRefResponse { - fn from(source_ref: KnowledgePageSourceRef) -> Self { - Self { - ref_id: source_ref.ref_id, - page_id: source_ref.page_id, - section_id: source_ref.section_id, - source_kind: source_ref.source_kind, - source_id: source_ref.source_id, - source_status: source_ref.source_status, - source_updated_at: source_ref.source_updated_at, - source_content_hash: source_ref.source_content_hash, - source_snapshot: source_ref.source_snapshot, - citation_metadata: source_ref.citation_metadata, - created_at: source_ref.created_at, - } - } -} - -/// Readback DTO for one knowledge page lint finding. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageLintFindingResponse { - /// Lint finding identifier. - pub finding_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Associated section, when available. - pub section_id: Option, - /// Finding type. - pub finding_type: String, - /// Finding severity. - pub severity: String, - /// Source kind associated with the finding, when available. - pub source_kind: Option, - /// Source identifier associated with the finding, when available. - pub source_id: Option, - /// Human-readable finding message. - pub message: String, - /// Structured finding details. - pub details: Value, - /// Operator guidance for repair or rebuild. - pub repair_guidance: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} -impl From for KnowledgePageLintFindingResponse { - fn from(finding: KnowledgePageLintFinding) -> Self { - let repair_guidance = - repair_guidance_for_finding_type(finding.finding_type.as_str()).to_string(); - - Self { - finding_id: finding.finding_id, - page_id: finding.page_id, - section_id: finding.section_id, - finding_type: finding.finding_type, - severity: finding.severity, - source_kind: finding.source_kind, - source_id: finding.source_id, - message: finding.message, - repair_guidance, - details: finding.details, - created_at: finding.created_at, - } - } -} - -/// Search result for one derived knowledge page section. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageSearchItem { - /// Result type discriminator for clients that mix pages with notes. - pub result_kind: String, - /// Derived page identifier. - pub page_id: Uuid, - /// Page kind. - pub page_kind: String, - /// Stable page key. - pub page_key: String, - /// Page title. - pub title: String, - /// Page lifecycle status. - pub status: String, - /// Section identifier. - pub section_id: Uuid, - /// Stable section key. - pub section_key: String, - /// Section heading. - pub heading: String, - /// Section role. - pub role: String, - /// Bounded matching section snippet. - pub snippet: String, - /// Section citations for visible provenance. - pub citations: Value, - /// Count of section-local citations. - pub citation_count: usize, - /// Count of normalized source refs attached to this section. - pub source_ref_count: usize, - /// Section-local source refs for backlink readback. - pub source_refs: Vec, - /// Page-level source coverage metadata. - pub source_coverage: Value, - /// Page-level rebuild metadata. - pub rebuild_metadata: Value, - /// Previous-version diff metadata, when present. - pub previous_version_diff: Option, - /// Lint summary for distinguishing clean, stale, and unsupported pages. - pub lint_summary: KnowledgePageLintSummary, - /// Trust state discriminator for viewer/search clients. - pub trust_state: String, - /// Explicit notice that the result is derived, not authoritative source truth. - pub derived_notice: String, - /// Repair or rebuild guidance when lint or coverage indicates risk. - pub repair_guidance: Option, - /// Page update timestamp. - pub updated_at: OffsetDateTime, - /// Page rebuild timestamp. - pub rebuilt_at: OffsetDateTime, -} - -/// Aggregate lint counts for page search results. -#[derive(Clone, Debug, Serialize)] -pub struct KnowledgePageLintSummary { - /// Error finding count. - pub error_count: i64, - /// Warning finding count. - pub warning_count: i64, - /// Info finding count. - pub info_count: i64, - /// True when at least one error finding exists. - pub has_errors: bool, - /// True when at least one warning finding exists. - pub has_warnings: bool, -} - -#[derive(Clone, Debug)] -struct SourceSnapshot { - kind: KnowledgeSourceKind, - id: Uuid, - status: Option, - updated_at: Option, - content_hash: Option, - snapshot: Value, - citation_metadata: Value, - line: String, -} - -#[derive(Clone, Debug)] -struct DraftSection { - section_id: Uuid, - section_key: String, - heading: String, - role: String, - content: String, - ordinal: i32, - source_indexes: Vec, - unsupported_reason: Option, - content_hash: String, - citations: Value, -} - -#[derive(Clone, Debug)] -struct LintDraft { - section_id: Option, - finding_type: String, - severity: String, - source_kind: Option, - source_id: Option, - message: String, - details: Value, -} - -#[derive(Clone, Debug)] -struct SourceIds { - doc_ids: Vec, - doc_chunk_ids: Vec, - note_ids: Vec, - event_ids: Vec, - relation_ids: Vec, - proposal_ids: Vec, -} -impl SourceIds { - fn from_request(req: &KnowledgePageRebuildRequest) -> Result { - let ids = Self { - doc_ids: sorted_unique(&req.doc_ids), - doc_chunk_ids: sorted_unique(&req.doc_chunk_ids), - note_ids: sorted_unique(&req.note_ids), - event_ids: sorted_unique(&req.event_ids), - relation_ids: sorted_unique(&req.relation_ids), - proposal_ids: sorted_unique(&req.proposal_ids), - }; - - ids.validate_non_empty()?; - - Ok(ids) - } - - fn from_source_refs(source_refs: &[KnowledgePageSourceRef]) -> Result { - let mut doc_ids = Vec::new(); - let mut doc_chunk_ids = Vec::new(); - let mut note_ids = Vec::new(); - let mut event_ids = Vec::new(); - let mut relation_ids = Vec::new(); - let mut proposal_ids = Vec::new(); - - for source_ref in source_refs { - match KnowledgeSourceKind::parse(source_ref.source_kind.as_str()) { - Some(KnowledgeSourceKind::Doc) => doc_ids.push(source_ref.source_id), - Some(KnowledgeSourceKind::DocChunk) => doc_chunk_ids.push(source_ref.source_id), - Some(KnowledgeSourceKind::Note) => note_ids.push(source_ref.source_id), - Some(KnowledgeSourceKind::Event) => event_ids.push(source_ref.source_id), - Some(KnowledgeSourceKind::Relation) => relation_ids.push(source_ref.source_id), - Some(KnowledgeSourceKind::Proposal) => proposal_ids.push(source_ref.source_id), - None => { - return Err(Error::InvalidRequest { - message: "stored knowledge page source kind is invalid".to_string(), - }); - }, - } - } - - Ok(Self { - doc_ids: sorted_unique(&doc_ids), - doc_chunk_ids: sorted_unique(&doc_chunk_ids), - note_ids: sorted_unique(¬e_ids), - event_ids: sorted_unique(&event_ids), - relation_ids: sorted_unique(&relation_ids), - proposal_ids: sorted_unique(&proposal_ids), - }) - } - - fn validate_non_empty(&self) -> Result<()> { - if self.doc_ids.is_empty() - && self.doc_chunk_ids.is_empty() - && self.note_ids.is_empty() - && self.event_ids.is_empty() - && self.relation_ids.is_empty() - && self.proposal_ids.is_empty() - { - return Err(Error::InvalidRequest { - message: "at least one source id is required for a knowledge page rebuild" - .to_string(), - }); - } - - Ok(()) - } - - fn require_counts( - &self, - docs: usize, - doc_chunks: usize, - notes: usize, - events: usize, - relations: usize, - proposals: usize, - ) -> Result<()> { - if docs != self.doc_ids.len() - || doc_chunks != self.doc_chunk_ids.len() - || notes != self.note_ids.len() - || events != self.event_ids.len() - || relations != self.relation_ids.len() - || proposals != self.proposal_ids.len() - { - return Err(Error::InvalidRequest { - message: - "all requested knowledge page sources must exist, source rows must be active and readable, and proposals must be applied" - .to_string(), - }); - } - - Ok(()) - } -} - -struct WatchRebuildOutcome { - item: KnowledgePageWatchRebuildItem, - candidates: Vec, -} -impl ElfService { - /// Rebuilds and persists one derived knowledge page from explicit source ids. - pub async fn knowledge_page_rebuild( - &self, - req: KnowledgePageRebuildRequest, - ) -> Result { - validate_context(req.tenant_id.as_str(), req.project_id.as_str(), req.agent_id.as_str())?; - validate_non_empty("page_key", req.page_key.as_str())?; - validate_object("provider_metadata", &req.provider_metadata)?; - - let ids = SourceIds::from_request(&req)?; - let title = - req.title.clone().unwrap_or_else(|| generated_title(req.page_kind, &req.page_key)); - let previous_page = knowledge::get_knowledge_page_by_key( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.page_kind.as_str(), - req.page_key.as_str(), - ) - .await?; - let previous_sections = match &previous_page { - Some(page) => - knowledge::list_knowledge_page_sections(&self.db.pool, page.page_id).await?, - None => Vec::new(), - }; - let sources = self.resolve_sources(&req, &ids).await?; - let now = OffsetDateTime::now_utc(); - let source_snapshot = source_snapshot_value(&sources); - let source_hash = hash_json(&source_snapshot)?; - let mut sections = build_sections(&sources)?; - let lint = lint_unsupported_sections(§ions); - - for section in &mut sections { - section.citations = citations_value(section, &sources); - section.content_hash = hash_json(§ion_hash_payload(section))?; - } - - let source_coverage = - source_coverage_value(req.page_kind, &req.page_key, §ions, &sources); - let base_rebuild_metadata = rebuild_metadata(&source_hash, &req.provider_metadata, &req); - let content_hash = - page_content_hash(&title, §ions, &source_coverage, &base_rebuild_metadata)?; - let previous_version_diff = previous_version_diff_value( - previous_page.as_ref(), - &previous_sections, - title.as_str(), - source_hash.as_str(), - content_hash.as_str(), - §ions, - ); - let version_identity = version_identity_value( - req.page_kind, - req.page_key.as_str(), - source_hash.as_str(), - content_hash.as_str(), - §ions, - ); - let rebuild_metadata = rebuild_metadata_with_previous_version_diff( - base_rebuild_metadata, - previous_version_diff, - version_identity, - ); - let page_id = Uuid::new_v4(); - let mut tx = self.db.pool.begin().await?; - let page = knowledge::upsert_knowledge_page( - &mut *tx, - KnowledgePageUpsert { - page_id, - tenant_id: req.tenant_id.as_str(), - project_id: req.project_id.as_str(), - page_kind: req.page_kind.as_str(), - page_key: req.page_key.as_str(), - title: title.as_str(), - contract_schema: KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, - status: "active", - rebuild_source_hash: source_hash.as_str(), - content_hash: content_hash.as_str(), - source_coverage: &source_coverage, - source_snapshot: &source_snapshot, - rebuild_metadata: &rebuild_metadata, - now, - }, - ) - .await?; - - replace_page_children(&mut tx, page.page_id, §ions, &sources, &lint, now).await?; - - tx.commit().await?; - - Ok(KnowledgePageRebuildResponse { page: self.knowledge_page_response(page).await? }) - } - - /// Rebuilds pages affected by changed source refs and queues reviewable candidates. - pub async fn knowledge_pages_watch_rebuild( - &self, - req: KnowledgePageWatchRebuildRequest, - ) -> Result { - validate_context(req.tenant_id.as_str(), req.project_id.as_str(), req.agent_id.as_str())?; - - let changed_sources = normalized_changed_sources(&req.changed_sources)?; - let (source_kinds, source_ids) = changed_source_arrays(&changed_sources); - let page_kind = req.page_kind.map(KnowledgePageKind::as_str); - let pages = knowledge::list_knowledge_pages_for_sources( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - page_kind, - &source_kinds, - &source_ids, - bounded_limit(req.limit), - ) - .await?; - let mut items = Vec::new(); - let mut candidates = Vec::new(); - - for page in pages { - let outcome = - self.watch_rebuild_page(req.agent_id.as_str(), page, &changed_sources).await?; - - candidates.extend(outcome.candidates); - items.push(outcome.item); - } - - let proposal_run = if req.generate_memory_candidates && !candidates.is_empty() { - Some(self.queue_knowledge_delta_candidates(&req, &changed_sources, &candidates).await?) - } else { - None - }; - let summary = watch_rebuild_summary(changed_sources.len(), &items, candidates.len()); - let operator_summary = watch_operator_summary(&summary, proposal_run.as_ref()); - - Ok(KnowledgePageWatchRebuildResponse { - schema: KNOWLEDGE_PAGE_WATCH_REBUILD_SCHEMA_V1.to_string(), - summary, - pages: items, - memory_candidates: candidates, - proposal_run, - operator_summary, - }) - } - - /// Gets one derived knowledge page with sections, source refs, and lint findings. - pub async fn knowledge_page_get( - &self, - req: KnowledgePageGetRequest, - ) -> Result { - let page = knowledge::get_knowledge_page( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.page_id, - ) - .await? - .ok_or_else(|| Error::NotFound { message: "knowledge page not found".to_string() })?; - - self.knowledge_page_response(page).await - } - - /// Lists derived knowledge pages. - pub async fn knowledge_pages_list( - &self, - req: KnowledgePagesListRequest, - ) -> Result { - let page_kind = req.page_kind.map(KnowledgePageKind::as_str); - let pages = knowledge::list_knowledge_pages( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - page_kind, - bounded_limit(req.limit), - ) - .await? - .into_iter() - .map(KnowledgePageSummary::from) - .collect(); - - Ok(KnowledgePagesListResponse { pages }) - } - - /// Searches derived knowledge page sections and returns provenance-rich snippets. - pub async fn knowledge_pages_search( - &self, - req: KnowledgePageSearchRequest, - ) -> Result { - validate_non_empty("tenant_id", req.tenant_id.as_str())?; - validate_non_empty("project_id", req.project_id.as_str())?; - validate_non_empty("agent_id", req.agent_id.as_str())?; - validate_non_empty("read_profile", req.read_profile.as_str())?; - validate_non_empty("query", req.query.as_str())?; - - if !english_gate::is_english_natural_language(req.query.as_str()) { - return Err(Error::NonEnglishInput { field: "$.query".to_string() }); - } - - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, req.read_profile.as_str())?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.agent_id.as_str(), - org_shared_allowed, - ) - .await?; - let query = req.query.trim().to_ascii_lowercase(); - let query_pattern = format!("%{query}%"); - let page_kind = req.page_kind.map(KnowledgePageKind::as_str); - let rows = knowledge::search_knowledge_page_sections( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - page_kind, - query_pattern.as_str(), - bounded_limit(req.limit), - ) - .await?; - let page_ids = sorted_unique(&rows.iter().map(|row| row.page_id).collect::>()); - let source_refs = - knowledge::list_knowledge_page_source_refs_for_pages(&self.db.pool, &page_ids).await?; - let current_source_keys = self - .resolve_current_recallable_source_keys( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.agent_id.as_str(), - &allowed_scopes, - &shared_grants, - &source_refs, - ) - .await?; - let source_refs_by_section = source_refs_by_section(&source_refs); - let items = rows - .into_iter() - .filter_map(|row| { - let refs = cloned_source_refs(source_refs_by_section.get(&row.section_id)); - - recallable_source_refs(refs.as_slice(), ¤t_source_keys) - .then(|| knowledge_page_search_item(row, refs, req.query.as_str())) - }) - .collect(); - - Ok(KnowledgePageSearchResponse { items }) - } - - /// Lints a derived knowledge page against current source snapshots. - pub async fn knowledge_page_lint( - &self, - req: KnowledgePageLintRequest, - ) -> Result { - let page = knowledge::get_knowledge_page( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.page_id, - ) - .await? - .ok_or_else(|| Error::NotFound { message: "knowledge page not found".to_string() })?; - let source_refs = - knowledge::list_knowledge_page_source_refs(&self.db.pool, page.page_id).await?; - let sections = knowledge::list_knowledge_page_sections(&self.db.pool, page.page_id).await?; - let mut findings = self.lint_source_refs(&page, &source_refs).await?; - - findings.extend(lint_page_sections(&page, §ions, &source_refs)); - - let now = OffsetDateTime::now_utc(); - let mut tx = self.db.pool.begin().await?; - - knowledge::delete_knowledge_page_lint_findings(&mut *tx, page.page_id).await?; - - for finding in &findings { - insert_lint_finding(&mut tx, page.page_id, finding, now).await?; - } - - tx.commit().await?; - - let persisted = knowledge::list_knowledge_page_lint_findings(&self.db.pool, page.page_id) - .await? - .into_iter() - .map(KnowledgePageLintFindingResponse::from) - .collect(); - - Ok(KnowledgePageLintResponse { page_id: page.page_id, findings: persisted }) - } - - async fn knowledge_page_response(&self, page: KnowledgePage) -> Result { - let page_id = page.page_id; - let section_rows = knowledge::list_knowledge_page_sections(&self.db.pool, page_id).await?; - let source_ref_rows = - knowledge::list_knowledge_page_source_refs(&self.db.pool, page_id).await?; - let source_refs_by_section = source_refs_by_section(&source_ref_rows); - let sections = section_rows - .into_iter() - .map(|section| { - let refs = cloned_source_refs(source_refs_by_section.get(§ion.section_id)); - - section_response(section, refs) - }) - .collect(); - let source_refs = - source_ref_rows.into_iter().map(KnowledgePageSourceRefResponse::from).collect(); - let lint_findings = knowledge::list_knowledge_page_lint_findings(&self.db.pool, page_id) - .await? - .into_iter() - .map(KnowledgePageLintFindingResponse::from) - .collect(); - - Ok(KnowledgePageResponse { - page: KnowledgePageSummary::from(page), - sections, - source_refs, - lint_findings, - }) - } - - async fn resolve_sources( - &self, - req: &KnowledgePageRebuildRequest, - ids: &SourceIds, - ) -> Result> { - let allowed_scopes = self.cfg.scopes.allowed.as_slice(); - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.agent_id.as_str(), - org_shared_allowed, - ) - .await?; - let (docs, doc_chunks, notes, events, relations, proposals) = self - .resolve_existing_source_rows( - req.tenant_id.as_str(), - req.project_id.as_str(), - Some(req.agent_id.as_str()), - allowed_scopes, - &shared_grants, - ids, - ) - .await?; - - ids.require_counts( - docs.len(), - doc_chunks.len(), - notes.len(), - events.len(), - relations.len(), - proposals.len(), - )?; - - Ok(source_snapshots(docs, doc_chunks, notes, events, relations, proposals)) - } - - async fn resolve_existing_source_rows( - &self, - tenant_id: &str, - project_id: &str, - agent_id: Option<&str>, - allowed_scopes: &[String], - shared_grants: &HashSet, - ids: &SourceIds, - ) -> Result<( - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - )> { - let docs = knowledge::fetch_knowledge_doc_sources( - &self.db.pool, - tenant_id, - project_id, - agent_id, - allowed_scopes, - &ids.doc_ids, - ) - .await?; - let docs = docs - .into_iter() - .filter(|source| { - source_row_read_allowed( - source.agent_id.as_str(), - source.scope.as_str(), - agent_id, - allowed_scopes, - shared_grants, - ) - }) - .collect(); - let doc_chunks = knowledge::fetch_knowledge_doc_chunk_sources( - &self.db.pool, - tenant_id, - project_id, - agent_id, - allowed_scopes, - &ids.doc_chunk_ids, - ) - .await?; - let doc_chunks = doc_chunks - .into_iter() - .filter(|source| { - source_row_read_allowed( - source.agent_id.as_str(), - source.scope.as_str(), - agent_id, - allowed_scopes, - shared_grants, - ) - }) - .collect(); - let notes = knowledge::fetch_knowledge_note_sources( - &self.db.pool, - tenant_id, - project_id, - agent_id, - allowed_scopes, - &ids.note_ids, - ) - .await?; - let notes = notes - .into_iter() - .filter(|source| { - source_row_read_allowed( - source.agent_id.as_str(), - source.scope.as_str(), - agent_id, - allowed_scopes, - shared_grants, - ) - }) - .collect(); - let events = knowledge::fetch_knowledge_event_sources( - &self.db.pool, - tenant_id, - project_id, - agent_id, - allowed_scopes, - &ids.event_ids, - ) - .await?; - let events = events - .into_iter() - .filter(|source| { - source_row_read_allowed( - source.agent_id.as_str(), - source.scope.as_str(), - agent_id, - allowed_scopes, - shared_grants, - ) - }) - .collect(); - let shared_scope_keys = access::shared_scope_key_strings(shared_grants); - let private_allowed = allowed_scopes.iter().any(|scope| scope == "agent_private"); - let relations = knowledge::fetch_knowledge_relation_sources( - &self.db.pool, - KnowledgeRelationSourcesFetch { - tenant_id, - project_id, - agent_id, - allowed_scopes, - shared_scope_keys: shared_scope_keys.as_slice(), - private_allowed, - fact_ids: &ids.relation_ids, - }, - ) - .await?; - let proposals = knowledge::fetch_knowledge_proposal_sources( - &self.db.pool, - tenant_id, - project_id, - &ids.proposal_ids, - ) - .await?; - - Ok((docs, doc_chunks, notes, events, relations, proposals)) - } - - async fn lint_source_refs( - &self, - page: &KnowledgePage, - source_refs: &[KnowledgePageSourceRef], - ) -> Result> { - let ids = SourceIds::from_source_refs(source_refs)?; - let current = self.resolve_current_source_map(page, &ids).await?; - let mut findings = Vec::new(); - - for source_ref in source_refs { - let key = current_key(source_ref.source_kind.as_str(), source_ref.source_id); - let Some(snapshot) = current.get(&key) else { - findings.push(missing_source_finding(source_ref)); - - continue; - }; - - if source_changed(source_ref, snapshot) { - findings.push(stale_source_finding(source_ref, snapshot)); - } - } - - Ok(findings) - } - - async fn resolve_current_source_map( - &self, - page: &KnowledgePage, - ids: &SourceIds, - ) -> Result> { - let _page_kind = KnowledgePageKind::parse(page.page_kind.as_str()).ok_or_else(|| { - Error::InvalidRequest { message: "stored knowledge page kind is invalid".to_string() } - })?; - let (docs, doc_chunks, notes, events, relations, proposals) = self - .resolve_existing_source_rows( - page.tenant_id.as_str(), - page.project_id.as_str(), - None, - self.cfg.scopes.allowed.as_slice(), - &HashSet::new(), - ids, - ) - .await?; - let mut sources = source_snapshots(docs, doc_chunks, notes, events, relations, proposals); - - Ok(sources.drain(..).map(|source| (source_key(&source), source)).collect()) - } - - async fn resolve_current_recallable_source_keys( - &self, - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, - source_refs: &[KnowledgePageSourceRef], - ) -> Result> { - let ids = SourceIds::from_source_refs(source_refs)?; - let (docs, doc_chunks, notes, events, relations, proposals) = self - .resolve_existing_source_rows( - tenant_id, - project_id, - Some(agent_id), - allowed_scopes, - shared_grants, - &ids, - ) - .await?; - - Ok(source_snapshots(docs, doc_chunks, notes, events, relations, proposals) - .into_iter() - .map(|source| source_key(&source)) - .collect()) - } - - async fn watch_rebuild_page( - &self, - agent_id: &str, - page: KnowledgePage, - changed_sources: &[KnowledgePageChangedSource], - ) -> Result { - let source_refs = - knowledge::list_knowledge_page_source_refs(&self.db.pool, page.page_id).await?; - let sections = knowledge::list_knowledge_page_sections(&self.db.pool, page.page_id).await?; - let before_lint = self.watch_rebuild_lint(&page, §ions, &source_refs).await?; - let request = rebuild_request_from_page(agent_id, &page, &source_refs); - let rebuild = match request { - Ok(request) => self.knowledge_page_rebuild(request).await, - Err(err) => Err(err), - }; - - match rebuild { - Ok(response) => Ok(successful_watch_rebuild( - sections, - source_refs, - before_lint, - response.page, - changed_sources, - )), - Err(err) => Ok(blocked_watch_rebuild(page, sections, before_lint, err)), - } - } - - async fn watch_rebuild_lint( - &self, - page: &KnowledgePage, - sections: &[KnowledgePageSection], - source_refs: &[KnowledgePageSourceRef], - ) -> Result> { - let mut lint = self.lint_source_refs(page, source_refs).await?; - - lint.extend(lint_page_sections(page, sections, source_refs)); - - Ok(lint) - } - - async fn queue_knowledge_delta_candidates( - &self, - req: &KnowledgePageWatchRebuildRequest, - changed_sources: &[KnowledgePageChangedSource], - candidates: &[KnowledgeDeltaMemoryCandidate], - ) -> Result { - let source_refs = candidate_run_input_refs(candidates); - let source_snapshot = knowledge_delta_source_snapshot(changed_sources, candidates); - let lineage = ConsolidationLineage { - source_refs: source_refs.clone(), - parent_run_id: None, - parent_proposal_ids: Vec::new(), - }; - let proposals = candidates.iter().map(candidate_proposal_input).collect::>(); - let created = self - .consolidation_run_create(ConsolidationRunCreateRequest { - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - agent_id: req.agent_id.clone(), - job_kind: "manual".to_string(), - input_refs: source_refs, - source_snapshot, - lineage, - proposals, - }) - .await?; - - Ok(proposal_run_summary(created, candidates.len())) - } -} - -fn normalized_changed_sources( - changed_sources: &[KnowledgePageChangedSource], -) -> Result> { - if changed_sources.is_empty() { - return Err(Error::InvalidRequest { - message: "changed_sources must not be empty.".to_string(), - }); - } - - let mut seen = BTreeSet::new(); - let mut out = Vec::new(); - - for source in changed_sources { - if seen.insert((source.source_kind.as_str().to_string(), source.source_id)) { - out.push(source.clone()); - } - } - - Ok(out) -} - -fn changed_source_arrays( - changed_sources: &[KnowledgePageChangedSource], -) -> (Vec, Vec) { - changed_sources - .iter() - .map(|source| (source.source_kind.as_str().to_string(), source.source_id)) - .unzip() -} - -fn rebuild_request_from_page( - agent_id: &str, - page: &KnowledgePage, - source_refs: &[KnowledgePageSourceRef], -) -> Result { - let ids = SourceIds::from_source_refs(source_refs)?; - let page_kind = KnowledgePageKind::parse(page.page_kind.as_str()).ok_or_else(|| { - Error::InvalidRequest { message: "stored knowledge page kind is invalid".to_string() } - })?; - let provider_metadata = page - .rebuild_metadata - .get("provider_metadata") - .filter(|metadata| matches!(metadata, Value::Object(_))) - .cloned() - .unwrap_or_else(empty_object); - - Ok(KnowledgePageRebuildRequest { - tenant_id: page.tenant_id.clone(), - project_id: page.project_id.clone(), - agent_id: agent_id.to_string(), - page_kind, - page_key: page.page_key.clone(), - title: Some(page.title.clone()), - doc_ids: ids.doc_ids, - doc_chunk_ids: ids.doc_chunk_ids, - note_ids: ids.note_ids, - event_ids: ids.event_ids, - relation_ids: ids.relation_ids, - proposal_ids: ids.proposal_ids, - provider_metadata, - }) -} - -fn successful_watch_rebuild( - before_sections: Vec, - before_source_refs: Vec, - before_lint: Vec, - rebuilt_page: KnowledgePageResponse, - changed_sources: &[KnowledgePageChangedSource], -) -> WatchRebuildOutcome { - let previous_version_diff = rebuilt_page.page.previous_version_diff.clone(); - let outputs = rebuild_outputs( - &before_sections, - &before_source_refs, - &before_lint, - previous_version_diff.as_ref(), - changed_sources, - ); - let sections = successful_section_states(&before_sections, &rebuilt_page.sections, &outputs); - let rebuild_state = successful_rebuild_state(previous_version_diff.as_ref(), &outputs); - let candidates = memory_candidates_for_page(&rebuilt_page, &outputs); - let operator_summary = page_operator_summary( - rebuilt_page.page.page_key.as_str(), - rebuild_state.as_str(), - outputs.len(), - candidates.len(), - ); - let item = KnowledgePageWatchRebuildItem { - page_id: rebuilt_page.page.page_id, - page_kind: rebuilt_page.page.page_kind.clone(), - page_key: rebuilt_page.page.page_key.clone(), - title: rebuilt_page.page.title.clone(), - rebuild_state, - sections, - outputs, - rebuilt_page: Some(rebuilt_page), - blocked_reason: None, - previous_version_diff, - operator_summary, - }; - - WatchRebuildOutcome { item, candidates } -} - -fn blocked_watch_rebuild( - page: KnowledgePage, - sections: Vec, - before_lint: Vec, - err: Error, -) -> WatchRebuildOutcome { - let outputs = blocked_outputs(§ions, &before_lint, err.to_string().as_str()); - let section_states = blocked_section_states(§ions, &outputs); - let operator_summary = - page_operator_summary(page.page_key.as_str(), "blocked", outputs.len(), 0); - let item = KnowledgePageWatchRebuildItem { - page_id: page.page_id, - page_kind: page.page_kind, - page_key: page.page_key, - title: page.title, - rebuild_state: "blocked".to_string(), - sections: section_states, - outputs, - rebuilt_page: None, - blocked_reason: Some(err.to_string()), - previous_version_diff: previous_version_diff_from_metadata(&page.rebuild_metadata), - operator_summary, - }; - - WatchRebuildOutcome { item, candidates: Vec::new() } -} - -fn rebuild_outputs( - sections: &[KnowledgePageSection], - source_refs: &[KnowledgePageSourceRef], - lint: &[LintDraft], - diff: Option<&Value>, - changed_sources: &[KnowledgePageChangedSource], -) -> Vec { - let section_index = section_lookup(sections); - let changed_keys = diff_section_keys(diff, "changed_section_keys"); - let mut outputs = lint_outputs(lint, §ion_index); - - outputs.extend(changed_claim_outputs(sections, &changed_keys)); - outputs.extend(conflict_outputs(&outputs)); - outputs.extend(changed_source_outputs(source_refs, changed_sources)); - - outputs -} - -fn blocked_outputs( - sections: &[KnowledgePageSection], - lint: &[LintDraft], - blocked_reason: &str, -) -> Vec { - let section_index = section_lookup(sections); - let mut outputs = lint_outputs(lint, §ion_index); - - outputs.push(KnowledgePageRebuildOutput { - output_type: "blocked".to_string(), - severity: "error".to_string(), - section_key: None, - source_kind: None, - source_id: None, - message: "Knowledge page could not be rebuilt from its stored source refs.".to_string(), - details: serde_json::json!({ "blocked_reason": blocked_reason }), - }); - - outputs -} - -fn lint_outputs( - lint: &[LintDraft], - section_index: &BTreeMap, -) -> Vec { - lint.iter().filter_map(|finding| lint_output(finding, section_index)).collect() -} - -fn lint_output( - finding: &LintDraft, - section_index: &BTreeMap, -) -> Option { - let output_type = match finding.finding_type.as_str() { - "stale_source_ref" => "stale_section", - "missing_citation" | "missing_source_ref" => "missing_citation", - _ => return None, - }; - let (section_key, heading) = finding - .section_id - .and_then(|section_id| section_index.get(§ion_id)) - .cloned() - .unwrap_or_else(|| ("page".to_string(), "Page".to_string())); - - Some(KnowledgePageRebuildOutput { - output_type: output_type.to_string(), - severity: finding.severity.clone(), - section_key: Some(section_key.clone()), - source_kind: finding.source_kind.map(KnowledgeSourceKind::as_str).map(ToString::to_string), - source_id: finding.source_id, - message: lint_output_message(output_type, heading.as_str()), - details: serde_json::json!({ - "finding_type": finding.finding_type, - "section_key": section_key, - "lint_details": finding.details, - }), - }) -} - -fn changed_claim_outputs( - sections: &[KnowledgePageSection], - changed_keys: &BTreeSet, -) -> Vec { - sections - .iter() - .filter(|section| changed_keys.contains(section.section_key.as_str())) - .map(|section| KnowledgePageRebuildOutput { - output_type: "changed_claim".to_string(), - severity: "info".to_string(), - section_key: Some(section.section_key.clone()), - source_kind: None, - source_id: None, - message: format!( - "Knowledge page section '{}' changed after rebuilding from current sources.", - section.heading - ), - details: serde_json::json!({ - "section_key": section.section_key, - "section_hash": section.content_hash, - }), - }) - .collect() -} - -fn changed_source_outputs( - source_refs: &[KnowledgePageSourceRef], - changed_sources: &[KnowledgePageChangedSource], -) -> Vec { - let changed = changed_source_set(changed_sources); - - source_refs - .iter() - .filter(|source_ref| { - changed.contains(&(source_ref.source_kind.clone(), source_ref.source_id)) - }) - .map(|source_ref| KnowledgePageRebuildOutput { - output_type: "changed_source".to_string(), - severity: "info".to_string(), - section_key: None, - source_kind: Some(source_ref.source_kind.clone()), - source_id: Some(source_ref.source_id), - message: "Changed source is attached to this knowledge page.".to_string(), - details: serde_json::json!({ - "source_kind": source_ref.source_kind, - "source_id": source_ref.source_id, - "section_id": source_ref.section_id, - }), - }) - .collect() -} - -fn conflict_outputs(outputs: &[KnowledgePageRebuildOutput]) -> Vec { - let stale = output_section_keys(outputs, "stale_section"); - let changed = output_section_keys(outputs, "changed_claim"); - - stale - .intersection(&changed) - .map(|section_key| { - KnowledgePageRebuildOutput { - output_type: "conflict".to_string(), - severity: "warning".to_string(), - section_key: Some(section_key.clone()), - source_kind: None, - source_id: None, - message: - "Stored derived section was stale and changed after rebuilding from current sources." - .to_string(), - details: serde_json::json!({ - "section_key": section_key, - "reason": "stale_snapshot_changed_claim", - }), - } - }) - .collect() -} - -fn successful_section_states( - before_sections: &[KnowledgePageSection], - rebuilt_sections: &[KnowledgePageSectionResponse], - outputs: &[KnowledgePageRebuildOutput], -) -> Vec { - let output_map = outputs_by_section(outputs); - let before_by_key = before_sections - .iter() - .map(|section| (section.section_key.as_str(), section)) - .collect::>(); - - rebuilt_sections - .iter() - .map(|section| { - let output_types = - output_map.get(section.section_key.as_str()).cloned().unwrap_or_default(); - let lint_finding_types = lint_finding_types_for_outputs(&output_types); - let state = section_state( - before_by_key.get(section.section_key.as_str()).copied(), - section, - &output_types, - ); - - KnowledgePageSectionRebuildState { - section_key: section.section_key.clone(), - heading: section.heading.clone(), - state, - output_types, - lint_finding_types, - } - }) - .collect() -} - -fn blocked_section_states( - sections: &[KnowledgePageSection], - outputs: &[KnowledgePageRebuildOutput], -) -> Vec { - let output_map = outputs_by_section(outputs); - - sections - .iter() - .map(|section| { - let output_types = - output_map.get(section.section_key.as_str()).cloned().unwrap_or_default(); - let lint_finding_types = lint_finding_types_for_outputs(&output_types); - let state = if output_types.iter().any(|kind| kind == "missing_citation") { - "blocked" - } else if output_types.iter().any(|kind| kind == "stale_section") { - "stale" - } else { - "blocked" - }; - - KnowledgePageSectionRebuildState { - section_key: section.section_key.clone(), - heading: section.heading.clone(), - state: state.to_string(), - output_types, - lint_finding_types, - } - }) - .collect() -} - -fn section_state( - before: Option<&KnowledgePageSection>, - after: &KnowledgePageSectionResponse, - output_types: &[String], -) -> String { - if output_types.iter().any(|kind| kind == "missing_citation") { - return "blocked".to_string(); - } - if before.is_some_and(|section| section.content_hash != after.content_hash) - || output_types.iter().any(|kind| kind == "changed_claim" || kind == "conflict") - { - return "changed".to_string(); - } - - if output_types.iter().any(|kind| kind == "stale_section") { - return "stale".to_string(); - } - - "unchanged".to_string() -} - -fn successful_rebuild_state( - diff: Option<&Value>, - outputs: &[KnowledgePageRebuildOutput], -) -> String { - if diff_content_changed(diff) { - return "changed".to_string(); - } - - if outputs.iter().any(|output| output.output_type == "stale_section") { - return "stale".to_string(); - } - - "unchanged".to_string() -} - -fn memory_candidates_for_page( - page: &KnowledgePageResponse, - outputs: &[KnowledgePageRebuildOutput], -) -> Vec { - let reasons = candidate_reasons_by_section(outputs); - - page.sections - .iter() - .filter_map(|section| { - let reason = reasons.get(section.section_key.as_str())?; - - memory_candidate_for_section(page, section, reason.as_str()) - }) - .collect() -} - -fn memory_candidate_for_section( - page: &KnowledgePageResponse, - section: &KnowledgePageSectionResponse, - reason: &str, -) -> Option { - let source_refs = page - .source_refs - .iter() - .filter(|source_ref| source_ref.section_id == Some(section.section_id)) - .filter_map(|source_ref| consolidation_input_ref(source_ref, page, section, reason)) - .collect::>(); - - if source_refs.is_empty() { - return None; - } - - let source_snapshot = candidate_source_snapshot(page, section, reason, &source_refs); - let diff = candidate_diff(page, section, reason); - let proposed_payload = candidate_proposed_payload(page, section, reason); - - Some(KnowledgeDeltaMemoryCandidate { - reason: reason.to_string(), - page_id: page.page.page_id, - section_id: section.section_id, - section_key: section.section_key.clone(), - source_refs, - source_snapshot, - diff, - proposed_payload, - }) -} - -fn consolidation_input_ref( - source_ref: &KnowledgePageSourceRefResponse, - page: &KnowledgePageResponse, - section: &KnowledgePageSectionResponse, - reason: &str, -) -> Option { - let kind = consolidation_source_kind(source_ref.source_kind.as_str())?; - - Some(ConsolidationInputRef { - kind, - id: source_ref.source_id, - snapshot: ConsolidationSourceSnapshot { - status: source_ref.source_status.clone(), - updated_at: source_ref.source_updated_at, - content_hash: source_ref.source_content_hash.clone(), - embedding_version: None, - trace_version: None, - source_ref: source_ref.source_snapshot.clone(), - metadata: serde_json::json!({ - "schema": "elf.knowledge_delta.source_ref/v1", - "reason": reason, - "page_id": page.page.page_id, - "page_kind": page.page.page_kind, - "page_key": page.page.page_key, - "section_id": section.section_id, - "section_key": section.section_key, - }), - }, - }) -} - -fn consolidation_source_kind(source_kind: &str) -> Option { - match KnowledgeSourceKind::parse(source_kind)? { - KnowledgeSourceKind::Doc => Some(ConsolidationSourceKind::Doc), - KnowledgeSourceKind::DocChunk => Some(ConsolidationSourceKind::DocChunk), - KnowledgeSourceKind::Note => Some(ConsolidationSourceKind::Note), - KnowledgeSourceKind::Event => Some(ConsolidationSourceKind::Event), - KnowledgeSourceKind::Relation | KnowledgeSourceKind::Proposal => None, - } -} - -fn candidate_source_snapshot( - page: &KnowledgePageResponse, - section: &KnowledgePageSectionResponse, - reason: &str, - source_refs: &[ConsolidationInputRef], -) -> Value { - serde_json::json!({ - "schema": "elf.knowledge_delta.source_snapshot/v1", - "reason": reason, - "page": { - "page_id": page.page.page_id, - "page_kind": page.page.page_kind, - "page_key": page.page.page_key, - "content_hash": page.page.content_hash, - "rebuild_source_hash": page.page.rebuild_source_hash, - "previous_version_diff": page.page.previous_version_diff, - }, - "section": { - "section_id": section.section_id, - "section_key": section.section_key, - "heading": section.heading, - "content_hash": section.content_hash, - "citation_count": section.citation_count, - "source_ref_count": section.source_ref_count, - }, - "source_ref_count": source_refs.len(), - "source_mutation_allowed": false, - }) -} - -fn candidate_diff( - page: &KnowledgePageResponse, - section: &KnowledgePageSectionResponse, - reason: &str, -) -> ConsolidationProposalDiff { - ConsolidationProposalDiff { - summary: format!( - "Create a reviewable memory candidate for knowledge page '{}' section '{}' because {reason}.", - page.page.page_key, section.section_key - ), - before: serde_json::json!({ - "page_id": page.page.page_id, - "section_id": section.section_id, - "previous_version_diff": page.page.previous_version_diff, - }), - after: serde_json::json!({ - "target": "derived_note", - "reason": reason, - "page_id": page.page.page_id, - "section_id": section.section_id, - "section_key": section.section_key, - }), - } -} - -fn candidate_proposed_payload( - page: &KnowledgePageResponse, - section: &KnowledgePageSectionResponse, - reason: &str, -) -> Value { - let text = truncate_chars( - format!( - "Plan: Review knowledge page {} section {} because source changes produced a {reason} delta.", - page.page.page_key, section.section_key - ) - .as_str(), - 220, - ); - - serde_json::json!({ - "type": "plan", - "key": format!( - "knowledge_delta_{}_{}", - page.page.page_key.replace('-', "_"), - section.section_key.replace('-', "_") - ), - "text": text, - "scope": "project_shared", - "importance": 0.65, - "confidence": 0.72, - "source_ref": { - "schema": "elf.knowledge_delta/v1", - "reason": reason, - "page_id": page.page.page_id, - "section_id": section.section_id, - "page_key": page.page.page_key, - "section_key": section.section_key, - "source_mutation_allowed": false, - } - }) -} - -fn candidate_proposal_input( - candidate: &KnowledgeDeltaMemoryCandidate, -) -> ConsolidationProposalInput { - ConsolidationProposalInput { - proposal_kind: "knowledge_delta_memory_candidate".to_string(), - apply_intent: ConsolidationApplyIntent::CreateDerivedNote, - source_refs: candidate.source_refs.clone(), - source_snapshot: candidate.source_snapshot.clone(), - lineage: ConsolidationLineage { - source_refs: candidate.source_refs.clone(), - parent_run_id: None, - parent_proposal_ids: Vec::new(), - }, - confidence: 0.72, - unsupported_claim_flags: Vec::new(), - markers: candidate_markers(candidate), - diff: candidate.diff.clone(), - target_ref: empty_object(), - proposed_payload: candidate.proposed_payload.clone(), - } -} - -fn candidate_markers(candidate: &KnowledgeDeltaMemoryCandidate) -> ConsolidationMarkers { - let marker = ConsolidationMarker { - severity: ConsolidationMarkerSeverity::Medium, - message: format!( - "Knowledge delta '{}' requires reviewer confirmation before memory promotion.", - candidate.reason - ), - source: candidate.source_refs.first().cloned(), - }; - - if candidate.reason == "conflict" { - ConsolidationMarkers { contradictions: vec![marker], staleness: Vec::new() } - } else { - ConsolidationMarkers { contradictions: Vec::new(), staleness: vec![marker] } - } -} - -fn candidate_run_input_refs( - candidates: &[KnowledgeDeltaMemoryCandidate], -) -> Vec { - let mut seen = BTreeSet::new(); - let mut out = Vec::new(); - - for source_ref in candidates.iter().flat_map(|candidate| &candidate.source_refs) { - if seen.insert((source_ref.kind.as_str().to_string(), source_ref.id)) { - out.push(source_ref.clone()); - } - } - - out -} - -fn knowledge_delta_source_snapshot( - changed_sources: &[KnowledgePageChangedSource], - candidates: &[KnowledgeDeltaMemoryCandidate], -) -> Value { - serde_json::json!({ - "schema": "elf.knowledge_delta.run_source_snapshot/v1", - "changed_sources": changed_sources, - "candidate_count": candidates.len(), - "candidate_reasons": candidates - .iter() - .map(|candidate| candidate.reason.clone()) - .collect::>(), - "source_mutation_allowed": false, - }) -} - -fn proposal_run_summary( - created: ConsolidationRunCreateResponse, - proposal_count: usize, -) -> KnowledgePageProposalRunSummary { - KnowledgePageProposalRunSummary { - run_id: created.run.run_id, - job_id: created.job_id, - proposal_count, - review_surface: "consolidation_proposals".to_string(), - } -} - -fn watch_rebuild_summary( - changed_source_count: usize, - items: &[KnowledgePageWatchRebuildItem], - memory_candidate_count: usize, -) -> KnowledgePageWatchRebuildSummary { - KnowledgePageWatchRebuildSummary { - changed_source_count, - affected_page_count: items.len(), - changed_page_count: items.iter().filter(|item| item.rebuild_state == "changed").count(), - unchanged_page_count: items.iter().filter(|item| item.rebuild_state == "unchanged").count(), - stale_page_count: items - .iter() - .filter(|item| item.outputs.iter().any(|output| output.output_type == "stale_section")) - .count(), - blocked_page_count: items.iter().filter(|item| item.rebuild_state == "blocked").count(), - memory_candidate_count, - } -} - -fn watch_operator_summary( - summary: &KnowledgePageWatchRebuildSummary, - proposal_run: Option<&KnowledgePageProposalRunSummary>, -) -> Vec { - let mut out = vec![format!( - "Changed-source rebuild inspected {} sources and {} affected knowledge pages.", - summary.changed_source_count, summary.affected_page_count - )]; - - out.push(format!( - "Page states: changed={}, unchanged={}, stale={}, blocked={}.", - summary.changed_page_count, - summary.unchanged_page_count, - summary.stale_page_count, - summary.blocked_page_count - )); - out.push(format!( - "Generated {} reviewable memory candidate proposals; source mutation remains disabled.", - summary.memory_candidate_count - )); - - if let Some(run) = proposal_run { - out.push(format!( - "Queued consolidation run {} with {} proposal payloads for review.", - run.run_id, run.proposal_count - )); - } - - out -} - -fn page_operator_summary( - page_key: &str, - rebuild_state: &str, - output_count: usize, - candidate_count: usize, -) -> String { - format!( - "Knowledge page '{page_key}' rebuild_state={rebuild_state}, outputs={output_count}, memory_candidates={candidate_count}." - ) -} - -fn section_lookup(sections: &[KnowledgePageSection]) -> BTreeMap { - sections - .iter() - .map(|section| (section.section_id, (section.section_key.clone(), section.heading.clone()))) - .collect() -} - -fn diff_section_keys(diff: Option<&Value>, key: &str) -> BTreeSet { - diff.and_then(|value| value.get(key)) - .and_then(Value::as_array) - .map(|items| items.iter().filter_map(Value::as_str).map(ToString::to_string).collect()) - .unwrap_or_default() -} - -fn diff_content_changed(diff: Option<&Value>) -> bool { - diff.and_then(|value| value.get("content_changed")).and_then(Value::as_bool).unwrap_or(false) - || !diff_section_keys(diff, "added_section_keys").is_empty() - || !diff_section_keys(diff, "removed_section_keys").is_empty() - || !diff_section_keys(diff, "changed_section_keys").is_empty() -} - -fn changed_source_set(changed_sources: &[KnowledgePageChangedSource]) -> BTreeSet<(String, Uuid)> { - changed_sources - .iter() - .map(|source| (source.source_kind.as_str().to_string(), source.source_id)) - .collect() -} - -fn output_section_keys( - outputs: &[KnowledgePageRebuildOutput], - output_type: &str, -) -> BTreeSet { - outputs - .iter() - .filter(|output| output.output_type == output_type) - .filter_map(|output| output.section_key.clone()) - .collect() -} - -fn outputs_by_section(outputs: &[KnowledgePageRebuildOutput]) -> BTreeMap<&str, Vec> { - let mut map = BTreeMap::<&str, Vec>::new(); - - for output in outputs { - let Some(section_key) = output.section_key.as_deref() else { - continue; - }; - - map.entry(section_key).or_default().push(output.output_type.clone()); - } - for values in map.values_mut() { - values.sort(); - values.dedup(); - } - - map -} - -fn lint_finding_types_for_outputs(output_types: &[String]) -> Vec { - let mut out = output_types - .iter() - .filter_map(|output_type| match output_type.as_str() { - "stale_section" => Some("stale_source_ref".to_string()), - "missing_citation" => Some("missing_citation".to_string()), - _ => None, - }) - .collect::>(); - - out.sort(); - out.dedup(); - - out -} - -fn candidate_reasons_by_section(outputs: &[KnowledgePageRebuildOutput]) -> BTreeMap<&str, String> { - let mut reasons = BTreeMap::<&str, String>::new(); - - for output in outputs { - let Some(section_key) = output.section_key.as_deref() else { - continue; - }; - - match output.output_type.as_str() { - "conflict" => { - reasons.insert(section_key, "conflict".to_string()); - }, - "changed_claim" => { - reasons.entry(section_key).or_insert_with(|| "changed_claim".to_string()); - }, - _ => {}, - } - } - - reasons -} - -fn lint_output_message(output_type: &str, heading: &str) -> String { - match output_type { - "stale_section" => - format!("Knowledge page section '{heading}' cites a stale or missing source."), - "missing_citation" => - format!("Knowledge page section '{heading}' is missing citation coverage."), - _ => format!("Knowledge page section '{heading}' needs operator review."), - } -} - -fn default_generate_memory_candidates() -> bool { - true -} - -fn source_snapshots( - docs: Vec, - doc_chunks: Vec, - notes: Vec, - events: Vec, - relations: Vec, - proposals: Vec, -) -> Vec { - let mut sources = Vec::new(); - - sources.extend(docs.into_iter().map(doc_source_snapshot)); - sources.extend(doc_chunks.into_iter().map(doc_chunk_source_snapshot)); - sources.extend(notes.into_iter().map(note_source_snapshot)); - sources.extend(events.into_iter().map(event_source_snapshot)); - sources.extend(relations.into_iter().map(relation_source_snapshot)); - sources.extend(proposals.into_iter().map(proposal_source_snapshot)); - sources.sort_by_key(source_sort_key); - - sources -} - -fn source_refs_by_section( - source_refs: &[KnowledgePageSourceRef], -) -> HashMap> { - let mut by_section = HashMap::>::new(); - - for source_ref in source_refs { - let Some(section_id) = source_ref.section_id else { - continue; - }; - - by_section.entry(section_id).or_default().push(clone_source_ref(source_ref)); - } - - by_section -} - -fn recallable_source_refs( - source_refs: &[KnowledgePageSourceRef], - current_source_keys: &BTreeSet, -) -> bool { - !source_refs.is_empty() - && source_refs.iter().all(|source_ref| { - current_source_keys - .contains(¤t_key(source_ref.source_kind.as_str(), source_ref.source_id)) - && recallable_source_ref(source_ref) - }) -} - -fn source_row_read_allowed( - owner_agent_id: &str, - scope: &str, - requester_agent_id: Option<&str>, - allowed_scopes: &[String], - shared_grants: &HashSet, -) -> bool { - if !allowed_scopes.iter().any(|allowed_scope| allowed_scope == scope) { - return false; - } - - let Some(requester_agent_id) = requester_agent_id else { - return true; - }; - - if scope == "agent_private" { - return owner_agent_id == requester_agent_id; - } - if !matches!(scope, "project_shared" | "org_shared") { - return false; - } - if owner_agent_id == requester_agent_id { - return true; - } - - shared_grants.contains(&access::SharedSpaceGrantKey { - scope: scope.to_string(), - space_owner_agent_id: owner_agent_id.to_string(), - }) -} - -fn recallable_source_ref(source_ref: &KnowledgePageSourceRef) -> bool { - let Some(status) = source_ref.source_status.as_deref().map(str::trim) else { - return false; - }; - - if !matches!(status, "active" | "remember" | "update" | "current" | "historical" | "applied") { - return false; - } - - !has_non_recallable_span(&source_ref.source_snapshot) -} - -fn has_non_recallable_span(source_snapshot: &Value) -> bool { - match source_snapshot { - Value::Object(object) => - policy_spans_are_non_recallable(object.get("policy_spans")) - || object.get("source_span").is_some_and(span_is_non_recallable) - || source_spans_are_non_recallable(object.get("source_spans")) - || object.values().any(has_non_recallable_span), - Value::Array(items) => items.iter().any(has_non_recallable_span), - _ => false, - } -} - -fn policy_spans_are_non_recallable(policy_spans: Option<&Value>) -> bool { - match policy_spans { - Some(Value::Array(spans)) => !spans.is_empty(), - Some(Value::Null) | None => false, - Some(_) => true, - } -} - -fn source_spans_are_non_recallable(source_spans: Option<&Value>) -> bool { - match source_spans { - Some(Value::Array(spans)) => spans.iter().any(span_is_non_recallable), - Some(Value::Null) | None => false, - Some(_) => true, - } -} - -fn span_is_non_recallable(span: &Value) -> bool { - !matches!(span.get("status").and_then(Value::as_str), Some("captured")) -} - -fn cloned_source_refs( - source_refs: Option<&Vec>, -) -> Vec { - source_refs.map(|refs| refs.iter().map(clone_source_ref).collect()).unwrap_or_default() -} - -fn clone_source_ref(source_ref: &KnowledgePageSourceRef) -> KnowledgePageSourceRef { - KnowledgePageSourceRef { - ref_id: source_ref.ref_id, - page_id: source_ref.page_id, - section_id: source_ref.section_id, - source_kind: source_ref.source_kind.clone(), - source_id: source_ref.source_id, - source_status: source_ref.source_status.clone(), - source_updated_at: source_ref.source_updated_at, - source_content_hash: source_ref.source_content_hash.clone(), - source_snapshot: source_ref.source_snapshot.clone(), - citation_metadata: source_ref.citation_metadata.clone(), - created_at: source_ref.created_at, - } -} - -fn section_response( - section: KnowledgePageSection, - source_refs: Vec, -) -> KnowledgePageSectionResponse { - let citation_count = citation_count(§ion.citations); - let source_ref_count = source_refs.len(); - let source_backlinks = - source_refs.iter().map(KnowledgePageSectionSourceBacklink::from).collect(); - - KnowledgePageSectionResponse { - citation_count, - source_ref_count, - coverage_complete: citation_count > 0 && source_ref_count > 0, - source_backlinks, - ..KnowledgePageSectionResponse::from(section) - } -} - -fn knowledge_page_search_item( - row: KnowledgePageSearchRow, - source_refs: Vec, - query: &str, -) -> KnowledgePageSearchItem { - let source_ref_count = usize::try_from(row.section_source_ref_count).unwrap_or(0); - let citation_count = citation_count(&row.citations); - let lint_summary = KnowledgePageLintSummary { - error_count: row.lint_error_count, - warning_count: row.lint_warning_count, - info_count: row.lint_info_count, - has_errors: row.lint_error_count > 0, - has_warnings: row.lint_warning_count > 0, - }; - let coverage_complete = - row.source_coverage.get("coverage_complete").and_then(Value::as_bool).unwrap_or(false); - let trust_state = search_trust_state(&lint_summary, coverage_complete, &row); - let repair_guidance = search_repair_guidance(&trust_state); - let previous_version_diff = previous_version_diff_from_metadata(&row.rebuild_metadata); - - KnowledgePageSearchItem { - result_kind: "knowledge_page_section".to_string(), - page_id: row.page_id, - page_kind: row.page_kind, - page_key: row.page_key, - title: row.title, - status: row.status, - section_id: row.section_id, - section_key: row.section_key, - heading: row.heading, - role: row.role, - snippet: snippet_for_query(row.content.as_str(), query, SEARCH_SNIPPET_CHARS), - citations: sanitize_search_citations(row.citations), - citation_count, - source_ref_count, - source_refs: source_refs.into_iter().map(search_source_ref_response).collect(), - source_coverage: row.source_coverage, - rebuild_metadata: row.rebuild_metadata, - previous_version_diff, - lint_summary, - trust_state, - derived_notice: - "Derived knowledge page snippet. Verify cited source documents, spans, memory notes, events, relations, or proposals before treating it as authoritative." - .to_string(), - repair_guidance, - updated_at: row.page_updated_at, - rebuilt_at: row.rebuilt_at, - } -} - -fn search_source_ref_response( - source_ref: KnowledgePageSourceRef, -) -> KnowledgePageSourceRefResponse { - let mut response = KnowledgePageSourceRefResponse::from(source_ref); - - if response.source_kind == KnowledgeSourceKind::Proposal.as_str() { - response.source_snapshot = sanitize_proposal_snapshot(&response.source_snapshot); - } - - response -} - -fn sanitize_search_citations(citations: Value) -> Value { - let Value::Array(citations) = citations else { - return citations; - }; - - Value::Array(citations.into_iter().map(sanitize_search_citation).collect()) -} - -fn sanitize_search_citation(mut citation: Value) -> Value { - let is_proposal = citation - .get("source_kind") - .and_then(Value::as_str) - .is_some_and(|kind| kind == KnowledgeSourceKind::Proposal.as_str()); - - if !is_proposal { - return citation; - } - - if let Some(object) = citation.as_object_mut() - && let Some(source_snapshot) = object.get_mut("source_snapshot") - { - *source_snapshot = sanitize_proposal_snapshot(source_snapshot); - } - - citation -} - -fn search_trust_state( - lint: &KnowledgePageLintSummary, - coverage_complete: bool, - row: &KnowledgePageSearchRow, -) -> String { - if lint.has_errors { - return "derived_error".to_string(); - } - if lint.has_warnings || row.unsupported_reason.is_some() { - return "derived_warning".to_string(); - } - - if !coverage_complete || row.section_source_ref_count == 0 { - return "derived_low_coverage".to_string(); - } - - "derived_clean".to_string() -} - -fn search_repair_guidance(trust_state: &str) -> Option { - match trust_state { - "derived_error" => Some( - "Run knowledge page lint, inspect stale or missing source refs, then rebuild the page from current authoritative sources." - .to_string(), - ), - "derived_warning" => Some( - "Inspect unsupported or stale findings before using this derived snippet; rebuild after source review." - .to_string(), - ), - "derived_low_coverage" => Some( - "Rebuild with complete citations or add source-backed sections before relying on this page." - .to_string(), - ), - _ => None, - } -} - -fn build_sections(sources: &[SourceSnapshot]) -> Result> { - let doc_indexes = source_indexes(sources, KnowledgeSourceKind::Doc); - let doc_chunk_indexes = source_indexes(sources, KnowledgeSourceKind::DocChunk); - let note_indexes = source_indexes(sources, KnowledgeSourceKind::Note); - let event_indexes = source_indexes(sources, KnowledgeSourceKind::Event); - let relation_indexes = source_indexes(sources, KnowledgeSourceKind::Relation); - let proposal_indexes = source_indexes(sources, KnowledgeSourceKind::Proposal); - let mut sections = Vec::new(); - - push_section( - &mut sections, - "source-documents", - "Source Documents", - "source_documents", - sources, - doc_indexes, - ); - push_section( - &mut sections, - "source-spans", - "Source Spans", - "source_spans", - sources, - doc_chunk_indexes, - ); - push_section( - &mut sections, - "source-notes", - "Source Notes", - "current_truth", - sources, - note_indexes, - ); - push_section(&mut sections, "event-audits", "Event Audits", "history", sources, event_indexes); - push_section(&mut sections, "relations", "Relations", "relations", sources, relation_indexes); - push_section( - &mut sections, - "reviewed-proposals", - "Reviewed Proposals", - "proposals", - sources, - proposal_indexes, - ); - - if sections.is_empty() { - return Err(Error::InvalidRequest { - message: "knowledge page rebuild did not produce any cited sections".to_string(), - }); - } - - Ok(sections) -} - -fn push_section( - sections: &mut Vec, - section_key: &str, - heading: &str, - role: &str, - sources: &[SourceSnapshot], - source_indexes: Vec, -) { - if source_indexes.is_empty() { - return; - } - - let ordinal = i32::try_from(sections.len()).unwrap_or(i32::MAX); - let content = source_indexes - .iter() - .filter_map(|index| sources.get(*index)) - .map(|source| format!("- {}", source.line)) - .collect::>() - .join("\n"); - - sections.push(DraftSection { - section_id: Uuid::new_v4(), - section_key: section_key.to_string(), - heading: heading.to_string(), - role: role.to_string(), - content, - ordinal, - source_indexes, - unsupported_reason: None, - content_hash: String::new(), - citations: Value::Array(Vec::new()), - }); -} - -fn lint_unsupported_sections(sections: &[DraftSection]) -> Vec { - sections - .iter() - .filter_map(|section| { - section.unsupported_reason.as_ref().map(|reason| LintDraft { - section_id: Some(section.section_id), - finding_type: "unsupported_claim".to_string(), - severity: "warning".to_string(), - source_kind: None, - source_id: None, - message: format!("Knowledge page section has unsupported content: {reason}"), - details: serde_json::json!({ - "section_key": section.section_key, - "unsupported_reason": reason, - "repair_guidance": repair_guidance_for_finding_type("unsupported_claim"), - }), - }) - }) - .collect() -} - -fn lint_page_sections( - page: &KnowledgePage, - sections: &[KnowledgePageSection], - source_refs: &[KnowledgePageSourceRef], -) -> Vec { - let source_refs_by_section = source_refs_by_section(source_refs); - let mut findings = Vec::new(); - - for section in sections { - findings.extend(lint_one_section(section, &source_refs_by_section)); - } - - if !coverage_complete(page.source_coverage.as_object()) { - findings.push(low_source_coverage_finding(page)); - } - - findings -} - -fn lint_one_section( - section: &KnowledgePageSection, - source_refs_by_section: &HashMap>, -) -> Vec { - let citation_count = citation_count(§ion.citations); - let source_ref_count = - source_refs_by_section.get(§ion.section_id).map(Vec::len).unwrap_or_default(); - let mut findings = Vec::new(); - - if let Some(reason) = §ion.unsupported_reason { - findings.push(section_finding( - section, - "unsupported_claim", - "warning", - "Knowledge page section contains unsupported content.", - serde_json::json!({ - "unsupported_reason": reason, - "citation_count": citation_count, - "source_ref_count": source_ref_count, - }), - )); - } - - if citation_count == 0 && section.unsupported_reason.is_none() { - findings.push(section_finding( - section, - "missing_citation", - "error", - "Knowledge page section has no citations.", - serde_json::json!({ "source_ref_count": source_ref_count }), - )); - } - if source_ref_count == 0 && section.unsupported_reason.is_none() { - findings.push(section_finding( - section, - "missing_source_ref", - "error", - "Knowledge page section has no normalized source backlinks.", - serde_json::json!({ "citation_count": citation_count }), - )); - } - - findings -} - -fn section_finding( - section: &KnowledgePageSection, - finding_type: &str, - severity: &str, - message: &str, - details: Value, -) -> LintDraft { - LintDraft { - section_id: Some(section.section_id), - finding_type: finding_type.to_string(), - severity: severity.to_string(), - source_kind: None, - source_id: None, - message: message.to_string(), - details: with_repair_guidance( - details, - section.section_key.as_str(), - repair_guidance_for_finding_type(finding_type), - ), - } -} - -fn low_source_coverage_finding(page: &KnowledgePage) -> LintDraft { - LintDraft { - section_id: None, - finding_type: "low_source_coverage".to_string(), - severity: "warning".to_string(), - source_kind: None, - source_id: None, - message: "Knowledge page source coverage is incomplete.".to_string(), - details: serde_json::json!({ - "source_coverage": page.source_coverage.clone(), - "repair_guidance": repair_guidance_for_finding_type("low_source_coverage"), - }), - } -} - -fn with_repair_guidance(details: Value, section_key: &str, guidance: &str) -> Value { - let mut object = details.as_object().cloned().unwrap_or_default(); - - object.insert("section_key".to_string(), Value::String(section_key.to_string())); - object.insert("repair_guidance".to_string(), Value::String(guidance.to_string())); - - Value::Object(object) -} - -fn coverage_complete(coverage: Option<&Map>) -> bool { - let Some(coverage) = coverage else { - return false; - }; - let source_count = coverage.get("source_count").and_then(Value::as_u64).unwrap_or(0); - let cited_count = coverage.get("cited_source_count").and_then(Value::as_u64).unwrap_or(0); - let complete = coverage.get("coverage_complete").and_then(Value::as_bool).unwrap_or(false); - - complete && source_count == cited_count -} - -fn citation_count(citations: &Value) -> usize { - citations.as_array().map(Vec::len).unwrap_or_default() -} - -fn source_indexes(sources: &[SourceSnapshot], kind: KnowledgeSourceKind) -> Vec { - sources - .iter() - .enumerate() - .filter_map(|(index, source)| (source.kind == kind).then_some(index)) - .collect() -} - -fn citations_value(section: &DraftSection, sources: &[SourceSnapshot]) -> Value { - Value::Array( - section - .source_indexes - .iter() - .filter_map(|index| sources.get(*index)) - .map(source_citation_value) - .collect(), - ) -} - -fn doc_source_snapshot(row: KnowledgeDocSource) -> SourceSnapshot { - let title = row.title.clone().unwrap_or_else(|| "Untitled source document".to_string()); - let excerpt = truncate_chars(normalize_whitespace(row.content.as_str()).as_str(), 240); - let line = format!("[doc:{}] {title}: {excerpt}", row.doc_type); - let snapshot = serde_json::json!({ - "kind": "doc", - "doc_id": row.doc_id, - "agent_id": row.agent_id.clone(), - "scope": row.scope.clone(), - "doc_type": row.doc_type.clone(), - "status": row.status.clone(), - "title": row.title.clone(), - "content_bytes": row.content_bytes, - "content_hash": row.content_hash.clone(), - "source_ref": row.source_ref.clone(), - "created_at": row.created_at, - "updated_at": row.updated_at, - }); - - SourceSnapshot { - kind: KnowledgeSourceKind::Doc, - id: row.doc_id, - status: Some(row.status), - updated_at: Some(row.updated_at), - content_hash: Some(row.content_hash), - snapshot, - citation_metadata: serde_json::json!({ "section_role": "source_document" }), - line, - } -} - -fn doc_chunk_source_snapshot(row: KnowledgeDocChunkSource) -> SourceSnapshot { - let title = row.title.clone().unwrap_or_else(|| "Untitled source document".to_string()); - let excerpt = truncate_chars(normalize_whitespace(row.chunk_text.as_str()).as_str(), 240); - let span_id = source_span_id( - row.doc_content_hash.as_str(), - row.start_offset.max(0) as usize, - row.end_offset.max(row.start_offset).max(0) as usize, - "captured", - ); - let line = format!( - "[doc_chunk:{}:{}-{}] {title}: {excerpt}", - row.chunk_index, row.start_offset, row.end_offset - ); - let source_span = serde_json::json!({ - "schema": "doc_source_span/v1", - "span_id": span_id, - "chunk_id": row.chunk_id, - "status": "captured", - "reason_code": null, - "start_offset": row.start_offset, - "end_offset": row.end_offset, - "content_hash": row.doc_content_hash.clone(), - "chunk_hash": row.chunk_hash.clone(), - }); - let snapshot = serde_json::json!({ - "kind": "doc_chunk", - "chunk_id": row.chunk_id, - "doc_id": row.doc_id, - "agent_id": row.agent_id.clone(), - "scope": row.scope.clone(), - "doc_type": row.doc_type.clone(), - "status": row.status.clone(), - "title": row.title.clone(), - "source_ref": row.source_ref.clone(), - "doc_content_hash": row.doc_content_hash.clone(), - "doc_updated_at": row.doc_updated_at, - "chunk_index": row.chunk_index, - "start_offset": row.start_offset, - "end_offset": row.end_offset, - "chunk_hash": row.chunk_hash.clone(), - "chunk_created_at": row.chunk_created_at, - "source_span": source_span, - }); - - SourceSnapshot { - kind: KnowledgeSourceKind::DocChunk, - id: row.chunk_id, - status: Some(row.status), - updated_at: Some(row.doc_updated_at), - content_hash: Some(row.chunk_hash), - snapshot, - citation_metadata: serde_json::json!({ - "section_role": "source_span", - "doc_id": row.doc_id, - "span_id": span_id, - "start_offset": row.start_offset, - "end_offset": row.end_offset, - }), - line, - } -} - -fn note_source_snapshot(row: KnowledgeNoteSource) -> SourceSnapshot { - let content_hash = hash_text(row.text.as_str()); - let line = format!("{}{}", note_prefix(&row), row.text); - let snapshot = serde_json::json!({ - "kind": "note", - "note_id": row.note_id, - "agent_id": row.agent_id.clone(), - "scope": row.scope.clone(), - "type": row.note_type.clone(), - "key": row.key.clone(), - "status": row.status.clone(), - "updated_at": row.updated_at, - "created_at": row.created_at, - "expires_at": row.expires_at, - "embedding_version": row.embedding_version.clone(), - "content_hash": content_hash, - "source_ref": row.source_ref.clone(), - "importance": row.importance, - "confidence": row.confidence, - }); - - SourceSnapshot { - kind: KnowledgeSourceKind::Note, - id: row.note_id, - status: Some(row.status), - updated_at: Some(row.updated_at), - content_hash: Some(content_hash), - snapshot, - citation_metadata: serde_json::json!({ "section_role": "source_note" }), - line, - } -} - -fn event_source_snapshot(row: KnowledgeEventSource) -> SourceSnapshot { - let content_hash = hash_json_lossy(&row.details); - let line = format!( - "add_event audit {} {} for {}{}", - row.note_op, - row.policy_decision, - row.note_type, - row.note_key.as_ref().map(|key| format!(" key {key}")).unwrap_or_default() - ); - let snapshot = serde_json::json!({ - "kind": "event", - "decision_id": row.decision_id, - "agent_id": row.agent_id.clone(), - "scope": row.scope.clone(), - "pipeline": row.pipeline.clone(), - "note_type": row.note_type.clone(), - "note_key": row.note_key.clone(), - "note_id": row.note_id, - "policy_decision": row.policy_decision.clone(), - "note_op": row.note_op.clone(), - "reason_code": row.reason_code.clone(), - "details_hash": content_hash, - "ts": row.ts, - }); - - SourceSnapshot { - kind: KnowledgeSourceKind::Event, - id: row.decision_id, - status: Some(row.policy_decision), - updated_at: Some(row.ts), - content_hash: Some(content_hash), - snapshot, - citation_metadata: serde_json::json!({ "section_role": "event_audit" }), - line, - } -} - -fn relation_source_snapshot(row: KnowledgeRelationSource) -> SourceSnapshot { - let object = row.object_entity.clone().or(row.object_value.clone()).unwrap_or_default(); - let temporal_status = if row.valid_to.is_some() { "historical" } else { "current" }; - let line = format!("{} {} {} ({temporal_status}).", row.subject, row.predicate, object); - let content_hash = hash_text(line.as_str()); - let snapshot = serde_json::json!({ - "kind": "relation", - "fact_id": row.fact_id, - "agent_id": row.agent_id.clone(), - "scope": row.scope.clone(), - "subject": { "canonical": row.subject.clone(), "kind": row.subject_kind.clone() }, - "predicate": row.predicate.clone(), - "object": { - "entity": row.object_entity.clone(), - "kind": row.object_kind.clone(), - "value": row.object_value.clone() - }, - "valid_from": row.valid_from, - "valid_to": row.valid_to, - "updated_at": row.updated_at, - "content_hash": content_hash, - "evidence_notes": row.evidence_notes.clone(), - }); - - SourceSnapshot { - kind: KnowledgeSourceKind::Relation, - id: row.fact_id, - status: Some(temporal_status.to_string()), - updated_at: Some(row.updated_at), - content_hash: Some(content_hash), - snapshot, - citation_metadata: serde_json::json!({ "section_role": "relation_fact" }), - line, - } -} - -fn proposal_source_snapshot(row: KnowledgeProposalSource) -> SourceSnapshot { - let content_hash = hash_json_lossy(&serde_json::json!({ - "diff": row.diff.clone(), - "proposed_payload": row.proposed_payload.clone(), - "review_state": row.review_state.clone(), - })); - let line = format!("Applied proposal {}", row.proposal_kind); - let snapshot = sanitize_proposal_snapshot(&serde_json::json!({ - "kind": "proposal", - "proposal_id": row.proposal_id, - "run_id": row.run_id, - "agent_id": row.agent_id.clone(), - "proposal_kind": row.proposal_kind.clone(), - "apply_intent": row.apply_intent.clone(), - "review_state": row.review_state.clone(), - "source_refs": row.source_refs.clone(), - "source_snapshot": row.source_snapshot.clone(), - "lineage": row.lineage.clone(), - "diff": row.diff.clone(), - "confidence": row.confidence, - "unsupported_claim_flags": row.unsupported_claim_flags.clone(), - "contradiction_markers": row.contradiction_markers.clone(), - "staleness_markers": row.staleness_markers.clone(), - "target_ref": row.target_ref.clone(), - "proposed_payload_hash": content_hash, - "updated_at": row.updated_at, - })); - - SourceSnapshot { - kind: KnowledgeSourceKind::Proposal, - id: row.proposal_id, - status: Some(row.review_state), - updated_at: Some(row.updated_at), - content_hash: Some(content_hash), - snapshot, - citation_metadata: serde_json::json!({ "section_role": "reviewed_proposal" }), - line, - } -} - -fn sanitize_proposal_snapshot(source_snapshot: &Value) -> Value { - let Some(object) = source_snapshot.as_object() else { - return serde_json::json!({ - "kind": "proposal", - "sanitized": true, - "source_visibility": "proposal_metadata_only", - }); - }; - let nested_source_count = - object.get("source_refs").and_then(Value::as_array).map(Vec::len).unwrap_or_default(); - let mut sanitized = Map::new(); - - for key in [ - "kind", - "proposal_id", - "run_id", - "agent_id", - "proposal_kind", - "apply_intent", - "review_state", - "confidence", - "proposed_payload_hash", - "updated_at", - ] { - if let Some(value) = object.get(key) { - sanitized.insert(key.to_string(), value.clone()); - } - } - - sanitized.insert("sanitized".to_string(), Value::Bool(true)); - sanitized.insert( - "source_visibility".to_string(), - Value::String("proposal_metadata_only".to_string()), - ); - sanitized.insert( - "omitted_fields".to_string(), - serde_json::json!([ - "source_refs", - "source_snapshot", - "lineage", - "diff", - "unsupported_claim_flags", - "contradiction_markers", - "staleness_markers", - "target_ref" - ]), - ); - sanitized.insert( - "nested_source_ref_count".to_string(), - Value::Number(Number::from(nested_source_count)), - ); - - Value::Object(sanitized) -} - -fn source_citation_value(source: &SourceSnapshot) -> Value { - serde_json::json!({ - "source_kind": source.kind.as_str(), - "source_id": source.id, - "source_status": source.status.clone(), - "source_updated_at": source.updated_at, - "source_content_hash": source.content_hash.clone(), - "source_snapshot": source.snapshot.clone(), - "citation_metadata": source.citation_metadata.clone(), - }) -} - -fn source_snapshot_value(sources: &[SourceSnapshot]) -> Value { - serde_json::json!({ - "schema": KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, - "sources": sources.iter().map(source_citation_value).collect::>(), - }) -} - -fn source_coverage_value( - page_kind: KnowledgePageKind, - page_key: &str, - sections: &[DraftSection], - sources: &[SourceSnapshot], -) -> Value { - let cited = sections - .iter() - .flat_map(|section| section.source_indexes.iter().copied()) - .collect::>(); - let counts = source_counts(sources); - - serde_json::json!({ - "schema": KNOWLEDGE_PAGE_SOURCE_COVERAGE_SCHEMA_V1, - "page_kind": page_kind.as_str(), - "page_key": page_key, - "source_counts": counts, - "source_count": sources.len(), - "cited_source_count": cited.len(), - "section_count": sections.len(), - "unsupported_section_count": sections.iter().filter(|section| section.unsupported_reason.is_some()).count(), - "coverage_complete": cited.len() == sources.len(), - }) -} - -fn source_counts(sources: &[SourceSnapshot]) -> Value { - let mut counts = BTreeMap::<&str, usize>::new(); - - for source in sources { - *counts.entry(source.kind.as_str()).or_insert(0) += 1; - } - - serde_json::json!(counts) -} - -fn rebuild_metadata( - source_hash: &str, - provider_metadata: &Value, - req: &KnowledgePageRebuildRequest, -) -> Value { - let llm_derived = - provider_metadata.get("llm_derived").and_then(Value::as_bool).unwrap_or(false); - - serde_json::json!({ - "schema": KNOWLEDGE_PAGE_REBUILD_SCHEMA_V1, - "source_snapshot_hash": source_hash, - "deterministic": !llm_derived, - "provider_metadata": provider_metadata, - "generated_by": { - "schema": "elf.knowledge_page.generated_by/v1", - "runtime": "ElfService::knowledge_page_rebuild", - "actor_agent_id": req.agent_id, - "mode": if llm_derived { "provider_metadata_declared_llm" } else { "deterministic_service" }, - "source_input_counts": { - "doc": req.doc_ids.len(), - "doc_chunk": req.doc_chunk_ids.len(), - "note": req.note_ids.len(), - "event": req.event_ids.len(), - "relation": req.relation_ids.len(), - "proposal": req.proposal_ids.len(), - }, - }, - "memory_candidate_policy": { - "schema": "elf.knowledge_page.memory_candidate_policy/v1", - "review_required": true, - "review_surface": "consolidation_proposals", - "proposal_contract_schema": "elf.consolidation/v1", - "allowed_apply_intents": ["create_derived_note", "update_derived_note"], - "direct_memory_ledger_mutation_allowed": false, - "source_mutation_allowed": false, - }, - "allowed_variance": if llm_derived { - serde_json::json!(["LLM-derived page text may vary; provider metadata records the nondeterministic input path."]) - } else { - serde_json::json!([]) - }, - }) -} - -fn rebuild_metadata_with_previous_version_diff( - mut metadata: Value, - diff: Value, - version_identity: Value, -) -> Value { - let Some(object) = metadata.as_object_mut() else { - return serde_json::json!({ - PREVIOUS_VERSION_DIFF_KEY: diff, - "version_identity": version_identity, - }); - }; - - object.insert(PREVIOUS_VERSION_DIFF_KEY.to_string(), diff); - object.insert("version_identity".to_string(), version_identity); - - metadata -} - -fn previous_version_diff_from_metadata(metadata: &Value) -> Option { - metadata - .get(PREVIOUS_VERSION_DIFF_KEY) - .filter(|diff| diff.as_object().is_some_and(|object| !object.is_empty())) - .cloned() -} - -fn previous_version_diff_value( - previous: Option<&KnowledgePage>, - previous_sections: &[KnowledgePageSection], - new_title: &str, - new_source_hash: &str, - new_content_hash: &str, - new_sections: &[DraftSection], -) -> Value { - let Some(previous) = previous else { - return serde_json::json!({ - "schema": KNOWLEDGE_PAGE_VERSION_DIFF_SCHEMA_V1, - "available": false, - "reason": "no_previous_version", - "summary": "Initial rebuild; no previous knowledge page version exists.", - "source_mutation_allowed": false, - }); - }; - let previous_by_key = previous_sections - .iter() - .map(|section| (section.section_key.as_str(), section)) - .collect::>(); - let new_by_key = new_sections - .iter() - .map(|section| (section.section_key.as_str(), section)) - .collect::>(); - let previous_keys = previous_by_key.keys().copied().collect::>(); - let new_keys = new_by_key.keys().copied().collect::>(); - let added_section_keys = sorted_strings(new_keys.difference(&previous_keys).copied()); - let removed_section_keys = sorted_strings(previous_keys.difference(&new_keys).copied()); - let mut changed_section_keys = Vec::new(); - let mut unchanged_section_keys = Vec::new(); - - for key in previous_keys.intersection(&new_keys).copied() { - let previous_section = previous_by_key[key]; - let new_section = new_by_key[key]; - - if previous_section.content_hash == new_section.content_hash - && previous_section.heading == new_section.heading - && previous_section.role == new_section.role - && previous_section.unsupported_reason == new_section.unsupported_reason - { - unchanged_section_keys.push(key.to_string()); - } else { - changed_section_keys.push(key.to_string()); - } - } - - let title_changed = previous.title != new_title; - let source_changed = previous.rebuild_source_hash != new_source_hash; - let content_changed = previous.content_hash != new_content_hash; - let summary = version_diff_summary( - title_changed, - source_changed, - content_changed, - added_section_keys.len(), - removed_section_keys.len(), - changed_section_keys.len(), - ); - - serde_json::json!({ - "schema": KNOWLEDGE_PAGE_VERSION_DIFF_SCHEMA_V1, - "available": true, - "previous_page_id": previous.page_id, - "previous_content_hash": previous.content_hash, - "new_content_hash": new_content_hash, - "previous_source_hash": previous.rebuild_source_hash, - "new_source_hash": new_source_hash, - "title_changed": title_changed, - "source_changed": source_changed, - "content_changed": content_changed, - "section_added_count": added_section_keys.len(), - "section_removed_count": removed_section_keys.len(), - "section_changed_count": changed_section_keys.len(), - "section_unchanged_count": unchanged_section_keys.len(), - "added_section_keys": added_section_keys, - "removed_section_keys": removed_section_keys, - "changed_section_keys": changed_section_keys, - "unchanged_section_keys": unchanged_section_keys, - "source_mutation_allowed": false, - "summary": summary, - }) -} - -fn version_identity_value( - page_kind: KnowledgePageKind, - page_key: &str, - source_hash: &str, - content_hash: &str, - sections: &[DraftSection], -) -> Value { - serde_json::json!({ - "schema": "elf.knowledge_page.version_identity/v1", - "contract_schema": KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, - "page_kind": page_kind.as_str(), - "page_key": page_key, - "source_snapshot_hash": source_hash, - "content_hash": content_hash, - "section_hashes": sections - .iter() - .map(|section| { - serde_json::json!({ - "section_key": section.section_key.clone(), - "content_hash": section.content_hash.clone(), - }) - }) - .collect::>(), - "source_mutation_allowed": false, - }) -} - -fn sorted_strings<'a>(items: impl Iterator) -> Vec { - let mut out = items.map(ToString::to_string).collect::>(); - - out.sort(); - - out -} - -fn version_diff_summary( - title_changed: bool, - source_changed: bool, - content_changed: bool, - added: usize, - removed: usize, - changed: usize, -) -> String { - if !title_changed - && !source_changed - && !content_changed - && added == 0 - && removed == 0 - && changed == 0 - { - return "No page-level or section-level changes from the previous rebuild.".to_string(); - } - - format!( - "Previous rebuild diff: title_changed={title_changed}, source_changed={source_changed}, content_changed={content_changed}, sections added={added}, removed={removed}, changed={changed}." - ) -} - -fn content_hash_rebuild_metadata(rebuild_metadata: &Value) -> Value { - let Some(object) = rebuild_metadata.as_object() else { - return rebuild_metadata.clone(); - }; - let mut stable = object.clone(); - - stable.remove(PREVIOUS_VERSION_DIFF_KEY); - stable.remove("generated_by"); - stable.remove("memory_candidate_policy"); - stable.remove("version_identity"); - - Value::Object(stable) -} - -fn section_hash_payload(section: &DraftSection) -> Value { - serde_json::json!({ - "section_key": section.section_key.clone(), - "heading": section.heading.clone(), - "role": section.role.clone(), - "content": section.content.clone(), - "citations": section.citations.clone(), - "unsupported_reason": section.unsupported_reason.clone(), - }) -} - -fn page_content_hash( - title: &str, - sections: &[DraftSection], - source_coverage: &Value, - rebuild_metadata: &Value, -) -> Result { - let stable_rebuild_metadata = content_hash_rebuild_metadata(rebuild_metadata); - - hash_json(&serde_json::json!({ - "title": title, - "sections": sections.iter().map(section_hash_payload).collect::>(), - "source_coverage": source_coverage, - "rebuild_metadata": stable_rebuild_metadata, - })) -} - -fn missing_source_finding(source_ref: &KnowledgePageSourceRef) -> LintDraft { - LintDraft { - section_id: source_ref.section_id, - finding_type: "stale_source_ref".to_string(), - severity: "error".to_string(), - source_kind: KnowledgeSourceKind::parse(source_ref.source_kind.as_str()), - source_id: Some(source_ref.source_id), - message: "Knowledge page source reference no longer resolves.".to_string(), - details: serde_json::json!({ - "source_kind": source_ref.source_kind.clone(), - "source_id": source_ref.source_id, - "repair_guidance": repair_guidance_for_finding_type("stale_source_ref"), - }), - } -} - -fn stale_source_finding( - source_ref: &KnowledgePageSourceRef, - current: &SourceSnapshot, -) -> LintDraft { - LintDraft { - section_id: source_ref.section_id, - finding_type: "stale_source_ref".to_string(), - severity: "warning".to_string(), - source_kind: Some(current.kind), - source_id: Some(current.id), - message: "Knowledge page source reference snapshot is stale.".to_string(), - details: serde_json::json!({ - "stored": { - "status": source_ref.source_status.clone(), - "updated_at": source_ref.source_updated_at, - "content_hash": source_ref.source_content_hash.clone(), - }, - "current": { - "status": current.status.clone(), - "updated_at": current.updated_at, - "content_hash": current.content_hash.clone(), - }, - "repair_guidance": repair_guidance_for_finding_type("stale_source_ref"), - }), - } -} - -fn repair_guidance_for_finding_type(finding_type: &str) -> &'static str { - match finding_type { - "stale_source_ref" => - "Inspect the stale or missing source, then rebuild the page from current authoritative sources.", - "unsupported_claim" => - "Replace the unsupported section content with source-backed text or rebuild from cited sources.", - "missing_citation" => - "Rebuild the page section with explicit citations or mark the section unsupported with a reason.", - "missing_source_ref" => - "Rebuild the page so each section citation is normalized into knowledge_page_source_refs.", - "low_source_coverage" => - "Rebuild with all intended sources or remove uncited material before relying on this page.", - _ => "Inspect the finding and rebuild the page after source review.", - } -} - -fn source_changed(source_ref: &KnowledgePageSourceRef, current: &SourceSnapshot) -> bool { - source_ref.source_status.as_deref() != current.status.as_deref() - || source_ref.source_updated_at != current.updated_at - || source_ref.source_content_hash.as_deref() != current.content_hash.as_deref() -} - -fn snippet_for_query(content: &str, query: &str, max_chars: usize) -> String { - let normalized = normalize_whitespace(content); - let query = query.trim(); - - if query.is_empty() { - return truncate_chars(normalized.as_str(), max_chars); - } - - let lower = normalized.to_ascii_lowercase(); - let lower_query = query.to_ascii_lowercase(); - let Some(byte_idx) = lower.find(lower_query.as_str()) else { - return truncate_chars(normalized.as_str(), max_chars); - }; - let before_chars = normalized[..byte_idx].chars().count(); - let start = before_chars.saturating_sub(40); - let mut snippet: String = normalized.chars().skip(start).take(max_chars).collect(); - - if start > 0 { - snippet = format!("...{snippet}"); - } - if normalized.chars().count() > start + snippet.chars().count() { - snippet.push_str("..."); - } - - snippet -} - -fn normalize_whitespace(raw: &str) -> String { - let mut out = String::with_capacity(raw.len()); - let mut prev_space = false; - - for ch in raw.chars() { - if ch.is_whitespace() { - if !prev_space { - out.push(' '); - - prev_space = true; - } - - continue; - } - - out.push(ch); - - prev_space = false; - } - - out.trim().to_string() -} - -fn truncate_chars(raw: &str, max_chars: usize) -> String { - if raw.chars().count() <= max_chars { - return raw.to_string(); - } - - const TRUNCATION_MARKER: &str = "..."; - - let marker_chars = TRUNCATION_MARKER.chars().count(); - - if max_chars <= marker_chars { - return TRUNCATION_MARKER.chars().take(max_chars).collect(); - } - - let truncated_chars = max_chars - marker_chars; - let mut out = raw.chars().take(truncated_chars).collect::(); - - out.push_str(TRUNCATION_MARKER); - - out -} - -fn source_sort_key(source: &SourceSnapshot) -> (String, Uuid) { - (source.kind.as_str().to_string(), source.id) -} - -fn source_key(source: &SourceSnapshot) -> String { - current_key(source.kind.as_str(), source.id) -} - -fn current_key(kind: &str, source_id: Uuid) -> String { - format!("{kind}:{source_id}") -} - -fn note_prefix(row: &KnowledgeNoteSource) -> String { - row.key - .as_ref() - .map(|key| format!("[{}:{key}] ", row.note_type)) - .unwrap_or_else(|| format!("[{}] ", row.note_type)) -} - -fn generated_title(page_kind: KnowledgePageKind, page_key: &str) -> String { - format!("{} Knowledge Page: {page_key}", title_kind(page_kind)) -} - -fn title_kind(page_kind: KnowledgePageKind) -> &'static str { - match page_kind { - KnowledgePageKind::Project => "Project", - KnowledgePageKind::Entity => "Entity", - KnowledgePageKind::Concept => "Concept", - KnowledgePageKind::Issue => "Issue", - KnowledgePageKind::Decision => "Decision", - KnowledgePageKind::Author => "Author", - KnowledgePageKind::Timeline => "Timeline", - } -} - -fn sorted_unique(ids: &[Uuid]) -> Vec { - ids.iter().copied().collect::>().into_iter().collect() -} - -fn bounded_limit(limit: Option) -> i64 { - limit.map(i64::from).unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, MAX_LIST_LIMIT) -} - -fn validate_context(tenant_id: &str, project_id: &str, agent_id: &str) -> Result<()> { - validate_non_empty("tenant_id", tenant_id)?; - validate_non_empty("project_id", project_id)?; - - validate_non_empty("agent_id", agent_id) -} - -fn validate_non_empty(field: &'static str, value: &str) -> Result<()> { - if value.trim().is_empty() { - return Err(Error::InvalidRequest { message: format!("{field} must not be empty.") }); - } - - Ok(()) -} - -fn validate_object(field: &str, value: &Value) -> Result<()> { - if matches!(value, Value::Object(_)) { - Ok(()) - } else { - Err(Error::InvalidRequest { message: format!("{field} must be a JSON object.") }) - } -} - -fn empty_object() -> Value { - Value::Object(Map::new()) -} - -fn hash_text(text: &str) -> String { - blake3::hash(text.as_bytes()).to_hex().to_string() -} - -fn hash_json_lossy(value: &Value) -> String { - serde_json::to_vec(value) - .map(|raw| blake3::hash(&raw).to_hex().to_string()) - .unwrap_or_else(|_| hash_text(value.to_string().as_str())) -} - -fn hash_json(value: &Value) -> Result { - let raw = serde_json::to_vec(value).map_err(|err| Error::InvalidRequest { - message: format!("failed to serialize knowledge page payload: {err}"), - })?; - - Ok(blake3::hash(&raw).to_hex().to_string()) -} - -fn source_span_id(content_hash: &str, start: usize, end: usize, span_kind: &str) -> Uuid { - let name = serde_json::json!(["elf-doc-source-span/v1", content_hash, start, end, span_kind]) - .to_string(); - - Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) -} - -async fn replace_page_children( - tx: &mut Transaction<'_, Postgres>, - page_id: Uuid, - sections: &[DraftSection], - sources: &[SourceSnapshot], - lint: &[LintDraft], - now: OffsetDateTime, -) -> Result<()> { - knowledge::delete_knowledge_page_children(&mut **tx, page_id).await?; - - for section in sections { - insert_section(tx, page_id, section, now).await?; - - for source_index in §ion.source_indexes { - let source = sources.get(*source_index).ok_or_else(|| Error::InvalidRequest { - message: "knowledge page section referenced an unknown source".to_string(), - })?; - - insert_source_ref(tx, page_id, section.section_id, source, now).await?; - } - } - for finding in lint { - insert_lint_finding(tx, page_id, finding, now).await?; - } - - Ok(()) -} - -async fn insert_section( - tx: &mut Transaction<'_, Postgres>, - page_id: Uuid, - section: &DraftSection, - now: OffsetDateTime, -) -> Result<()> { - knowledge::insert_knowledge_page_section( - &mut **tx, - KnowledgePageSectionInsert { - section_id: section.section_id, - page_id, - section_key: section.section_key.as_str(), - heading: section.heading.as_str(), - role: section.role.as_str(), - content: section.content.as_str(), - ordinal: section.ordinal, - citations: §ion.citations, - unsupported_reason: section.unsupported_reason.as_deref(), - content_hash: section.content_hash.as_str(), - now, - }, - ) - .await - .map_err(Error::from) -} - -async fn insert_source_ref( - tx: &mut Transaction<'_, Postgres>, - page_id: Uuid, - section_id: Uuid, - source: &SourceSnapshot, - now: OffsetDateTime, -) -> Result<()> { - knowledge::insert_knowledge_page_source_ref( - &mut **tx, - KnowledgePageSourceRefInsert { - ref_id: Uuid::new_v4(), - page_id, - section_id: Some(section_id), - source_kind: source.kind.as_str(), - source_id: source.id, - source_status: source.status.as_deref(), - source_updated_at: source.updated_at, - source_content_hash: source.content_hash.as_deref(), - source_snapshot: &source.snapshot, - citation_metadata: &source.citation_metadata, - now, - }, - ) - .await - .map_err(Error::from) -} - -async fn insert_lint_finding( - tx: &mut Transaction<'_, Postgres>, - page_id: Uuid, - finding: &LintDraft, - now: OffsetDateTime, -) -> Result<()> { - knowledge::insert_knowledge_page_lint_finding( - &mut **tx, - KnowledgePageLintFindingInsert { - finding_id: Uuid::new_v4(), - page_id, - section_id: finding.section_id, - finding_type: finding.finding_type.as_str(), - severity: finding.severity.as_str(), - source_kind: finding.source_kind.map(KnowledgeSourceKind::as_str), - source_id: finding.source_id, - message: finding.message.as_str(), - details: &finding.details, - now, - }, - ) - .await - .map_err(Error::from) -} - #[cfg(test)] -mod tests { - use std::{ - collections::{BTreeSet, HashSet}, - slice, - }; - - use crate::{ - access::SharedSpaceGrantKey, - knowledge::{ - self, DraftSection, KnowledgeDeltaMemoryCandidate, KnowledgePage, KnowledgePageKind, - KnowledgePageResponse, KnowledgePageSearchRow, KnowledgePageSection, - KnowledgePageSectionResponse, KnowledgePageSourceRef, KnowledgePageSourceRefResponse, - KnowledgePageSummary, KnowledgeSourceKind, LintDraft, OffsetDateTime, SourceSnapshot, - Uuid, - }, - }; - use elf_domain::consolidation::ConsolidationApplyIntent; - - fn test_source(kind: KnowledgeSourceKind, raw_id: u128, line: &str) -> SourceSnapshot { - let id = Uuid::from_u128(raw_id); - let content_hash = knowledge::hash_text(line); - - SourceSnapshot { - kind, - id, - status: Some("active".to_string()), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - content_hash: Some(content_hash.clone()), - snapshot: serde_json::json!({ - "kind": kind.as_str(), - "id": id, - "status": "active", - "updated_at": OffsetDateTime::UNIX_EPOCH, - "content_hash": content_hash, - }), - citation_metadata: serde_json::json!({ "fixture": "knowledge_unit" }), - line: line.to_string(), - } - } - - fn test_rebuild_request( - page_kind: KnowledgePageKind, - ) -> knowledge::KnowledgePageRebuildRequest { - knowledge::KnowledgePageRebuildRequest { - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - page_kind, - page_key: "elf".to_string(), - title: Some("ELF".to_string()), - doc_ids: Vec::new(), - doc_chunk_ids: Vec::new(), - note_ids: Vec::new(), - event_ids: Vec::new(), - relation_ids: Vec::new(), - proposal_ids: Vec::new(), - provider_metadata: knowledge::empty_object(), - } - } - - #[test] - fn build_sections_preserves_citations_and_deterministic_hashes() { - let sources = vec![ - test_source(KnowledgeSourceKind::Doc, 1, "A source document supports the page."), - test_source(KnowledgeSourceKind::DocChunk, 2, "A source span supports the page."), - test_source(KnowledgeSourceKind::Note, 3, "A source note supports the page."), - test_source(KnowledgeSourceKind::Event, 4, "An event audit supports the page."), - test_source(KnowledgeSourceKind::Relation, 5, "A relation supports the page."), - test_source(KnowledgeSourceKind::Proposal, 6, "An applied proposal supports the page."), - ]; - let mut first_sections = - knowledge::build_sections(&sources).expect("sections should build"); - - for section in &mut first_sections { - section.citations = knowledge::citations_value(section, &sources); - section.content_hash = knowledge::hash_json(&knowledge::section_hash_payload(section)) - .expect("section hash should serialize"); - } - - assert_eq!(first_sections.len(), 6); - assert!(first_sections.iter().all(|section| { - section.citations.as_array().is_some_and(|citations| !citations.is_empty()) - })); - - let coverage = knowledge::source_coverage_value( - KnowledgePageKind::Project, - "elf", - &first_sections, - &sources, - ); - let request = test_rebuild_request(KnowledgePageKind::Project); - let metadata = - knowledge::rebuild_metadata("source-hash", &knowledge::empty_object(), &request); - let first_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) - .expect("page hash should serialize"); - let second_hash = - knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) - .expect("page hash should serialize"); - - assert_eq!(coverage["coverage_complete"], true); - assert_eq!(metadata["deterministic"], true); - assert_eq!( - metadata["memory_candidate_policy"]["direct_memory_ledger_mutation_allowed"], - false - ); - assert_eq!(first_hash, second_hash); - } - - #[test] - fn rebuild_metadata_records_llm_variance() { - let metadata = knowledge::rebuild_metadata( - "source-hash", - &serde_json::json!({ - "llm_derived": true, - "provider_id": "fixture", - "model": "fixture-model", - }), - &test_rebuild_request(KnowledgePageKind::Timeline), - ); - - assert_eq!(metadata["deterministic"], false); - assert!(metadata["allowed_variance"].as_array().is_some_and(|items| !items.is_empty())); - assert_eq!(metadata["provider_metadata"]["provider_id"], "fixture"); - assert_eq!(metadata["generated_by"]["actor_agent_id"], "agent"); - } - - #[test] - fn generated_titles_cover_author_and_timeline_pages() { - assert_eq!( - knowledge::generated_title(KnowledgePageKind::Author, "ada"), - "Author Knowledge Page: ada" - ); - assert_eq!( - knowledge::generated_title(KnowledgePageKind::Timeline, "release-plan"), - "Timeline Knowledge Page: release-plan" - ); - } - - #[test] - fn previous_version_diff_records_delta_without_changing_content_hash() { - let previous = test_page(); - let previous_section = - test_section(Uuid::from_u128(10), "source-notes", serde_json::json!([]), None); - let sections = vec![DraftSection { - section_id: Uuid::from_u128(12), - section_key: "source-notes".to_string(), - heading: "source-notes".to_string(), - role: "current_truth".to_string(), - content: "Updated section content.".to_string(), - ordinal: 0, - source_indexes: vec![0], - unsupported_reason: None, - content_hash: "new-section-hash".to_string(), - citations: serde_json::json!([{ "source_kind": "note" }]), - }]; - let request = test_rebuild_request(KnowledgePageKind::Project); - let base_metadata = - knowledge::rebuild_metadata("new-source-hash", &knowledge::empty_object(), &request); - let coverage = serde_json::json!({ "coverage_complete": true }); - let hash_without_diff = - knowledge::page_content_hash("ELF", §ions, &coverage, &base_metadata) - .expect("stable hash should serialize"); - let diff = knowledge::previous_version_diff_value( - Some(&previous), - &[previous_section], - "ELF", - "new-source-hash", - hash_without_diff.as_str(), - §ions, - ); - let version_identity = knowledge::version_identity_value( - KnowledgePageKind::Project, - "elf", - "new-source-hash", - hash_without_diff.as_str(), - §ions, - ); - let metadata_with_diff = knowledge::rebuild_metadata_with_previous_version_diff( - base_metadata, - diff.clone(), - version_identity, - ); - let hash_with_diff = - knowledge::page_content_hash("ELF", §ions, &coverage, &metadata_with_diff) - .expect("hash should ignore previous-version diff metadata"); - - assert_eq!(hash_without_diff, hash_with_diff); - assert_eq!(diff["schema"], "elf.knowledge_page.version_diff/v1"); - assert_eq!(diff["available"], true); - assert_eq!(diff["source_mutation_allowed"], false); - assert_eq!(diff["section_changed_count"], 1); - assert_eq!( - knowledge::previous_version_diff_from_metadata(&metadata_with_diff) - .expect("diff should be extractable")["section_changed_count"], - 1 - ); - assert_eq!( - metadata_with_diff["version_identity"]["schema"], - "elf.knowledge_page.version_identity/v1" - ); - } - - #[test] - fn stale_source_comparison_detects_changed_snapshot() { - let source_id = Uuid::from_u128(42); - let stored = KnowledgePageSourceRef { - ref_id: Uuid::from_u128(1), - page_id: Uuid::from_u128(2), - section_id: Some(Uuid::from_u128(3)), - source_kind: "note".to_string(), - source_id, - source_status: Some("active".to_string()), - source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), - source_content_hash: Some("old-hash".to_string()), - source_snapshot: serde_json::json!({}), - citation_metadata: serde_json::json!({}), - created_at: OffsetDateTime::UNIX_EPOCH, - }; - let current = SourceSnapshot { - kind: KnowledgeSourceKind::Note, - id: source_id, - status: Some("active".to_string()), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - content_hash: Some("new-hash".to_string()), - snapshot: serde_json::json!({}), - citation_metadata: serde_json::json!({}), - line: "Updated note source.".to_string(), - }; - let finding = knowledge::stale_source_finding(&stored, ¤t); - - assert!(knowledge::source_changed(&stored, ¤t)); - assert_eq!(finding.finding_type, "stale_source_ref"); - assert_eq!(finding.source_kind, Some(KnowledgeSourceKind::Note)); - assert_eq!(finding.source_id, Some(source_id)); - } - - #[test] - fn watch_rebuild_outputs_cover_source_update_and_stale_page() { - let section_id = Uuid::from_u128(50); - let source_id = Uuid::from_u128(51); - let section = test_section( - section_id, - "source-notes", - serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), - None, - ); - let source_ref = test_source_ref_for(section_id, source_id, "old-hash"); - let lint = vec![LintDraft { - section_id: Some(section_id), - finding_type: "stale_source_ref".to_string(), - severity: "warning".to_string(), - source_kind: Some(KnowledgeSourceKind::Note), - source_id: Some(source_id), - message: "Knowledge page source reference snapshot is stale.".to_string(), - details: serde_json::json!({ "stored": "old", "current": "new" }), - }]; - let diff = serde_json::json!({ - "available": true, - "content_changed": true, - "changed_section_keys": ["source-notes"] - }); - let changed_sources = vec![knowledge::KnowledgePageChangedSource { - source_kind: KnowledgeSourceKind::Note, - source_id, - }]; - let outputs = knowledge::rebuild_outputs( - &[section], - &[source_ref], - &lint, - Some(&diff), - &changed_sources, - ); - let output_types = - outputs.iter().map(|output| output.output_type.as_str()).collect::>(); - - assert!(output_types.contains(&"stale_section")); - assert!(output_types.contains(&"changed_claim")); - assert!(output_types.contains(&"conflict")); - assert!(output_types.contains(&"changed_source")); - } - - #[test] - fn memory_candidate_uses_reviewable_consolidation_proposal_contract() { - let section_id = Uuid::from_u128(60); - let source_id = Uuid::from_u128(61); - let page = test_page_response(section_id, source_id); - let outputs = vec![knowledge::KnowledgePageRebuildOutput { - output_type: "changed_claim".to_string(), - severity: "info".to_string(), - section_key: Some("source-notes".to_string()), - source_kind: Some("note".to_string()), - source_id: Some(source_id), - message: "Changed section.".to_string(), - details: serde_json::json!({ "reason": "source_update" }), - }]; - let candidates = knowledge::memory_candidates_for_page(&page, &outputs); - - assert_eq!(candidates.len(), 1); - - assert_candidate_is_reviewable(&candidates[0]); - - let proposal = knowledge::candidate_proposal_input(&candidates[0]); - - assert_eq!(proposal.apply_intent, ConsolidationApplyIntent::CreateDerivedNote); - assert_eq!(proposal.source_refs.len(), 1); - assert_eq!(proposal.proposed_payload["source_ref"]["source_mutation_allowed"], false); - assert_eq!(proposal.proposed_payload["source_ref"]["reason"], "changed_claim"); - assert!(!proposal.markers.staleness.is_empty()); - } - - #[test] - fn lint_page_sections_detects_unsupported_missing_and_low_coverage() { - let page = test_page(); - let unsupported = test_section( - Uuid::from_u128(10), - "unsupported", - serde_json::json!([]), - Some("No source supports this claim.".to_string()), - ); - let missing = test_section(Uuid::from_u128(11), "missing", serde_json::json!([]), None); - let findings = knowledge::lint_page_sections(&page, &[unsupported, missing], &[]); - let finding_types = - findings.iter().map(|finding| finding.finding_type.as_str()).collect::>(); - - assert!(finding_types.contains(&"unsupported_claim")); - assert!(finding_types.contains(&"missing_citation")); - assert!(finding_types.contains(&"missing_source_ref")); - assert!(finding_types.contains(&"low_source_coverage")); - assert!(findings.iter().all(|finding| { - finding - .details - .get("repair_guidance") - .and_then(serde_json::Value::as_str) - .is_some_and(|guidance| !guidance.is_empty()) - })); - } - - #[test] - fn search_item_marks_derived_page_snippet_with_provenance() { - let section_id = Uuid::from_u128(20); - let source_ref = test_source_ref(section_id); - let row = KnowledgePageSearchRow { - page_id: Uuid::from_u128(21), - page_kind: "project".to_string(), - page_key: "elf".to_string(), - title: "ELF Knowledge".to_string(), - status: "active".to_string(), - source_coverage: serde_json::json!({ - "source_count": 1, - "cited_source_count": 1, - "coverage_complete": true - }), - rebuild_metadata: serde_json::json!({ "deterministic": true }), - page_updated_at: OffsetDateTime::UNIX_EPOCH, - rebuilt_at: OffsetDateTime::UNIX_EPOCH, - section_id, - section_key: "source-notes".to_string(), - heading: "Source Notes".to_string(), - role: "current_truth".to_string(), - content: "Derived knowledge pages cite source notes before they are trusted." - .to_string(), - ordinal: 0, - citations: serde_json::json!([{ "source_kind": "note", "source_id": source_ref.source_id }]), - unsupported_reason: None, - lint_error_count: 0, - lint_warning_count: 1, - lint_info_count: 0, - section_source_ref_count: 1, - }; - let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "source notes"); - - assert_eq!(item.result_kind, "knowledge_page_section"); - assert_eq!(item.trust_state, "derived_warning"); - assert_eq!(item.citation_count, 1); - assert_eq!(item.source_ref_count, 1); - assert_eq!(item.source_refs.len(), 1); - assert!(item.derived_notice.contains("Derived knowledge page snippet")); - assert!(item.repair_guidance.is_some()); - assert!(item.snippet.contains("source notes")); - } - - #[test] - fn search_source_refs_suppress_deleted_and_unreviewed_sources() { - let section_id = Uuid::from_u128(70); - let mut active = test_source_ref(section_id); - let mut deleted = test_source_ref(section_id); - let mut ignored = test_source_ref(section_id); - let current_keys = current_source_keys_for(&[&active, &deleted, &ignored]); - - deleted.source_status = Some("deleted".to_string()); - ignored.source_status = Some("ignore".to_string()); - - assert!(knowledge::recallable_source_refs(slice::from_ref(&active), ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[deleted], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[ignored], ¤t_keys)); - - active.source_status = None; - - assert!(!knowledge::recallable_source_refs(&[active], ¤t_keys)); - } - - #[test] - fn search_source_refs_suppress_non_captured_spans() { - let section_id = Uuid::from_u128(71); - let mut excluded = test_source_ref(section_id); - let mut source_ref_span = test_source_ref(section_id); - let mut policy_span = test_source_ref(section_id); - let mut malformed_span = test_source_ref(section_id); - let current_keys = - current_source_keys_for(&[&excluded, &source_ref_span, &policy_span, &malformed_span]); - - excluded.source_snapshot = serde_json::json!({ - "source_span": { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - }); - source_ref_span.source_snapshot = serde_json::json!({ - "source_ref": { - "source_spans": [ - { - "schema": "doc_source_span/v1", - "status": "redacted", - "reason_code": "WRITE_POLICY_REDACTION" - } - ] - } - }); - policy_span.source_snapshot = serde_json::json!({ - "source_ref": { - "policy_spans": [ - { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - ] - } - }); - malformed_span.source_snapshot = serde_json::json!({ - "source_span": { - "schema": "doc_source_span/v1", - "reason_code": "WRITE_POLICY_REDACTION" - } - }); - - assert!(!knowledge::recallable_source_refs(&[excluded], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[source_ref_span], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[policy_span], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[malformed_span], ¤t_keys)); - } - - #[test] - fn search_source_refs_suppress_nested_proposal_non_captured_spans() { - let section_id = Uuid::from_u128(73); - let mut proposal = test_source_ref_for(section_id, Uuid::from_u128(74), "proposal-hash"); - - proposal.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); - proposal.source_status = Some("applied".to_string()); - proposal.source_snapshot = serde_json::json!({ - "kind": "proposal", - "proposal_id": proposal.source_id, - "source_refs": [ - { - "kind": "doc_chunk", - "source_ref": { - "policy_spans": [ - { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - ] - } - } - ], - "source_snapshot": { - "sources": [ - { - "source_snapshot": { - "source_span": { - "schema": "doc_source_span/v1", - "status": "redacted", - "reason_code": "WRITE_POLICY_REDACTION" - } - } - } - ] - }, - "diff": { - "after": { - "source_ref": { - "source_spans": [ - { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - ] - } - } - } - }); - - let current_keys = current_source_keys_for(&[&proposal]); - - assert!(!knowledge::recallable_source_refs(&[proposal], ¤t_keys)); - } - - #[test] - fn search_item_sanitizes_proposal_citations_and_source_refs() { - let section_id = Uuid::from_u128(75); - let mut source_ref = test_source_ref_for(section_id, Uuid::from_u128(76), "proposal-hash"); - - source_ref.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); - source_ref.source_status = Some("applied".to_string()); - source_ref.source_snapshot = serde_json::json!({ - "kind": "proposal", - "proposal_id": source_ref.source_id, - "proposal_kind": "create_derived_note", - "source_refs": [{ "kind": "doc", "source_id": Uuid::from_u128(77) }], - "source_snapshot": { "sources": [{ "source_snapshot": { "text": "private raw source" } }] }, - "lineage": { "parents": ["private"] }, - "diff": { "summary": "private raw diff" }, - "unsupported_claim_flags": [{ "quote": "private raw flag" }], - "target_ref": { "text": "private raw target" } - }); - - let row = KnowledgePageSearchRow { - page_id: Uuid::from_u128(78), - page_kind: "project".to_string(), - page_key: "elf".to_string(), - title: "ELF Knowledge".to_string(), - status: "active".to_string(), - source_coverage: serde_json::json!({ - "source_count": 1, - "cited_source_count": 1, - "coverage_complete": true - }), - rebuild_metadata: serde_json::json!({ "deterministic": true }), - page_updated_at: OffsetDateTime::UNIX_EPOCH, - rebuilt_at: OffsetDateTime::UNIX_EPOCH, - section_id, - section_key: "reviewed-proposals".to_string(), - heading: "Reviewed Proposals".to_string(), - role: "proposals".to_string(), - content: "Applied proposal create_derived_note".to_string(), - ordinal: 0, - citations: serde_json::json!([{ - "source_kind": "proposal", - "source_id": source_ref.source_id, - "source_snapshot": source_ref.source_snapshot.clone() - }]), - unsupported_reason: None, - lint_error_count: 0, - lint_warning_count: 0, - lint_info_count: 0, - section_source_ref_count: 1, - }; - let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "proposal"); - let citation_snapshot = &item.citations[0]["source_snapshot"]; - let source_ref_snapshot = &item.source_refs[0].source_snapshot; - - assert_eq!(citation_snapshot["sanitized"], true); - assert_eq!(source_ref_snapshot["sanitized"], true); - assert!(citation_snapshot.get("source_refs").is_none()); - assert!(citation_snapshot.get("source_snapshot").is_none()); - assert!(citation_snapshot.get("diff").is_none()); - assert!(source_ref_snapshot.get("source_refs").is_none()); - assert!(source_ref_snapshot.get("source_snapshot").is_none()); - assert!(source_ref_snapshot.get("diff").is_none()); - } - - #[test] - fn search_source_refs_suppress_missing_current_sources() { - let section_id = Uuid::from_u128(72); - let source_ref = test_source_ref(section_id); - - assert!(!knowledge::recallable_source_refs(&[source_ref], &BTreeSet::new())); - } - - #[test] - fn source_row_read_allowed_requires_shared_grant_for_other_agent_sources() { - let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; - let shared_grants = HashSet::new(); - - assert!(knowledge::source_row_read_allowed( - "owner-agent", - "project_shared", - Some("owner-agent"), - &allowed_scopes, - &shared_grants - )); - assert!(!knowledge::source_row_read_allowed( - "owner-agent", - "project_shared", - Some("reader-agent"), - &allowed_scopes, - &shared_grants - )); - - let shared_grants = HashSet::from([SharedSpaceGrantKey { - scope: "project_shared".to_string(), - space_owner_agent_id: "owner-agent".to_string(), - }]); - - assert!(knowledge::source_row_read_allowed( - "owner-agent", - "project_shared", - Some("reader-agent"), - &allowed_scopes, - &shared_grants - )); - } - - fn test_page() -> KnowledgePage { - KnowledgePage { - page_id: Uuid::from_u128(1), - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - page_kind: "project".to_string(), - page_key: "elf".to_string(), - title: "ELF".to_string(), - contract_schema: "elf.knowledge_page/v1".to_string(), - status: "active".to_string(), - rebuild_source_hash: "source-hash".to_string(), - content_hash: "content-hash".to_string(), - source_coverage: serde_json::json!({ - "source_count": 2, - "cited_source_count": 1, - "coverage_complete": false - }), - source_snapshot: serde_json::json!({}), - rebuild_metadata: serde_json::json!({}), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - rebuilt_at: OffsetDateTime::UNIX_EPOCH, - } - } - - fn test_section( - section_id: Uuid, - section_key: &str, - citations: serde_json::Value, - unsupported_reason: Option, - ) -> KnowledgePageSection { - KnowledgePageSection { - section_id, - page_id: Uuid::from_u128(1), - section_key: section_key.to_string(), - heading: section_key.to_string(), - role: "current_truth".to_string(), - content: "Section content.".to_string(), - ordinal: 0, - citations, - unsupported_reason, - content_hash: "section-hash".to_string(), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - } - } - - fn test_source_ref(section_id: Uuid) -> KnowledgePageSourceRef { - test_source_ref_for(section_id, Uuid::from_u128(31), "source-hash") - } - - fn test_source_ref_for( - section_id: Uuid, - source_id: Uuid, - source_hash: &str, - ) -> KnowledgePageSourceRef { - KnowledgePageSourceRef { - ref_id: Uuid::from_u128(30), - page_id: Uuid::from_u128(21), - section_id: Some(section_id), - source_kind: "note".to_string(), - source_id, - source_status: Some("active".to_string()), - source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), - source_content_hash: Some(source_hash.to_string()), - source_snapshot: serde_json::json!({ - "schema": "test_source/v1", - "source_id": source_id, - "content_hash": source_hash, - }), - citation_metadata: serde_json::json!({}), - created_at: OffsetDateTime::UNIX_EPOCH, - } - } - - fn current_source_keys_for(source_refs: &[&KnowledgePageSourceRef]) -> BTreeSet { - source_refs - .iter() - .map(|source_ref| { - knowledge::current_key(source_ref.source_kind.as_str(), source_ref.source_id) - }) - .collect() - } - - fn test_page_response(section_id: Uuid, source_id: Uuid) -> KnowledgePageResponse { - let page = test_page(); - let section = test_section( - section_id, - "source-notes", - serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), - None, - ); - let source_ref = test_source_ref_for(section_id, source_id, "new-hash"); - - KnowledgePageResponse { - page: KnowledgePageSummary::from(page), - sections: vec![KnowledgePageSectionResponse { - citation_count: 1, - source_ref_count: 1, - coverage_complete: true, - source_backlinks: Vec::new(), - ..KnowledgePageSectionResponse::from(section) - }], - source_refs: vec![KnowledgePageSourceRefResponse::from(source_ref)], - lint_findings: Vec::new(), - } - } - - fn assert_candidate_is_reviewable(candidate: &KnowledgeDeltaMemoryCandidate) { - assert_eq!(candidate.reason, "changed_claim"); - assert_eq!(candidate.source_refs.len(), 1); - assert_eq!(candidate.source_refs[0].kind.as_str(), "note"); - assert_eq!(candidate.source_snapshot["source_mutation_allowed"], false); - assert_eq!(candidate.diff.after["reason"], "changed_claim"); - assert_eq!(candidate.proposed_payload["type"], "plan"); - assert_eq!(candidate.proposed_payload["source_ref"]["schema"], "elf.knowledge_delta/v1"); - } -} +#[path = "knowledge/tests.rs"] +mod tests; diff --git a/packages/elf-service/src/knowledge/api.rs b/packages/elf-service/src/knowledge/api.rs new file mode 100644 index 00000000..2667e4c7 --- /dev/null +++ b/packages/elf-service/src/knowledge/api.rs @@ -0,0 +1,40 @@ +mod readback; +mod requests; +mod search; +mod watch; + +pub use self::{ + readback::{ + KnowledgePageLintFindingResponse, KnowledgePageLintResponse, KnowledgePageRebuildResponse, + KnowledgePageResponse, KnowledgePageSectionResponse, KnowledgePageSectionSourceBacklink, + KnowledgePageSourceRefResponse, KnowledgePageSummary, KnowledgePagesListResponse, + }, + requests::{ + KnowledgePageChangedSource, KnowledgePageGetRequest, KnowledgePageLintRequest, + KnowledgePageRebuildRequest, KnowledgePageSearchRequest, KnowledgePageWatchRebuildRequest, + KnowledgePagesListRequest, + }, + search::{KnowledgePageLintSummary, KnowledgePageSearchItem, KnowledgePageSearchResponse}, + watch::{ + KnowledgeDeltaMemoryCandidate, KnowledgePageProposalRunSummary, KnowledgePageRebuildOutput, + KnowledgePageSectionRebuildState, KnowledgePageWatchRebuildItem, + KnowledgePageWatchRebuildResponse, KnowledgePageWatchRebuildSummary, + }, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::knowledge::{ + default_generate_memory_candidates, empty_object, previous_version_diff_from_metadata, + repair_guidance_for_finding_type, +}; +use elf_domain::{ + consolidation::{ConsolidationInputRef, ConsolidationProposalDiff}, + knowledge::{KnowledgePageKind, KnowledgeSourceKind}, +}; +use elf_storage::models::{ + KnowledgePage, KnowledgePageLintFinding, KnowledgePageSection, KnowledgePageSourceRef, +}; diff --git a/packages/elf-service/src/knowledge/api/readback.rs b/packages/elf-service/src/knowledge/api/readback.rs new file mode 100644 index 00000000..069eb6d1 --- /dev/null +++ b/packages/elf-service/src/knowledge/api/readback.rs @@ -0,0 +1,275 @@ +use crate::knowledge::api::{ + self, KnowledgePage, KnowledgePageLintFinding, KnowledgePageSection, KnowledgePageSourceRef, + OffsetDateTime, Serialize, Uuid, Value, +}; + +/// Response returned after rebuilding a derived knowledge page. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageRebuildResponse { + /// Rebuilt page with sections, source refs, and lint findings. + pub page: KnowledgePageResponse, +} + +/// Response returned by derived knowledge page listing. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePagesListResponse { + /// Returned pages. + pub pages: Vec, +} + +/// Response returned after linting one knowledge page. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageLintResponse { + /// Page identifier. + pub page_id: Uuid, + /// Current lint findings. + pub findings: Vec, +} + +/// Summary DTO for one derived knowledge page. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSummary { + /// Page identifier. + pub page_id: Uuid, + /// Tenant that owns the page. + pub tenant_id: String, + /// Project that owns the page. + pub project_id: String, + /// Page kind. + pub page_kind: String, + /// Stable page key. + pub page_key: String, + /// Page title. + pub title: String, + /// Versioned page contract schema. + pub contract_schema: String, + /// Page lifecycle status. + pub status: String, + /// Canonical source snapshot hash. + pub rebuild_source_hash: String, + /// Canonical page content hash. + pub content_hash: String, + /// Source coverage metadata. + pub source_coverage: Value, + /// Rebuild metadata. + pub rebuild_metadata: Value, + /// Previous-version diff metadata, when present. + pub previous_version_diff: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, + /// Last rebuild timestamp. + pub rebuilt_at: OffsetDateTime, +} +impl From for KnowledgePageSummary { + fn from(page: KnowledgePage) -> Self { + Self { + page_id: page.page_id, + tenant_id: page.tenant_id, + project_id: page.project_id, + page_kind: page.page_kind, + page_key: page.page_key, + title: page.title, + contract_schema: page.contract_schema, + status: page.status, + rebuild_source_hash: page.rebuild_source_hash, + content_hash: page.content_hash, + source_coverage: page.source_coverage, + previous_version_diff: api::previous_version_diff_from_metadata(&page.rebuild_metadata), + rebuild_metadata: page.rebuild_metadata, + created_at: page.created_at, + updated_at: page.updated_at, + rebuilt_at: page.rebuilt_at, + } + } +} + +/// Full readback DTO for one derived knowledge page. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageResponse { + /// Page summary. + pub page: KnowledgePageSummary, + /// Page sections. + pub sections: Vec, + /// Normalized source refs. + pub source_refs: Vec, + /// Lint findings. + pub lint_findings: Vec, +} + +/// Readback DTO for one page section. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSectionResponse { + /// Section identifier. + pub section_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Stable section key. + pub section_key: String, + /// Section heading. + pub heading: String, + /// Section role. + pub role: String, + /// Section content. + pub content: String, + /// Display order. + pub ordinal: i32, + /// Serialized citation array. + pub citations: Value, + /// Reason this section is intentionally unsupported, when present. + pub unsupported_reason: Option, + /// Count of section-local citations. + pub citation_count: usize, + /// Count of normalized source refs attached to this section. + pub source_ref_count: usize, + /// True when the section has both citations and normalized source backlinks. + pub coverage_complete: bool, + /// Section-local normalized source backlinks. + pub source_backlinks: Vec, + /// Section content hash. + pub content_hash: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} +impl From for KnowledgePageSectionResponse { + fn from(section: KnowledgePageSection) -> Self { + Self { + section_id: section.section_id, + page_id: section.page_id, + section_key: section.section_key, + heading: section.heading, + role: section.role, + content: section.content, + ordinal: section.ordinal, + citations: section.citations, + unsupported_reason: section.unsupported_reason, + citation_count: 0, + source_ref_count: 0, + coverage_complete: false, + source_backlinks: Vec::new(), + content_hash: section.content_hash, + created_at: section.created_at, + updated_at: section.updated_at, + } + } +} + +/// Section-local source backlink used by page readback and viewer provenance. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSectionSourceBacklink { + /// Source kind. + pub source_kind: String, + /// Authoritative source identifier. + pub source_id: Uuid, + /// Captured source status. + pub source_status: Option, + /// Captured source update timestamp. + pub source_updated_at: Option, + /// Captured source content hash. + pub source_content_hash: Option, +} +impl From<&KnowledgePageSourceRef> for KnowledgePageSectionSourceBacklink { + fn from(source_ref: &KnowledgePageSourceRef) -> Self { + Self { + source_kind: source_ref.source_kind.clone(), + source_id: source_ref.source_id, + source_status: source_ref.source_status.clone(), + source_updated_at: source_ref.source_updated_at, + source_content_hash: source_ref.source_content_hash.clone(), + } + } +} + +/// Readback DTO for one normalized source reference. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSourceRefResponse { + /// Source-reference row identifier. + pub ref_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Citing section, when section-scoped. + pub section_id: Option, + /// Source kind. + pub source_kind: String, + /// Authoritative source identifier. + pub source_id: Uuid, + /// Captured source status. + pub source_status: Option, + /// Captured source update timestamp. + pub source_updated_at: Option, + /// Captured source content hash. + pub source_content_hash: Option, + /// Captured source snapshot. + pub source_snapshot: Value, + /// Citation-local metadata. + pub citation_metadata: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} +impl From for KnowledgePageSourceRefResponse { + fn from(source_ref: KnowledgePageSourceRef) -> Self { + Self { + ref_id: source_ref.ref_id, + page_id: source_ref.page_id, + section_id: source_ref.section_id, + source_kind: source_ref.source_kind, + source_id: source_ref.source_id, + source_status: source_ref.source_status, + source_updated_at: source_ref.source_updated_at, + source_content_hash: source_ref.source_content_hash, + source_snapshot: source_ref.source_snapshot, + citation_metadata: source_ref.citation_metadata, + created_at: source_ref.created_at, + } + } +} + +/// Readback DTO for one knowledge page lint finding. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageLintFindingResponse { + /// Lint finding identifier. + pub finding_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Associated section, when available. + pub section_id: Option, + /// Finding type. + pub finding_type: String, + /// Finding severity. + pub severity: String, + /// Source kind associated with the finding, when available. + pub source_kind: Option, + /// Source identifier associated with the finding, when available. + pub source_id: Option, + /// Human-readable finding message. + pub message: String, + /// Structured finding details. + pub details: Value, + /// Operator guidance for repair or rebuild. + pub repair_guidance: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} +impl From for KnowledgePageLintFindingResponse { + fn from(finding: KnowledgePageLintFinding) -> Self { + let repair_guidance = + api::repair_guidance_for_finding_type(finding.finding_type.as_str()).to_string(); + + Self { + finding_id: finding.finding_id, + page_id: finding.page_id, + section_id: finding.section_id, + finding_type: finding.finding_type, + severity: finding.severity, + source_kind: finding.source_kind, + source_id: finding.source_id, + message: finding.message, + repair_guidance, + details: finding.details, + created_at: finding.created_at, + } + } +} diff --git a/packages/elf-service/src/knowledge/api/requests.rs b/packages/elf-service/src/knowledge/api/requests.rs new file mode 100644 index 00000000..ab0097ea --- /dev/null +++ b/packages/elf-service/src/knowledge/api/requests.rs @@ -0,0 +1,125 @@ +use crate::knowledge::api::{ + Deserialize, KnowledgePageKind, KnowledgeSourceKind, Serialize, Uuid, Value, + default_generate_memory_candidates, empty_object, +}; + +/// Request to rebuild one derived knowledge page from explicit source ids. +#[derive(Clone, Debug, Deserialize)] +pub struct KnowledgePageRebuildRequest { + /// Tenant that owns the page and source records. + pub tenant_id: String, + /// Project that owns the page and source records. + pub project_id: String, + /// Agent requesting the rebuild. + pub agent_id: String, + /// Page kind. + pub page_kind: KnowledgePageKind, + /// Stable page key within the tenant/project/kind namespace. + pub page_key: String, + /// Optional display title; a deterministic title is generated when omitted. + pub title: Option, + #[serde(default)] + /// Source Library documents to compile into the page. + pub doc_ids: Vec, + #[serde(default)] + /// Source Library document chunks or spans to compile into the page. + pub doc_chunk_ids: Vec, + #[serde(default)] + /// Memory note sources to compile into the page. + pub note_ids: Vec, + #[serde(default)] + /// Durable add_event audit source ids to compile into the page. + pub event_ids: Vec, + #[serde(default)] + /// Graph relation fact ids to compile into the page. + pub relation_ids: Vec, + #[serde(default)] + /// Applied consolidation proposal ids to compile into the page. + pub proposal_ids: Vec, + #[serde(default = "empty_object")] + /// Provider metadata for nondeterministic or future LLM-derived rebuilds. + pub provider_metadata: Value, +} + +/// Request to get one derived knowledge page. +#[derive(Clone, Debug, Deserialize)] +pub struct KnowledgePageGetRequest { + /// Tenant that owns the page. + pub tenant_id: String, + /// Project that owns the page. + pub project_id: String, + /// Page identifier. + pub page_id: Uuid, +} + +/// Request to list derived knowledge pages. +#[derive(Clone, Debug, Deserialize)] +pub struct KnowledgePagesListRequest { + /// Tenant that owns the pages. + pub tenant_id: String, + /// Project that owns the pages. + pub project_id: String, + /// Optional page-kind filter. + pub page_kind: Option, + /// Maximum number of pages to return. + pub limit: Option, +} + +/// Request to lint one derived knowledge page against current source snapshots. +#[derive(Clone, Debug, Deserialize)] +pub struct KnowledgePageLintRequest { + /// Tenant that owns the page. + pub tenant_id: String, + /// Project that owns the page. + pub project_id: String, + /// Page identifier. + pub page_id: Uuid, +} + +/// Request to search derived knowledge page sections. +#[derive(Clone, Debug, Deserialize)] +pub struct KnowledgePageSearchRequest { + /// Tenant that owns the pages. + pub tenant_id: String, + /// Project that owns the pages. + pub project_id: String, + /// Agent requesting the page search. + pub agent_id: String, + /// Read profile controlling source visibility. + pub read_profile: String, + /// English-only query for page title, key, heading, or section content. + pub query: String, + /// Optional page-kind filter. + pub page_kind: Option, + /// Maximum number of section snippets to return. + pub limit: Option, +} + +/// Request to rebuild pages affected by changed authoritative sources. +#[derive(Clone, Debug, Deserialize)] +pub struct KnowledgePageWatchRebuildRequest { + /// Tenant that owns the pages and changed sources. + pub tenant_id: String, + /// Project that owns the pages and changed sources. + pub project_id: String, + /// Agent requesting the watch/rebuild operation. + pub agent_id: String, + /// Changed source references observed by a watcher or operator. + pub changed_sources: Vec, + /// Optional page-kind filter for the affected-page lookup. + pub page_kind: Option, + /// Maximum number of affected pages to rebuild. + pub limit: Option, + #[serde(default = "default_generate_memory_candidates")] + /// Whether changed knowledge deltas should queue reviewable memory proposals. + pub generate_memory_candidates: bool, +} + +/// Changed authoritative source reference for the watch/rebuild loop. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KnowledgePageChangedSource { + /// Changed source kind. + pub source_kind: KnowledgeSourceKind, + /// Changed source identifier. + pub source_id: Uuid, +} diff --git a/packages/elf-service/src/knowledge/api/search.rs b/packages/elf-service/src/knowledge/api/search.rs new file mode 100644 index 00000000..ea69c275 --- /dev/null +++ b/packages/elf-service/src/knowledge/api/search.rs @@ -0,0 +1,78 @@ +use crate::knowledge::api::{ + KnowledgePageSourceRefResponse, OffsetDateTime, Serialize, Uuid, Value, +}; + +/// Response returned by derived knowledge page section search. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSearchResponse { + /// Matching derived page snippets. + pub items: Vec, +} + +/// Search result for one derived knowledge page section. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSearchItem { + /// Result type discriminator for clients that mix pages with notes. + pub result_kind: String, + /// Derived page identifier. + pub page_id: Uuid, + /// Page kind. + pub page_kind: String, + /// Stable page key. + pub page_key: String, + /// Page title. + pub title: String, + /// Page lifecycle status. + pub status: String, + /// Section identifier. + pub section_id: Uuid, + /// Stable section key. + pub section_key: String, + /// Section heading. + pub heading: String, + /// Section role. + pub role: String, + /// Bounded matching section snippet. + pub snippet: String, + /// Section citations for visible provenance. + pub citations: Value, + /// Count of section-local citations. + pub citation_count: usize, + /// Count of normalized source refs attached to this section. + pub source_ref_count: usize, + /// Section-local source refs for backlink readback. + pub source_refs: Vec, + /// Page-level source coverage metadata. + pub source_coverage: Value, + /// Page-level rebuild metadata. + pub rebuild_metadata: Value, + /// Previous-version diff metadata, when present. + pub previous_version_diff: Option, + /// Lint summary for distinguishing clean, stale, and unsupported pages. + pub lint_summary: KnowledgePageLintSummary, + /// Trust state discriminator for viewer/search clients. + pub trust_state: String, + /// Explicit notice that the result is derived, not authoritative source truth. + pub derived_notice: String, + /// Repair or rebuild guidance when lint or coverage indicates risk. + pub repair_guidance: Option, + /// Page update timestamp. + pub updated_at: OffsetDateTime, + /// Page rebuild timestamp. + pub rebuilt_at: OffsetDateTime, +} + +/// Aggregate lint counts for page search results. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageLintSummary { + /// Error finding count. + pub error_count: i64, + /// Warning finding count. + pub warning_count: i64, + /// Info finding count. + pub info_count: i64, + /// True when at least one error finding exists. + pub has_errors: bool, + /// True when at least one warning finding exists. + pub has_warnings: bool, +} diff --git a/packages/elf-service/src/knowledge/api/watch.rs b/packages/elf-service/src/knowledge/api/watch.rs new file mode 100644 index 00000000..5257938e --- /dev/null +++ b/packages/elf-service/src/knowledge/api/watch.rs @@ -0,0 +1,135 @@ +use crate::knowledge::api::{ + ConsolidationInputRef, ConsolidationProposalDiff, KnowledgePageResponse, Serialize, Uuid, Value, +}; + +/// Response returned after rebuilding pages affected by changed sources. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageWatchRebuildResponse { + /// Versioned response schema. + pub schema: String, + /// Operator-readable aggregate summary. + pub summary: KnowledgePageWatchRebuildSummary, + /// Per-page rebuild results. + pub pages: Vec, + /// Reviewable memory candidates derived from knowledge deltas. + pub memory_candidates: Vec, + /// Queued consolidation run, when memory candidates were generated. + pub proposal_run: Option, + /// One-line operator summary messages. + pub operator_summary: Vec, +} + +/// Aggregate watch/rebuild outcome counters. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageWatchRebuildSummary { + /// Changed source count after de-duplication. + pub changed_source_count: usize, + /// Knowledge pages that cited one of the changed sources. + pub affected_page_count: usize, + /// Pages rebuilt with changed derived output. + pub changed_page_count: usize, + /// Pages rebuilt with unchanged derived output. + pub unchanged_page_count: usize, + /// Pages that had stale lint findings before rebuild. + pub stale_page_count: usize, + /// Pages that could not be rebuilt. + pub blocked_page_count: usize, + /// Memory candidates generated for review. + pub memory_candidate_count: usize, +} + +/// Per-page changed-source rebuild result. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageWatchRebuildItem { + /// Knowledge page identifier. + pub page_id: Uuid, + /// Page kind. + pub page_kind: String, + /// Stable page key. + pub page_key: String, + /// Page title. + pub title: String, + /// Page rebuild state: changed, unchanged, stale, or blocked. + pub rebuild_state: String, + /// Per-section rebuild states. + pub sections: Vec, + /// Classified rebuild/lint outputs. + pub outputs: Vec, + /// Rebuilt page readback, omitted when blocked. + pub rebuilt_page: Option, + /// Blocking error text, when rebuild failed. + pub blocked_reason: Option, + /// Previous-version diff metadata, when available. + pub previous_version_diff: Option, + /// Operator-readable page summary. + pub operator_summary: String, +} + +/// Per-section rebuild state for changed-source rebuild output. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageSectionRebuildState { + /// Stable section key. + pub section_key: String, + /// Section heading. + pub heading: String, + /// Section state: changed, unchanged, stale, or blocked. + pub state: String, + /// Output types attached to the section. + pub output_types: Vec, + /// Lint finding types attached to the section before rebuild. + pub lint_finding_types: Vec, +} + +/// Classified output emitted by the watch/rebuild loop. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageRebuildOutput { + /// Output type, such as stale_section, changed_claim, missing_citation, conflict, + /// changed_source, or blocked. + pub output_type: String, + /// Severity for operator triage. + pub severity: String, + /// Associated section key, when section-scoped. + pub section_key: Option, + /// Associated source kind, when source-scoped. + pub source_kind: Option, + /// Associated source id, when source-scoped. + pub source_id: Option, + /// Human-readable output message. + pub message: String, + /// Structured reason and evidence details. + pub details: Value, +} + +/// Reviewable memory candidate produced from a knowledge delta. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgeDeltaMemoryCandidate { + /// Candidate reason, such as changed_claim or conflict. + pub reason: String, + /// Knowledge page identifier. + pub page_id: Uuid, + /// Section identifier that produced the candidate. + pub section_id: Uuid, + /// Stable section key. + pub section_key: String, + /// Source refs copied into the reviewable proposal. + pub source_refs: Vec, + /// Source snapshot summary for reviewer inspection. + pub source_snapshot: Value, + /// Reviewable proposal diff. + pub diff: ConsolidationProposalDiff, + /// Proposed memory note payload. + pub proposed_payload: Value, +} + +/// Queued reviewable proposal run produced by changed-source rebuild. +#[derive(Clone, Debug, Serialize)] +pub struct KnowledgePageProposalRunSummary { + /// Consolidation run identifier. + pub run_id: Uuid, + /// Queued worker job identifier. + pub job_id: Uuid, + /// Number of memory candidate proposals queued in the run payload. + pub proposal_count: usize, + /// Review surface for the queued candidates. + pub review_surface: String, +} diff --git a/packages/elf-service/src/knowledge/lint.rs b/packages/elf-service/src/knowledge/lint.rs new file mode 100644 index 00000000..e3367bd2 --- /dev/null +++ b/packages/elf-service/src/knowledge/lint.rs @@ -0,0 +1,75 @@ +use crate::knowledge::{ + ElfService, Error, KnowledgePage, KnowledgePageLintFindingResponse, KnowledgePageLintRequest, + KnowledgePageLintResponse, KnowledgePageSourceRef, LintDraft, OffsetDateTime, Result, + SourceIds, knowledge, +}; + +impl ElfService { + /// Lints a derived knowledge page against current source snapshots. + pub async fn knowledge_page_lint( + &self, + req: KnowledgePageLintRequest, + ) -> Result { + let page = knowledge::get_knowledge_page( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.page_id, + ) + .await? + .ok_or_else(|| Error::NotFound { message: "knowledge page not found".to_string() })?; + let source_refs = + knowledge::list_knowledge_page_source_refs(&self.db.pool, page.page_id).await?; + let sections = knowledge::list_knowledge_page_sections(&self.db.pool, page.page_id).await?; + let mut findings = self.lint_source_refs(&page, &source_refs).await?; + + findings.extend(crate::knowledge::lint_page_sections(&page, §ions, &source_refs)); + + let now = OffsetDateTime::now_utc(); + let mut tx = self.db.pool.begin().await?; + + knowledge::delete_knowledge_page_lint_findings(&mut *tx, page.page_id).await?; + + for finding in &findings { + crate::knowledge::insert_lint_finding(&mut tx, page.page_id, finding, now).await?; + } + + tx.commit().await?; + + let persisted = knowledge::list_knowledge_page_lint_findings(&self.db.pool, page.page_id) + .await? + .into_iter() + .map(KnowledgePageLintFindingResponse::from) + .collect(); + + Ok(KnowledgePageLintResponse { page_id: page.page_id, findings: persisted }) + } + + pub(in crate::knowledge) async fn lint_source_refs( + &self, + page: &KnowledgePage, + source_refs: &[KnowledgePageSourceRef], + ) -> Result> { + let ids = SourceIds::from_source_refs(source_refs)?; + let current = self.resolve_current_source_map(page, &ids).await?; + let mut findings = Vec::new(); + + for source_ref in source_refs { + let key = crate::knowledge::current_key( + source_ref.source_kind.as_str(), + source_ref.source_id, + ); + let Some(snapshot) = current.get(&key) else { + findings.push(crate::knowledge::missing_source_finding(source_ref)); + + continue; + }; + + if crate::knowledge::source_changed(source_ref, snapshot) { + findings.push(crate::knowledge::stale_source_finding(source_ref, snapshot)); + } + } + + Ok(findings) + } +} diff --git a/packages/elf-service/src/knowledge/persistence.rs b/packages/elf-service/src/knowledge/persistence.rs new file mode 100644 index 00000000..1b658782 --- /dev/null +++ b/packages/elf-service/src/knowledge/persistence.rs @@ -0,0 +1,111 @@ +use crate::knowledge::{ + DraftSection, Error, KnowledgePageLintFindingInsert, KnowledgePageSectionInsert, + KnowledgePageSourceRefInsert, KnowledgeSourceKind, LintDraft, OffsetDateTime, Postgres, Result, + SourceSnapshot, Transaction, Uuid, knowledge, +}; + +pub(super) async fn replace_page_children( + tx: &mut Transaction<'_, Postgres>, + page_id: Uuid, + sections: &[DraftSection], + sources: &[SourceSnapshot], + lint: &[LintDraft], + now: OffsetDateTime, +) -> Result<()> { + knowledge::delete_knowledge_page_children(&mut **tx, page_id).await?; + + for section in sections { + insert_section(tx, page_id, section, now).await?; + + for source_index in §ion.source_indexes { + let source = sources.get(*source_index).ok_or_else(|| Error::InvalidRequest { + message: "knowledge page section referenced an unknown source".to_string(), + })?; + + insert_source_ref(tx, page_id, section.section_id, source, now).await?; + } + } + for finding in lint { + insert_lint_finding(tx, page_id, finding, now).await?; + } + + Ok(()) +} + +pub(super) async fn insert_section( + tx: &mut Transaction<'_, Postgres>, + page_id: Uuid, + section: &DraftSection, + now: OffsetDateTime, +) -> Result<()> { + knowledge::insert_knowledge_page_section( + &mut **tx, + KnowledgePageSectionInsert { + section_id: section.section_id, + page_id, + section_key: section.section_key.as_str(), + heading: section.heading.as_str(), + role: section.role.as_str(), + content: section.content.as_str(), + ordinal: section.ordinal, + citations: §ion.citations, + unsupported_reason: section.unsupported_reason.as_deref(), + content_hash: section.content_hash.as_str(), + now, + }, + ) + .await + .map_err(Error::from) +} + +pub(super) async fn insert_source_ref( + tx: &mut Transaction<'_, Postgres>, + page_id: Uuid, + section_id: Uuid, + source: &SourceSnapshot, + now: OffsetDateTime, +) -> Result<()> { + knowledge::insert_knowledge_page_source_ref( + &mut **tx, + KnowledgePageSourceRefInsert { + ref_id: Uuid::new_v4(), + page_id, + section_id: Some(section_id), + source_kind: source.kind.as_str(), + source_id: source.id, + source_status: source.status.as_deref(), + source_updated_at: source.updated_at, + source_content_hash: source.content_hash.as_deref(), + source_snapshot: &source.snapshot, + citation_metadata: &source.citation_metadata, + now, + }, + ) + .await + .map_err(Error::from) +} + +pub(super) async fn insert_lint_finding( + tx: &mut Transaction<'_, Postgres>, + page_id: Uuid, + finding: &LintDraft, + now: OffsetDateTime, +) -> Result<()> { + knowledge::insert_knowledge_page_lint_finding( + &mut **tx, + KnowledgePageLintFindingInsert { + finding_id: Uuid::new_v4(), + page_id, + section_id: finding.section_id, + finding_type: finding.finding_type.as_str(), + severity: finding.severity.as_str(), + source_kind: finding.source_kind.map(KnowledgeSourceKind::as_str), + source_id: finding.source_id, + message: finding.message.as_str(), + details: &finding.details, + now, + }, + ) + .await + .map_err(Error::from) +} diff --git a/packages/elf-service/src/knowledge/read.rs b/packages/elf-service/src/knowledge/read.rs new file mode 100644 index 00000000..c069f61f --- /dev/null +++ b/packages/elf-service/src/knowledge/read.rs @@ -0,0 +1,153 @@ +use crate::knowledge::{ + ElfService, Error, KnowledgePage, KnowledgePageGetRequest, KnowledgePageKind, + KnowledgePageLintFindingResponse, KnowledgePageResponse, KnowledgePageSearchRequest, + KnowledgePageSearchResponse, KnowledgePageSourceRefResponse, KnowledgePageSummary, + KnowledgePagesListRequest, KnowledgePagesListResponse, Result, access, english_gate, knowledge, + search, +}; + +impl ElfService { + /// Gets one derived knowledge page with sections, source refs, and lint findings. + pub async fn knowledge_page_get( + &self, + req: KnowledgePageGetRequest, + ) -> Result { + let page = knowledge::get_knowledge_page( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.page_id, + ) + .await? + .ok_or_else(|| Error::NotFound { message: "knowledge page not found".to_string() })?; + + self.knowledge_page_response(page).await + } + + /// Lists derived knowledge pages. + pub async fn knowledge_pages_list( + &self, + req: KnowledgePagesListRequest, + ) -> Result { + let page_kind = req.page_kind.map(KnowledgePageKind::as_str); + let pages = knowledge::list_knowledge_pages( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + page_kind, + crate::knowledge::bounded_limit(req.limit), + ) + .await? + .into_iter() + .map(KnowledgePageSummary::from) + .collect(); + + Ok(KnowledgePagesListResponse { pages }) + } + + /// Searches derived knowledge page sections and returns provenance-rich snippets. + pub async fn knowledge_pages_search( + &self, + req: KnowledgePageSearchRequest, + ) -> Result { + crate::knowledge::validate_non_empty("tenant_id", req.tenant_id.as_str())?; + crate::knowledge::validate_non_empty("project_id", req.project_id.as_str())?; + crate::knowledge::validate_non_empty("agent_id", req.agent_id.as_str())?; + crate::knowledge::validate_non_empty("read_profile", req.read_profile.as_str())?; + crate::knowledge::validate_non_empty("query", req.query.as_str())?; + + if !english_gate::is_english_natural_language(req.query.as_str()) { + return Err(Error::NonEnglishInput { field: "$.query".to_string() }); + } + + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, req.read_profile.as_str())?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let query = req.query.trim().to_ascii_lowercase(); + let query_pattern = format!("%{query}%"); + let page_kind = req.page_kind.map(KnowledgePageKind::as_str); + let rows = knowledge::search_knowledge_page_sections( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + page_kind, + query_pattern.as_str(), + crate::knowledge::bounded_limit(req.limit), + ) + .await?; + let page_ids = crate::knowledge::sorted_unique( + &rows.iter().map(|row| row.page_id).collect::>(), + ); + let source_refs = + knowledge::list_knowledge_page_source_refs_for_pages(&self.db.pool, &page_ids).await?; + let current_source_keys = self + .resolve_current_recallable_source_keys( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + &allowed_scopes, + &shared_grants, + &source_refs, + ) + .await?; + let source_refs_by_section = crate::knowledge::source_refs_by_section(&source_refs); + let items = rows + .into_iter() + .filter_map(|row| { + let refs = crate::knowledge::cloned_source_refs( + source_refs_by_section.get(&row.section_id), + ); + + crate::knowledge::recallable_source_refs(refs.as_slice(), ¤t_source_keys) + .then(|| { + crate::knowledge::knowledge_page_search_item(row, refs, req.query.as_str()) + }) + }) + .collect(); + + Ok(KnowledgePageSearchResponse { items }) + } + + pub(in crate::knowledge) async fn knowledge_page_response( + &self, + page: KnowledgePage, + ) -> Result { + let page_id = page.page_id; + let section_rows = knowledge::list_knowledge_page_sections(&self.db.pool, page_id).await?; + let source_ref_rows = + knowledge::list_knowledge_page_source_refs(&self.db.pool, page_id).await?; + let source_refs_by_section = crate::knowledge::source_refs_by_section(&source_ref_rows); + let sections = section_rows + .into_iter() + .map(|section| { + let refs = crate::knowledge::cloned_source_refs( + source_refs_by_section.get(§ion.section_id), + ); + + crate::knowledge::section_response(section, refs) + }) + .collect(); + let source_refs = + source_ref_rows.into_iter().map(KnowledgePageSourceRefResponse::from).collect(); + let lint_findings = knowledge::list_knowledge_page_lint_findings(&self.db.pool, page_id) + .await? + .into_iter() + .map(KnowledgePageLintFindingResponse::from) + .collect(); + + Ok(KnowledgePageResponse { + page: KnowledgePageSummary::from(page), + sections, + source_refs, + lint_findings, + }) + } +} diff --git a/packages/elf-service/src/knowledge/rebuild.rs b/packages/elf-service/src/knowledge/rebuild.rs new file mode 100644 index 00000000..14eb6252 --- /dev/null +++ b/packages/elf-service/src/knowledge/rebuild.rs @@ -0,0 +1,123 @@ +use crate::knowledge::{ + ElfService, KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, KnowledgePageRebuildRequest, + KnowledgePageRebuildResponse, KnowledgePageUpsert, OffsetDateTime, Result, SourceIds, Uuid, + knowledge, +}; + +impl ElfService { + /// Rebuilds and persists one derived knowledge page from explicit source ids. + pub async fn knowledge_page_rebuild( + &self, + req: KnowledgePageRebuildRequest, + ) -> Result { + crate::knowledge::validate_context( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + )?; + crate::knowledge::validate_non_empty("page_key", req.page_key.as_str())?; + crate::knowledge::validate_object("provider_metadata", &req.provider_metadata)?; + + let ids = SourceIds::from_request(&req)?; + let title = req + .title + .clone() + .unwrap_or_else(|| crate::knowledge::generated_title(req.page_kind, &req.page_key)); + let previous_page = knowledge::get_knowledge_page_by_key( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.page_kind.as_str(), + req.page_key.as_str(), + ) + .await?; + let previous_sections = match &previous_page { + Some(page) => + knowledge::list_knowledge_page_sections(&self.db.pool, page.page_id).await?, + None => Vec::new(), + }; + let sources = self.resolve_sources(&req, &ids).await?; + let now = OffsetDateTime::now_utc(); + let source_snapshot = crate::knowledge::source_snapshot_value(&sources); + let source_hash = crate::knowledge::hash_json(&source_snapshot)?; + let mut sections = crate::knowledge::build_sections(&sources)?; + let lint = crate::knowledge::lint_unsupported_sections(§ions); + + for section in &mut sections { + section.citations = crate::knowledge::citations_value(section, &sources); + section.content_hash = + crate::knowledge::hash_json(&crate::knowledge::section_hash_payload(section))?; + } + + let source_coverage = crate::knowledge::source_coverage_value( + req.page_kind, + &req.page_key, + §ions, + &sources, + ); + let base_rebuild_metadata = + crate::knowledge::rebuild_metadata(&source_hash, &req.provider_metadata, &req); + let content_hash = crate::knowledge::page_content_hash( + &title, + §ions, + &source_coverage, + &base_rebuild_metadata, + )?; + let previous_version_diff = crate::knowledge::previous_version_diff_value( + previous_page.as_ref(), + &previous_sections, + title.as_str(), + source_hash.as_str(), + content_hash.as_str(), + §ions, + ); + let version_identity = crate::knowledge::version_identity_value( + req.page_kind, + req.page_key.as_str(), + source_hash.as_str(), + content_hash.as_str(), + §ions, + ); + let rebuild_metadata = crate::knowledge::rebuild_metadata_with_previous_version_diff( + base_rebuild_metadata, + previous_version_diff, + version_identity, + ); + let page_id = Uuid::new_v4(); + let mut tx = self.db.pool.begin().await?; + let page = knowledge::upsert_knowledge_page( + &mut *tx, + KnowledgePageUpsert { + page_id, + tenant_id: req.tenant_id.as_str(), + project_id: req.project_id.as_str(), + page_kind: req.page_kind.as_str(), + page_key: req.page_key.as_str(), + title: title.as_str(), + contract_schema: KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, + status: "active", + rebuild_source_hash: source_hash.as_str(), + content_hash: content_hash.as_str(), + source_coverage: &source_coverage, + source_snapshot: &source_snapshot, + rebuild_metadata: &rebuild_metadata, + now, + }, + ) + .await?; + + crate::knowledge::replace_page_children( + &mut tx, + page.page_id, + §ions, + &sources, + &lint, + now, + ) + .await?; + + tx.commit().await?; + + Ok(KnowledgePageRebuildResponse { page: self.knowledge_page_response(page).await? }) + } +} diff --git a/packages/elf-service/src/knowledge/resolve.rs b/packages/elf-service/src/knowledge/resolve.rs new file mode 100644 index 00000000..dd1a01a9 --- /dev/null +++ b/packages/elf-service/src/knowledge/resolve.rs @@ -0,0 +1,236 @@ +use crate::{ + access::SharedSpaceGrantKey, + knowledge::{ + BTreeMap, BTreeSet, ElfService, Error, HashSet, KnowledgeDocChunkSource, + KnowledgeDocSource, KnowledgeEventSource, KnowledgeNoteSource, KnowledgePage, + KnowledgePageKind, KnowledgePageRebuildRequest, KnowledgePageSourceRef, + KnowledgeProposalSource, KnowledgeRelationSource, KnowledgeRelationSourcesFetch, Result, + SourceIds, SourceSnapshot, access, knowledge, + }, +}; + +impl ElfService { + pub(in crate::knowledge) async fn resolve_sources( + &self, + req: &KnowledgePageRebuildRequest, + ids: &SourceIds, + ) -> Result> { + let allowed_scopes = self.cfg.scopes.allowed.as_slice(); + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let (docs, doc_chunks, notes, events, relations, proposals) = self + .resolve_existing_source_rows( + req.tenant_id.as_str(), + req.project_id.as_str(), + Some(req.agent_id.as_str()), + allowed_scopes, + &shared_grants, + ids, + ) + .await?; + + ids.require_counts( + docs.len(), + doc_chunks.len(), + notes.len(), + events.len(), + relations.len(), + proposals.len(), + )?; + + Ok(crate::knowledge::source_snapshots( + docs, doc_chunks, notes, events, relations, proposals, + )) + } + + #[allow(clippy::type_complexity)] + pub(in crate::knowledge) async fn resolve_existing_source_rows( + &self, + tenant_id: &str, + project_id: &str, + agent_id: Option<&str>, + allowed_scopes: &[String], + shared_grants: &HashSet, + ids: &SourceIds, + ) -> Result<( + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + )> { + let docs = knowledge::fetch_knowledge_doc_sources( + &self.db.pool, + tenant_id, + project_id, + agent_id, + allowed_scopes, + &ids.doc_ids, + ) + .await?; + let docs = docs + .into_iter() + .filter(|source| { + crate::knowledge::source_row_read_allowed( + source.agent_id.as_str(), + source.scope.as_str(), + agent_id, + allowed_scopes, + shared_grants, + ) + }) + .collect(); + let doc_chunks = knowledge::fetch_knowledge_doc_chunk_sources( + &self.db.pool, + tenant_id, + project_id, + agent_id, + allowed_scopes, + &ids.doc_chunk_ids, + ) + .await?; + let doc_chunks = doc_chunks + .into_iter() + .filter(|source| { + crate::knowledge::source_row_read_allowed( + source.agent_id.as_str(), + source.scope.as_str(), + agent_id, + allowed_scopes, + shared_grants, + ) + }) + .collect(); + let notes = knowledge::fetch_knowledge_note_sources( + &self.db.pool, + tenant_id, + project_id, + agent_id, + allowed_scopes, + &ids.note_ids, + ) + .await?; + let notes = notes + .into_iter() + .filter(|source| { + crate::knowledge::source_row_read_allowed( + source.agent_id.as_str(), + source.scope.as_str(), + agent_id, + allowed_scopes, + shared_grants, + ) + }) + .collect(); + let events = knowledge::fetch_knowledge_event_sources( + &self.db.pool, + tenant_id, + project_id, + agent_id, + allowed_scopes, + &ids.event_ids, + ) + .await?; + let events = events + .into_iter() + .filter(|source| { + crate::knowledge::source_row_read_allowed( + source.agent_id.as_str(), + source.scope.as_str(), + agent_id, + allowed_scopes, + shared_grants, + ) + }) + .collect(); + let shared_scope_keys = access::shared_scope_key_strings(shared_grants); + let private_allowed = allowed_scopes.iter().any(|scope| scope == "agent_private"); + let relations = knowledge::fetch_knowledge_relation_sources( + &self.db.pool, + KnowledgeRelationSourcesFetch { + tenant_id, + project_id, + agent_id, + allowed_scopes, + shared_scope_keys: shared_scope_keys.as_slice(), + private_allowed, + fact_ids: &ids.relation_ids, + }, + ) + .await?; + let proposals = knowledge::fetch_knowledge_proposal_sources( + &self.db.pool, + tenant_id, + project_id, + &ids.proposal_ids, + ) + .await?; + + Ok((docs, doc_chunks, notes, events, relations, proposals)) + } + + pub(in crate::knowledge) async fn resolve_current_source_map( + &self, + page: &KnowledgePage, + ids: &SourceIds, + ) -> Result> { + let _page_kind = KnowledgePageKind::parse(page.page_kind.as_str()).ok_or_else(|| { + Error::InvalidRequest { message: "stored knowledge page kind is invalid".to_string() } + })?; + let (docs, doc_chunks, notes, events, relations, proposals) = self + .resolve_existing_source_rows( + page.tenant_id.as_str(), + page.project_id.as_str(), + None, + self.cfg.scopes.allowed.as_slice(), + &HashSet::new(), + ids, + ) + .await?; + let mut sources = crate::knowledge::source_snapshots( + docs, doc_chunks, notes, events, relations, proposals, + ); + + Ok(sources + .drain(..) + .map(|source| (crate::knowledge::source_key(&source), source)) + .collect()) + } + + pub(in crate::knowledge) async fn resolve_current_recallable_source_keys( + &self, + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, + source_refs: &[KnowledgePageSourceRef], + ) -> Result> { + let ids = SourceIds::from_source_refs(source_refs)?; + let (docs, doc_chunks, notes, events, relations, proposals) = self + .resolve_existing_source_rows( + tenant_id, + project_id, + Some(agent_id), + allowed_scopes, + shared_grants, + &ids, + ) + .await?; + + Ok(crate::knowledge::source_snapshots( + docs, doc_chunks, notes, events, relations, proposals, + ) + .into_iter() + .map(|source| crate::knowledge::source_key(&source)) + .collect()) + } +} diff --git a/packages/elf-service/src/knowledge/responses.rs b/packages/elf-service/src/knowledge/responses.rs new file mode 100644 index 00000000..fca3b5c0 --- /dev/null +++ b/packages/elf-service/src/knowledge/responses.rs @@ -0,0 +1,151 @@ +use crate::knowledge::{ + self, KnowledgePageLintSummary, KnowledgePageSearchItem, KnowledgePageSearchRow, + KnowledgePageSection, KnowledgePageSectionResponse, KnowledgePageSectionSourceBacklink, + KnowledgePageSourceRef, KnowledgePageSourceRefResponse, KnowledgeSourceKind, + SEARCH_SNIPPET_CHARS, Value, +}; + +pub(super) fn section_response( + section: KnowledgePageSection, + source_refs: Vec, +) -> KnowledgePageSectionResponse { + let citation_count = knowledge::citation_count(§ion.citations); + let source_ref_count = source_refs.len(); + let source_backlinks = + source_refs.iter().map(KnowledgePageSectionSourceBacklink::from).collect(); + + KnowledgePageSectionResponse { + citation_count, + source_ref_count, + coverage_complete: citation_count > 0 && source_ref_count > 0, + source_backlinks, + ..KnowledgePageSectionResponse::from(section) + } +} + +pub(super) fn knowledge_page_search_item( + row: KnowledgePageSearchRow, + source_refs: Vec, + query: &str, +) -> KnowledgePageSearchItem { + let source_ref_count = usize::try_from(row.section_source_ref_count).unwrap_or(0); + let citation_count = knowledge::citation_count(&row.citations); + let lint_summary = KnowledgePageLintSummary { + error_count: row.lint_error_count, + warning_count: row.lint_warning_count, + info_count: row.lint_info_count, + has_errors: row.lint_error_count > 0, + has_warnings: row.lint_warning_count > 0, + }; + let coverage_complete = + row.source_coverage.get("coverage_complete").and_then(Value::as_bool).unwrap_or(false); + let trust_state = search_trust_state(&lint_summary, coverage_complete, &row); + let repair_guidance = search_repair_guidance(&trust_state); + let previous_version_diff = + knowledge::previous_version_diff_from_metadata(&row.rebuild_metadata); + + KnowledgePageSearchItem { + result_kind: "knowledge_page_section".to_string(), + page_id: row.page_id, + page_kind: row.page_kind, + page_key: row.page_key, + title: row.title, + status: row.status, + section_id: row.section_id, + section_key: row.section_key, + heading: row.heading, + role: row.role, + snippet: knowledge::snippet_for_query(row.content.as_str(), query, SEARCH_SNIPPET_CHARS), + citations: sanitize_search_citations(row.citations), + citation_count, + source_ref_count, + source_refs: source_refs.into_iter().map(search_source_ref_response).collect(), + source_coverage: row.source_coverage, + rebuild_metadata: row.rebuild_metadata, + previous_version_diff, + lint_summary, + trust_state, + derived_notice: + "Derived knowledge page snippet. Verify cited source documents, spans, memory notes, events, relations, or proposals before treating it as authoritative." + .to_string(), + repair_guidance, + updated_at: row.page_updated_at, + rebuilt_at: row.rebuilt_at, + } +} + +pub(super) fn search_source_ref_response( + source_ref: KnowledgePageSourceRef, +) -> KnowledgePageSourceRefResponse { + let mut response = KnowledgePageSourceRefResponse::from(source_ref); + + if response.source_kind == KnowledgeSourceKind::Proposal.as_str() { + response.source_snapshot = knowledge::sanitize_proposal_snapshot(&response.source_snapshot); + } + + response +} + +pub(super) fn sanitize_search_citations(citations: Value) -> Value { + let Value::Array(citations) = citations else { + return citations; + }; + + Value::Array(citations.into_iter().map(sanitize_search_citation).collect()) +} + +pub(super) fn sanitize_search_citation(mut citation: Value) -> Value { + let is_proposal = citation + .get("source_kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == KnowledgeSourceKind::Proposal.as_str()); + + if !is_proposal { + return citation; + } + + if let Some(object) = citation.as_object_mut() + && let Some(source_snapshot) = object.get_mut("source_snapshot") + { + *source_snapshot = knowledge::sanitize_proposal_snapshot(source_snapshot); + } + + citation +} + +pub(super) fn search_trust_state( + lint: &KnowledgePageLintSummary, + coverage_complete: bool, + row: &KnowledgePageSearchRow, +) -> String { + if lint.has_errors { + return "derived_error".to_string(); + } + if lint.has_warnings || row.unsupported_reason.is_some() { + return "derived_warning".to_string(); + } + + if !coverage_complete || row.section_source_ref_count == 0 { + return "derived_low_coverage".to_string(); + } + + "derived_clean".to_string() +} + +pub(super) fn search_repair_guidance(trust_state: &str) -> Option { + match trust_state { + "derived_error" => Some( + "Run knowledge page lint, inspect stale or missing source refs, then rebuild the page from current authoritative sources." + .to_string(), + ), + "derived_warning" => Some( + "Inspect unsupported or stale findings before using this derived snippet; rebuild after source review." + .to_string(), + ), + "derived_low_coverage" => Some( + "Rebuild with complete citations or add source-backed sections before relying on this page." + .to_string(), + ), + _ => None, + } +} diff --git a/packages/elf-service/src/knowledge/sections.rs b/packages/elf-service/src/knowledge/sections.rs new file mode 100644 index 00000000..7d4e05de --- /dev/null +++ b/packages/elf-service/src/knowledge/sections.rs @@ -0,0 +1,199 @@ +use crate::knowledge::{ + self, DraftSection, Error, HashMap, KnowledgePage, KnowledgePageSection, + KnowledgePageSourceRef, KnowledgeSourceKind, LintDraft, Result, SourceSnapshot, Uuid, Value, + serde_json, +}; + +pub(super) fn build_sections(sources: &[SourceSnapshot]) -> Result> { + let doc_indexes = knowledge::source_indexes(sources, KnowledgeSourceKind::Doc); + let doc_chunk_indexes = knowledge::source_indexes(sources, KnowledgeSourceKind::DocChunk); + let note_indexes = knowledge::source_indexes(sources, KnowledgeSourceKind::Note); + let event_indexes = knowledge::source_indexes(sources, KnowledgeSourceKind::Event); + let relation_indexes = knowledge::source_indexes(sources, KnowledgeSourceKind::Relation); + let proposal_indexes = knowledge::source_indexes(sources, KnowledgeSourceKind::Proposal); + let mut sections = Vec::new(); + + push_section( + &mut sections, + "source-documents", + "Source Documents", + "source_documents", + sources, + doc_indexes, + ); + push_section( + &mut sections, + "source-spans", + "Source Spans", + "source_spans", + sources, + doc_chunk_indexes, + ); + push_section( + &mut sections, + "source-notes", + "Source Notes", + "current_truth", + sources, + note_indexes, + ); + push_section(&mut sections, "event-audits", "Event Audits", "history", sources, event_indexes); + push_section(&mut sections, "relations", "Relations", "relations", sources, relation_indexes); + push_section( + &mut sections, + "reviewed-proposals", + "Reviewed Proposals", + "proposals", + sources, + proposal_indexes, + ); + + if sections.is_empty() { + return Err(Error::InvalidRequest { + message: "knowledge page rebuild did not produce any cited sections".to_string(), + }); + } + + Ok(sections) +} + +pub(super) fn push_section( + sections: &mut Vec, + section_key: &str, + heading: &str, + role: &str, + sources: &[SourceSnapshot], + source_indexes: Vec, +) { + if source_indexes.is_empty() { + return; + } + + let ordinal = i32::try_from(sections.len()).unwrap_or(i32::MAX); + let content = source_indexes + .iter() + .filter_map(|index| sources.get(*index)) + .map(|source| format!("- {}", source.line)) + .collect::>() + .join("\n"); + + sections.push(DraftSection { + section_id: Uuid::new_v4(), + section_key: section_key.to_string(), + heading: heading.to_string(), + role: role.to_string(), + content, + ordinal, + source_indexes, + unsupported_reason: None, + content_hash: String::new(), + citations: Value::Array(Vec::new()), + }); +} + +pub(super) fn lint_unsupported_sections(sections: &[DraftSection]) -> Vec { + sections + .iter() + .filter_map(|section| { + section.unsupported_reason.as_ref().map(|reason| LintDraft { + section_id: Some(section.section_id), + finding_type: "unsupported_claim".to_string(), + severity: "warning".to_string(), + source_kind: None, + source_id: None, + message: format!("Knowledge page section has unsupported content: {reason}"), + details: serde_json::json!({ + "section_key": section.section_key, + "unsupported_reason": reason, + "repair_guidance": knowledge::repair_guidance_for_finding_type("unsupported_claim"), + }), + }) + }) + .collect() +} + +pub(super) fn lint_page_sections( + page: &KnowledgePage, + sections: &[KnowledgePageSection], + source_refs: &[KnowledgePageSourceRef], +) -> Vec { + let source_refs_by_section = knowledge::source_refs_by_section(source_refs); + let mut findings = Vec::new(); + + for section in sections { + findings.extend(lint_one_section(section, &source_refs_by_section)); + } + + if !knowledge::coverage_complete(page.source_coverage.as_object()) { + findings.push(knowledge::low_source_coverage_finding(page)); + } + + findings +} + +pub(super) fn lint_one_section( + section: &KnowledgePageSection, + source_refs_by_section: &HashMap>, +) -> Vec { + let citation_count = knowledge::citation_count(§ion.citations); + let source_ref_count = + source_refs_by_section.get(§ion.section_id).map(Vec::len).unwrap_or_default(); + let mut findings = Vec::new(); + + if let Some(reason) = §ion.unsupported_reason { + findings.push(section_finding( + section, + "unsupported_claim", + "warning", + "Knowledge page section contains unsupported content.", + serde_json::json!({ + "unsupported_reason": reason, + "citation_count": citation_count, + "source_ref_count": source_ref_count, + }), + )); + } + + if citation_count == 0 && section.unsupported_reason.is_none() { + findings.push(section_finding( + section, + "missing_citation", + "error", + "Knowledge page section has no citations.", + serde_json::json!({ "source_ref_count": source_ref_count }), + )); + } + if source_ref_count == 0 && section.unsupported_reason.is_none() { + findings.push(section_finding( + section, + "missing_source_ref", + "error", + "Knowledge page section has no normalized source backlinks.", + serde_json::json!({ "citation_count": citation_count }), + )); + } + + findings +} + +pub(super) fn section_finding( + section: &KnowledgePageSection, + finding_type: &str, + severity: &str, + message: &str, + details: Value, +) -> LintDraft { + LintDraft { + section_id: Some(section.section_id), + finding_type: finding_type.to_string(), + severity: severity.to_string(), + source_kind: None, + source_id: None, + message: message.to_string(), + details: knowledge::with_repair_guidance( + details, + section.section_key.as_str(), + knowledge::repair_guidance_for_finding_type(finding_type), + ), + } +} diff --git a/packages/elf-service/src/knowledge/sources.rs b/packages/elf-service/src/knowledge/sources.rs new file mode 100644 index 00000000..00f8bd12 --- /dev/null +++ b/packages/elf-service/src/knowledge/sources.rs @@ -0,0 +1,154 @@ +use crate::knowledge::{ + self, BTreeSet, HashMap, HashSet, KnowledgeDocChunkSource, KnowledgeDocSource, + KnowledgeEventSource, KnowledgeNoteSource, KnowledgePageSourceRef, KnowledgeProposalSource, + KnowledgeRelationSource, SourceSnapshot, Uuid, Value, access, doc_chunk_source_snapshot, + doc_source_snapshot, event_source_snapshot, note_source_snapshot, proposal_source_snapshot, + relation_source_snapshot, source_sort_key, +}; + +pub(super) fn source_snapshots( + docs: Vec, + doc_chunks: Vec, + notes: Vec, + events: Vec, + relations: Vec, + proposals: Vec, +) -> Vec { + let mut sources = Vec::new(); + + sources.extend(docs.into_iter().map(doc_source_snapshot)); + sources.extend(doc_chunks.into_iter().map(doc_chunk_source_snapshot)); + sources.extend(notes.into_iter().map(note_source_snapshot)); + sources.extend(events.into_iter().map(event_source_snapshot)); + sources.extend(relations.into_iter().map(relation_source_snapshot)); + sources.extend(proposals.into_iter().map(proposal_source_snapshot)); + sources.sort_by_key(source_sort_key); + + sources +} + +pub(super) fn source_refs_by_section( + source_refs: &[KnowledgePageSourceRef], +) -> HashMap> { + let mut by_section = HashMap::>::new(); + + for source_ref in source_refs { + let Some(section_id) = source_ref.section_id else { + continue; + }; + + by_section.entry(section_id).or_default().push(clone_source_ref(source_ref)); + } + + by_section +} + +pub(super) fn recallable_source_refs( + source_refs: &[KnowledgePageSourceRef], + current_source_keys: &BTreeSet, +) -> bool { + !source_refs.is_empty() + && source_refs.iter().all(|source_ref| { + current_source_keys.contains(&knowledge::current_key( + source_ref.source_kind.as_str(), + source_ref.source_id, + )) && recallable_source_ref(source_ref) + }) +} + +pub(super) fn source_row_read_allowed( + owner_agent_id: &str, + scope: &str, + requester_agent_id: Option<&str>, + allowed_scopes: &[String], + shared_grants: &HashSet, +) -> bool { + if !allowed_scopes.iter().any(|allowed_scope| allowed_scope == scope) { + return false; + } + + let Some(requester_agent_id) = requester_agent_id else { + return true; + }; + + if scope == "agent_private" { + return owner_agent_id == requester_agent_id; + } + if !matches!(scope, "project_shared" | "org_shared") { + return false; + } + if owner_agent_id == requester_agent_id { + return true; + } + + shared_grants.contains(&access::SharedSpaceGrantKey { + scope: scope.to_string(), + space_owner_agent_id: owner_agent_id.to_string(), + }) +} + +pub(super) fn recallable_source_ref(source_ref: &KnowledgePageSourceRef) -> bool { + let Some(status) = source_ref.source_status.as_deref().map(str::trim) else { + return false; + }; + + if !matches!(status, "active" | "remember" | "update" | "current" | "historical" | "applied") { + return false; + } + + !has_non_recallable_span(&source_ref.source_snapshot) +} + +pub(super) fn has_non_recallable_span(source_snapshot: &Value) -> bool { + match source_snapshot { + Value::Object(object) => + policy_spans_are_non_recallable(object.get("policy_spans")) + || object.get("source_span").is_some_and(span_is_non_recallable) + || source_spans_are_non_recallable(object.get("source_spans")) + || object.values().any(has_non_recallable_span), + Value::Array(items) => items.iter().any(has_non_recallable_span), + _ => false, + } +} + +pub(super) fn policy_spans_are_non_recallable(policy_spans: Option<&Value>) -> bool { + match policy_spans { + Some(Value::Array(spans)) => !spans.is_empty(), + Some(Value::Null) | None => false, + Some(_) => true, + } +} + +pub(super) fn source_spans_are_non_recallable(source_spans: Option<&Value>) -> bool { + match source_spans { + Some(Value::Array(spans)) => spans.iter().any(span_is_non_recallable), + Some(Value::Null) | None => false, + Some(_) => true, + } +} + +pub(super) fn span_is_non_recallable(span: &Value) -> bool { + !matches!(span.get("status").and_then(Value::as_str), Some("captured")) +} + +pub(super) fn cloned_source_refs( + source_refs: Option<&Vec>, +) -> Vec { + source_refs.map(|refs| refs.iter().map(clone_source_ref).collect()).unwrap_or_default() +} + +pub(super) fn clone_source_ref(source_ref: &KnowledgePageSourceRef) -> KnowledgePageSourceRef { + KnowledgePageSourceRef { + ref_id: source_ref.ref_id, + page_id: source_ref.page_id, + section_id: source_ref.section_id, + source_kind: source_ref.source_kind.clone(), + source_id: source_ref.source_id, + source_status: source_ref.source_status.clone(), + source_updated_at: source_ref.source_updated_at, + source_content_hash: source_ref.source_content_hash.clone(), + source_snapshot: source_ref.source_snapshot.clone(), + citation_metadata: source_ref.citation_metadata.clone(), + created_at: source_ref.created_at, + } +} diff --git a/packages/elf-service/src/knowledge/support.rs b/packages/elf-service/src/knowledge/support.rs new file mode 100644 index 00000000..a4483243 --- /dev/null +++ b/packages/elf-service/src/knowledge/support.rs @@ -0,0 +1,47 @@ +mod coverage; +mod diff; +mod findings; +mod hash; +mod keys; +mod metadata; +mod snapshots; +mod text; +mod validation; + +pub(super) use self::{ + coverage::{ + citation_count, citations_value, coverage_complete, source_coverage_value, source_indexes, + source_snapshot_value, + }, + diff::previous_version_diff_value, + findings::{ + low_source_coverage_finding, missing_source_finding, repair_guidance_for_finding_type, + source_changed, stale_source_finding, with_repair_guidance, + }, + hash::{hash_json, hash_json_lossy, hash_text}, + keys::{ + bounded_limit, current_key, sorted_unique, source_key, source_sort_key, source_span_id, + }, + metadata::{ + page_content_hash, previous_version_diff_from_metadata, rebuild_metadata, + rebuild_metadata_with_previous_version_diff, section_hash_payload, version_identity_value, + }, + snapshots::{ + doc_chunk_source_snapshot, doc_source_snapshot, event_source_snapshot, + note_source_snapshot, proposal_source_snapshot, relation_source_snapshot, + sanitize_proposal_snapshot, + }, + text::{generated_title, normalize_whitespace, note_prefix, snippet_for_query, truncate_chars}, + validation::{empty_object, validate_context, validate_non_empty, validate_object}, +}; + +use crate::knowledge::{ + BTreeMap, BTreeSet, DEFAULT_LIST_LIMIT, DraftSection, Error, KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, + KNOWLEDGE_PAGE_REBUILD_SCHEMA_V1, KNOWLEDGE_PAGE_SOURCE_COVERAGE_SCHEMA_V1, + KNOWLEDGE_PAGE_VERSION_DIFF_SCHEMA_V1, KnowledgeDocChunkSource, KnowledgeDocSource, + KnowledgeEventSource, KnowledgeNoteSource, KnowledgePage, KnowledgePageKind, + KnowledgePageRebuildRequest, KnowledgePageSection, KnowledgePageSourceRef, + KnowledgeProposalSource, KnowledgeRelationSource, KnowledgeSourceKind, LintDraft, + MAX_LIST_LIMIT, Map, Number, PREVIOUS_VERSION_DIFF_KEY, Result, SourceSnapshot, Uuid, Value, + serde_json, +}; diff --git a/packages/elf-service/src/knowledge/support/coverage.rs b/packages/elf-service/src/knowledge/support/coverage.rs new file mode 100644 index 00000000..940cd01d --- /dev/null +++ b/packages/elf-service/src/knowledge/support/coverage.rs @@ -0,0 +1,99 @@ +use crate::knowledge::support::{ + BTreeMap, BTreeSet, DraftSection, KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, + KNOWLEDGE_PAGE_SOURCE_COVERAGE_SCHEMA_V1, KnowledgePageKind, KnowledgeSourceKind, Map, + SourceSnapshot, Value, serde_json, +}; + +pub(in crate::knowledge) fn coverage_complete(coverage: Option<&Map>) -> bool { + let Some(coverage) = coverage else { + return false; + }; + let source_count = coverage.get("source_count").and_then(Value::as_u64).unwrap_or(0); + let cited_count = coverage.get("cited_source_count").and_then(Value::as_u64).unwrap_or(0); + let complete = coverage.get("coverage_complete").and_then(Value::as_bool).unwrap_or(false); + + complete && source_count == cited_count +} + +pub(in crate::knowledge) fn citation_count(citations: &Value) -> usize { + citations.as_array().map(Vec::len).unwrap_or_default() +} + +pub(in crate::knowledge) fn source_indexes( + sources: &[SourceSnapshot], + kind: KnowledgeSourceKind, +) -> Vec { + sources + .iter() + .enumerate() + .filter_map(|(index, source)| (source.kind == kind).then_some(index)) + .collect() +} + +pub(in crate::knowledge) fn citations_value( + section: &DraftSection, + sources: &[SourceSnapshot], +) -> Value { + Value::Array( + section + .source_indexes + .iter() + .filter_map(|index| sources.get(*index)) + .map(source_citation_value) + .collect(), + ) +} + +pub(in crate::knowledge) fn source_citation_value(source: &SourceSnapshot) -> Value { + serde_json::json!({ + "source_kind": source.kind.as_str(), + "source_id": source.id, + "source_status": source.status.clone(), + "source_updated_at": source.updated_at, + "source_content_hash": source.content_hash.clone(), + "source_snapshot": source.snapshot.clone(), + "citation_metadata": source.citation_metadata.clone(), + }) +} + +pub(in crate::knowledge) fn source_snapshot_value(sources: &[SourceSnapshot]) -> Value { + serde_json::json!({ + "schema": KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, + "sources": sources.iter().map(source_citation_value).collect::>(), + }) +} + +pub(in crate::knowledge) fn source_coverage_value( + page_kind: KnowledgePageKind, + page_key: &str, + sections: &[DraftSection], + sources: &[SourceSnapshot], +) -> Value { + let cited = sections + .iter() + .flat_map(|section| section.source_indexes.iter().copied()) + .collect::>(); + let counts = source_counts(sources); + + serde_json::json!({ + "schema": KNOWLEDGE_PAGE_SOURCE_COVERAGE_SCHEMA_V1, + "page_kind": page_kind.as_str(), + "page_key": page_key, + "source_counts": counts, + "source_count": sources.len(), + "cited_source_count": cited.len(), + "section_count": sections.len(), + "unsupported_section_count": sections.iter().filter(|section| section.unsupported_reason.is_some()).count(), + "coverage_complete": cited.len() == sources.len(), + }) +} + +pub(in crate::knowledge) fn source_counts(sources: &[SourceSnapshot]) -> Value { + let mut counts = BTreeMap::<&str, usize>::new(); + + for source in sources { + *counts.entry(source.kind.as_str()).or_insert(0) += 1; + } + + serde_json::json!(counts) +} diff --git a/packages/elf-service/src/knowledge/support/diff.rs b/packages/elf-service/src/knowledge/support/diff.rs new file mode 100644 index 00000000..2bc248f4 --- /dev/null +++ b/packages/elf-service/src/knowledge/support/diff.rs @@ -0,0 +1,120 @@ +use crate::knowledge::support::{ + BTreeMap, BTreeSet, DraftSection, KNOWLEDGE_PAGE_VERSION_DIFF_SCHEMA_V1, KnowledgePage, + KnowledgePageSection, Value, serde_json, +}; + +pub(in crate::knowledge) fn previous_version_diff_value( + previous: Option<&KnowledgePage>, + previous_sections: &[KnowledgePageSection], + new_title: &str, + new_source_hash: &str, + new_content_hash: &str, + new_sections: &[DraftSection], +) -> Value { + let Some(previous) = previous else { + return serde_json::json!({ + "schema": KNOWLEDGE_PAGE_VERSION_DIFF_SCHEMA_V1, + "available": false, + "reason": "no_previous_version", + "summary": "Initial rebuild; no previous knowledge page version exists.", + "source_mutation_allowed": false, + }); + }; + let previous_by_key = previous_sections + .iter() + .map(|section| (section.section_key.as_str(), section)) + .collect::>(); + let new_by_key = new_sections + .iter() + .map(|section| (section.section_key.as_str(), section)) + .collect::>(); + let previous_keys = previous_by_key.keys().copied().collect::>(); + let new_keys = new_by_key.keys().copied().collect::>(); + let added_section_keys = sorted_strings(new_keys.difference(&previous_keys).copied()); + let removed_section_keys = sorted_strings(previous_keys.difference(&new_keys).copied()); + let mut changed_section_keys = Vec::new(); + let mut unchanged_section_keys = Vec::new(); + + for key in previous_keys.intersection(&new_keys).copied() { + let previous_section = previous_by_key[key]; + let new_section = new_by_key[key]; + + if previous_section.content_hash == new_section.content_hash + && previous_section.heading == new_section.heading + && previous_section.role == new_section.role + && previous_section.unsupported_reason == new_section.unsupported_reason + { + unchanged_section_keys.push(key.to_string()); + } else { + changed_section_keys.push(key.to_string()); + } + } + + let title_changed = previous.title != new_title; + let source_changed = previous.rebuild_source_hash != new_source_hash; + let content_changed = previous.content_hash != new_content_hash; + let summary = version_diff_summary( + title_changed, + source_changed, + content_changed, + added_section_keys.len(), + removed_section_keys.len(), + changed_section_keys.len(), + ); + + serde_json::json!({ + "schema": KNOWLEDGE_PAGE_VERSION_DIFF_SCHEMA_V1, + "available": true, + "previous_page_id": previous.page_id, + "previous_content_hash": previous.content_hash, + "new_content_hash": new_content_hash, + "previous_source_hash": previous.rebuild_source_hash, + "new_source_hash": new_source_hash, + "title_changed": title_changed, + "source_changed": source_changed, + "content_changed": content_changed, + "section_added_count": added_section_keys.len(), + "section_removed_count": removed_section_keys.len(), + "section_changed_count": changed_section_keys.len(), + "section_unchanged_count": unchanged_section_keys.len(), + "added_section_keys": added_section_keys, + "removed_section_keys": removed_section_keys, + "changed_section_keys": changed_section_keys, + "unchanged_section_keys": unchanged_section_keys, + "source_mutation_allowed": false, + "summary": summary, + }) +} + +pub(in crate::knowledge) fn sorted_strings<'a>( + items: impl Iterator, +) -> Vec { + let mut out = items.map(ToString::to_string).collect::>(); + + out.sort(); + + out +} + +pub(in crate::knowledge) fn version_diff_summary( + title_changed: bool, + source_changed: bool, + content_changed: bool, + added: usize, + removed: usize, + changed: usize, +) -> String { + if !title_changed + && !source_changed + && !content_changed + && added == 0 + && removed == 0 + && changed == 0 + { + return "No page-level or section-level changes from the previous rebuild.".to_string(); + } + + format!( + "Previous rebuild diff: title_changed={title_changed}, source_changed={source_changed}, content_changed={content_changed}, sections added={added}, removed={removed}, changed={changed}." + ) +} diff --git a/packages/elf-service/src/knowledge/support/findings.rs b/packages/elf-service/src/knowledge/support/findings.rs new file mode 100644 index 00000000..fdb9d5ff --- /dev/null +++ b/packages/elf-service/src/knowledge/support/findings.rs @@ -0,0 +1,102 @@ +use crate::knowledge::support::{ + KnowledgePage, KnowledgePageSourceRef, KnowledgeSourceKind, LintDraft, SourceSnapshot, Value, + serde_json, +}; + +pub(in crate::knowledge) fn low_source_coverage_finding(page: &KnowledgePage) -> LintDraft { + LintDraft { + section_id: None, + finding_type: "low_source_coverage".to_string(), + severity: "warning".to_string(), + source_kind: None, + source_id: None, + message: "Knowledge page source coverage is incomplete.".to_string(), + details: serde_json::json!({ + "source_coverage": page.source_coverage.clone(), + "repair_guidance": repair_guidance_for_finding_type("low_source_coverage"), + }), + } +} + +pub(in crate::knowledge) fn with_repair_guidance( + details: Value, + section_key: &str, + guidance: &str, +) -> Value { + let mut object = details.as_object().cloned().unwrap_or_default(); + + object.insert("section_key".to_string(), Value::String(section_key.to_string())); + object.insert("repair_guidance".to_string(), Value::String(guidance.to_string())); + + Value::Object(object) +} + +pub(in crate::knowledge) fn missing_source_finding( + source_ref: &KnowledgePageSourceRef, +) -> LintDraft { + LintDraft { + section_id: source_ref.section_id, + finding_type: "stale_source_ref".to_string(), + severity: "error".to_string(), + source_kind: KnowledgeSourceKind::parse(source_ref.source_kind.as_str()), + source_id: Some(source_ref.source_id), + message: "Knowledge page source reference no longer resolves.".to_string(), + details: serde_json::json!({ + "source_kind": source_ref.source_kind.clone(), + "source_id": source_ref.source_id, + "repair_guidance": repair_guidance_for_finding_type("stale_source_ref"), + }), + } +} + +pub(in crate::knowledge) fn stale_source_finding( + source_ref: &KnowledgePageSourceRef, + current: &SourceSnapshot, +) -> LintDraft { + LintDraft { + section_id: source_ref.section_id, + finding_type: "stale_source_ref".to_string(), + severity: "warning".to_string(), + source_kind: Some(current.kind), + source_id: Some(current.id), + message: "Knowledge page source reference snapshot is stale.".to_string(), + details: serde_json::json!({ + "stored": { + "status": source_ref.source_status.clone(), + "updated_at": source_ref.source_updated_at, + "content_hash": source_ref.source_content_hash.clone(), + }, + "current": { + "status": current.status.clone(), + "updated_at": current.updated_at, + "content_hash": current.content_hash.clone(), + }, + "repair_guidance": repair_guidance_for_finding_type("stale_source_ref"), + }), + } +} + +pub(in crate::knowledge) fn repair_guidance_for_finding_type(finding_type: &str) -> &'static str { + match finding_type { + "stale_source_ref" => + "Inspect the stale or missing source, then rebuild the page from current authoritative sources.", + "unsupported_claim" => + "Replace the unsupported section content with source-backed text or rebuild from cited sources.", + "missing_citation" => + "Rebuild the page section with explicit citations or mark the section unsupported with a reason.", + "missing_source_ref" => + "Rebuild the page so each section citation is normalized into knowledge_page_source_refs.", + "low_source_coverage" => + "Rebuild with all intended sources or remove uncited material before relying on this page.", + _ => "Inspect the finding and rebuild the page after source review.", + } +} + +pub(in crate::knowledge) fn source_changed( + source_ref: &KnowledgePageSourceRef, + current: &SourceSnapshot, +) -> bool { + source_ref.source_status.as_deref() != current.status.as_deref() + || source_ref.source_updated_at != current.updated_at + || source_ref.source_content_hash.as_deref() != current.content_hash.as_deref() +} diff --git a/packages/elf-service/src/knowledge/support/hash.rs b/packages/elf-service/src/knowledge/support/hash.rs new file mode 100644 index 00000000..f72d8683 --- /dev/null +++ b/packages/elf-service/src/knowledge/support/hash.rs @@ -0,0 +1,19 @@ +use crate::knowledge::support::{Error, Result, Value, serde_json}; + +pub(in crate::knowledge) fn hash_text(text: &str) -> String { + blake3::hash(text.as_bytes()).to_hex().to_string() +} + +pub(in crate::knowledge) fn hash_json_lossy(value: &Value) -> String { + serde_json::to_vec(value) + .map(|raw| blake3::hash(&raw).to_hex().to_string()) + .unwrap_or_else(|_| hash_text(value.to_string().as_str())) +} + +pub(in crate::knowledge) fn hash_json(value: &Value) -> Result { + let raw = serde_json::to_vec(value).map_err(|err| Error::InvalidRequest { + message: format!("failed to serialize knowledge page payload: {err}"), + })?; + + Ok(blake3::hash(&raw).to_hex().to_string()) +} diff --git a/packages/elf-service/src/knowledge/support/keys.rs b/packages/elf-service/src/knowledge/support/keys.rs new file mode 100644 index 00000000..0e87b915 --- /dev/null +++ b/packages/elf-service/src/knowledge/support/keys.rs @@ -0,0 +1,35 @@ +use crate::knowledge::support::{ + BTreeSet, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, SourceSnapshot, Uuid, serde_json, +}; + +pub(in crate::knowledge) fn source_sort_key(source: &SourceSnapshot) -> (String, Uuid) { + (source.kind.as_str().to_string(), source.id) +} + +pub(in crate::knowledge) fn source_key(source: &SourceSnapshot) -> String { + current_key(source.kind.as_str(), source.id) +} + +pub(in crate::knowledge) fn current_key(kind: &str, source_id: Uuid) -> String { + format!("{kind}:{source_id}") +} + +pub(in crate::knowledge) fn sorted_unique(ids: &[Uuid]) -> Vec { + ids.iter().copied().collect::>().into_iter().collect() +} + +pub(in crate::knowledge) fn bounded_limit(limit: Option) -> i64 { + limit.map(i64::from).unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, MAX_LIST_LIMIT) +} + +pub(in crate::knowledge) fn source_span_id( + content_hash: &str, + start: usize, + end: usize, + span_kind: &str, +) -> Uuid { + let name = serde_json::json!(["elf-doc-source-span/v1", content_hash, start, end, span_kind]) + .to_string(); + + Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()) +} diff --git a/packages/elf-service/src/knowledge/support/metadata.rs b/packages/elf-service/src/knowledge/support/metadata.rs new file mode 100644 index 00000000..8c3693a4 --- /dev/null +++ b/packages/elf-service/src/knowledge/support/metadata.rs @@ -0,0 +1,142 @@ +use crate::knowledge::support::{ + self, DraftSection, KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, KNOWLEDGE_PAGE_REBUILD_SCHEMA_V1, + KnowledgePageKind, KnowledgePageRebuildRequest, PREVIOUS_VERSION_DIFF_KEY, Result, Value, + serde_json, +}; + +pub(in crate::knowledge) fn rebuild_metadata( + source_hash: &str, + provider_metadata: &Value, + req: &KnowledgePageRebuildRequest, +) -> Value { + let llm_derived = + provider_metadata.get("llm_derived").and_then(Value::as_bool).unwrap_or(false); + + serde_json::json!({ + "schema": KNOWLEDGE_PAGE_REBUILD_SCHEMA_V1, + "source_snapshot_hash": source_hash, + "deterministic": !llm_derived, + "provider_metadata": provider_metadata, + "generated_by": { + "schema": "elf.knowledge_page.generated_by/v1", + "runtime": "ElfService::knowledge_page_rebuild", + "actor_agent_id": req.agent_id, + "mode": if llm_derived { "provider_metadata_declared_llm" } else { "deterministic_service" }, + "source_input_counts": { + "doc": req.doc_ids.len(), + "doc_chunk": req.doc_chunk_ids.len(), + "note": req.note_ids.len(), + "event": req.event_ids.len(), + "relation": req.relation_ids.len(), + "proposal": req.proposal_ids.len(), + }, + }, + "memory_candidate_policy": { + "schema": "elf.knowledge_page.memory_candidate_policy/v1", + "review_required": true, + "review_surface": "consolidation_proposals", + "proposal_contract_schema": "elf.consolidation/v1", + "allowed_apply_intents": ["create_derived_note", "update_derived_note"], + "direct_memory_ledger_mutation_allowed": false, + "source_mutation_allowed": false, + }, + "allowed_variance": if llm_derived { + serde_json::json!(["LLM-derived page text may vary; provider metadata records the nondeterministic input path."]) + } else { + serde_json::json!([]) + }, + }) +} + +pub(in crate::knowledge) fn rebuild_metadata_with_previous_version_diff( + mut metadata: Value, + diff: Value, + version_identity: Value, +) -> Value { + let Some(object) = metadata.as_object_mut() else { + return serde_json::json!({ + PREVIOUS_VERSION_DIFF_KEY: diff, + "version_identity": version_identity, + }); + }; + + object.insert(PREVIOUS_VERSION_DIFF_KEY.to_string(), diff); + object.insert("version_identity".to_string(), version_identity); + + metadata +} + +pub(in crate::knowledge) fn previous_version_diff_from_metadata(metadata: &Value) -> Option { + metadata + .get(PREVIOUS_VERSION_DIFF_KEY) + .filter(|diff| diff.as_object().is_some_and(|object| !object.is_empty())) + .cloned() +} + +pub(in crate::knowledge) fn version_identity_value( + page_kind: KnowledgePageKind, + page_key: &str, + source_hash: &str, + content_hash: &str, + sections: &[DraftSection], +) -> Value { + serde_json::json!({ + "schema": "elf.knowledge_page.version_identity/v1", + "contract_schema": KNOWLEDGE_PAGE_CONTRACT_SCHEMA_V1, + "page_kind": page_kind.as_str(), + "page_key": page_key, + "source_snapshot_hash": source_hash, + "content_hash": content_hash, + "section_hashes": sections + .iter() + .map(|section| { + serde_json::json!({ + "section_key": section.section_key.clone(), + "content_hash": section.content_hash.clone(), + }) + }) + .collect::>(), + "source_mutation_allowed": false, + }) +} + +pub(in crate::knowledge) fn content_hash_rebuild_metadata(rebuild_metadata: &Value) -> Value { + let Some(object) = rebuild_metadata.as_object() else { + return rebuild_metadata.clone(); + }; + let mut stable = object.clone(); + + stable.remove(PREVIOUS_VERSION_DIFF_KEY); + stable.remove("generated_by"); + stable.remove("memory_candidate_policy"); + stable.remove("version_identity"); + + Value::Object(stable) +} + +pub(in crate::knowledge) fn section_hash_payload(section: &DraftSection) -> Value { + serde_json::json!({ + "section_key": section.section_key.clone(), + "heading": section.heading.clone(), + "role": section.role.clone(), + "content": section.content.clone(), + "citations": section.citations.clone(), + "unsupported_reason": section.unsupported_reason.clone(), + }) +} + +pub(in crate::knowledge) fn page_content_hash( + title: &str, + sections: &[DraftSection], + source_coverage: &Value, + rebuild_metadata: &Value, +) -> Result { + let stable_rebuild_metadata = content_hash_rebuild_metadata(rebuild_metadata); + + support::hash_json(&serde_json::json!({ + "title": title, + "sections": sections.iter().map(section_hash_payload).collect::>(), + "source_coverage": source_coverage, + "rebuild_metadata": stable_rebuild_metadata, + })) +} diff --git a/packages/elf-service/src/knowledge/support/snapshots.rs b/packages/elf-service/src/knowledge/support/snapshots.rs new file mode 100644 index 00000000..23d0f576 --- /dev/null +++ b/packages/elf-service/src/knowledge/support/snapshots.rs @@ -0,0 +1,309 @@ +use crate::knowledge::support::{ + self, KnowledgeDocChunkSource, KnowledgeDocSource, KnowledgeEventSource, KnowledgeNoteSource, + KnowledgeProposalSource, KnowledgeRelationSource, KnowledgeSourceKind, Map, Number, + SourceSnapshot, Value, serde_json, +}; + +pub(in crate::knowledge) fn doc_source_snapshot(row: KnowledgeDocSource) -> SourceSnapshot { + let title = row.title.clone().unwrap_or_else(|| "Untitled source document".to_string()); + let excerpt = + support::truncate_chars(support::normalize_whitespace(row.content.as_str()).as_str(), 240); + let line = format!("[doc:{}] {title}: {excerpt}", row.doc_type); + let snapshot = serde_json::json!({ + "kind": "doc", + "doc_id": row.doc_id, + "agent_id": row.agent_id.clone(), + "scope": row.scope.clone(), + "doc_type": row.doc_type.clone(), + "status": row.status.clone(), + "title": row.title.clone(), + "content_bytes": row.content_bytes, + "content_hash": row.content_hash.clone(), + "source_ref": row.source_ref.clone(), + "created_at": row.created_at, + "updated_at": row.updated_at, + }); + + SourceSnapshot { + kind: KnowledgeSourceKind::Doc, + id: row.doc_id, + status: Some(row.status), + updated_at: Some(row.updated_at), + content_hash: Some(row.content_hash), + snapshot, + citation_metadata: serde_json::json!({ "section_role": "source_document" }), + line, + } +} + +pub(in crate::knowledge) fn doc_chunk_source_snapshot( + row: KnowledgeDocChunkSource, +) -> SourceSnapshot { + let title = row.title.clone().unwrap_or_else(|| "Untitled source document".to_string()); + let excerpt = support::truncate_chars( + support::normalize_whitespace(row.chunk_text.as_str()).as_str(), + 240, + ); + let span_id = support::source_span_id( + row.doc_content_hash.as_str(), + row.start_offset.max(0) as usize, + row.end_offset.max(row.start_offset).max(0) as usize, + "captured", + ); + let line = format!( + "[doc_chunk:{}:{}-{}] {title}: {excerpt}", + row.chunk_index, row.start_offset, row.end_offset + ); + let source_span = serde_json::json!({ + "schema": "doc_source_span/v1", + "span_id": span_id, + "chunk_id": row.chunk_id, + "status": "captured", + "reason_code": null, + "start_offset": row.start_offset, + "end_offset": row.end_offset, + "content_hash": row.doc_content_hash.clone(), + "chunk_hash": row.chunk_hash.clone(), + }); + let snapshot = serde_json::json!({ + "kind": "doc_chunk", + "chunk_id": row.chunk_id, + "doc_id": row.doc_id, + "agent_id": row.agent_id.clone(), + "scope": row.scope.clone(), + "doc_type": row.doc_type.clone(), + "status": row.status.clone(), + "title": row.title.clone(), + "source_ref": row.source_ref.clone(), + "doc_content_hash": row.doc_content_hash.clone(), + "doc_updated_at": row.doc_updated_at, + "chunk_index": row.chunk_index, + "start_offset": row.start_offset, + "end_offset": row.end_offset, + "chunk_hash": row.chunk_hash.clone(), + "chunk_created_at": row.chunk_created_at, + "source_span": source_span, + }); + + SourceSnapshot { + kind: KnowledgeSourceKind::DocChunk, + id: row.chunk_id, + status: Some(row.status), + updated_at: Some(row.doc_updated_at), + content_hash: Some(row.chunk_hash), + snapshot, + citation_metadata: serde_json::json!({ + "section_role": "source_span", + "doc_id": row.doc_id, + "span_id": span_id, + "start_offset": row.start_offset, + "end_offset": row.end_offset, + }), + line, + } +} + +pub(in crate::knowledge) fn note_source_snapshot(row: KnowledgeNoteSource) -> SourceSnapshot { + let content_hash = support::hash_text(row.text.as_str()); + let line = format!("{}{}", support::note_prefix(&row), row.text); + let snapshot = serde_json::json!({ + "kind": "note", + "note_id": row.note_id, + "agent_id": row.agent_id.clone(), + "scope": row.scope.clone(), + "type": row.note_type.clone(), + "key": row.key.clone(), + "status": row.status.clone(), + "updated_at": row.updated_at, + "created_at": row.created_at, + "expires_at": row.expires_at, + "embedding_version": row.embedding_version.clone(), + "content_hash": content_hash, + "source_ref": row.source_ref.clone(), + "importance": row.importance, + "confidence": row.confidence, + }); + + SourceSnapshot { + kind: KnowledgeSourceKind::Note, + id: row.note_id, + status: Some(row.status), + updated_at: Some(row.updated_at), + content_hash: Some(content_hash), + snapshot, + citation_metadata: serde_json::json!({ "section_role": "source_note" }), + line, + } +} + +pub(in crate::knowledge) fn event_source_snapshot(row: KnowledgeEventSource) -> SourceSnapshot { + let content_hash = support::hash_json_lossy(&row.details); + let line = format!( + "add_event audit {} {} for {}{}", + row.note_op, + row.policy_decision, + row.note_type, + row.note_key.as_ref().map(|key| format!(" key {key}")).unwrap_or_default() + ); + let snapshot = serde_json::json!({ + "kind": "event", + "decision_id": row.decision_id, + "agent_id": row.agent_id.clone(), + "scope": row.scope.clone(), + "pipeline": row.pipeline.clone(), + "note_type": row.note_type.clone(), + "note_key": row.note_key.clone(), + "note_id": row.note_id, + "policy_decision": row.policy_decision.clone(), + "note_op": row.note_op.clone(), + "reason_code": row.reason_code.clone(), + "details_hash": content_hash, + "ts": row.ts, + }); + + SourceSnapshot { + kind: KnowledgeSourceKind::Event, + id: row.decision_id, + status: Some(row.policy_decision), + updated_at: Some(row.ts), + content_hash: Some(content_hash), + snapshot, + citation_metadata: serde_json::json!({ "section_role": "event_audit" }), + line, + } +} + +pub(in crate::knowledge) fn relation_source_snapshot( + row: KnowledgeRelationSource, +) -> SourceSnapshot { + let object = row.object_entity.clone().or(row.object_value.clone()).unwrap_or_default(); + let temporal_status = if row.valid_to.is_some() { "historical" } else { "current" }; + let line = format!("{} {} {} ({temporal_status}).", row.subject, row.predicate, object); + let content_hash = support::hash_text(line.as_str()); + let snapshot = serde_json::json!({ + "kind": "relation", + "fact_id": row.fact_id, + "agent_id": row.agent_id.clone(), + "scope": row.scope.clone(), + "subject": { "canonical": row.subject.clone(), "kind": row.subject_kind.clone() }, + "predicate": row.predicate.clone(), + "object": { + "entity": row.object_entity.clone(), + "kind": row.object_kind.clone(), + "value": row.object_value.clone() + }, + "valid_from": row.valid_from, + "valid_to": row.valid_to, + "updated_at": row.updated_at, + "content_hash": content_hash, + "evidence_notes": row.evidence_notes.clone(), + }); + + SourceSnapshot { + kind: KnowledgeSourceKind::Relation, + id: row.fact_id, + status: Some(temporal_status.to_string()), + updated_at: Some(row.updated_at), + content_hash: Some(content_hash), + snapshot, + citation_metadata: serde_json::json!({ "section_role": "relation_fact" }), + line, + } +} + +pub(in crate::knowledge) fn proposal_source_snapshot( + row: KnowledgeProposalSource, +) -> SourceSnapshot { + let content_hash = support::hash_json_lossy(&serde_json::json!({ + "diff": row.diff.clone(), + "proposed_payload": row.proposed_payload.clone(), + "review_state": row.review_state.clone(), + })); + let line = format!("Applied proposal {}", row.proposal_kind); + let snapshot = sanitize_proposal_snapshot(&serde_json::json!({ + "kind": "proposal", + "proposal_id": row.proposal_id, + "run_id": row.run_id, + "agent_id": row.agent_id.clone(), + "proposal_kind": row.proposal_kind.clone(), + "apply_intent": row.apply_intent.clone(), + "review_state": row.review_state.clone(), + "source_refs": row.source_refs.clone(), + "source_snapshot": row.source_snapshot.clone(), + "lineage": row.lineage.clone(), + "diff": row.diff.clone(), + "confidence": row.confidence, + "unsupported_claim_flags": row.unsupported_claim_flags.clone(), + "contradiction_markers": row.contradiction_markers.clone(), + "staleness_markers": row.staleness_markers.clone(), + "target_ref": row.target_ref.clone(), + "proposed_payload_hash": content_hash, + "updated_at": row.updated_at, + })); + + SourceSnapshot { + kind: KnowledgeSourceKind::Proposal, + id: row.proposal_id, + status: Some(row.review_state), + updated_at: Some(row.updated_at), + content_hash: Some(content_hash), + snapshot, + citation_metadata: serde_json::json!({ "section_role": "reviewed_proposal" }), + line, + } +} + +pub(in crate::knowledge) fn sanitize_proposal_snapshot(source_snapshot: &Value) -> Value { + let Some(object) = source_snapshot.as_object() else { + return serde_json::json!({ + "kind": "proposal", + "sanitized": true, + "source_visibility": "proposal_metadata_only", + }); + }; + let nested_source_count = + object.get("source_refs").and_then(Value::as_array).map(Vec::len).unwrap_or_default(); + let mut sanitized = Map::new(); + + for key in [ + "kind", + "proposal_id", + "run_id", + "agent_id", + "proposal_kind", + "apply_intent", + "review_state", + "confidence", + "proposed_payload_hash", + "updated_at", + ] { + if let Some(value) = object.get(key) { + sanitized.insert(key.to_string(), value.clone()); + } + } + + sanitized.insert("sanitized".to_string(), Value::Bool(true)); + sanitized.insert( + "source_visibility".to_string(), + Value::String("proposal_metadata_only".to_string()), + ); + sanitized.insert( + "omitted_fields".to_string(), + serde_json::json!([ + "source_refs", + "source_snapshot", + "lineage", + "diff", + "unsupported_claim_flags", + "contradiction_markers", + "staleness_markers", + "target_ref" + ]), + ); + sanitized.insert( + "nested_source_ref_count".to_string(), + Value::Number(Number::from(nested_source_count)), + ); + + Value::Object(sanitized) +} diff --git a/packages/elf-service/src/knowledge/support/text.rs b/packages/elf-service/src/knowledge/support/text.rs new file mode 100644 index 00000000..b13aba51 --- /dev/null +++ b/packages/elf-service/src/knowledge/support/text.rs @@ -0,0 +1,102 @@ +use crate::knowledge::support::{KnowledgeNoteSource, KnowledgePageKind}; + +pub(in crate::knowledge) fn snippet_for_query( + content: &str, + query: &str, + max_chars: usize, +) -> String { + let normalized = normalize_whitespace(content); + let query = query.trim(); + + if query.is_empty() { + return truncate_chars(normalized.as_str(), max_chars); + } + + let lower = normalized.to_ascii_lowercase(); + let lower_query = query.to_ascii_lowercase(); + let Some(byte_idx) = lower.find(lower_query.as_str()) else { + return truncate_chars(normalized.as_str(), max_chars); + }; + let before_chars = normalized[..byte_idx].chars().count(); + let start = before_chars.saturating_sub(40); + let mut snippet: String = normalized.chars().skip(start).take(max_chars).collect(); + + if start > 0 { + snippet = format!("...{snippet}"); + } + if normalized.chars().count() > start + snippet.chars().count() { + snippet.push_str("..."); + } + + snippet +} + +pub(in crate::knowledge) fn normalize_whitespace(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut prev_space = false; + + for ch in raw.chars() { + if ch.is_whitespace() { + if !prev_space { + out.push(' '); + + prev_space = true; + } + + continue; + } + + out.push(ch); + + prev_space = false; + } + + out.trim().to_string() +} + +pub(in crate::knowledge) fn truncate_chars(raw: &str, max_chars: usize) -> String { + if raw.chars().count() <= max_chars { + return raw.to_string(); + } + + const TRUNCATION_MARKER: &str = "..."; + + let marker_chars = TRUNCATION_MARKER.chars().count(); + + if max_chars <= marker_chars { + return TRUNCATION_MARKER.chars().take(max_chars).collect(); + } + + let truncated_chars = max_chars - marker_chars; + let mut out = raw.chars().take(truncated_chars).collect::(); + + out.push_str(TRUNCATION_MARKER); + + out +} + +pub(in crate::knowledge) fn note_prefix(row: &KnowledgeNoteSource) -> String { + row.key + .as_ref() + .map(|key| format!("[{}:{key}] ", row.note_type)) + .unwrap_or_else(|| format!("[{}] ", row.note_type)) +} + +pub(in crate::knowledge) fn generated_title( + page_kind: KnowledgePageKind, + page_key: &str, +) -> String { + format!("{} Knowledge Page: {page_key}", title_kind(page_kind)) +} + +pub(in crate::knowledge) fn title_kind(page_kind: KnowledgePageKind) -> &'static str { + match page_kind { + KnowledgePageKind::Project => "Project", + KnowledgePageKind::Entity => "Entity", + KnowledgePageKind::Concept => "Concept", + KnowledgePageKind::Issue => "Issue", + KnowledgePageKind::Decision => "Decision", + KnowledgePageKind::Author => "Author", + KnowledgePageKind::Timeline => "Timeline", + } +} diff --git a/packages/elf-service/src/knowledge/support/validation.rs b/packages/elf-service/src/knowledge/support/validation.rs new file mode 100644 index 00000000..2359bb5e --- /dev/null +++ b/packages/elf-service/src/knowledge/support/validation.rs @@ -0,0 +1,32 @@ +use crate::knowledge::support::{Error, Map, Result, Value}; + +pub(in crate::knowledge) fn validate_context( + tenant_id: &str, + project_id: &str, + agent_id: &str, +) -> Result<()> { + validate_non_empty("tenant_id", tenant_id)?; + validate_non_empty("project_id", project_id)?; + + validate_non_empty("agent_id", agent_id) +} + +pub(in crate::knowledge) fn validate_non_empty(field: &'static str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} must not be empty.") }); + } + + Ok(()) +} + +pub(in crate::knowledge) fn validate_object(field: &str, value: &Value) -> Result<()> { + if matches!(value, Value::Object(_)) { + Ok(()) + } else { + Err(Error::InvalidRequest { message: format!("{field} must be a JSON object.") }) + } +} + +pub(in crate::knowledge) fn empty_object() -> Value { + Value::Object(Map::new()) +} diff --git a/packages/elf-service/src/knowledge/tests.rs b/packages/elf-service/src/knowledge/tests.rs new file mode 100644 index 00000000..73850c94 --- /dev/null +++ b/packages/elf-service/src/knowledge/tests.rs @@ -0,0 +1,718 @@ +use std::{ + collections::{BTreeSet, HashSet}, + slice, +}; + +use serde_json::Value; + +use crate::{ + access::SharedSpaceGrantKey, + knowledge::{ + self, DraftSection, KnowledgeDeltaMemoryCandidate, KnowledgePage, KnowledgePageKind, + KnowledgePageResponse, KnowledgePageSearchRow, KnowledgePageSection, + KnowledgePageSectionResponse, KnowledgePageSourceRef, KnowledgePageSourceRefResponse, + KnowledgePageSummary, KnowledgeSourceKind, LintDraft, OffsetDateTime, SourceSnapshot, Uuid, + }, +}; +use elf_domain::consolidation::ConsolidationApplyIntent; + +fn test_source(kind: KnowledgeSourceKind, raw_id: u128, line: &str) -> SourceSnapshot { + let id = Uuid::from_u128(raw_id); + let content_hash = knowledge::hash_text(line); + + SourceSnapshot { + kind, + id, + status: Some("active".to_string()), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + content_hash: Some(content_hash.clone()), + snapshot: serde_json::json!({ + "kind": kind.as_str(), + "id": id, + "status": "active", + "updated_at": OffsetDateTime::UNIX_EPOCH, + "content_hash": content_hash, + }), + citation_metadata: serde_json::json!({ "fixture": "knowledge_unit" }), + line: line.to_string(), + } +} + +fn test_rebuild_request(page_kind: KnowledgePageKind) -> knowledge::KnowledgePageRebuildRequest { + knowledge::KnowledgePageRebuildRequest { + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + page_kind, + page_key: "elf".to_string(), + title: Some("ELF".to_string()), + doc_ids: Vec::new(), + doc_chunk_ids: Vec::new(), + note_ids: Vec::new(), + event_ids: Vec::new(), + relation_ids: Vec::new(), + proposal_ids: Vec::new(), + provider_metadata: knowledge::empty_object(), + } +} + +#[test] +fn build_sections_preserves_citations_and_deterministic_hashes() { + let sources = vec![ + test_source(KnowledgeSourceKind::Doc, 1, "A source document supports the page."), + test_source(KnowledgeSourceKind::DocChunk, 2, "A source span supports the page."), + test_source(KnowledgeSourceKind::Note, 3, "A source note supports the page."), + test_source(KnowledgeSourceKind::Event, 4, "An event audit supports the page."), + test_source(KnowledgeSourceKind::Relation, 5, "A relation supports the page."), + test_source(KnowledgeSourceKind::Proposal, 6, "An applied proposal supports the page."), + ]; + let mut first_sections = knowledge::build_sections(&sources).expect("sections should build"); + + for section in &mut first_sections { + section.citations = knowledge::citations_value(section, &sources); + section.content_hash = knowledge::hash_json(&knowledge::section_hash_payload(section)) + .expect("section hash should serialize"); + } + + assert_eq!(first_sections.len(), 6); + assert!(first_sections.iter().all(|section| { + section.citations.as_array().is_some_and(|citations| !citations.is_empty()) + })); + + let coverage = knowledge::source_coverage_value( + KnowledgePageKind::Project, + "elf", + &first_sections, + &sources, + ); + let request = test_rebuild_request(KnowledgePageKind::Project); + let metadata = knowledge::rebuild_metadata("source-hash", &knowledge::empty_object(), &request); + let first_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) + .expect("page hash should serialize"); + let second_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) + .expect("page hash should serialize"); + + assert_eq!(coverage["coverage_complete"], true); + assert_eq!(metadata["deterministic"], true); + assert_eq!(metadata["memory_candidate_policy"]["direct_memory_ledger_mutation_allowed"], false); + assert_eq!(first_hash, second_hash); +} + +#[test] +fn rebuild_metadata_records_llm_variance() { + let metadata = knowledge::rebuild_metadata( + "source-hash", + &serde_json::json!({ + "llm_derived": true, + "provider_id": "fixture", + "model": "fixture-model", + }), + &test_rebuild_request(KnowledgePageKind::Timeline), + ); + + assert_eq!(metadata["deterministic"], false); + assert!(metadata["allowed_variance"].as_array().is_some_and(|items| !items.is_empty())); + assert_eq!(metadata["provider_metadata"]["provider_id"], "fixture"); + assert_eq!(metadata["generated_by"]["actor_agent_id"], "agent"); +} + +#[test] +fn generated_titles_cover_author_and_timeline_pages() { + assert_eq!( + knowledge::generated_title(KnowledgePageKind::Author, "ada"), + "Author Knowledge Page: ada" + ); + assert_eq!( + knowledge::generated_title(KnowledgePageKind::Timeline, "release-plan"), + "Timeline Knowledge Page: release-plan" + ); +} + +#[test] +fn previous_version_diff_records_delta_without_changing_content_hash() { + let previous = test_page(); + let previous_section = + test_section(Uuid::from_u128(10), "source-notes", serde_json::json!([]), None); + let sections = vec![DraftSection { + section_id: Uuid::from_u128(12), + section_key: "source-notes".to_string(), + heading: "source-notes".to_string(), + role: "current_truth".to_string(), + content: "Updated section content.".to_string(), + ordinal: 0, + source_indexes: vec![0], + unsupported_reason: None, + content_hash: "new-section-hash".to_string(), + citations: serde_json::json!([{ "source_kind": "note" }]), + }]; + let request = test_rebuild_request(KnowledgePageKind::Project); + let base_metadata = + knowledge::rebuild_metadata("new-source-hash", &knowledge::empty_object(), &request); + let coverage = serde_json::json!({ "coverage_complete": true }); + let hash_without_diff = + knowledge::page_content_hash("ELF", §ions, &coverage, &base_metadata) + .expect("stable hash should serialize"); + let diff = knowledge::previous_version_diff_value( + Some(&previous), + &[previous_section], + "ELF", + "new-source-hash", + hash_without_diff.as_str(), + §ions, + ); + let version_identity = knowledge::version_identity_value( + KnowledgePageKind::Project, + "elf", + "new-source-hash", + hash_without_diff.as_str(), + §ions, + ); + let metadata_with_diff = knowledge::rebuild_metadata_with_previous_version_diff( + base_metadata, + diff.clone(), + version_identity, + ); + let hash_with_diff = + knowledge::page_content_hash("ELF", §ions, &coverage, &metadata_with_diff) + .expect("hash should ignore previous-version diff metadata"); + + assert_eq!(hash_without_diff, hash_with_diff); + assert_eq!(diff["schema"], "elf.knowledge_page.version_diff/v1"); + assert_eq!(diff["available"], true); + assert_eq!(diff["source_mutation_allowed"], false); + assert_eq!(diff["section_changed_count"], 1); + assert_eq!( + knowledge::previous_version_diff_from_metadata(&metadata_with_diff) + .expect("diff should be extractable")["section_changed_count"], + 1 + ); + assert_eq!( + metadata_with_diff["version_identity"]["schema"], + "elf.knowledge_page.version_identity/v1" + ); +} + +#[test] +fn stale_source_comparison_detects_changed_snapshot() { + let source_id = Uuid::from_u128(42); + let stored = KnowledgePageSourceRef { + ref_id: Uuid::from_u128(1), + page_id: Uuid::from_u128(2), + section_id: Some(Uuid::from_u128(3)), + source_kind: "note".to_string(), + source_id, + source_status: Some("active".to_string()), + source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), + source_content_hash: Some("old-hash".to_string()), + source_snapshot: serde_json::json!({}), + citation_metadata: serde_json::json!({}), + created_at: OffsetDateTime::UNIX_EPOCH, + }; + let current = SourceSnapshot { + kind: KnowledgeSourceKind::Note, + id: source_id, + status: Some("active".to_string()), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + content_hash: Some("new-hash".to_string()), + snapshot: serde_json::json!({}), + citation_metadata: serde_json::json!({}), + line: "Updated note source.".to_string(), + }; + let finding = knowledge::stale_source_finding(&stored, ¤t); + + assert!(knowledge::source_changed(&stored, ¤t)); + assert_eq!(finding.finding_type, "stale_source_ref"); + assert_eq!(finding.source_kind, Some(KnowledgeSourceKind::Note)); + assert_eq!(finding.source_id, Some(source_id)); +} + +#[test] +fn watch_rebuild_outputs_cover_source_update_and_stale_page() { + let section_id = Uuid::from_u128(50); + let source_id = Uuid::from_u128(51); + let section = test_section( + section_id, + "source-notes", + serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), + None, + ); + let source_ref = test_source_ref_for(section_id, source_id, "old-hash"); + let lint = vec![LintDraft { + section_id: Some(section_id), + finding_type: "stale_source_ref".to_string(), + severity: "warning".to_string(), + source_kind: Some(KnowledgeSourceKind::Note), + source_id: Some(source_id), + message: "Knowledge page source reference snapshot is stale.".to_string(), + details: serde_json::json!({ "stored": "old", "current": "new" }), + }]; + let diff = serde_json::json!({ + "available": true, + "content_changed": true, + "changed_section_keys": ["source-notes"] + }); + let changed_sources = vec![knowledge::KnowledgePageChangedSource { + source_kind: KnowledgeSourceKind::Note, + source_id, + }]; + let outputs = + knowledge::rebuild_outputs(&[section], &[source_ref], &lint, Some(&diff), &changed_sources); + let output_types = outputs.iter().map(|output| output.output_type.as_str()).collect::>(); + + assert!(output_types.contains(&"stale_section")); + assert!(output_types.contains(&"changed_claim")); + assert!(output_types.contains(&"conflict")); + assert!(output_types.contains(&"changed_source")); +} + +#[test] +fn memory_candidate_uses_reviewable_consolidation_proposal_contract() { + let section_id = Uuid::from_u128(60); + let source_id = Uuid::from_u128(61); + let page = test_page_response(section_id, source_id); + let outputs = vec![knowledge::KnowledgePageRebuildOutput { + output_type: "changed_claim".to_string(), + severity: "info".to_string(), + section_key: Some("source-notes".to_string()), + source_kind: Some("note".to_string()), + source_id: Some(source_id), + message: "Changed section.".to_string(), + details: serde_json::json!({ "reason": "source_update" }), + }]; + let candidates = knowledge::memory_candidates_for_page(&page, &outputs); + + assert_eq!(candidates.len(), 1); + + assert_candidate_is_reviewable(&candidates[0]); + + let proposal = knowledge::candidate_proposal_input(&candidates[0]); + + assert_eq!(proposal.apply_intent, ConsolidationApplyIntent::CreateDerivedNote); + assert_eq!(proposal.source_refs.len(), 1); + assert_eq!(proposal.proposed_payload["source_ref"]["source_mutation_allowed"], false); + assert_eq!(proposal.proposed_payload["source_ref"]["reason"], "changed_claim"); + assert!(!proposal.markers.staleness.is_empty()); +} + +#[test] +fn lint_page_sections_detects_unsupported_missing_and_low_coverage() { + let page = test_page(); + let unsupported = test_section( + Uuid::from_u128(10), + "unsupported", + serde_json::json!([]), + Some("No source supports this claim.".to_string()), + ); + let missing = test_section(Uuid::from_u128(11), "missing", serde_json::json!([]), None); + let findings = knowledge::lint_page_sections(&page, &[unsupported, missing], &[]); + let finding_types = + findings.iter().map(|finding| finding.finding_type.as_str()).collect::>(); + + assert!(finding_types.contains(&"unsupported_claim")); + assert!(finding_types.contains(&"missing_citation")); + assert!(finding_types.contains(&"missing_source_ref")); + assert!(finding_types.contains(&"low_source_coverage")); + assert!(findings.iter().all(|finding| { + finding + .details + .get("repair_guidance") + .and_then(serde_json::Value::as_str) + .is_some_and(|guidance| !guidance.is_empty()) + })); +} + +#[test] +fn search_item_marks_derived_page_snippet_with_provenance() { + let section_id = Uuid::from_u128(20); + let source_ref = test_source_ref(section_id); + let row = KnowledgePageSearchRow { + page_id: Uuid::from_u128(21), + page_kind: "project".to_string(), + page_key: "elf".to_string(), + title: "ELF Knowledge".to_string(), + status: "active".to_string(), + source_coverage: serde_json::json!({ + "source_count": 1, + "cited_source_count": 1, + "coverage_complete": true + }), + rebuild_metadata: serde_json::json!({ "deterministic": true }), + page_updated_at: OffsetDateTime::UNIX_EPOCH, + rebuilt_at: OffsetDateTime::UNIX_EPOCH, + section_id, + section_key: "source-notes".to_string(), + heading: "Source Notes".to_string(), + role: "current_truth".to_string(), + content: "Derived knowledge pages cite source notes before they are trusted.".to_string(), + ordinal: 0, + citations: serde_json::json!([{ "source_kind": "note", "source_id": source_ref.source_id }]), + unsupported_reason: None, + lint_error_count: 0, + lint_warning_count: 1, + lint_info_count: 0, + section_source_ref_count: 1, + }; + let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "source notes"); + + assert_eq!(item.result_kind, "knowledge_page_section"); + assert_eq!(item.trust_state, "derived_warning"); + assert_eq!(item.citation_count, 1); + assert_eq!(item.source_ref_count, 1); + assert_eq!(item.source_refs.len(), 1); + assert!(item.derived_notice.contains("Derived knowledge page snippet")); + assert!(item.repair_guidance.is_some()); + assert!(item.snippet.contains("source notes")); +} + +#[test] +fn search_source_refs_suppress_deleted_and_unreviewed_sources() { + let section_id = Uuid::from_u128(70); + let mut active = test_source_ref(section_id); + let mut deleted = test_source_ref(section_id); + let mut ignored = test_source_ref(section_id); + let current_keys = current_source_keys_for(&[&active, &deleted, &ignored]); + + deleted.source_status = Some("deleted".to_string()); + ignored.source_status = Some("ignore".to_string()); + + assert!(knowledge::recallable_source_refs(slice::from_ref(&active), ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[deleted], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[ignored], ¤t_keys)); + + active.source_status = None; + + assert!(!knowledge::recallable_source_refs(&[active], ¤t_keys)); +} + +#[test] +fn search_source_refs_suppress_non_captured_spans() { + let section_id = Uuid::from_u128(71); + let mut excluded = test_source_ref(section_id); + let mut source_ref_span = test_source_ref(section_id); + let mut policy_span = test_source_ref(section_id); + let mut malformed_span = test_source_ref(section_id); + let current_keys = + current_source_keys_for(&[&excluded, &source_ref_span, &policy_span, &malformed_span]); + + excluded.source_snapshot = serde_json::json!({ + "source_span": { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + }); + source_ref_span.source_snapshot = serde_json::json!({ + "source_ref": { + "source_spans": [ + { + "schema": "doc_source_span/v1", + "status": "redacted", + "reason_code": "WRITE_POLICY_REDACTION" + } + ] + } + }); + policy_span.source_snapshot = serde_json::json!({ + "source_ref": { + "policy_spans": [ + { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + ] + } + }); + malformed_span.source_snapshot = serde_json::json!({ + "source_span": { + "schema": "doc_source_span/v1", + "reason_code": "WRITE_POLICY_REDACTION" + } + }); + + assert!(!knowledge::recallable_source_refs(&[excluded], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[source_ref_span], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[policy_span], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[malformed_span], ¤t_keys)); +} + +#[test] +fn search_source_refs_suppress_nested_proposal_non_captured_spans() { + let section_id = Uuid::from_u128(73); + let mut proposal = test_source_ref_for(section_id, Uuid::from_u128(74), "proposal-hash"); + + proposal.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); + proposal.source_status = Some("applied".to_string()); + proposal.source_snapshot = serde_json::json!({ + "kind": "proposal", + "proposal_id": proposal.source_id, + "source_refs": [ + { + "kind": "doc_chunk", + "source_ref": { + "policy_spans": [ + { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + ] + } + } + ], + "source_snapshot": { + "sources": [ + { + "source_snapshot": { + "source_span": { + "schema": "doc_source_span/v1", + "status": "redacted", + "reason_code": "WRITE_POLICY_REDACTION" + } + } + } + ] + }, + "diff": { + "after": { + "source_ref": { + "source_spans": [ + { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + ] + } + } + } + }); + + let current_keys = current_source_keys_for(&[&proposal]); + + assert!(!knowledge::recallable_source_refs(&[proposal], ¤t_keys)); +} + +#[test] +fn search_item_sanitizes_proposal_citations_and_source_refs() { + let section_id = Uuid::from_u128(75); + let mut source_ref = test_source_ref_for(section_id, Uuid::from_u128(76), "proposal-hash"); + + source_ref.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); + source_ref.source_status = Some("applied".to_string()); + source_ref.source_snapshot = serde_json::json!({ + "kind": "proposal", + "proposal_id": source_ref.source_id, + "proposal_kind": "create_derived_note", + "source_refs": [{ "kind": "doc", "source_id": Uuid::from_u128(77) }], + "source_snapshot": { "sources": [{ "source_snapshot": { "text": "private raw source" } }] }, + "lineage": { "parents": ["private"] }, + "diff": { "summary": "private raw diff" }, + "unsupported_claim_flags": [{ "quote": "private raw flag" }], + "target_ref": { "text": "private raw target" } + }); + + let row = KnowledgePageSearchRow { + page_id: Uuid::from_u128(78), + page_kind: "project".to_string(), + page_key: "elf".to_string(), + title: "ELF Knowledge".to_string(), + status: "active".to_string(), + source_coverage: serde_json::json!({ + "source_count": 1, + "cited_source_count": 1, + "coverage_complete": true + }), + rebuild_metadata: serde_json::json!({ "deterministic": true }), + page_updated_at: OffsetDateTime::UNIX_EPOCH, + rebuilt_at: OffsetDateTime::UNIX_EPOCH, + section_id, + section_key: "reviewed-proposals".to_string(), + heading: "Reviewed Proposals".to_string(), + role: "proposals".to_string(), + content: "Applied proposal create_derived_note".to_string(), + ordinal: 0, + citations: serde_json::json!([{ + "source_kind": "proposal", + "source_id": source_ref.source_id, + "source_snapshot": source_ref.source_snapshot.clone() + }]), + unsupported_reason: None, + lint_error_count: 0, + lint_warning_count: 0, + lint_info_count: 0, + section_source_ref_count: 1, + }; + let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "proposal"); + let citation_snapshot = &item.citations[0]["source_snapshot"]; + let source_ref_snapshot = &item.source_refs[0].source_snapshot; + + assert_eq!(citation_snapshot["sanitized"], true); + assert_eq!(source_ref_snapshot["sanitized"], true); + assert!(citation_snapshot.get("source_refs").is_none()); + assert!(citation_snapshot.get("source_snapshot").is_none()); + assert!(citation_snapshot.get("diff").is_none()); + assert!(source_ref_snapshot.get("source_refs").is_none()); + assert!(source_ref_snapshot.get("source_snapshot").is_none()); + assert!(source_ref_snapshot.get("diff").is_none()); +} + +#[test] +fn search_source_refs_suppress_missing_current_sources() { + let section_id = Uuid::from_u128(72); + let source_ref = test_source_ref(section_id); + + assert!(!knowledge::recallable_source_refs(&[source_ref], &BTreeSet::new())); +} + +#[test] +fn source_row_read_allowed_requires_shared_grant_for_other_agent_sources() { + let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; + let shared_grants = HashSet::new(); + + assert!(knowledge::source_row_read_allowed( + "owner-agent", + "project_shared", + Some("owner-agent"), + &allowed_scopes, + &shared_grants + )); + assert!(!knowledge::source_row_read_allowed( + "owner-agent", + "project_shared", + Some("reader-agent"), + &allowed_scopes, + &shared_grants + )); + + let shared_grants = HashSet::from([SharedSpaceGrantKey { + scope: "project_shared".to_string(), + space_owner_agent_id: "owner-agent".to_string(), + }]); + + assert!(knowledge::source_row_read_allowed( + "owner-agent", + "project_shared", + Some("reader-agent"), + &allowed_scopes, + &shared_grants + )); +} + +fn test_page() -> KnowledgePage { + KnowledgePage { + page_id: Uuid::from_u128(1), + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + page_kind: "project".to_string(), + page_key: "elf".to_string(), + title: "ELF".to_string(), + contract_schema: "elf.knowledge_page/v1".to_string(), + status: "active".to_string(), + rebuild_source_hash: "source-hash".to_string(), + content_hash: "content-hash".to_string(), + source_coverage: serde_json::json!({ + "source_count": 2, + "cited_source_count": 1, + "coverage_complete": false + }), + source_snapshot: serde_json::json!({}), + rebuild_metadata: serde_json::json!({}), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + rebuilt_at: OffsetDateTime::UNIX_EPOCH, + } +} + +fn test_section( + section_id: Uuid, + section_key: &str, + citations: Value, + unsupported_reason: Option, +) -> KnowledgePageSection { + KnowledgePageSection { + section_id, + page_id: Uuid::from_u128(1), + section_key: section_key.to_string(), + heading: section_key.to_string(), + role: "current_truth".to_string(), + content: "Section content.".to_string(), + ordinal: 0, + citations, + unsupported_reason, + content_hash: "section-hash".to_string(), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + } +} + +fn test_source_ref(section_id: Uuid) -> KnowledgePageSourceRef { + test_source_ref_for(section_id, Uuid::from_u128(31), "source-hash") +} + +fn test_source_ref_for( + section_id: Uuid, + source_id: Uuid, + source_hash: &str, +) -> KnowledgePageSourceRef { + KnowledgePageSourceRef { + ref_id: Uuid::from_u128(30), + page_id: Uuid::from_u128(21), + section_id: Some(section_id), + source_kind: "note".to_string(), + source_id, + source_status: Some("active".to_string()), + source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), + source_content_hash: Some(source_hash.to_string()), + source_snapshot: serde_json::json!({ + "schema": "test_source/v1", + "source_id": source_id, + "content_hash": source_hash, + }), + citation_metadata: serde_json::json!({}), + created_at: OffsetDateTime::UNIX_EPOCH, + } +} + +fn current_source_keys_for(source_refs: &[&KnowledgePageSourceRef]) -> BTreeSet { + source_refs + .iter() + .map(|source_ref| { + knowledge::current_key(source_ref.source_kind.as_str(), source_ref.source_id) + }) + .collect() +} + +fn test_page_response(section_id: Uuid, source_id: Uuid) -> KnowledgePageResponse { + let page = test_page(); + let section = test_section( + section_id, + "source-notes", + serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), + None, + ); + let source_ref = test_source_ref_for(section_id, source_id, "new-hash"); + + KnowledgePageResponse { + page: KnowledgePageSummary::from(page), + sections: vec![KnowledgePageSectionResponse { + citation_count: 1, + source_ref_count: 1, + coverage_complete: true, + source_backlinks: Vec::new(), + ..KnowledgePageSectionResponse::from(section) + }], + source_refs: vec![KnowledgePageSourceRefResponse::from(source_ref)], + lint_findings: Vec::new(), + } +} + +fn assert_candidate_is_reviewable(candidate: &KnowledgeDeltaMemoryCandidate) { + assert_eq!(candidate.reason, "changed_claim"); + assert_eq!(candidate.source_refs.len(), 1); + assert_eq!(candidate.source_refs[0].kind.as_str(), "note"); + assert_eq!(candidate.source_snapshot["source_mutation_allowed"], false); + assert_eq!(candidate.diff.after["reason"], "changed_claim"); + assert_eq!(candidate.proposed_payload["type"], "plan"); + assert_eq!(candidate.proposed_payload["source_ref"]["schema"], "elf.knowledge_delta/v1"); +} diff --git a/packages/elf-service/src/knowledge/types.rs b/packages/elf-service/src/knowledge/types.rs new file mode 100644 index 00000000..f9166e03 --- /dev/null +++ b/packages/elf-service/src/knowledge/types.rs @@ -0,0 +1,150 @@ +use crate::knowledge::{ + self, Error, KnowledgeDeltaMemoryCandidate, KnowledgePageRebuildRequest, + KnowledgePageSourceRef, KnowledgePageWatchRebuildItem, KnowledgeSourceKind, OffsetDateTime, + Result, Uuid, Value, +}; + +#[derive(Clone, Debug)] +pub(super) struct SourceSnapshot { + pub(super) kind: KnowledgeSourceKind, + pub(super) id: Uuid, + pub(super) status: Option, + pub(super) updated_at: Option, + pub(super) content_hash: Option, + pub(super) snapshot: Value, + pub(super) citation_metadata: Value, + pub(super) line: String, +} + +#[derive(Clone, Debug)] +pub(super) struct DraftSection { + pub(super) section_id: Uuid, + pub(super) section_key: String, + pub(super) heading: String, + pub(super) role: String, + pub(super) content: String, + pub(super) ordinal: i32, + pub(super) source_indexes: Vec, + pub(super) unsupported_reason: Option, + pub(super) content_hash: String, + pub(super) citations: Value, +} + +#[derive(Clone, Debug)] +pub(super) struct LintDraft { + pub(super) section_id: Option, + pub(super) finding_type: String, + pub(super) severity: String, + pub(super) source_kind: Option, + pub(super) source_id: Option, + pub(super) message: String, + pub(super) details: Value, +} + +#[derive(Clone, Debug)] +pub(super) struct SourceIds { + pub(super) doc_ids: Vec, + pub(super) doc_chunk_ids: Vec, + pub(super) note_ids: Vec, + pub(super) event_ids: Vec, + pub(super) relation_ids: Vec, + pub(super) proposal_ids: Vec, +} +impl SourceIds { + pub(super) fn from_request(req: &KnowledgePageRebuildRequest) -> Result { + let ids = Self { + doc_ids: knowledge::sorted_unique(&req.doc_ids), + doc_chunk_ids: knowledge::sorted_unique(&req.doc_chunk_ids), + note_ids: knowledge::sorted_unique(&req.note_ids), + event_ids: knowledge::sorted_unique(&req.event_ids), + relation_ids: knowledge::sorted_unique(&req.relation_ids), + proposal_ids: knowledge::sorted_unique(&req.proposal_ids), + }; + + ids.validate_non_empty()?; + + Ok(ids) + } + + pub(super) fn from_source_refs(source_refs: &[KnowledgePageSourceRef]) -> Result { + let mut doc_ids = Vec::new(); + let mut doc_chunk_ids = Vec::new(); + let mut note_ids = Vec::new(); + let mut event_ids = Vec::new(); + let mut relation_ids = Vec::new(); + let mut proposal_ids = Vec::new(); + + for source_ref in source_refs { + match KnowledgeSourceKind::parse(source_ref.source_kind.as_str()) { + Some(KnowledgeSourceKind::Doc) => doc_ids.push(source_ref.source_id), + Some(KnowledgeSourceKind::DocChunk) => doc_chunk_ids.push(source_ref.source_id), + Some(KnowledgeSourceKind::Note) => note_ids.push(source_ref.source_id), + Some(KnowledgeSourceKind::Event) => event_ids.push(source_ref.source_id), + Some(KnowledgeSourceKind::Relation) => relation_ids.push(source_ref.source_id), + Some(KnowledgeSourceKind::Proposal) => proposal_ids.push(source_ref.source_id), + None => { + return Err(Error::InvalidRequest { + message: "stored knowledge page source kind is invalid".to_string(), + }); + }, + } + } + + Ok(Self { + doc_ids: knowledge::sorted_unique(&doc_ids), + doc_chunk_ids: knowledge::sorted_unique(&doc_chunk_ids), + note_ids: knowledge::sorted_unique(¬e_ids), + event_ids: knowledge::sorted_unique(&event_ids), + relation_ids: knowledge::sorted_unique(&relation_ids), + proposal_ids: knowledge::sorted_unique(&proposal_ids), + }) + } + + pub(super) fn validate_non_empty(&self) -> Result<()> { + if self.doc_ids.is_empty() + && self.doc_chunk_ids.is_empty() + && self.note_ids.is_empty() + && self.event_ids.is_empty() + && self.relation_ids.is_empty() + && self.proposal_ids.is_empty() + { + return Err(Error::InvalidRequest { + message: "at least one source id is required for a knowledge page rebuild" + .to_string(), + }); + } + + Ok(()) + } + + pub(super) fn require_counts( + &self, + docs: usize, + doc_chunks: usize, + notes: usize, + events: usize, + relations: usize, + proposals: usize, + ) -> Result<()> { + if docs != self.doc_ids.len() + || doc_chunks != self.doc_chunk_ids.len() + || notes != self.note_ids.len() + || events != self.event_ids.len() + || relations != self.relation_ids.len() + || proposals != self.proposal_ids.len() + { + return Err(Error::InvalidRequest { + message: + "all requested knowledge page sources must exist, source rows must be active and readable, and proposals must be applied" + .to_string(), + }); + } + + Ok(()) + } +} + +pub(super) struct WatchRebuildOutcome { + pub(super) item: KnowledgePageWatchRebuildItem, + pub(super) candidates: Vec, +} diff --git a/packages/elf-service/src/knowledge/watch.rs b/packages/elf-service/src/knowledge/watch.rs new file mode 100644 index 00000000..5fe47a59 --- /dev/null +++ b/packages/elf-service/src/knowledge/watch.rs @@ -0,0 +1,36 @@ +mod candidates; +mod inputs; +mod outcomes; +mod outputs; +mod summary; + +pub(super) use self::{ + candidates::{ + candidate_proposal_input, candidate_run_input_refs, knowledge_delta_source_snapshot, + memory_candidates_for_page, proposal_run_summary, + }, + inputs::{ + changed_source_arrays, default_generate_memory_candidates, normalized_changed_sources, + rebuild_request_from_page, + }, + outcomes::{blocked_watch_rebuild, successful_watch_rebuild}, + outputs::{ + blocked_outputs, blocked_section_states, candidate_reasons_by_section, rebuild_outputs, + successful_rebuild_state, successful_section_states, + }, + summary::{page_operator_summary, watch_operator_summary, watch_rebuild_summary}, +}; + +use crate::knowledge::{ + BTreeMap, BTreeSet, ConsolidationApplyIntent, ConsolidationInputRef, ConsolidationLineage, + ConsolidationMarker, ConsolidationMarkerSeverity, ConsolidationMarkers, + ConsolidationProposalDiff, ConsolidationProposalInput, ConsolidationRunCreateResponse, + ConsolidationSourceKind, ConsolidationSourceSnapshot, Error, KnowledgeDeltaMemoryCandidate, + KnowledgePage, KnowledgePageChangedSource, KnowledgePageKind, KnowledgePageProposalRunSummary, + KnowledgePageRebuildOutput, KnowledgePageRebuildRequest, KnowledgePageResponse, + KnowledgePageSection, KnowledgePageSectionRebuildState, KnowledgePageSectionResponse, + KnowledgePageSourceRef, KnowledgePageSourceRefResponse, KnowledgePageWatchRebuildItem, + KnowledgePageWatchRebuildSummary, KnowledgeSourceKind, LintDraft, Result, SourceIds, Uuid, + Value, WatchRebuildOutcome, empty_object, previous_version_diff_from_metadata, serde_json, + truncate_chars, +}; diff --git a/packages/elf-service/src/knowledge/watch/candidates.rs b/packages/elf-service/src/knowledge/watch/candidates.rs new file mode 100644 index 00000000..f0c30179 --- /dev/null +++ b/packages/elf-service/src/knowledge/watch/candidates.rs @@ -0,0 +1,276 @@ +use crate::knowledge::watch::{ + self, BTreeSet, ConsolidationApplyIntent, ConsolidationInputRef, ConsolidationLineage, + ConsolidationMarker, ConsolidationMarkerSeverity, ConsolidationMarkers, + ConsolidationProposalDiff, ConsolidationProposalInput, ConsolidationRunCreateResponse, + ConsolidationSourceKind, ConsolidationSourceSnapshot, KnowledgeDeltaMemoryCandidate, + KnowledgePageChangedSource, KnowledgePageProposalRunSummary, KnowledgePageRebuildOutput, + KnowledgePageResponse, KnowledgePageSectionResponse, KnowledgePageSourceRefResponse, + KnowledgeSourceKind, Value, serde_json, +}; + +pub(in crate::knowledge) fn memory_candidates_for_page( + page: &KnowledgePageResponse, + outputs: &[KnowledgePageRebuildOutput], +) -> Vec { + let reasons = watch::candidate_reasons_by_section(outputs); + + page.sections + .iter() + .filter_map(|section| { + let reason = reasons.get(section.section_key.as_str())?; + + memory_candidate_for_section(page, section, reason.as_str()) + }) + .collect() +} + +pub(in crate::knowledge) fn memory_candidate_for_section( + page: &KnowledgePageResponse, + section: &KnowledgePageSectionResponse, + reason: &str, +) -> Option { + let source_refs = page + .source_refs + .iter() + .filter(|source_ref| source_ref.section_id == Some(section.section_id)) + .filter_map(|source_ref| consolidation_input_ref(source_ref, page, section, reason)) + .collect::>(); + + if source_refs.is_empty() { + return None; + } + + let source_snapshot = candidate_source_snapshot(page, section, reason, &source_refs); + let diff = candidate_diff(page, section, reason); + let proposed_payload = candidate_proposed_payload(page, section, reason); + + Some(KnowledgeDeltaMemoryCandidate { + reason: reason.to_string(), + page_id: page.page.page_id, + section_id: section.section_id, + section_key: section.section_key.clone(), + source_refs, + source_snapshot, + diff, + proposed_payload, + }) +} + +pub(in crate::knowledge) fn consolidation_input_ref( + source_ref: &KnowledgePageSourceRefResponse, + page: &KnowledgePageResponse, + section: &KnowledgePageSectionResponse, + reason: &str, +) -> Option { + let kind = consolidation_source_kind(source_ref.source_kind.as_str())?; + + Some(ConsolidationInputRef { + kind, + id: source_ref.source_id, + snapshot: ConsolidationSourceSnapshot { + status: source_ref.source_status.clone(), + updated_at: source_ref.source_updated_at, + content_hash: source_ref.source_content_hash.clone(), + embedding_version: None, + trace_version: None, + source_ref: source_ref.source_snapshot.clone(), + metadata: serde_json::json!({ + "schema": "elf.knowledge_delta.source_ref/v1", + "reason": reason, + "page_id": page.page.page_id, + "page_kind": page.page.page_kind, + "page_key": page.page.page_key, + "section_id": section.section_id, + "section_key": section.section_key, + }), + }, + }) +} + +pub(in crate::knowledge) fn consolidation_source_kind( + source_kind: &str, +) -> Option { + match KnowledgeSourceKind::parse(source_kind)? { + KnowledgeSourceKind::Doc => Some(ConsolidationSourceKind::Doc), + KnowledgeSourceKind::DocChunk => Some(ConsolidationSourceKind::DocChunk), + KnowledgeSourceKind::Note => Some(ConsolidationSourceKind::Note), + KnowledgeSourceKind::Event => Some(ConsolidationSourceKind::Event), + KnowledgeSourceKind::Relation | KnowledgeSourceKind::Proposal => None, + } +} + +pub(in crate::knowledge) fn candidate_source_snapshot( + page: &KnowledgePageResponse, + section: &KnowledgePageSectionResponse, + reason: &str, + source_refs: &[ConsolidationInputRef], +) -> Value { + serde_json::json!({ + "schema": "elf.knowledge_delta.source_snapshot/v1", + "reason": reason, + "page": { + "page_id": page.page.page_id, + "page_kind": page.page.page_kind, + "page_key": page.page.page_key, + "content_hash": page.page.content_hash, + "rebuild_source_hash": page.page.rebuild_source_hash, + "previous_version_diff": page.page.previous_version_diff, + }, + "section": { + "section_id": section.section_id, + "section_key": section.section_key, + "heading": section.heading, + "content_hash": section.content_hash, + "citation_count": section.citation_count, + "source_ref_count": section.source_ref_count, + }, + "source_ref_count": source_refs.len(), + "source_mutation_allowed": false, + }) +} + +pub(in crate::knowledge) fn candidate_diff( + page: &KnowledgePageResponse, + section: &KnowledgePageSectionResponse, + reason: &str, +) -> ConsolidationProposalDiff { + ConsolidationProposalDiff { + summary: format!( + "Create a reviewable memory candidate for knowledge page '{}' section '{}' because {reason}.", + page.page.page_key, section.section_key + ), + before: serde_json::json!({ + "page_id": page.page.page_id, + "section_id": section.section_id, + "previous_version_diff": page.page.previous_version_diff, + }), + after: serde_json::json!({ + "target": "derived_note", + "reason": reason, + "page_id": page.page.page_id, + "section_id": section.section_id, + "section_key": section.section_key, + }), + } +} + +pub(in crate::knowledge) fn candidate_proposed_payload( + page: &KnowledgePageResponse, + section: &KnowledgePageSectionResponse, + reason: &str, +) -> Value { + let text = watch::truncate_chars( + format!( + "Plan: Review knowledge page {} section {} because source changes produced a {reason} delta.", + page.page.page_key, section.section_key + ) + .as_str(), + 220, + ); + + serde_json::json!({ + "type": "plan", + "key": format!( + "knowledge_delta_{}_{}", + page.page.page_key.replace('-', "_"), + section.section_key.replace('-', "_") + ), + "text": text, + "scope": "project_shared", + "importance": 0.65, + "confidence": 0.72, + "source_ref": { + "schema": "elf.knowledge_delta/v1", + "reason": reason, + "page_id": page.page.page_id, + "section_id": section.section_id, + "page_key": page.page.page_key, + "section_key": section.section_key, + "source_mutation_allowed": false, + } + }) +} + +pub(in crate::knowledge) fn candidate_proposal_input( + candidate: &KnowledgeDeltaMemoryCandidate, +) -> ConsolidationProposalInput { + ConsolidationProposalInput { + proposal_kind: "knowledge_delta_memory_candidate".to_string(), + apply_intent: ConsolidationApplyIntent::CreateDerivedNote, + source_refs: candidate.source_refs.clone(), + source_snapshot: candidate.source_snapshot.clone(), + lineage: ConsolidationLineage { + source_refs: candidate.source_refs.clone(), + parent_run_id: None, + parent_proposal_ids: Vec::new(), + }, + confidence: 0.72, + unsupported_claim_flags: Vec::new(), + markers: candidate_markers(candidate), + diff: candidate.diff.clone(), + target_ref: watch::empty_object(), + proposed_payload: candidate.proposed_payload.clone(), + } +} + +pub(in crate::knowledge) fn candidate_markers( + candidate: &KnowledgeDeltaMemoryCandidate, +) -> ConsolidationMarkers { + let marker = ConsolidationMarker { + severity: ConsolidationMarkerSeverity::Medium, + message: format!( + "Knowledge delta '{}' requires reviewer confirmation before memory promotion.", + candidate.reason + ), + source: candidate.source_refs.first().cloned(), + }; + + if candidate.reason == "conflict" { + ConsolidationMarkers { contradictions: vec![marker], staleness: Vec::new() } + } else { + ConsolidationMarkers { contradictions: Vec::new(), staleness: vec![marker] } + } +} + +pub(in crate::knowledge) fn candidate_run_input_refs( + candidates: &[KnowledgeDeltaMemoryCandidate], +) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + + for source_ref in candidates.iter().flat_map(|candidate| &candidate.source_refs) { + if seen.insert((source_ref.kind.as_str().to_string(), source_ref.id)) { + out.push(source_ref.clone()); + } + } + + out +} + +pub(in crate::knowledge) fn knowledge_delta_source_snapshot( + changed_sources: &[KnowledgePageChangedSource], + candidates: &[KnowledgeDeltaMemoryCandidate], +) -> Value { + serde_json::json!({ + "schema": "elf.knowledge_delta.run_source_snapshot/v1", + "changed_sources": changed_sources, + "candidate_count": candidates.len(), + "candidate_reasons": candidates + .iter() + .map(|candidate| candidate.reason.clone()) + .collect::>(), + "source_mutation_allowed": false, + }) +} + +pub(in crate::knowledge) fn proposal_run_summary( + created: ConsolidationRunCreateResponse, + proposal_count: usize, +) -> KnowledgePageProposalRunSummary { + KnowledgePageProposalRunSummary { + run_id: created.run.run_id, + job_id: created.job_id, + proposal_count, + review_surface: "consolidation_proposals".to_string(), + } +} diff --git a/packages/elf-service/src/knowledge/watch/inputs.rs b/packages/elf-service/src/knowledge/watch/inputs.rs new file mode 100644 index 00000000..05500d13 --- /dev/null +++ b/packages/elf-service/src/knowledge/watch/inputs.rs @@ -0,0 +1,72 @@ +use crate::knowledge::watch::{ + BTreeSet, Error, KnowledgePage, KnowledgePageChangedSource, KnowledgePageKind, + KnowledgePageRebuildRequest, KnowledgePageSourceRef, Result, SourceIds, Uuid, Value, + empty_object, +}; + +pub(in crate::knowledge) fn normalized_changed_sources( + changed_sources: &[KnowledgePageChangedSource], +) -> Result> { + if changed_sources.is_empty() { + return Err(Error::InvalidRequest { + message: "changed_sources must not be empty.".to_string(), + }); + } + + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + + for source in changed_sources { + if seen.insert((source.source_kind.as_str().to_string(), source.source_id)) { + out.push(source.clone()); + } + } + + Ok(out) +} + +pub(in crate::knowledge) fn changed_source_arrays( + changed_sources: &[KnowledgePageChangedSource], +) -> (Vec, Vec) { + changed_sources + .iter() + .map(|source| (source.source_kind.as_str().to_string(), source.source_id)) + .unzip() +} + +pub(in crate::knowledge) fn rebuild_request_from_page( + agent_id: &str, + page: &KnowledgePage, + source_refs: &[KnowledgePageSourceRef], +) -> Result { + let ids = SourceIds::from_source_refs(source_refs)?; + let page_kind = KnowledgePageKind::parse(page.page_kind.as_str()).ok_or_else(|| { + Error::InvalidRequest { message: "stored knowledge page kind is invalid".to_string() } + })?; + let provider_metadata = page + .rebuild_metadata + .get("provider_metadata") + .filter(|metadata| matches!(metadata, Value::Object(_))) + .cloned() + .unwrap_or_else(empty_object); + + Ok(KnowledgePageRebuildRequest { + tenant_id: page.tenant_id.clone(), + project_id: page.project_id.clone(), + agent_id: agent_id.to_string(), + page_kind, + page_key: page.page_key.clone(), + title: Some(page.title.clone()), + doc_ids: ids.doc_ids, + doc_chunk_ids: ids.doc_chunk_ids, + note_ids: ids.note_ids, + event_ids: ids.event_ids, + relation_ids: ids.relation_ids, + proposal_ids: ids.proposal_ids, + provider_metadata, + }) +} + +pub(in crate::knowledge) fn default_generate_memory_candidates() -> bool { + true +} diff --git a/packages/elf-service/src/knowledge/watch/outcomes.rs b/packages/elf-service/src/knowledge/watch/outcomes.rs new file mode 100644 index 00000000..44d07f47 --- /dev/null +++ b/packages/elf-service/src/knowledge/watch/outcomes.rs @@ -0,0 +1,74 @@ +use crate::knowledge::watch::{ + self, Error, KnowledgePage, KnowledgePageChangedSource, KnowledgePageResponse, + KnowledgePageSection, KnowledgePageSourceRef, KnowledgePageWatchRebuildItem, LintDraft, + WatchRebuildOutcome, +}; + +pub(in crate::knowledge) fn successful_watch_rebuild( + before_sections: Vec, + before_source_refs: Vec, + before_lint: Vec, + rebuilt_page: KnowledgePageResponse, + changed_sources: &[KnowledgePageChangedSource], +) -> WatchRebuildOutcome { + let previous_version_diff = rebuilt_page.page.previous_version_diff.clone(); + let outputs = watch::rebuild_outputs( + &before_sections, + &before_source_refs, + &before_lint, + previous_version_diff.as_ref(), + changed_sources, + ); + let sections = + watch::successful_section_states(&before_sections, &rebuilt_page.sections, &outputs); + let rebuild_state = watch::successful_rebuild_state(previous_version_diff.as_ref(), &outputs); + let candidates = watch::memory_candidates_for_page(&rebuilt_page, &outputs); + let operator_summary = watch::page_operator_summary( + rebuilt_page.page.page_key.as_str(), + rebuild_state.as_str(), + outputs.len(), + candidates.len(), + ); + let item = KnowledgePageWatchRebuildItem { + page_id: rebuilt_page.page.page_id, + page_kind: rebuilt_page.page.page_kind.clone(), + page_key: rebuilt_page.page.page_key.clone(), + title: rebuilt_page.page.title.clone(), + rebuild_state, + sections, + outputs, + rebuilt_page: Some(rebuilt_page), + blocked_reason: None, + previous_version_diff, + operator_summary, + }; + + WatchRebuildOutcome { item, candidates } +} + +pub(in crate::knowledge) fn blocked_watch_rebuild( + page: KnowledgePage, + sections: Vec, + before_lint: Vec, + err: Error, +) -> WatchRebuildOutcome { + let outputs = watch::blocked_outputs(§ions, &before_lint, err.to_string().as_str()); + let section_states = watch::blocked_section_states(§ions, &outputs); + let operator_summary = + watch::page_operator_summary(page.page_key.as_str(), "blocked", outputs.len(), 0); + let item = KnowledgePageWatchRebuildItem { + page_id: page.page_id, + page_kind: page.page_kind, + page_key: page.page_key, + title: page.title, + rebuild_state: "blocked".to_string(), + sections: section_states, + outputs, + rebuilt_page: None, + blocked_reason: Some(err.to_string()), + previous_version_diff: watch::previous_version_diff_from_metadata(&page.rebuild_metadata), + operator_summary, + }; + + WatchRebuildOutcome { item, candidates: Vec::new() } +} diff --git a/packages/elf-service/src/knowledge/watch/outputs.rs b/packages/elf-service/src/knowledge/watch/outputs.rs new file mode 100644 index 00000000..371cca0b --- /dev/null +++ b/packages/elf-service/src/knowledge/watch/outputs.rs @@ -0,0 +1,374 @@ +use crate::knowledge::watch::{ + BTreeMap, BTreeSet, KnowledgePageChangedSource, KnowledgePageRebuildOutput, + KnowledgePageSection, KnowledgePageSectionRebuildState, KnowledgePageSectionResponse, + KnowledgePageSourceRef, KnowledgeSourceKind, LintDraft, Uuid, Value, serde_json, +}; + +pub(in crate::knowledge) fn rebuild_outputs( + sections: &[KnowledgePageSection], + source_refs: &[KnowledgePageSourceRef], + lint: &[LintDraft], + diff: Option<&Value>, + changed_sources: &[KnowledgePageChangedSource], +) -> Vec { + let section_index = section_lookup(sections); + let changed_keys = diff_section_keys(diff, "changed_section_keys"); + let mut outputs = lint_outputs(lint, §ion_index); + + outputs.extend(changed_claim_outputs(sections, &changed_keys)); + outputs.extend(conflict_outputs(&outputs)); + outputs.extend(changed_source_outputs(source_refs, changed_sources)); + + outputs +} + +pub(in crate::knowledge) fn blocked_outputs( + sections: &[KnowledgePageSection], + lint: &[LintDraft], + blocked_reason: &str, +) -> Vec { + let section_index = section_lookup(sections); + let mut outputs = lint_outputs(lint, §ion_index); + + outputs.push(KnowledgePageRebuildOutput { + output_type: "blocked".to_string(), + severity: "error".to_string(), + section_key: None, + source_kind: None, + source_id: None, + message: "Knowledge page could not be rebuilt from its stored source refs.".to_string(), + details: serde_json::json!({ "blocked_reason": blocked_reason }), + }); + + outputs +} + +pub(in crate::knowledge) fn lint_outputs( + lint: &[LintDraft], + section_index: &BTreeMap, +) -> Vec { + lint.iter().filter_map(|finding| lint_output(finding, section_index)).collect() +} + +pub(in crate::knowledge) fn lint_output( + finding: &LintDraft, + section_index: &BTreeMap, +) -> Option { + let output_type = match finding.finding_type.as_str() { + "stale_source_ref" => "stale_section", + "missing_citation" | "missing_source_ref" => "missing_citation", + _ => return None, + }; + let (section_key, heading) = finding + .section_id + .and_then(|section_id| section_index.get(§ion_id)) + .cloned() + .unwrap_or_else(|| ("page".to_string(), "Page".to_string())); + + Some(KnowledgePageRebuildOutput { + output_type: output_type.to_string(), + severity: finding.severity.clone(), + section_key: Some(section_key.clone()), + source_kind: finding.source_kind.map(KnowledgeSourceKind::as_str).map(ToString::to_string), + source_id: finding.source_id, + message: lint_output_message(output_type, heading.as_str()), + details: serde_json::json!({ + "finding_type": finding.finding_type, + "section_key": section_key, + "lint_details": finding.details, + }), + }) +} + +pub(in crate::knowledge) fn changed_claim_outputs( + sections: &[KnowledgePageSection], + changed_keys: &BTreeSet, +) -> Vec { + sections + .iter() + .filter(|section| changed_keys.contains(section.section_key.as_str())) + .map(|section| KnowledgePageRebuildOutput { + output_type: "changed_claim".to_string(), + severity: "info".to_string(), + section_key: Some(section.section_key.clone()), + source_kind: None, + source_id: None, + message: format!( + "Knowledge page section '{}' changed after rebuilding from current sources.", + section.heading + ), + details: serde_json::json!({ + "section_key": section.section_key, + "section_hash": section.content_hash, + }), + }) + .collect() +} + +pub(in crate::knowledge) fn changed_source_outputs( + source_refs: &[KnowledgePageSourceRef], + changed_sources: &[KnowledgePageChangedSource], +) -> Vec { + let changed = changed_source_set(changed_sources); + + source_refs + .iter() + .filter(|source_ref| { + changed.contains(&(source_ref.source_kind.clone(), source_ref.source_id)) + }) + .map(|source_ref| KnowledgePageRebuildOutput { + output_type: "changed_source".to_string(), + severity: "info".to_string(), + section_key: None, + source_kind: Some(source_ref.source_kind.clone()), + source_id: Some(source_ref.source_id), + message: "Changed source is attached to this knowledge page.".to_string(), + details: serde_json::json!({ + "source_kind": source_ref.source_kind, + "source_id": source_ref.source_id, + "section_id": source_ref.section_id, + }), + }) + .collect() +} + +pub(in crate::knowledge) fn conflict_outputs( + outputs: &[KnowledgePageRebuildOutput], +) -> Vec { + let stale = output_section_keys(outputs, "stale_section"); + let changed = output_section_keys(outputs, "changed_claim"); + + stale + .intersection(&changed) + .map(|section_key| { + KnowledgePageRebuildOutput { + output_type: "conflict".to_string(), + severity: "warning".to_string(), + section_key: Some(section_key.clone()), + source_kind: None, + source_id: None, + message: + "Stored derived section was stale and changed after rebuilding from current sources." + .to_string(), + details: serde_json::json!({ + "section_key": section_key, + "reason": "stale_snapshot_changed_claim", + }), + } + }) + .collect() +} + +pub(in crate::knowledge) fn successful_section_states( + before_sections: &[KnowledgePageSection], + rebuilt_sections: &[KnowledgePageSectionResponse], + outputs: &[KnowledgePageRebuildOutput], +) -> Vec { + let output_map = outputs_by_section(outputs); + let before_by_key = before_sections + .iter() + .map(|section| (section.section_key.as_str(), section)) + .collect::>(); + + rebuilt_sections + .iter() + .map(|section| { + let output_types = + output_map.get(section.section_key.as_str()).cloned().unwrap_or_default(); + let lint_finding_types = lint_finding_types_for_outputs(&output_types); + let state = section_state( + before_by_key.get(section.section_key.as_str()).copied(), + section, + &output_types, + ); + + KnowledgePageSectionRebuildState { + section_key: section.section_key.clone(), + heading: section.heading.clone(), + state, + output_types, + lint_finding_types, + } + }) + .collect() +} + +pub(in crate::knowledge) fn blocked_section_states( + sections: &[KnowledgePageSection], + outputs: &[KnowledgePageRebuildOutput], +) -> Vec { + let output_map = outputs_by_section(outputs); + + sections + .iter() + .map(|section| { + let output_types = + output_map.get(section.section_key.as_str()).cloned().unwrap_or_default(); + let lint_finding_types = lint_finding_types_for_outputs(&output_types); + let state = if output_types.iter().any(|kind| kind == "missing_citation") { + "blocked" + } else if output_types.iter().any(|kind| kind == "stale_section") { + "stale" + } else { + "blocked" + }; + + KnowledgePageSectionRebuildState { + section_key: section.section_key.clone(), + heading: section.heading.clone(), + state: state.to_string(), + output_types, + lint_finding_types, + } + }) + .collect() +} + +pub(in crate::knowledge) fn section_state( + before: Option<&KnowledgePageSection>, + after: &KnowledgePageSectionResponse, + output_types: &[String], +) -> String { + if output_types.iter().any(|kind| kind == "missing_citation") { + return "blocked".to_string(); + } + if before.is_some_and(|section| section.content_hash != after.content_hash) + || output_types.iter().any(|kind| kind == "changed_claim" || kind == "conflict") + { + return "changed".to_string(); + } + + if output_types.iter().any(|kind| kind == "stale_section") { + return "stale".to_string(); + } + + "unchanged".to_string() +} + +pub(in crate::knowledge) fn successful_rebuild_state( + diff: Option<&Value>, + outputs: &[KnowledgePageRebuildOutput], +) -> String { + if diff_content_changed(diff) { + return "changed".to_string(); + } + + if outputs.iter().any(|output| output.output_type == "stale_section") { + return "stale".to_string(); + } + + "unchanged".to_string() +} + +pub(in crate::knowledge) fn section_lookup( + sections: &[KnowledgePageSection], +) -> BTreeMap { + sections + .iter() + .map(|section| (section.section_id, (section.section_key.clone(), section.heading.clone()))) + .collect() +} + +pub(in crate::knowledge) fn diff_section_keys(diff: Option<&Value>, key: &str) -> BTreeSet { + diff.and_then(|value| value.get(key)) + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(Value::as_str).map(ToString::to_string).collect()) + .unwrap_or_default() +} + +pub(in crate::knowledge) fn diff_content_changed(diff: Option<&Value>) -> bool { + diff.and_then(|value| value.get("content_changed")).and_then(Value::as_bool).unwrap_or(false) + || !diff_section_keys(diff, "added_section_keys").is_empty() + || !diff_section_keys(diff, "removed_section_keys").is_empty() + || !diff_section_keys(diff, "changed_section_keys").is_empty() +} + +pub(in crate::knowledge) fn changed_source_set( + changed_sources: &[KnowledgePageChangedSource], +) -> BTreeSet<(String, Uuid)> { + changed_sources + .iter() + .map(|source| (source.source_kind.as_str().to_string(), source.source_id)) + .collect() +} + +pub(in crate::knowledge) fn output_section_keys( + outputs: &[KnowledgePageRebuildOutput], + output_type: &str, +) -> BTreeSet { + outputs + .iter() + .filter(|output| output.output_type == output_type) + .filter_map(|output| output.section_key.clone()) + .collect() +} + +pub(in crate::knowledge) fn outputs_by_section( + outputs: &[KnowledgePageRebuildOutput], +) -> BTreeMap<&str, Vec> { + let mut map = BTreeMap::<&str, Vec>::new(); + + for output in outputs { + let Some(section_key) = output.section_key.as_deref() else { + continue; + }; + + map.entry(section_key).or_default().push(output.output_type.clone()); + } + for values in map.values_mut() { + values.sort(); + values.dedup(); + } + + map +} + +pub(in crate::knowledge) fn lint_finding_types_for_outputs(output_types: &[String]) -> Vec { + let mut out = output_types + .iter() + .filter_map(|output_type| match output_type.as_str() { + "stale_section" => Some("stale_source_ref".to_string()), + "missing_citation" => Some("missing_citation".to_string()), + _ => None, + }) + .collect::>(); + + out.sort(); + out.dedup(); + + out +} + +pub(in crate::knowledge) fn candidate_reasons_by_section( + outputs: &[KnowledgePageRebuildOutput], +) -> BTreeMap<&str, String> { + let mut reasons = BTreeMap::<&str, String>::new(); + + for output in outputs { + let Some(section_key) = output.section_key.as_deref() else { + continue; + }; + + match output.output_type.as_str() { + "conflict" => { + reasons.insert(section_key, "conflict".to_string()); + }, + "changed_claim" => { + reasons.entry(section_key).or_insert_with(|| "changed_claim".to_string()); + }, + _ => {}, + } + } + + reasons +} + +pub(in crate::knowledge) fn lint_output_message(output_type: &str, heading: &str) -> String { + match output_type { + "stale_section" => + format!("Knowledge page section '{heading}' cites a stale or missing source."), + "missing_citation" => + format!("Knowledge page section '{heading}' is missing citation coverage."), + _ => format!("Knowledge page section '{heading}' needs operator review."), + } +} diff --git a/packages/elf-service/src/knowledge/watch/summary.rs b/packages/elf-service/src/knowledge/watch/summary.rs new file mode 100644 index 00000000..9eaf5904 --- /dev/null +++ b/packages/elf-service/src/knowledge/watch/summary.rs @@ -0,0 +1,65 @@ +use crate::knowledge::watch::{ + KnowledgePageProposalRunSummary, KnowledgePageWatchRebuildItem, + KnowledgePageWatchRebuildSummary, +}; + +pub(in crate::knowledge) fn watch_rebuild_summary( + changed_source_count: usize, + items: &[KnowledgePageWatchRebuildItem], + memory_candidate_count: usize, +) -> KnowledgePageWatchRebuildSummary { + KnowledgePageWatchRebuildSummary { + changed_source_count, + affected_page_count: items.len(), + changed_page_count: items.iter().filter(|item| item.rebuild_state == "changed").count(), + unchanged_page_count: items.iter().filter(|item| item.rebuild_state == "unchanged").count(), + stale_page_count: items + .iter() + .filter(|item| item.outputs.iter().any(|output| output.output_type == "stale_section")) + .count(), + blocked_page_count: items.iter().filter(|item| item.rebuild_state == "blocked").count(), + memory_candidate_count, + } +} + +pub(in crate::knowledge) fn watch_operator_summary( + summary: &KnowledgePageWatchRebuildSummary, + proposal_run: Option<&KnowledgePageProposalRunSummary>, +) -> Vec { + let mut out = vec![format!( + "Changed-source rebuild inspected {} sources and {} affected knowledge pages.", + summary.changed_source_count, summary.affected_page_count + )]; + + out.push(format!( + "Page states: changed={}, unchanged={}, stale={}, blocked={}.", + summary.changed_page_count, + summary.unchanged_page_count, + summary.stale_page_count, + summary.blocked_page_count + )); + out.push(format!( + "Generated {} reviewable memory candidate proposals; source mutation remains disabled.", + summary.memory_candidate_count + )); + + if let Some(run) = proposal_run { + out.push(format!( + "Queued consolidation run {} with {} proposal payloads for review.", + run.run_id, run.proposal_count + )); + } + + out +} + +pub(in crate::knowledge) fn page_operator_summary( + page_key: &str, + rebuild_state: &str, + output_count: usize, + candidate_count: usize, +) -> String { + format!( + "Knowledge page '{page_key}' rebuild_state={rebuild_state}, outputs={output_count}, memory_candidates={candidate_count}." + ) +} diff --git a/packages/elf-service/src/knowledge/watch_service.rs b/packages/elf-service/src/knowledge/watch_service.rs new file mode 100644 index 00000000..0fce0aab --- /dev/null +++ b/packages/elf-service/src/knowledge/watch_service.rs @@ -0,0 +1,141 @@ +use crate::knowledge::{ + ConsolidationLineage, ConsolidationRunCreateRequest, ElfService, + KNOWLEDGE_PAGE_WATCH_REBUILD_SCHEMA_V1, KnowledgeDeltaMemoryCandidate, KnowledgePage, + KnowledgePageChangedSource, KnowledgePageKind, KnowledgePageProposalRunSummary, + KnowledgePageSection, KnowledgePageSourceRef, KnowledgePageWatchRebuildRequest, + KnowledgePageWatchRebuildResponse, LintDraft, Result, WatchRebuildOutcome, + candidate_proposal_input, knowledge, +}; + +impl ElfService { + /// Rebuilds pages affected by changed source refs and queues reviewable candidates. + pub async fn knowledge_pages_watch_rebuild( + &self, + req: KnowledgePageWatchRebuildRequest, + ) -> Result { + crate::knowledge::validate_context( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + )?; + + let changed_sources = crate::knowledge::normalized_changed_sources(&req.changed_sources)?; + let (source_kinds, source_ids) = crate::knowledge::changed_source_arrays(&changed_sources); + let page_kind = req.page_kind.map(KnowledgePageKind::as_str); + let pages = knowledge::list_knowledge_pages_for_sources( + &self.db.pool, + req.tenant_id.as_str(), + req.project_id.as_str(), + page_kind, + &source_kinds, + &source_ids, + crate::knowledge::bounded_limit(req.limit), + ) + .await?; + let mut items = Vec::new(); + let mut candidates = Vec::new(); + + for page in pages { + let outcome = + self.watch_rebuild_page(req.agent_id.as_str(), page, &changed_sources).await?; + + candidates.extend(outcome.candidates); + items.push(outcome.item); + } + + let proposal_run = if req.generate_memory_candidates && !candidates.is_empty() { + Some(self.queue_knowledge_delta_candidates(&req, &changed_sources, &candidates).await?) + } else { + None + }; + let summary = crate::knowledge::watch_rebuild_summary( + changed_sources.len(), + &items, + candidates.len(), + ); + let operator_summary = + crate::knowledge::watch_operator_summary(&summary, proposal_run.as_ref()); + + Ok(KnowledgePageWatchRebuildResponse { + schema: KNOWLEDGE_PAGE_WATCH_REBUILD_SCHEMA_V1.to_string(), + summary, + pages: items, + memory_candidates: candidates, + proposal_run, + operator_summary, + }) + } + + async fn watch_rebuild_page( + &self, + agent_id: &str, + page: KnowledgePage, + changed_sources: &[KnowledgePageChangedSource], + ) -> Result { + let source_refs = + knowledge::list_knowledge_page_source_refs(&self.db.pool, page.page_id).await?; + let sections = knowledge::list_knowledge_page_sections(&self.db.pool, page.page_id).await?; + let before_lint = self.watch_rebuild_lint(&page, §ions, &source_refs).await?; + let request = crate::knowledge::rebuild_request_from_page(agent_id, &page, &source_refs); + let rebuild = match request { + Ok(request) => self.knowledge_page_rebuild(request).await, + Err(err) => Err(err), + }; + + match rebuild { + Ok(response) => Ok(crate::knowledge::successful_watch_rebuild( + sections, + source_refs, + before_lint, + response.page, + changed_sources, + )), + Err(err) => + Ok(crate::knowledge::blocked_watch_rebuild(page, sections, before_lint, err)), + } + } + + async fn watch_rebuild_lint( + &self, + page: &KnowledgePage, + sections: &[KnowledgePageSection], + source_refs: &[KnowledgePageSourceRef], + ) -> Result> { + let mut lint = self.lint_source_refs(page, source_refs).await?; + + lint.extend(crate::knowledge::lint_page_sections(page, sections, source_refs)); + + Ok(lint) + } + + async fn queue_knowledge_delta_candidates( + &self, + req: &KnowledgePageWatchRebuildRequest, + changed_sources: &[KnowledgePageChangedSource], + candidates: &[KnowledgeDeltaMemoryCandidate], + ) -> Result { + let source_refs = crate::knowledge::candidate_run_input_refs(candidates); + let source_snapshot = + crate::knowledge::knowledge_delta_source_snapshot(changed_sources, candidates); + let lineage = ConsolidationLineage { + source_refs: source_refs.clone(), + parent_run_id: None, + parent_proposal_ids: Vec::new(), + }; + let proposals = candidates.iter().map(candidate_proposal_input).collect::>(); + let created = self + .consolidation_run_create(ConsolidationRunCreateRequest { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + agent_id: req.agent_id.clone(), + job_kind: "manual".to_string(), + input_refs: source_refs, + source_snapshot, + lineage, + proposals, + }) + .await?; + + Ok(crate::knowledge::proposal_run_summary(created, candidates.len())) + } +} diff --git a/packages/elf-service/src/lib.rs b/packages/elf-service/src/lib.rs index 8097224d..7c83da05 100644 --- a/packages/elf-service/src/lib.rs +++ b/packages/elf-service/src/lib.rs @@ -30,11 +30,19 @@ pub mod update; pub mod work_journal; mod access; +mod constants; mod error; mod graph_ingestion; +mod history; mod ingest_audit; mod ingestion_profiles; +mod ops; +mod providers; mod ranking_explain_v2; +mod service; +mod update_resolution; +mod vectors; +mod write_policy; pub use self::{ add_event::{AddEventRequest, AddEventResponse, AddEventResult, EventMessage}, @@ -53,6 +61,7 @@ pub use self::{ ConsolidationRunCreateRequest, ConsolidationRunCreateResponse, ConsolidationRunGetRequest, ConsolidationRunResponse, ConsolidationRunsListRequest, ConsolidationRunsListResponse, }, + constants::{REJECT_EVIDENCE_MISMATCH, REJECT_WRITE_POLICY_MISMATCH}, core_blocks::{ CoreBlockAttachRequest, CoreBlockAttachResponse, CoreBlockDetachRequest, CoreBlockDetachResponse, CoreBlockItem, CoreBlockRecord, CoreBlockUpsertRequest, @@ -111,6 +120,7 @@ pub use self::{ MemoryCorrectionAction, MemoryCorrectionRequest, MemoryCorrectionResponse, }, notes::{NoteFetchRequest, NoteFetchResponse}, + ops::NoteOp, progressive_search::{ SearchDetailsError, SearchDetailsRequest, SearchDetailsResponse, SearchDetailsResult, SearchIndexItem, SearchIndexPlannedResponse, SearchIndexResponse, SearchSessionGetRequest, @@ -122,6 +132,7 @@ pub use self::{ NoteProvenanceIngestDecision, NoteProvenanceNote, NoteProvenanceNoteVersion, NoteProvenanceRecentTrace, }, + providers::{BoxFuture, EmbeddingProvider, ExtractorProvider, Providers, RerankProvider}, recall_debug::{ ELF_RECALL_DEBUG_PANEL_SCHEMA_V1, ELF_RECALL_TRACE_SCHEMA_V1, RecallDebugLayer, RecallDebugPanelRequest, RecallDebugPanelRequestEcho, RecallDebugPanelResponse, @@ -139,6 +150,7 @@ pub use self::{ TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentListRequest, TraceRecentListResponse, TraceTrajectoryGetRequest, }, + service::ElfService, sharing::{ GranteeKind, PublishNoteRequest, PublishNoteResponse, ShareScope, SpaceGrantItem, SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, @@ -155,540 +167,11 @@ pub use self::{ }, }; -use std::{future::Future, pin::Pin, sync::Arc}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::PgExecutor; -use time::OffsetDateTime; -use uuid::Uuid; - -use elf_config::{Config, EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; -use elf_domain::writegate::RejectCode; -use elf_providers::{embedding, extractor, rerank}; -use elf_storage::{db::Db, models::MemoryNote, qdrant::QdrantStore}; - -/// Boxed future type used by provider traits. -pub type BoxFuture<'a, T> = Pin + Send + 'a>>; - -/// Rejection code emitted when event evidence quotes do not match the source messages. -pub const REJECT_EVIDENCE_MISMATCH: &str = "REJECT_EVIDENCE_MISMATCH"; -/// Rejection code emitted when a write policy and extracted output disagree. -pub const REJECT_WRITE_POLICY_MISMATCH: &str = "REJECT_WRITE_POLICY_MISMATCH"; - -const RESOLVE_UPDATE_QUERY: &str = "\ -WITH key_match AS ( - SELECT note_id - FROM memory_notes - WHERE tenant_id = $1 - AND project_id = $2 - AND agent_id = $3 - AND scope = $4 - AND type = $5 - AND $6::text IS NOT NULL - AND key = $6 - AND status = 'active' - AND (expires_at IS NULL OR expires_at > $7) - LIMIT 1 -), -existing AS ( - SELECT note_id - FROM memory_notes - WHERE tenant_id = $1 - AND project_id = $2 - AND agent_id = $3 - AND scope = $4 - AND type = $5 - AND status = 'active' - AND (expires_at IS NULL OR expires_at > $7) -), -best AS ( - SELECT - note_id, - (1 - (vec <=> $8::text::vector))::real AS similarity - FROM note_embeddings - WHERE note_id = ANY(ARRAY(SELECT note_id FROM existing)) - AND embedding_version = $9 - ORDER BY similarity DESC - LIMIT 1 -) - SELECT - (SELECT note_id FROM key_match) AS key_note_id, - (SELECT note_id FROM best) AS best_note_id, - (SELECT similarity FROM best) AS best_similarity"; - -/// Embedding provider contract used by the service layer. -pub trait EmbeddingProvider -where - Self: Send + Sync, -{ - /// Embeds one or more texts into dense vectors. - fn embed<'a>( - &'a self, - cfg: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, Result>>>; -} - -/// Rerank provider contract used by the service layer. -pub trait RerankProvider -where - Self: Send + Sync, -{ - /// Scores candidate documents for one query. - fn rerank<'a>( - &'a self, - cfg: &'a ProviderConfig, - query: &'a str, - docs: &'a [String], - ) -> BoxFuture<'a, Result>>; -} - -/// Extractor provider contract used by the service layer. -pub trait ExtractorProvider -where - Self: Send + Sync, -{ - /// Extracts structured JSON output from a message transcript. - fn extract<'a>( - &'a self, - cfg: &'a LlmProviderConfig, - messages: &'a [Value], - ) -> BoxFuture<'a, Result>; -} - -/// Note operation emitted by service mutations. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NoteOp { - /// A new note was inserted. - Add, - /// An existing note was updated. - Update, - /// No persisted change was required. - None, - /// A note was deleted. - Delete, - /// The request was rejected before persistence. - Rejected, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) enum UpdateDecision { - Add { note_id: Uuid, metadata: UpdateDecisionMetadata }, - Update { note_id: Uuid, metadata: UpdateDecisionMetadata }, - None { note_id: Uuid, metadata: UpdateDecisionMetadata }, -} -impl UpdateDecision { - pub(crate) fn note_id(&self) -> Uuid { - match self { - Self::Add { note_id, .. } - | Self::Update { note_id, .. } - | Self::None { note_id, .. } => *note_id, - } - } - - pub(crate) fn metadata(&self) -> UpdateDecisionMetadata { - match self { - Self::Add { metadata, .. } - | Self::Update { metadata, .. } - | Self::None { metadata, .. } => *metadata, - } - } -} - -#[derive(Clone, Copy, Debug)] -pub(crate) struct UpdateDecisionMetadata { - pub similarity_best: Option, - pub key_match: bool, - pub matched_dup: bool, -} - -/// Provider bundle used by `ElfService`. -#[derive(Clone)] -pub struct Providers { - /// Dense embedding provider implementation. - pub embedding: Arc, - /// Rerank provider implementation. - pub rerank: Arc, - /// Structured extraction provider implementation. - pub extractor: Arc, -} -impl Providers { - /// Builds a provider bundle from explicit provider implementations. - pub fn new( - embedding: Arc, - rerank: Arc, - extractor: Arc, - ) -> Self { - Self { embedding, rerank, extractor } - } -} - -impl Default for Providers { - fn default() -> Self { - let provider = Arc::new(DefaultProviders); - - Self { embedding: provider.clone(), rerank: provider.clone(), extractor: provider } - } -} - -/// Main service container for ELF request handling. -pub struct ElfService { - /// Repository configuration snapshot. - pub cfg: Config, - /// Postgres storage handle. - pub db: Db, - /// Qdrant storage handle. - pub qdrant: QdrantStore, - /// External model-provider adapters. - pub providers: Providers, -} -impl ElfService { - /// Builds a service with the default provider adapters. - pub fn new(cfg: Config, db: Db, qdrant: QdrantStore) -> Self { - Self { cfg, db, qdrant, providers: Providers::default() } - } - - /// Builds a service with explicit provider adapters. - pub fn with_providers(cfg: Config, db: Db, qdrant: QdrantStore, providers: Providers) -> Self { - Self { cfg, db, qdrant, providers } - } -} - -struct ResolveUpdateArgs<'a> { - pub(crate) cfg: &'a Config, - pub(crate) providers: &'a Providers, - pub(crate) tenant_id: &'a str, - pub(crate) project_id: &'a str, - pub(crate) agent_id: &'a str, - pub(crate) scope: &'a str, - pub(crate) note_type: &'a str, - pub(crate) key: Option<&'a str>, - pub(crate) text: &'a str, - pub(crate) now: OffsetDateTime, -} - -struct InsertVersionArgs<'a> { - pub(crate) note_id: Uuid, - pub(crate) op: &'a str, - pub(crate) prev_snapshot: Option, - pub(crate) new_snapshot: Option, - pub(crate) reason: &'a str, - pub(crate) actor: &'a str, - pub(crate) ts: OffsetDateTime, -} - -struct DefaultProviders; -impl EmbeddingProvider for DefaultProviders { - fn embed<'a>( - &'a self, - cfg: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, Result>>> { - Box::pin(async move { - embedding::embed(cfg, texts) - .await - .map_err(|err| Error::Provider { message: err.to_string() }) - }) - } -} - -impl RerankProvider for DefaultProviders { - fn rerank<'a>( - &'a self, - cfg: &'a ProviderConfig, - query: &'a str, - docs: &'a [String], - ) -> BoxFuture<'a, Result>> { - Box::pin(async move { - rerank::rerank(cfg, query, docs) - .await - .map_err(|err| Error::Provider { message: err.to_string() }) - }) - } -} - -impl ExtractorProvider for DefaultProviders { - fn extract<'a>( - &'a self, - cfg: &'a LlmProviderConfig, - messages: &'a [Value], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - extractor::extract(cfg, messages) - .await - .map_err(|err| Error::Provider { message: err.to_string() }) - }) - } -} - -pub(crate) fn embedding_version(cfg: &Config) -> String { - format!( - "{}:{}:{}", - cfg.providers.embedding.provider_id, - cfg.providers.embedding.model, - cfg.storage.qdrant.vector_dim - ) -} - -pub(crate) fn writegate_reason_code(code: RejectCode) -> &'static str { - match code { - RejectCode::RejectNonEnglish => "REJECT_NON_ENGLISH", - RejectCode::RejectTooLong => "REJECT_TOO_LONG", - RejectCode::RejectSecret => "REJECT_SECRET", - RejectCode::RejectInvalidType => "REJECT_INVALID_TYPE", - RejectCode::RejectScopeDenied => "REJECT_SCOPE_DENIED", - RejectCode::RejectEmpty => "REJECT_EMPTY", - } -} - -pub(crate) fn vector_to_pg(vec: &[f32]) -> String { - let mut out = String::with_capacity(vec.len() * 8); - - out.push('['); - - for (i, value) in vec.iter().enumerate() { - if i > 0 { - out.push(','); - } - - out.push_str(&value.to_string()); - } - - out.push(']'); - - out -} - -pub(crate) fn parse_pg_vector(text: &str) -> Result> { - let trimmed = text.trim(); - let without_brackets = - trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')).ok_or_else(|| { - Error::InvalidRequest { message: "Vector text is not bracketed.".to_string() } - })?; - - if without_brackets.trim().is_empty() { - return Ok(Vec::new()); - } - - let mut vec = Vec::new(); - - for part in without_brackets.split(',') { - let value: f32 = part.trim().parse().map_err(|_| Error::InvalidRequest { - message: "Vector text contains a non-numeric value.".to_string(), - })?; - - vec.push(value); - } - - Ok(vec) -} - -pub(crate) fn note_snapshot(note: &MemoryNote) -> Value { - serde_json::json!({ - "note_id": note.note_id, - "tenant_id": note.tenant_id, - "project_id": note.project_id, - "agent_id": note.agent_id, - "scope": note.scope, - "type": note.r#type, - "key": note.key, - "text": note.text, - "importance": note.importance, - "confidence": note.confidence, - "status": note.status, - "created_at": note.created_at, - "updated_at": note.updated_at, - "expires_at": note.expires_at, - "embedding_version": note.embedding_version, - "source_ref": note.source_ref, - "hit_count": note.hit_count, - "last_hit_at": note.last_hit_at, - }) -} - -pub(crate) async fn resolve_update<'e, E>( - executor: E, - args: ResolveUpdateArgs<'_>, -) -> Result -where - E: PgExecutor<'e>, -{ - let ResolveUpdateArgs { - cfg, - providers, - tenant_id, - project_id, - agent_id, - scope, - note_type, - key, - text, - now, - } = args; - let embeddings = - providers.embedding.embed(&cfg.providers.embedding, &[text.to_string()]).await?; - let Some(vec) = embeddings.into_iter().next() else { - return Err(Error::Provider { - message: "Embedding provider returned no vectors.".to_string(), - }); - }; - - if vec.len() != cfg.storage.qdrant.vector_dim as usize { - return Err(Error::Provider { - message: "Embedding vector dimension mismatch.".to_string(), - }); - } - - let vec_text = vector_to_pg(&vec); - let embed_version = embedding_version(cfg); - let key = key.map(|value| value.trim()).filter(|value| !value.is_empty()); - let row: (Option, Option, Option) = sqlx::query_as(RESOLVE_UPDATE_QUERY) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(scope) - .bind(note_type) - .bind(key) - .bind(now) - .bind(vec_text.as_str()) - .bind(embed_version.as_str()) - .fetch_one(executor) - .await?; - let (key_note_id, best_note_id, best_similarity) = row; - - if let Some(note_id) = key_note_id { - return Ok(UpdateDecision::Update { - note_id, - metadata: UpdateDecisionMetadata { - similarity_best: None, - key_match: true, - matched_dup: false, - }, - }); - } - - let Some(best_id) = best_note_id else { - return Ok(UpdateDecision::Add { - note_id: Uuid::new_v4(), - metadata: UpdateDecisionMetadata { - similarity_best: None, - key_match: false, - matched_dup: false, - }, - }); - }; - let Some(best_score) = best_similarity else { - return Ok(UpdateDecision::Add { - note_id: Uuid::new_v4(), - metadata: UpdateDecisionMetadata { - similarity_best: None, - key_match: false, - matched_dup: false, - }, - }); - }; - - if best_score >= cfg.memory.dup_sim_threshold { - return Ok(UpdateDecision::None { - note_id: best_id, - metadata: UpdateDecisionMetadata { - similarity_best: Some(best_score), - key_match: false, - matched_dup: true, - }, - }); - } - if best_score >= cfg.memory.update_sim_threshold { - return Ok(UpdateDecision::Update { - note_id: best_id, - metadata: UpdateDecisionMetadata { - similarity_best: Some(best_score), - key_match: false, - matched_dup: false, - }, - }); - } - - Ok(UpdateDecision::Add { - note_id: Uuid::new_v4(), - metadata: UpdateDecisionMetadata { - similarity_best: Some(best_score), - key_match: false, - matched_dup: false, - }, - }) -} - -pub(crate) async fn insert_version<'e, E>(executor: E, args: InsertVersionArgs<'_>) -> Result -where - E: PgExecutor<'e>, -{ - let InsertVersionArgs { note_id, op, prev_snapshot, new_snapshot, reason, actor, ts } = args; - let version_id = Uuid::new_v4(); - - sqlx::query( - "\ -INSERT INTO memory_note_versions ( - version_id, - note_id, - op, - prev_snapshot, - new_snapshot, - reason, - actor, - ts -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", - ) - .bind(version_id) - .bind(note_id) - .bind(op) - .bind(prev_snapshot) - .bind(new_snapshot) - .bind(reason) - .bind(actor) - .bind(ts) - .execute(executor) - .await?; - - Ok(version_id) -} - -pub(crate) async fn enqueue_outbox_tx<'e, E>( - executor: E, - note_id: Uuid, - op: &str, - embedding_version: &str, - now: OffsetDateTime, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO indexing_outbox ( - outbox_id, - note_id, - op, - embedding_version, - status, - created_at, - updated_at, - available_at -) -VALUES ($1,$2,$3,$4,'PENDING',$5,$6,$7)", - ) - .bind(Uuid::new_v4()) - .bind(note_id) - .bind(op) - .bind(embedding_version) - .bind(now) - .bind(now) - .bind(now) - .execute(executor) - .await?; - - Ok(()) -} +use self::{ + history::{InsertVersionArgs, enqueue_outbox_tx, insert_version, note_snapshot}, + update_resolution::{ + ResolveUpdateArgs, UpdateDecision, UpdateDecisionMetadata, resolve_update, + }, + vectors::{embedding_version, parse_pg_vector, vector_to_pg}, + write_policy::writegate_reason_code, +}; diff --git a/packages/elf-service/src/memory_corrections.rs b/packages/elf-service/src/memory_corrections.rs index b95f9c20..9c8bfd82 100644 --- a/packages/elf-service/src/memory_corrections.rs +++ b/packages/elf-service/src/memory_corrections.rs @@ -1,693 +1,10 @@ //! Review-backed memory correction and rollback APIs. -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use sqlx::{Postgres, Transaction}; -use time::OffsetDateTime; -use uuid::Uuid; +mod service; +mod storage; +mod types; +mod validation; -use crate::{ElfService, Error, InsertVersionArgs, NoteOp, Result, access::ORG_PROJECT_ID}; -use elf_config::Scopes; -use elf_storage::models::MemoryNote; +pub use types::{MemoryCorrectionAction, MemoryCorrectionRequest, MemoryCorrectionResponse}; -/// Review-backed correction action for an approved memory record. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MemoryCorrectionAction { - /// Mark the memory as superseded while retaining historical readback. - Supersede, - /// Tombstone the memory while retaining historical readback. - Delete, - /// Restore the latest prior active snapshot from the memory ledger. - Restore, -} -impl MemoryCorrectionAction { - /// Returns the canonical action string. - pub fn as_str(self) -> &'static str { - match self { - Self::Supersede => "supersede", - Self::Delete => "delete", - Self::Restore => "restore", - } - } -} - -impl ElfService { - /// Applies a review-backed memory correction and writes an audit version row. - pub async fn memory_correction_apply( - &self, - req: MemoryCorrectionRequest, - ) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let actor_agent_id = req.actor_agent_id.trim(); - let reason = req.reason.trim(); - - validate_correction_request( - tenant_id, - project_id, - actor_agent_id, - reason, - &req.source_ref, - )?; - - let now = OffsetDateTime::now_utc(); - let mut tx = self.db.pool.begin().await?; - let mut note = - load_note_for_correction(&mut tx, req.note_id, tenant_id, project_id).await?; - - validate_write_scope(¬e, &self.cfg.scopes)?; - - let version_id = match req.action { - MemoryCorrectionAction::Supersede => - supersede_note(&mut tx, &mut note, actor_agent_id, reason, &req.source_ref, now) - .await?, - MemoryCorrectionAction::Delete => - delete_note(&mut tx, &mut note, actor_agent_id, reason, &req.source_ref, now) - .await?, - MemoryCorrectionAction::Restore => { - let embed_version = crate::embedding_version(&self.cfg); - - restore_note( - &mut tx, - &mut note, - RestoreNoteArgs { - actor_agent_id, - reason, - correction_source_ref: &req.source_ref, - restore_version_id: req.restore_version_id, - embedding_version: embed_version.as_str(), - now, - }, - ) - .await? - }, - }; - let op = match (req.action, version_id) { - (_, None) => NoteOp::None, - (MemoryCorrectionAction::Delete, Some(_)) => NoteOp::Delete, - (MemoryCorrectionAction::Supersede | MemoryCorrectionAction::Restore, Some(_)) => - NoteOp::Update, - }; - - tx.commit().await?; - - Ok(MemoryCorrectionResponse { - note_id: note.note_id, - action: req.action, - op, - status: note.status, - version_id, - }) - } -} - -/// Request payload for applying a memory correction. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MemoryCorrectionRequest { - /// Tenant that owns the memory. - pub tenant_id: String, - /// Project that owns the memory. - pub project_id: String, - /// Reviewer or policy actor applying the correction. - pub actor_agent_id: String, - /// Identifier of the memory note being corrected. - pub note_id: Uuid, - /// Correction action to apply. - pub action: MemoryCorrectionAction, - /// Reviewer or policy reason for the correction. - pub reason: String, - /// Source reference or review record that justifies the correction. - pub source_ref: Value, - /// Optional ledger version to restore from. Defaults to the latest supersede/delete snapshot. - pub restore_version_id: Option, -} - -/// Response returned after applying a memory correction. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MemoryCorrectionResponse { - /// Identifier of the corrected memory note. - pub note_id: Uuid, - /// Correction action that was requested. - pub action: MemoryCorrectionAction, - /// Storage operation applied to the memory record. - pub op: NoteOp, - /// Current lifecycle status after the correction. - pub status: String, - /// Version row written for this correction, when a change occurred. - pub version_id: Option, -} - -struct RestoreNoteArgs<'a> { - actor_agent_id: &'a str, - reason: &'a str, - correction_source_ref: &'a Value, - restore_version_id: Option, - embedding_version: &'a str, - now: OffsetDateTime, -} - -fn validate_correction_request( - tenant_id: &str, - project_id: &str, - actor_agent_id: &str, - reason: &str, - source_ref: &Value, -) -> Result<()> { - if tenant_id.is_empty() || project_id.is_empty() || actor_agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and actor_agent_id are required.".to_string(), - }); - } - if reason.is_empty() { - return Err(Error::InvalidRequest { message: "reason must not be empty.".to_string() }); - } - if !is_non_empty_object(source_ref) { - return Err(Error::InvalidRequest { - message: "source_ref must be a non-empty JSON object.".to_string(), - }); - } - - Ok(()) -} - -fn validate_write_scope(note: &MemoryNote, scopes: &Scopes) -> Result<()> { - if !scopes.allowed.iter().any(|scope| scope == ¬e.scope) { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - - let write_allowed = match note.scope.as_str() { - "agent_private" => scopes.write_allowed.agent_private, - "project_shared" => scopes.write_allowed.project_shared, - "org_shared" => scopes.write_allowed.org_shared, - _ => false, - }; - - if write_allowed { - Ok(()) - } else { - Err(Error::ScopeDenied { message: "Scope is not writable.".to_string() }) - } -} - -fn apply_restore_snapshot( - note: &mut MemoryNote, - snapshot: &Value, - now: OffsetDateTime, -) -> Result<()> { - let status = required_string(snapshot, "status")?; - - if status != "active" { - return Err(Error::InvalidRequest { - message: "Restore snapshot must represent an active memory.".to_string(), - }); - } - - note.scope = required_string(snapshot, "scope")?; - note.r#type = required_string(snapshot, "type")?; - note.key = optional_string(snapshot, "key")?; - note.text = required_string(snapshot, "text")?; - note.importance = required_f32(snapshot, "importance")?; - note.confidence = required_f32(snapshot, "confidence")?; - note.status = status; - note.updated_at = now; - note.expires_at = optional_offset_datetime(snapshot, "expires_at")?; - - Ok(()) -} - -fn correction_source_ref_for( - action: MemoryCorrectionAction, - prior_snapshot: &Value, - correction_source_ref: &Value, - reason: &str, - actor_agent_id: &str, - now: OffsetDateTime, - restore_version_id: Option, -) -> Value { - serde_json::json!({ - "schema": "elf.memory_correction/v1", - "action": action.as_str(), - "reason": reason, - "actor_agent_id": actor_agent_id, - "ts": now, - "restore_version_id": restore_version_id, - "prior_source_ref": prior_snapshot.get("source_ref").cloned().unwrap_or_else(empty_object), - "prior_snapshot": prior_snapshot, - "correction_source_ref": correction_source_ref, - }) -} - -fn is_non_empty_object(value: &Value) -> bool { - matches!(value, Value::Object(map) if !map.is_empty()) -} - -fn required_string(snapshot: &Value, field: &'static str) -> Result { - snapshot - .get(field) - .and_then(Value::as_str) - .map(str::to_string) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| Error::InvalidRequest { - message: format!("Restore snapshot field {field} must be a non-empty string."), - }) -} - -fn optional_string(snapshot: &Value, field: &'static str) -> Result> { - match snapshot.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => Ok(Some(value.clone())), - _ => Err(Error::InvalidRequest { - message: format!("Restore snapshot field {field} must be a string or null."), - }), - } -} - -fn required_f32(snapshot: &Value, field: &'static str) -> Result { - let Some(value) = snapshot.get(field).and_then(Value::as_f64) else { - return Err(Error::InvalidRequest { - message: format!("Restore snapshot field {field} must be a number."), - }); - }; - - if !value.is_finite() || value < f64::from(f32::MIN) || value > f64::from(f32::MAX) { - return Err(Error::InvalidRequest { - message: format!("Restore snapshot field {field} is out of range."), - }); - } - - Ok(value as f32) -} - -fn optional_offset_datetime( - snapshot: &Value, - field: &'static str, -) -> Result> { - let Some(value) = snapshot.get(field) else { - return Ok(None); - }; - - serde_json::from_value(value.clone()).map_err(|err| Error::InvalidRequest { - message: format!("Restore snapshot field {field} is not a valid timestamp: {err}."), - }) -} - -fn empty_object() -> Value { - Value::Object(Map::new()) -} - -async fn load_note_for_correction( - tx: &mut Transaction<'_, Postgres>, - note_id: Uuid, - tenant_id: &str, - project_id: &str, -) -> Result { - sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 AND tenant_id = $2 AND project_id IN ($3, $4) -FOR UPDATE", - ) - .bind(note_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut **tx) - .await? - .ok_or_else(|| Error::InvalidRequest { message: "Note not found.".to_string() }) -} - -async fn supersede_note( - tx: &mut Transaction<'_, Postgres>, - note: &mut MemoryNote, - actor_agent_id: &str, - reason: &str, - correction_source_ref: &Value, - now: OffsetDateTime, -) -> Result> { - if note.status == "deprecated" { - return Ok(None); - } - if note.status == "deleted" { - return Err(Error::InvalidRequest { - message: "Deleted memory must be restored before it can be superseded.".to_string(), - }); - } - - let prev_snapshot = crate::note_snapshot(note); - - note.status = "deprecated".to_string(); - note.updated_at = now; - note.source_ref = correction_source_ref_for( - MemoryCorrectionAction::Supersede, - &prev_snapshot, - correction_source_ref, - reason, - actor_agent_id, - now, - None, - ); - - update_note_lifecycle(tx, note).await?; - - let version_id = insert_correction_version( - tx, - note, - "DEPRECATE", - prev_snapshot, - actor_agent_id, - reason, - now, - ) - .await?; - - crate::enqueue_outbox_tx(&mut **tx, note.note_id, "DELETE", ¬e.embedding_version, now) - .await?; - - Ok(Some(version_id)) -} - -async fn delete_note( - tx: &mut Transaction<'_, Postgres>, - note: &mut MemoryNote, - actor_agent_id: &str, - reason: &str, - correction_source_ref: &Value, - now: OffsetDateTime, -) -> Result> { - if note.status == "deleted" { - return Ok(None); - } - - let prev_snapshot = crate::note_snapshot(note); - - note.status = "deleted".to_string(); - note.updated_at = now; - note.source_ref = correction_source_ref_for( - MemoryCorrectionAction::Delete, - &prev_snapshot, - correction_source_ref, - reason, - actor_agent_id, - now, - None, - ); - - update_note_lifecycle(tx, note).await?; - - let version_id = - insert_correction_version(tx, note, "DELETE", prev_snapshot, actor_agent_id, reason, now) - .await?; - - crate::enqueue_outbox_tx(&mut **tx, note.note_id, "DELETE", ¬e.embedding_version, now) - .await?; - - Ok(Some(version_id)) -} - -async fn restore_note( - tx: &mut Transaction<'_, Postgres>, - note: &mut MemoryNote, - args: RestoreNoteArgs<'_>, -) -> Result> { - if note.status == "active" { - return Ok(None); - } - - let (restore_version_id, restore_snapshot) = - load_restore_snapshot(tx, note.note_id, args.restore_version_id).await?; - let prev_snapshot = crate::note_snapshot(note); - - apply_restore_snapshot(note, &restore_snapshot, args.now)?; - - note.embedding_version = args.embedding_version.to_string(); - note.source_ref = correction_source_ref_for( - MemoryCorrectionAction::Restore, - &restore_snapshot, - args.correction_source_ref, - args.reason, - args.actor_agent_id, - args.now, - Some(restore_version_id), - ); - - update_note_restored(tx, note).await?; - - let version_id = insert_correction_version( - tx, - note, - "RESTORE", - prev_snapshot, - args.actor_agent_id, - args.reason, - args.now, - ) - .await?; - - crate::enqueue_outbox_tx(&mut **tx, note.note_id, "UPSERT", ¬e.embedding_version, args.now) - .await?; - - Ok(Some(version_id)) -} - -async fn update_note_lifecycle( - tx: &mut Transaction<'_, Postgres>, - note: &MemoryNote, -) -> Result<()> { - sqlx::query( - "\ -UPDATE memory_notes -SET status = $1, updated_at = $2, source_ref = $3 -WHERE note_id = $4", - ) - .bind(note.status.as_str()) - .bind(note.updated_at) - .bind(¬e.source_ref) - .bind(note.note_id) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn update_note_restored(tx: &mut Transaction<'_, Postgres>, note: &MemoryNote) -> Result<()> { - sqlx::query( - "\ -UPDATE memory_notes -SET - scope = $1, - type = $2, - key = $3, - text = $4, - importance = $5, - confidence = $6, - status = $7, - updated_at = $8, - expires_at = $9, - embedding_version = $10, - source_ref = $11 -WHERE note_id = $12", - ) - .bind(note.scope.as_str()) - .bind(note.r#type.as_str()) - .bind(note.key.as_deref()) - .bind(note.text.as_str()) - .bind(note.importance) - .bind(note.confidence) - .bind(note.status.as_str()) - .bind(note.updated_at) - .bind(note.expires_at) - .bind(note.embedding_version.as_str()) - .bind(¬e.source_ref) - .bind(note.note_id) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn insert_correction_version( - tx: &mut Transaction<'_, Postgres>, - note: &MemoryNote, - op: &str, - prev_snapshot: Value, - actor_agent_id: &str, - reason: &str, - now: OffsetDateTime, -) -> Result { - let reason = format!("memory_correction.{}: {reason}", op.to_ascii_lowercase()); - - crate::insert_version( - &mut **tx, - InsertVersionArgs { - note_id: note.note_id, - op, - prev_snapshot: Some(prev_snapshot), - new_snapshot: Some(crate::note_snapshot(note)), - reason: reason.as_str(), - actor: actor_agent_id, - ts: now, - }, - ) - .await -} - -async fn load_restore_snapshot( - tx: &mut Transaction<'_, Postgres>, - note_id: Uuid, - restore_version_id: Option, -) -> Result<(Uuid, Value)> { - let row: Option<(Uuid, Value)> = if let Some(version_id) = restore_version_id { - sqlx::query_as( - "\ -SELECT version_id, prev_snapshot -FROM memory_note_versions -WHERE note_id = $1 AND version_id = $2 AND prev_snapshot IS NOT NULL -LIMIT 1", - ) - .bind(note_id) - .bind(version_id) - .fetch_optional(&mut **tx) - .await? - } else { - sqlx::query_as( - "\ -SELECT version_id, prev_snapshot -FROM memory_note_versions -WHERE note_id = $1 - AND op IN ('DELETE', 'DEPRECATE') - AND prev_snapshot IS NOT NULL - AND prev_snapshot ->> 'status' = 'active' -ORDER BY ts DESC, version_id DESC -LIMIT 1", - ) - .bind(note_id) - .fetch_optional(&mut **tx) - .await? - }; - - row.ok_or_else(|| Error::InvalidRequest { - message: "No restorable memory snapshot was found.".to_string(), - }) -} - -#[cfg(test)] -mod tests { - use time::OffsetDateTime; - use uuid::Uuid; - - use crate::memory_corrections::{self, MemoryCorrectionAction}; - use elf_storage::models::MemoryNote; - - fn note(status: &str) -> MemoryNote { - MemoryNote { - note_id: Uuid::new_v4(), - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - scope: "agent_private".to_string(), - r#type: "fact".to_string(), - key: Some("target".to_string()), - text: "Fact: Original memory.".to_string(), - importance: 0.7, - confidence: 0.9, - status: status.to_string(), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - expires_at: None, - embedding_version: "test:test:4".to_string(), - source_ref: serde_json::json!({ "schema": "test/source" }), - hit_count: 0, - last_hit_at: None, - } - } - - #[test] - fn correction_request_requires_non_empty_reason_and_source() { - assert!( - memory_corrections::validate_correction_request( - "tenant", - "project", - "actor", - "because", - &serde_json::json!({ - "schema": "review" - }) - ) - .is_ok() - ); - assert!( - memory_corrections::validate_correction_request( - "tenant", - "project", - "actor", - "", - &serde_json::json!({ - "schema": "review" - }) - ) - .is_err() - ); - assert!( - memory_corrections::validate_correction_request( - "tenant", - "project", - "actor", - "because", - &serde_json::json!({}) - ) - .is_err() - ); - } - - #[test] - fn restore_snapshot_must_be_active_and_restores_memory_fields() { - let snapshot = serde_json::json!({ - "scope": "project_shared", - "type": "decision", - "key": null, - "text": "Decision: Restore the reviewed memory.", - "importance": 0.8, - "confidence": 0.95, - "status": "active", - "expires_at": null - }); - let mut note = note("deleted"); - - memory_corrections::apply_restore_snapshot( - &mut note, - &snapshot, - OffsetDateTime::UNIX_EPOCH, - ) - .expect("snapshot should restore"); - - assert_eq!(note.status, "active"); - assert_eq!(note.scope, "project_shared"); - assert_eq!(note.r#type, "decision"); - assert_eq!(note.key, None); - assert_eq!(note.text, "Decision: Restore the reviewed memory."); - } - - #[test] - fn correction_source_ref_preserves_prior_and_review_evidence() { - let prior = serde_json::json!({ - "source_ref": { "schema": "prior" }, - "text": "Fact: Prior memory." - }); - let correction = memory_corrections::correction_source_ref_for( - MemoryCorrectionAction::Supersede, - &prior, - &serde_json::json!({ "schema": "review" }), - "newer source wins", - "reviewer", - OffsetDateTime::UNIX_EPOCH, - None, - ); - - assert_eq!(correction["schema"], "elf.memory_correction/v1"); - assert_eq!(correction["action"], "supersede"); - assert_eq!(correction["prior_source_ref"]["schema"], "prior"); - assert_eq!(correction["correction_source_ref"]["schema"], "review"); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/memory_corrections/service.rs b/packages/elf-service/src/memory_corrections/service.rs new file mode 100644 index 00000000..bd2cb8ff --- /dev/null +++ b/packages/elf-service/src/memory_corrections/service.rs @@ -0,0 +1,94 @@ +use time::OffsetDateTime; + +use crate::{ + ElfService, NoteOp, Result, + memory_corrections::{ + storage::{self, RestoreNoteArgs}, + types::{MemoryCorrectionAction, MemoryCorrectionRequest, MemoryCorrectionResponse}, + validation::{self}, + }, +}; + +impl ElfService { + /// Applies a review-backed memory correction and writes an audit version row. + pub async fn memory_correction_apply( + &self, + req: MemoryCorrectionRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let actor_agent_id = req.actor_agent_id.trim(); + let reason = req.reason.trim(); + + validation::validate_correction_request( + tenant_id, + project_id, + actor_agent_id, + reason, + &req.source_ref, + )?; + + let now = OffsetDateTime::now_utc(); + let mut tx = self.db.pool.begin().await?; + let mut note = + storage::load_note_for_correction(&mut tx, req.note_id, tenant_id, project_id).await?; + + validation::validate_write_scope(¬e, &self.cfg.scopes)?; + + let version_id = match req.action { + MemoryCorrectionAction::Supersede => + storage::supersede_note( + &mut tx, + &mut note, + actor_agent_id, + reason, + &req.source_ref, + now, + ) + .await?, + MemoryCorrectionAction::Delete => + storage::delete_note( + &mut tx, + &mut note, + actor_agent_id, + reason, + &req.source_ref, + now, + ) + .await?, + MemoryCorrectionAction::Restore => { + let embed_version = crate::embedding_version(&self.cfg); + + storage::restore_note( + &mut tx, + &mut note, + RestoreNoteArgs { + actor_agent_id, + reason, + correction_source_ref: &req.source_ref, + restore_version_id: req.restore_version_id, + embedding_version: embed_version.as_str(), + now, + }, + ) + .await? + }, + }; + let op = match (req.action, version_id) { + (_, None) => NoteOp::None, + (MemoryCorrectionAction::Delete, Some(_)) => NoteOp::Delete, + (MemoryCorrectionAction::Supersede | MemoryCorrectionAction::Restore, Some(_)) => + NoteOp::Update, + }; + + tx.commit().await?; + + Ok(MemoryCorrectionResponse { + note_id: note.note_id, + action: req.action, + op, + status: note.status, + version_id, + }) + } +} diff --git a/packages/elf-service/src/memory_corrections/storage.rs b/packages/elf-service/src/memory_corrections/storage.rs new file mode 100644 index 00000000..62d46272 --- /dev/null +++ b/packages/elf-service/src/memory_corrections/storage.rs @@ -0,0 +1,299 @@ +use serde_json::Value; +use sqlx::{Postgres, Transaction}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Error, InsertVersionArgs, Result, + access::ORG_PROJECT_ID, + memory_corrections::{ + types::MemoryCorrectionAction, + validation::{self}, + }, +}; +use elf_storage::models::MemoryNote; + +pub(super) struct RestoreNoteArgs<'a> { + pub(super) actor_agent_id: &'a str, + pub(super) reason: &'a str, + pub(super) correction_source_ref: &'a Value, + pub(super) restore_version_id: Option, + pub(super) embedding_version: &'a str, + pub(super) now: OffsetDateTime, +} + +pub(super) async fn load_note_for_correction( + tx: &mut Transaction<'_, Postgres>, + note_id: Uuid, + tenant_id: &str, + project_id: &str, +) -> Result { + sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 AND tenant_id = $2 AND project_id IN ($3, $4) +FOR UPDATE", + ) + .bind(note_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| Error::InvalidRequest { message: "Note not found.".to_string() }) +} + +pub(super) async fn supersede_note( + tx: &mut Transaction<'_, Postgres>, + note: &mut MemoryNote, + actor_agent_id: &str, + reason: &str, + correction_source_ref: &Value, + now: OffsetDateTime, +) -> Result> { + if note.status == "deprecated" { + return Ok(None); + } + if note.status == "deleted" { + return Err(Error::InvalidRequest { + message: "Deleted memory must be restored before it can be superseded.".to_string(), + }); + } + + let prev_snapshot = crate::note_snapshot(note); + + note.status = "deprecated".to_string(); + note.updated_at = now; + note.source_ref = validation::correction_source_ref_for( + MemoryCorrectionAction::Supersede, + &prev_snapshot, + correction_source_ref, + reason, + actor_agent_id, + now, + None, + ); + + update_note_lifecycle(tx, note).await?; + + let version_id = insert_correction_version( + tx, + note, + "DEPRECATE", + prev_snapshot, + actor_agent_id, + reason, + now, + ) + .await?; + + crate::enqueue_outbox_tx(&mut **tx, note.note_id, "DELETE", ¬e.embedding_version, now) + .await?; + + Ok(Some(version_id)) +} + +pub(super) async fn delete_note( + tx: &mut Transaction<'_, Postgres>, + note: &mut MemoryNote, + actor_agent_id: &str, + reason: &str, + correction_source_ref: &Value, + now: OffsetDateTime, +) -> Result> { + if note.status == "deleted" { + return Ok(None); + } + + let prev_snapshot = crate::note_snapshot(note); + + note.status = "deleted".to_string(); + note.updated_at = now; + note.source_ref = validation::correction_source_ref_for( + MemoryCorrectionAction::Delete, + &prev_snapshot, + correction_source_ref, + reason, + actor_agent_id, + now, + None, + ); + + update_note_lifecycle(tx, note).await?; + + let version_id = + insert_correction_version(tx, note, "DELETE", prev_snapshot, actor_agent_id, reason, now) + .await?; + + crate::enqueue_outbox_tx(&mut **tx, note.note_id, "DELETE", ¬e.embedding_version, now) + .await?; + + Ok(Some(version_id)) +} + +pub(super) async fn restore_note( + tx: &mut Transaction<'_, Postgres>, + note: &mut MemoryNote, + args: RestoreNoteArgs<'_>, +) -> Result> { + if note.status == "active" { + return Ok(None); + } + + let (restore_version_id, restore_snapshot) = + load_restore_snapshot(tx, note.note_id, args.restore_version_id).await?; + let prev_snapshot = crate::note_snapshot(note); + + validation::apply_restore_snapshot(note, &restore_snapshot, args.now)?; + + note.embedding_version = args.embedding_version.to_string(); + note.source_ref = validation::correction_source_ref_for( + MemoryCorrectionAction::Restore, + &restore_snapshot, + args.correction_source_ref, + args.reason, + args.actor_agent_id, + args.now, + Some(restore_version_id), + ); + + update_note_restored(tx, note).await?; + + let version_id = insert_correction_version( + tx, + note, + "RESTORE", + prev_snapshot, + args.actor_agent_id, + args.reason, + args.now, + ) + .await?; + + crate::enqueue_outbox_tx(&mut **tx, note.note_id, "UPSERT", ¬e.embedding_version, args.now) + .await?; + + Ok(Some(version_id)) +} + +async fn update_note_lifecycle( + tx: &mut Transaction<'_, Postgres>, + note: &MemoryNote, +) -> Result<()> { + sqlx::query( + "\ +UPDATE memory_notes +SET status = $1, updated_at = $2, source_ref = $3 +WHERE note_id = $4", + ) + .bind(note.status.as_str()) + .bind(note.updated_at) + .bind(¬e.source_ref) + .bind(note.note_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn update_note_restored(tx: &mut Transaction<'_, Postgres>, note: &MemoryNote) -> Result<()> { + sqlx::query( + "\ +UPDATE memory_notes +SET + scope = $1, + type = $2, + key = $3, + text = $4, + importance = $5, + confidence = $6, + status = $7, + updated_at = $8, + expires_at = $9, + embedding_version = $10, + source_ref = $11 +WHERE note_id = $12", + ) + .bind(note.scope.as_str()) + .bind(note.r#type.as_str()) + .bind(note.key.as_deref()) + .bind(note.text.as_str()) + .bind(note.importance) + .bind(note.confidence) + .bind(note.status.as_str()) + .bind(note.updated_at) + .bind(note.expires_at) + .bind(note.embedding_version.as_str()) + .bind(¬e.source_ref) + .bind(note.note_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn insert_correction_version( + tx: &mut Transaction<'_, Postgres>, + note: &MemoryNote, + op: &str, + prev_snapshot: Value, + actor_agent_id: &str, + reason: &str, + now: OffsetDateTime, +) -> Result { + let reason = format!("memory_correction.{}: {reason}", op.to_ascii_lowercase()); + + crate::insert_version( + &mut **tx, + InsertVersionArgs { + note_id: note.note_id, + op, + prev_snapshot: Some(prev_snapshot), + new_snapshot: Some(crate::note_snapshot(note)), + reason: reason.as_str(), + actor: actor_agent_id, + ts: now, + }, + ) + .await +} + +async fn load_restore_snapshot( + tx: &mut Transaction<'_, Postgres>, + note_id: Uuid, + restore_version_id: Option, +) -> Result<(Uuid, Value)> { + let row: Option<(Uuid, Value)> = if let Some(version_id) = restore_version_id { + sqlx::query_as( + "\ +SELECT version_id, prev_snapshot +FROM memory_note_versions +WHERE note_id = $1 AND version_id = $2 AND prev_snapshot IS NOT NULL +LIMIT 1", + ) + .bind(note_id) + .bind(version_id) + .fetch_optional(&mut **tx) + .await? + } else { + sqlx::query_as( + "\ +SELECT version_id, prev_snapshot +FROM memory_note_versions +WHERE note_id = $1 + AND op IN ('DELETE', 'DEPRECATE') + AND prev_snapshot IS NOT NULL + AND prev_snapshot ->> 'status' = 'active' +ORDER BY ts DESC, version_id DESC +LIMIT 1", + ) + .bind(note_id) + .fetch_optional(&mut **tx) + .await? + }; + + row.ok_or_else(|| Error::InvalidRequest { + message: "No restorable memory snapshot was found.".to_string(), + }) +} diff --git a/packages/elf-service/src/memory_corrections/tests.rs b/packages/elf-service/src/memory_corrections/tests.rs new file mode 100644 index 00000000..1d8ce283 --- /dev/null +++ b/packages/elf-service/src/memory_corrections/tests.rs @@ -0,0 +1,115 @@ +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::memory_corrections::{ + MemoryCorrectionAction, + validation::{self}, +}; +use elf_storage::models::MemoryNote; + +fn note(status: &str) -> MemoryNote { + MemoryNote { + note_id: Uuid::new_v4(), + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + scope: "agent_private".to_string(), + r#type: "fact".to_string(), + key: Some("target".to_string()), + text: "Fact: Original memory.".to_string(), + importance: 0.7, + confidence: 0.9, + status: status.to_string(), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + expires_at: None, + embedding_version: "test:test:4".to_string(), + source_ref: serde_json::json!({ "schema": "test/source" }), + hit_count: 0, + last_hit_at: None, + } +} + +#[test] +fn correction_request_requires_non_empty_reason_and_source() { + assert!( + validation::validate_correction_request( + "tenant", + "project", + "actor", + "because", + &serde_json::json!({ + "schema": "review" + }) + ) + .is_ok() + ); + assert!( + validation::validate_correction_request( + "tenant", + "project", + "actor", + "", + &serde_json::json!({ + "schema": "review" + }) + ) + .is_err() + ); + assert!( + validation::validate_correction_request( + "tenant", + "project", + "actor", + "because", + &serde_json::json!({}) + ) + .is_err() + ); +} + +#[test] +fn restore_snapshot_must_be_active_and_restores_memory_fields() { + let snapshot = serde_json::json!({ + "scope": "project_shared", + "type": "decision", + "key": null, + "text": "Decision: Restore the reviewed memory.", + "importance": 0.8, + "confidence": 0.95, + "status": "active", + "expires_at": null + }); + let mut note = note("deleted"); + + validation::apply_restore_snapshot(&mut note, &snapshot, OffsetDateTime::UNIX_EPOCH) + .expect("snapshot should restore"); + + assert_eq!(note.status, "active"); + assert_eq!(note.scope, "project_shared"); + assert_eq!(note.r#type, "decision"); + assert_eq!(note.key, None); + assert_eq!(note.text, "Decision: Restore the reviewed memory."); +} + +#[test] +fn correction_source_ref_preserves_prior_and_review_evidence() { + let prior = serde_json::json!({ + "source_ref": { "schema": "prior" }, + "text": "Fact: Prior memory." + }); + let correction = validation::correction_source_ref_for( + MemoryCorrectionAction::Supersede, + &prior, + &serde_json::json!({ "schema": "review" }), + "newer source wins", + "reviewer", + OffsetDateTime::UNIX_EPOCH, + None, + ); + + assert_eq!(correction["schema"], "elf.memory_correction/v1"); + assert_eq!(correction["action"], "supersede"); + assert_eq!(correction["prior_source_ref"]["schema"], "prior"); + assert_eq!(correction["correction_source_ref"]["schema"], "review"); +} diff --git a/packages/elf-service/src/memory_corrections/types.rs b/packages/elf-service/src/memory_corrections/types.rs new file mode 100644 index 00000000..22972fec --- /dev/null +++ b/packages/elf-service/src/memory_corrections/types.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::NoteOp; + +/// Review-backed correction action for an approved memory record. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryCorrectionAction { + /// Mark the memory as superseded while retaining historical readback. + Supersede, + /// Tombstone the memory while retaining historical readback. + Delete, + /// Restore the latest prior active snapshot from the memory ledger. + Restore, +} +impl MemoryCorrectionAction { + /// Returns the canonical action string. + pub fn as_str(self) -> &'static str { + match self { + Self::Supersede => "supersede", + Self::Delete => "delete", + Self::Restore => "restore", + } + } +} + +/// Request payload for applying a memory correction. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MemoryCorrectionRequest { + /// Tenant that owns the memory. + pub tenant_id: String, + /// Project that owns the memory. + pub project_id: String, + /// Reviewer or policy actor applying the correction. + pub actor_agent_id: String, + /// Identifier of the memory note being corrected. + pub note_id: Uuid, + /// Correction action to apply. + pub action: MemoryCorrectionAction, + /// Reviewer or policy reason for the correction. + pub reason: String, + /// Source reference or review record that justifies the correction. + pub source_ref: Value, + /// Optional ledger version to restore from. Defaults to the latest supersede/delete snapshot. + pub restore_version_id: Option, +} + +/// Response returned after applying a memory correction. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MemoryCorrectionResponse { + /// Identifier of the corrected memory note. + pub note_id: Uuid, + /// Correction action that was requested. + pub action: MemoryCorrectionAction, + /// Storage operation applied to the memory record. + pub op: NoteOp, + /// Current lifecycle status after the correction. + pub status: String, + /// Version row written for this correction, when a change occurred. + pub version_id: Option, +} diff --git a/packages/elf-service/src/memory_corrections/validation.rs b/packages/elf-service/src/memory_corrections/validation.rs new file mode 100644 index 00000000..46307324 --- /dev/null +++ b/packages/elf-service/src/memory_corrections/validation.rs @@ -0,0 +1,156 @@ +use serde_json::{Map, Value}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{Error, Result, memory_corrections::types::MemoryCorrectionAction}; +use elf_config::Scopes; +use elf_storage::models::MemoryNote; + +pub(super) fn validate_correction_request( + tenant_id: &str, + project_id: &str, + actor_agent_id: &str, + reason: &str, + source_ref: &Value, +) -> Result<()> { + if tenant_id.is_empty() || project_id.is_empty() || actor_agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and actor_agent_id are required.".to_string(), + }); + } + if reason.is_empty() { + return Err(Error::InvalidRequest { message: "reason must not be empty.".to_string() }); + } + if !is_non_empty_object(source_ref) { + return Err(Error::InvalidRequest { + message: "source_ref must be a non-empty JSON object.".to_string(), + }); + } + + Ok(()) +} + +pub(super) fn validate_write_scope(note: &MemoryNote, scopes: &Scopes) -> Result<()> { + if !scopes.allowed.iter().any(|scope| scope == ¬e.scope) { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + + let write_allowed = match note.scope.as_str() { + "agent_private" => scopes.write_allowed.agent_private, + "project_shared" => scopes.write_allowed.project_shared, + "org_shared" => scopes.write_allowed.org_shared, + _ => false, + }; + + if write_allowed { + Ok(()) + } else { + Err(Error::ScopeDenied { message: "Scope is not writable.".to_string() }) + } +} + +pub(super) fn apply_restore_snapshot( + note: &mut MemoryNote, + snapshot: &Value, + now: OffsetDateTime, +) -> Result<()> { + let status = required_string(snapshot, "status")?; + + if status != "active" { + return Err(Error::InvalidRequest { + message: "Restore snapshot must represent an active memory.".to_string(), + }); + } + + note.scope = required_string(snapshot, "scope")?; + note.r#type = required_string(snapshot, "type")?; + note.key = optional_string(snapshot, "key")?; + note.text = required_string(snapshot, "text")?; + note.importance = required_f32(snapshot, "importance")?; + note.confidence = required_f32(snapshot, "confidence")?; + note.status = status; + note.updated_at = now; + note.expires_at = optional_offset_datetime(snapshot, "expires_at")?; + + Ok(()) +} + +pub(super) fn correction_source_ref_for( + action: MemoryCorrectionAction, + prior_snapshot: &Value, + correction_source_ref: &Value, + reason: &str, + actor_agent_id: &str, + now: OffsetDateTime, + restore_version_id: Option, +) -> Value { + serde_json::json!({ + "schema": "elf.memory_correction/v1", + "action": action.as_str(), + "reason": reason, + "actor_agent_id": actor_agent_id, + "ts": now, + "restore_version_id": restore_version_id, + "prior_source_ref": prior_snapshot.get("source_ref").cloned().unwrap_or_else(empty_object), + "prior_snapshot": prior_snapshot, + "correction_source_ref": correction_source_ref, + }) +} + +fn is_non_empty_object(value: &Value) -> bool { + matches!(value, Value::Object(map) if !map.is_empty()) +} + +fn required_string(snapshot: &Value, field: &'static str) -> Result { + snapshot + .get(field) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| Error::InvalidRequest { + message: format!("Restore snapshot field {field} must be a non-empty string."), + }) +} + +fn optional_string(snapshot: &Value, field: &'static str) -> Result> { + match snapshot.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + _ => Err(Error::InvalidRequest { + message: format!("Restore snapshot field {field} must be a string or null."), + }), + } +} + +fn required_f32(snapshot: &Value, field: &'static str) -> Result { + let Some(value) = snapshot.get(field).and_then(Value::as_f64) else { + return Err(Error::InvalidRequest { + message: format!("Restore snapshot field {field} must be a number."), + }); + }; + + if !value.is_finite() || value < f64::from(f32::MIN) || value > f64::from(f32::MAX) { + return Err(Error::InvalidRequest { + message: format!("Restore snapshot field {field} is out of range."), + }); + } + + Ok(value as f32) +} + +fn optional_offset_datetime( + snapshot: &Value, + field: &'static str, +) -> Result> { + let Some(value) = snapshot.get(field) else { + return Ok(None); + }; + + serde_json::from_value(value.clone()).map_err(|err| Error::InvalidRequest { + message: format!("Restore snapshot field {field} is not a valid timestamp: {err}."), + }) +} + +fn empty_object() -> Value { + Value::Object(Map::new()) +} diff --git a/packages/elf-service/src/ops.rs b/packages/elf-service/src/ops.rs new file mode 100644 index 00000000..f2cbf034 --- /dev/null +++ b/packages/elf-service/src/ops.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +/// Note operation emitted by service mutations. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NoteOp { + /// A new note was inserted. + Add, + /// An existing note was updated. + Update, + /// No persisted change was required. + None, + /// A note was deleted. + Delete, + /// The request was rejected before persistence. + Rejected, +} diff --git a/packages/elf-service/src/progressive_search.rs b/packages/elf-service/src/progressive_search.rs index 32a8b50d..659c40a6 100644 --- a/packages/elf-service/src/progressive_search.rs +++ b/packages/elf-service/src/progressive_search.rs @@ -1,1235 +1,13 @@ //! Progressive-search APIs. -use std::{ - cmp::Ordering, - collections::{BTreeMap, HashMap, hash_map::DefaultHasher, hash_set::HashSet}, - hash::{Hash, Hasher}, - str::FromStr, +mod details; +mod service; +mod storage; +mod types; + +pub use types::{ + SearchDetailsError, SearchDetailsRequest, SearchDetailsResponse, SearchDetailsResult, + SearchIndexItem, SearchIndexPlannedResponse, SearchIndexResponse, SearchSessionGetRequest, + SearchSessionGetResponse, SearchSessionMode, SearchTimelineGroup, SearchTimelineRequest, + SearchTimelineResponse, }; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{FromRow, PgExecutor}; -use time::{Duration, OffsetDateTime}; -use uuid::Uuid; - -use crate::{ - ElfService, NoteFetchResponse, PayloadLevel, QueryPlan, SearchRequest, SearchTrajectorySummary, - access::{self, ORG_PROJECT_ID, SharedSpaceGrantKey}, - structured_fields::{self, StructuredFields}, -}; -use elf_config::Config; -use elf_domain::english_gate; -use elf_storage::models::MemoryNote; - -const SESSION_SLIDING_TTL_HOURS: i64 = 6; -const SESSION_ABSOLUTE_TTL_HOURS: i64 = 24; - -/// Lightweight session-storable search hit used by progressive-search APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchIndexItem { - /// Note identifier. - pub note_id: Uuid, - /// Note type discriminator. - pub r#type: String, - /// Optional application-defined key. - pub key: Option, - /// Scope key for the note. - pub scope: String, - /// Importance score. - pub importance: f32, - /// Confidence score. - pub confidence: f32, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// Optional expiry timestamp. - pub expires_at: Option, - /// Final ranked score. - pub final_score: f32, - /// Short display summary. - pub summary: String, -} - -/// Response payload for initial indexed search results. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchIndexResponse { - /// Search trace identifier. - pub trace_id: Uuid, - /// Search session identifier used for follow-up requests. - pub search_session_id: Uuid, - #[serde(with = "crate::time_serde")] - /// Session expiry timestamp. - pub expires_at: OffsetDateTime, - /// Stored search hits. - pub items: Vec, - /// Optional condensed explain output. - pub trajectory_summary: Option, -} - -/// Search-session mode used by progressive-search APIs. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SearchSessionMode { - /// Quick-find session without a stored query plan. - QuickFind, - /// Planned-search session with a stored query plan. - PlannedSearch, -} -impl SearchSessionMode { - fn as_str(self) -> &'static str { - match self { - Self::QuickFind => "quick_find", - Self::PlannedSearch => "planned_search", - } - } -} - -impl FromStr for SearchSessionMode { - type Err = crate::Error; - - fn from_str(value: &str) -> std::result::Result { - match value { - "quick_find" => Ok(Self::QuickFind), - "planned_search" => Ok(Self::PlannedSearch), - _ => Err(crate::Error::Storage { - message: format!("Unknown search session mode: {value}"), - }), - } - } -} - -impl From for SearchSessionMode { - fn from(path: SearchSessionizePath) -> Self { - match path { - SearchSessionizePath::Quick => Self::QuickFind, - SearchSessionizePath::Planned => Self::PlannedSearch, - } - } -} - -/// Response payload for reloading a stored search session. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchSessionGetResponse { - /// Search trace identifier. - pub trace_id: Uuid, - /// Search session identifier. - pub search_session_id: Uuid, - #[serde(with = "crate::time_serde")] - /// Session expiry timestamp. - pub expires_at: OffsetDateTime, - /// Stored hits after trimming to the requested limit. - pub items: Vec, - /// Session mode. - pub mode: SearchSessionMode, - /// Stored query plan for planned-search sessions. - pub query_plan: Option, - /// Optional condensed explain output. - pub trajectory_summary: Option, -} - -/// Planned-search variant of the indexed search response. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchIndexPlannedResponse { - /// Search trace identifier. - pub trace_id: Uuid, - /// Search session identifier. - pub search_session_id: Uuid, - #[serde(with = "crate::time_serde")] - /// Session expiry timestamp. - pub expires_at: OffsetDateTime, - /// Stored hits. - pub items: Vec, - /// Optional condensed explain output. - pub trajectory_summary: Option, - /// Stored query plan for the session. - pub query_plan: QueryPlan, -} - -/// Request payload for reloading a search session. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchSessionGetRequest { - /// Tenant that owns the session. - pub tenant_id: String, - /// Project that owns the session. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Search session identifier. - pub search_session_id: Uuid, - #[serde(default)] - /// Desired payload-detail level. - pub payload_level: PayloadLevel, - /// Optional limit on returned items. - pub top_k: Option, - /// When true, extends the sliding session TTL. - pub touch: Option, -} - -/// Request payload for timeline projection of a search session. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTimelineRequest { - /// Tenant that owns the session. - pub tenant_id: String, - /// Project that owns the session. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Search session identifier. - pub search_session_id: Uuid, - /// Desired payload-detail level. - pub payload_level: PayloadLevel, - /// Optional timeline grouping mode. - pub group_by: Option, -} - -/// One timeline bucket for a search session. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTimelineGroup { - /// Group key, usually a day string. - pub date: String, - /// Items that belong to the group. - pub items: Vec, -} - -/// Response payload for timeline projection. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTimelineResponse { - /// Search session identifier. - pub search_session_id: Uuid, - #[serde(with = "crate::time_serde")] - /// Session expiry timestamp. - pub expires_at: OffsetDateTime, - /// Timeline groups. - pub groups: Vec, -} - -/// Request payload for materializing details from a search session. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchDetailsRequest { - /// Tenant that owns the session. - pub tenant_id: String, - /// Project that owns the session. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Search session identifier. - pub search_session_id: Uuid, - #[serde(default)] - /// Desired payload-detail level. - pub payload_level: PayloadLevel, - /// Requested subset of note identifiers. - pub note_ids: Vec, - /// When true, records note-hit metrics for returned details. - pub record_hits: Option, -} - -/// Per-note error payload for detail materialization. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchDetailsError { - /// Machine-readable error code. - pub code: String, - /// Human-readable error message. - pub message: String, -} - -/// Per-note detail result for a search session. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchDetailsResult { - /// Requested note identifier. - pub note_id: Uuid, - /// Materialized note payload, when loading succeeded. - pub note: Option, - /// Per-note failure, when loading failed. - pub error: Option, -} - -/// Response payload for detail materialization. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchDetailsResponse { - /// Search session identifier. - pub search_session_id: Uuid, - #[serde(with = "crate::time_serde")] - /// Session expiry timestamp. - pub expires_at: OffsetDateTime, - /// Per-note results. - pub results: Vec, -} - -struct HitItem { - note_id: Uuid, - chunk_id: Uuid, - rank: u32, - final_score: f32, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SearchSessionizePath { - Quick, - Planned, -} - -struct SearchSessionizedOutput { - index: SearchIndexResponse, - query_plan: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct SearchSessionItemRecord { - rank: u32, - note_id: Uuid, - chunk_id: Uuid, - final_score: f32, - #[serde(with = "crate::time_serde")] - updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - expires_at: Option, - r#type: String, - key: Option, - scope: String, - importance: f32, - confidence: f32, - summary: String, -} -impl SearchSessionItemRecord { - fn to_index_item(&self) -> SearchIndexItem { - SearchIndexItem { - note_id: self.note_id, - r#type: self.r#type.clone(), - key: self.key.clone(), - scope: self.scope.clone(), - importance: self.importance, - confidence: self.confidence, - updated_at: self.updated_at, - expires_at: self.expires_at, - final_score: self.final_score, - summary: self.summary.clone(), - } - } -} - -struct SearchSession { - search_session_id: Uuid, - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - mode: SearchSessionMode, - trajectory_summary: Option, - query_plan: Option, - items: Vec, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -#[derive(FromRow)] -struct SearchSessionRow { - search_session_id: Uuid, - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - mode: String, - trajectory_summary: Option, - query_plan: Option, - items: Value, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -struct NewSearchSession<'a> { - search_session_id: Uuid, - trace_id: Uuid, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - read_profile: &'a str, - query: &'a str, - mode: SearchSessionMode, - trajectory_summary: Option<&'a SearchTrajectorySummary>, - query_plan: Option<&'a QueryPlan>, - items: &'a [SearchSessionItemRecord], - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} - -impl ElfService { - /// Runs the default progressive-search path and returns indexed results. - pub async fn search(&self, req: SearchRequest) -> crate::Result { - let response = self.search_planned(req).await?; - - Ok(SearchIndexResponse { - trace_id: response.trace_id, - search_session_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - }) - } - - /// Runs quick-find search and stores a quick session without a query plan. - pub async fn search_quick(&self, req: SearchRequest) -> crate::Result { - self.search_sessionized(req, SearchSessionizePath::Quick).await.map(|output| output.index) - } - - /// Runs planned search and stores a session with a query plan. - pub async fn search_planned( - &self, - req: SearchRequest, - ) -> crate::Result { - let output = self.search_sessionized(req, SearchSessionizePath::Planned).await?; - let query_plan = output.query_plan.ok_or_else(|| crate::Error::Storage { - message: "Planned search response is missing query_plan.".to_string(), - })?; - - Ok(SearchIndexPlannedResponse { - trace_id: output.index.trace_id, - search_session_id: output.index.search_session_id, - expires_at: output.index.expires_at, - items: output.index.items, - trajectory_summary: output.index.trajectory_summary, - query_plan, - }) - } - - async fn search_sessionized( - &self, - req: SearchRequest, - path: SearchSessionizePath, - ) -> crate::Result { - let top_k = req.top_k.unwrap_or(self.cfg.memory.top_k).max(1); - let candidate_k = req.candidate_k.unwrap_or(self.cfg.memory.candidate_k).max(top_k); - let mut raw_req = req.clone(); - - raw_req.top_k = Some(candidate_k); - raw_req.record_hits = Some(false); - - let (trace_id, raw_items, trajectory_summary, query_plan) = match path { - SearchSessionizePath::Quick => { - let raw = self.search_raw_quick(raw_req).await?; - - (raw.trace_id, raw.items, raw.trajectory_summary, None) - }, - SearchSessionizePath::Planned => { - let raw = self.search_raw_planned(raw_req).await?; - - (raw.trace_id, raw.items, raw.trajectory_summary, Some(raw.query_plan)) - }, - }; - let now = OffsetDateTime::now_utc(); - let expires_at = now + Duration::hours(SESSION_SLIDING_TTL_HOURS); - let search_session_id = Uuid::new_v4(); - let note_ids: Vec = raw_items.iter().map(|item| item.note_id).collect(); - let structured_by_note = - structured_fields::fetch_structured_fields(&self.db.pool, ¬e_ids).await?; - let mut items = Vec::with_capacity(raw_items.len()); - - for (idx, item) in raw_items.iter().enumerate() { - let summary = structured_by_note - .get(&item.note_id) - .and_then(|value| value.summary.clone()) - .unwrap_or_else(|| { - build_summary(&item.snippet, self.cfg.memory.max_note_chars as usize) - }); - - items.push(SearchSessionItemRecord { - rank: idx as u32 + 1, - note_id: item.note_id, - chunk_id: item.chunk_id, - final_score: item.final_score, - updated_at: item.updated_at, - expires_at: item.expires_at, - r#type: item.r#type.clone(), - key: item.key.clone(), - scope: item.scope.clone(), - importance: item.importance, - confidence: item.confidence, - summary, - }); - } - - store_search_session( - &self.db.pool, - NewSearchSession { - search_session_id, - trace_id, - tenant_id: &req.tenant_id, - project_id: &req.project_id, - agent_id: &req.agent_id, - read_profile: &req.read_profile, - query: &req.query, - mode: SearchSessionMode::from(path), - query_plan: query_plan.as_ref(), - trajectory_summary: trajectory_summary.as_ref(), - items: &items, - created_at: now, - expires_at, - }, - ) - .await?; - - let response_items: Vec = - items.into_iter().take(top_k as usize).map(|item| item.to_index_item()).collect(); - - Ok(SearchSessionizedOutput { - index: SearchIndexResponse { - trace_id, - search_session_id, - expires_at, - items: response_items, - trajectory_summary, - }, - query_plan, - }) - } - - /// Reloads a stored search session and optionally extends its TTL. - pub async fn search_session_get( - &self, - req: SearchSessionGetRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let now = OffsetDateTime::now_utc(); - let session = load_search_session(&self.db.pool, req.search_session_id, now).await?; - - validate_search_session_access(&session, tenant_id, project_id, agent_id)?; - - let touch = req.touch.unwrap_or(true); - let expires_at = if touch { - touch_search_session(&self.db.pool, &session, now).await? - } else { - session.expires_at - }; - let top_k = req.top_k.unwrap_or(self.cfg.memory.top_k).max(1); - let items: Vec = session - .items - .into_iter() - .take(top_k as usize) - .map(|item| item.to_index_item()) - .collect(); - - Ok(SearchSessionGetResponse { - trace_id: session.trace_id, - search_session_id: session.search_session_id, - expires_at, - items, - mode: session.mode, - query_plan: session.query_plan, - trajectory_summary: session.trajectory_summary, - }) - } - - /// Reprojects a stored search session into timeline groups. - pub async fn search_timeline( - &self, - req: SearchTimelineRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let now = OffsetDateTime::now_utc(); - let session = load_search_session(&self.db.pool, req.search_session_id, now).await?; - - validate_search_session_access(&session, tenant_id, project_id, agent_id)?; - - let expires_at = touch_search_session(&self.db.pool, &session, now).await?; - let payload_level = req.payload_level; - let group_by = req.group_by.unwrap_or_else(|| { - if payload_level == PayloadLevel::L0 { "none".to_string() } else { "day".to_string() } - }); - - match group_by.as_str() { - "day" => build_timeline_by_day(session.search_session_id, expires_at, &session.items), - "none" => Ok(SearchTimelineResponse { - search_session_id: session.search_session_id, - expires_at, - groups: vec![SearchTimelineGroup { - date: "all".to_string(), - items: session - .items - .iter() - .map(SearchSessionItemRecord::to_index_item) - .collect(), - }], - }), - _ => Err(crate::Error::InvalidRequest { - message: "group_by must be one of: day, none.".to_string(), - }), - } - } - - /// Materializes selected note details out of a stored search session. - pub async fn search_details( - &self, - req: SearchDetailsRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let now = OffsetDateTime::now_utc(); - let session = load_search_session(&self.db.pool, req.search_session_id, now).await?; - - validate_search_session_access(&session, tenant_id, project_id, agent_id)?; - - let expires_at = touch_search_session(&self.db.pool, &session, now).await?; - let mut by_note_id: HashMap = HashMap::new(); - - for item in &session.items { - by_note_id.insert(item.note_id, item.clone()); - } - - let mut requested_in_session = Vec::new(); - let mut seen = HashSet::new(); - - for note_id in &req.note_ids { - if by_note_id.contains_key(note_id) && seen.insert(*note_id) { - requested_in_session.push(*note_id); - } - } - - let mut notes_by_id = HashMap::new(); - - if !requested_in_session.is_empty() { - let rows: Vec = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = ANY($1::uuid[]) - AND tenant_id = $2 - AND ( - project_id = $3 - OR (project_id = $4 AND scope = 'org_shared') - )", - ) - .bind(requested_in_session.as_slice()) - .bind(session.tenant_id.as_str()) - .bind(session.project_id.as_str()) - .bind(ORG_PROJECT_ID) - .fetch_all(&self.db.pool) - .await?; - - for note in rows { - notes_by_id.insert(note.note_id, note); - } - } - - let structured_by_note = if req.payload_level == PayloadLevel::L0 { - HashMap::new() - } else { - structured_fields::fetch_structured_fields( - &self.db.pool, - requested_in_session.as_slice(), - ) - .await? - }; - let allowed_scopes = resolve_read_scopes(&self.cfg, &session.read_profile)?; - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - session.tenant_id.as_str(), - session.project_id.as_str(), - agent_id, - allowed_scopes.iter().any(|scope| scope == "org_shared"), - ) - .await?; - let record_hits = req.record_hits.unwrap_or(true); - let details_args = SearchDetailsBuildArgs { - session_items_by_note_id: &by_note_id, - notes_by_id: ¬es_by_id, - structured_by_note: &structured_by_note, - session: &session, - shared_grants: &shared_grants, - allowed_scopes: &allowed_scopes, - now, - record_hits_enabled: record_hits, - payload_level: req.payload_level, - max_note_chars: self.cfg.memory.max_note_chars as usize, - }; - let (results, hits) = build_search_details_results(req.note_ids, details_args); - - if !hits.is_empty() { - let mut tx = self.db.pool.begin().await?; - - record_detail_hits(&mut *tx, &session.query, &hits, now).await?; - - tx.commit().await?; - } - - Ok(SearchDetailsResponse { - search_session_id: session.search_session_id, - expires_at, - results, - }) - } -} - -struct SearchDetailsBuildArgs<'a> { - session_items_by_note_id: &'a HashMap, - notes_by_id: &'a HashMap, - structured_by_note: &'a HashMap, - session: &'a SearchSession, - shared_grants: &'a HashSet, - allowed_scopes: &'a [String], - now: OffsetDateTime, - record_hits_enabled: bool, - payload_level: PayloadLevel, - max_note_chars: usize, -} - -fn build_search_details_results( - requested_note_ids: Vec, - args: SearchDetailsBuildArgs<'_>, -) -> (Vec, Vec) { - let mut results = Vec::with_capacity(requested_note_ids.len()); - let mut hits = Vec::new(); - let mut hit_seen = HashSet::new(); - - for note_id in requested_note_ids { - let Some(session_item) = args.session_items_by_note_id.get(¬e_id) else { - results.push(SearchDetailsResult { - note_id, - note: None, - error: Some(SearchDetailsError { - code: "NOT_IN_SESSION".to_string(), - message: "Requested note_id is not present in the search session.".to_string(), - }), - }); - - continue; - }; - let Some(note) = args.notes_by_id.get(¬e_id) else { - results.push(SearchDetailsResult { - note_id, - note: None, - error: Some(SearchDetailsError { - code: "NOTE_NOT_FOUND".to_string(), - message: "Note not found.".to_string(), - }), - }); - - continue; - }; - let error = validate_note_access( - note, - args.session, - args.allowed_scopes, - args.shared_grants, - args.now, - ); - - if let Some(error) = error { - results.push(SearchDetailsResult { note_id, note: None, error: Some(error) }); - - continue; - } - - let structured = if args.payload_level == PayloadLevel::L0 { - None - } else { - args.structured_by_note.get(¬e.note_id).cloned() - }; - let note_text = apply_payload_level_to_search_details_text( - note.text.as_str(), - structured.as_ref(), - args.payload_level, - args.max_note_chars, - ); - let source_ref = if args.payload_level == PayloadLevel::L2 { - note.source_ref.clone() - } else { - serde_json::json!({}) - }; - let note_response = NoteFetchResponse { - note_id: note.note_id, - tenant_id: note.tenant_id.clone(), - project_id: note.project_id.clone(), - agent_id: note.agent_id.clone(), - scope: note.scope.clone(), - r#type: note.r#type.clone(), - key: note.key.clone(), - text: note_text, - importance: note.importance, - confidence: note.confidence, - status: note.status.clone(), - updated_at: note.updated_at, - expires_at: note.expires_at, - source_ref, - structured, - }; - - results.push(SearchDetailsResult { note_id, note: Some(note_response), error: None }); - - if args.record_hits_enabled && hit_seen.insert(note_id) { - hits.push(HitItem { - note_id, - chunk_id: session_item.chunk_id, - rank: session_item.rank, - final_score: session_item.final_score, - }); - } - } - - (results, hits) -} - -fn apply_payload_level_to_search_details_text( - raw_text: &str, - structured: Option<&StructuredFields>, - payload_level: PayloadLevel, - max_note_chars: usize, -) -> String { - match payload_level { - PayloadLevel::L0 => build_summary(raw_text, max_note_chars), - PayloadLevel::L1 => { - let candidate_text = structured - .and_then(|item| item.summary.as_deref()) - .filter(|summary| !summary.trim().is_empty()) - .unwrap_or(raw_text); - - build_summary(candidate_text, max_note_chars) - }, - PayloadLevel::L2 => raw_text.to_string(), - } -} - -fn build_timeline_by_day( - search_session_id: Uuid, - expires_at: OffsetDateTime, - items: &[SearchSessionItemRecord], -) -> crate::Result { - let mut grouped: BTreeMap> = BTreeMap::new(); - - for item in items { - let date = item.updated_at.date().to_string(); - - grouped.entry(date).or_default().push(item.to_index_item()); - } - - let mut groups = Vec::with_capacity(grouped.len()); - - for (date, mut items) in grouped.into_iter().rev() { - items.sort_by(|a, b| { - b.updated_at - .cmp(&a.updated_at) - .then_with(|| b.final_score.partial_cmp(&a.final_score).unwrap_or(Ordering::Equal)) - }); - groups.push(SearchTimelineGroup { date, items }); - } - - Ok(SearchTimelineResponse { search_session_id, expires_at, groups }) -} - -fn build_summary(raw: &str, max_chars: usize) -> String { - let normalized = normalize_whitespace(raw); - - truncate_chars(&normalized, max_chars) -} - -fn normalize_whitespace(raw: &str) -> String { - let mut out = String::with_capacity(raw.len()); - let mut prev_space = false; - - for ch in raw.chars() { - if ch.is_whitespace() { - if !prev_space { - out.push(' '); - - prev_space = true; - } - - continue; - } - - out.push(ch); - - prev_space = false; - } - - out.trim().to_string() -} - -fn truncate_chars(raw: &str, max_chars: usize) -> String { - if raw.chars().count() <= max_chars { - return raw.to_string(); - } - - const TRUNCATION_MARKER: &str = "..."; - - let marker_chars = TRUNCATION_MARKER.chars().count(); - - if max_chars <= marker_chars { - return TRUNCATION_MARKER.chars().take(max_chars).collect(); - } - - let truncated_chars = max_chars - marker_chars; - let mut out = String::with_capacity(max_chars); - - for (idx, ch) in raw.chars().enumerate() { - if idx >= truncated_chars { - break; - } - - out.push(ch); - } - - out.push_str(TRUNCATION_MARKER); - - out -} - -fn resolve_read_scopes(cfg: &Config, profile: &str) -> crate::Result> { - match profile { - "private_only" => Ok(cfg.scopes.read_profiles.private_only.clone()), - "private_plus_project" => Ok(cfg.scopes.read_profiles.private_plus_project.clone()), - "all_scopes" => Ok(cfg.scopes.read_profiles.all_scopes.clone()), - _ => Err(crate::Error::InvalidRequest { message: "Unknown read_profile.".to_string() }), - } -} - -fn validate_search_session_access( - session: &SearchSession, - tenant_id: &str, - project_id: &str, - agent_id: &str, -) -> crate::Result<()> { - if session.tenant_id != tenant_id - || session.project_id != project_id - || session.agent_id != agent_id - { - return Err(crate::Error::InvalidRequest { - message: "Unknown search_session_id.".to_string(), - }); - } - - Ok(()) -} - -fn validate_note_access( - note: &MemoryNote, - session: &SearchSession, - allowed_scopes: &[String], - shared_grants: &HashSet, - now: OffsetDateTime, -) -> Option { - if note.status != "active" { - return Some(SearchDetailsError { - code: "NOTE_INACTIVE".to_string(), - message: "Note is not active.".to_string(), - }); - } - if note.expires_at.map(|ts| ts <= now).unwrap_or(false) { - return Some(SearchDetailsError { - code: "NOTE_EXPIRED".to_string(), - message: "Note is expired.".to_string(), - }); - } - if !allowed_scopes.iter().any(|scope| scope == ¬e.scope) { - return Some(SearchDetailsError { - code: "SCOPE_DENIED".to_string(), - message: "Note scope is not allowed for this read_profile.".to_string(), - }); - } - if !access::note_read_allowed( - note, - session.agent_id.as_str(), - allowed_scopes, - shared_grants, - now, - ) { - return Some(SearchDetailsError { - code: "SCOPE_DENIED".to_string(), - message: "Note scope is not allowed for this read_profile.".to_string(), - }); - } - - None -} - -fn hash_query(query: &str) -> String { - let mut hasher = DefaultHasher::new(); - - Hash::hash(query, &mut hasher); - - format!("{:x}", hasher.finish()) -} - -async fn store_search_session<'e, E>( - executor: E, - session: NewSearchSession<'_>, -) -> crate::Result<()> -where - E: PgExecutor<'e>, -{ - let items_json = serde_json::to_value(session.items).map_err(|err| crate::Error::Storage { - message: format!("Failed to encode search session items: {err}"), - })?; - let query_plan_json = - session.query_plan.map(serde_json::to_value).transpose().map_err(|err| { - crate::Error::Storage { - message: format!("Failed to encode search session query plan: {err}"), - } - })?; - let trajectory_summary_json = - session.trajectory_summary.map(serde_json::to_value).transpose().map_err(|err| { - crate::Error::Storage { - message: format!("Failed to encode search session trajectory summary: {err}"), - } - })?; - - sqlx::query( - "\ -INSERT INTO search_sessions ( - search_session_id, - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - mode, - trajectory_summary, - query_plan, - items, - created_at, - expires_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", - ) - .bind(session.search_session_id) - .bind(session.trace_id) - .bind(session.tenant_id.trim()) - .bind(session.project_id.trim()) - .bind(session.agent_id.trim()) - .bind(session.read_profile) - .bind(session.query) - .bind(session.mode.as_str()) - .bind(trajectory_summary_json) - .bind(query_plan_json) - .bind(items_json) - .bind(session.created_at) - .bind(session.expires_at) - .execute(executor) - .await?; - - Ok(()) -} - -async fn load_search_session<'e, E>( - executor: E, - search_session_id: Uuid, - now: OffsetDateTime, -) -> crate::Result -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, SearchSessionRow>( - "\ -SELECT - search_session_id, - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - mode, - trajectory_summary, - query_plan, - items, - created_at, - expires_at -FROM search_sessions -WHERE search_session_id = $1", - ) - .bind(search_session_id) - .fetch_optional(executor) - .await?; - let Some(row) = row else { - return Err(crate::Error::InvalidRequest { - message: "Unknown search_session_id.".to_string(), - }); - }; - let expires_at: OffsetDateTime = row.expires_at; - - if expires_at <= now { - return Err(crate::Error::InvalidRequest { - message: "Search session expired.".to_string(), - }); - } - - let items: Vec = serde_json::from_value(row.items).map_err(|err| { - crate::Error::Storage { message: format!("Failed to decode search session items: {err}") } - })?; - let mode = SearchSessionMode::from_str(row.mode.as_str())?; - let query_plan = match row.query_plan { - Some(value) => - Some(serde_json::from_value(value).map_err(|err| crate::Error::Storage { - message: format!("Failed to decode search session query_plan: {err}"), - })?), - None => None, - }; - let trajectory_summary = match row.trajectory_summary { - Some(value) => - Some(serde_json::from_value(value).map_err(|err| crate::Error::Storage { - message: format!("Failed to decode search session trajectory summary: {err}"), - })?), - None => None, - }; - - Ok(SearchSession { - search_session_id: row.search_session_id, - trace_id: row.trace_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - read_profile: row.read_profile, - query: row.query, - items, - mode, - trajectory_summary, - query_plan, - created_at: row.created_at, - expires_at, - }) -} - -async fn touch_search_session<'e, E>( - executor: E, - session: &SearchSession, - now: OffsetDateTime, -) -> crate::Result -where - E: PgExecutor<'e>, -{ - let absolute_expires_at = session.created_at + Duration::hours(SESSION_ABSOLUTE_TTL_HOURS); - let sliding_expires_at = now + Duration::hours(SESSION_SLIDING_TTL_HOURS); - let touched = if sliding_expires_at < absolute_expires_at { - sliding_expires_at - } else { - absolute_expires_at - }; - - if touched <= session.expires_at { - return Ok(session.expires_at); - } - - sqlx::query( - "UPDATE search_sessions SET expires_at = $1 WHERE search_session_id = $2 AND expires_at < $1", - ) - .bind(touched) - .bind(session.search_session_id) - .execute(executor) - .await?; - - Ok(touched) -} - -async fn record_detail_hits<'e, E>( - executor: E, - query: &str, - items: &[HitItem], - now: OffsetDateTime, -) -> crate::Result<()> -where - E: PgExecutor<'e>, -{ - if !english_gate::is_english_natural_language(query) { - return Err(crate::Error::NonEnglishInput { field: "$.query".to_string() }); - } - - let query_hash = hash_query(query); - let mut hit_ids = Vec::with_capacity(items.len()); - let mut note_ids = Vec::with_capacity(items.len()); - let mut chunk_ids = Vec::with_capacity(items.len()); - let mut ranks = Vec::with_capacity(items.len()); - let mut final_scores = Vec::with_capacity(items.len()); - - for item in items { - let rank = i32::try_from(item.rank).map_err(|_| crate::Error::InvalidRequest { - message: "Search session rank is out of range.".to_string(), - })?; - - hit_ids.push(Uuid::new_v4()); - note_ids.push(item.note_id); - chunk_ids.push(item.chunk_id); - ranks.push(rank); - final_scores.push(item.final_score); - } - - sqlx::query( - "\ -WITH hits AS ( - SELECT * - FROM unnest( - $1::uuid[], - $2::uuid[], - $3::uuid[], - $4::int4[], - $5::real[] -) AS t(hit_id, note_id, chunk_id, rank, final_score) -), -updated AS ( -UPDATE memory_notes -SET - hit_count = hit_count + 1, - last_hit_at = $6 -WHERE note_id = ANY($2) -) -INSERT INTO memory_hits ( - hit_id, - note_id, - chunk_id, - query_hash, - rank, - final_score, - ts -) -SELECT - hit_id, - note_id, - chunk_id, - $7, - rank, - final_score, - $6 -FROM hits", - ) - .bind(&hit_ids) - .bind(¬e_ids) - .bind(&chunk_ids) - .bind(&ranks) - .bind(&final_scores) - .bind(now) - .bind(query_hash.as_str()) - .execute(executor) - .await?; - - Ok(()) -} diff --git a/packages/elf-service/src/progressive_search/details.rs b/packages/elf-service/src/progressive_search/details.rs new file mode 100644 index 00000000..e9479dbd --- /dev/null +++ b/packages/elf-service/src/progressive_search/details.rs @@ -0,0 +1,300 @@ +use std::{ + cmp::Ordering, + collections::{BTreeMap, HashMap, hash_set::HashSet}, +}; + +use serde_json; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Error, NoteFetchResponse, PayloadLevel, Result, + access::{self, SharedSpaceGrantKey}, + progressive_search::types::{ + HitItem, SearchDetailsError, SearchDetailsResult, SearchIndexItem, SearchSession, + SearchSessionItemRecord, SearchTimelineGroup, SearchTimelineResponse, + }, + structured_fields::StructuredFields, +}; +use elf_config::Config; +use elf_storage::models::MemoryNote; + +pub(super) struct SearchDetailsBuildArgs<'a> { + pub(super) session_items_by_note_id: &'a HashMap, + pub(super) notes_by_id: &'a HashMap, + pub(super) structured_by_note: &'a HashMap, + pub(super) session: &'a SearchSession, + pub(super) shared_grants: &'a HashSet, + pub(super) allowed_scopes: &'a [String], + pub(super) now: OffsetDateTime, + pub(super) record_hits_enabled: bool, + pub(super) payload_level: PayloadLevel, + pub(super) max_note_chars: usize, +} + +pub(super) fn build_search_details_results( + requested_note_ids: Vec, + args: SearchDetailsBuildArgs<'_>, +) -> (Vec, Vec) { + let mut results = Vec::with_capacity(requested_note_ids.len()); + let mut hits = Vec::new(); + let mut hit_seen = HashSet::new(); + + for note_id in requested_note_ids { + let Some(session_item) = args.session_items_by_note_id.get(¬e_id) else { + results.push(SearchDetailsResult { + note_id, + note: None, + error: Some(SearchDetailsError { + code: "NOT_IN_SESSION".to_string(), + message: "Requested note_id is not present in the search session.".to_string(), + }), + }); + + continue; + }; + let Some(note) = args.notes_by_id.get(¬e_id) else { + results.push(SearchDetailsResult { + note_id, + note: None, + error: Some(SearchDetailsError { + code: "NOTE_NOT_FOUND".to_string(), + message: "Note not found.".to_string(), + }), + }); + + continue; + }; + let error = validate_note_access( + note, + args.session, + args.allowed_scopes, + args.shared_grants, + args.now, + ); + + if let Some(error) = error { + results.push(SearchDetailsResult { note_id, note: None, error: Some(error) }); + + continue; + } + + let structured = if args.payload_level == PayloadLevel::L0 { + None + } else { + args.structured_by_note.get(¬e.note_id).cloned() + }; + let note_text = apply_payload_level_to_search_details_text( + note.text.as_str(), + structured.as_ref(), + args.payload_level, + args.max_note_chars, + ); + let source_ref = if args.payload_level == PayloadLevel::L2 { + note.source_ref.clone() + } else { + serde_json::json!({}) + }; + let note_response = NoteFetchResponse { + note_id: note.note_id, + tenant_id: note.tenant_id.clone(), + project_id: note.project_id.clone(), + agent_id: note.agent_id.clone(), + scope: note.scope.clone(), + r#type: note.r#type.clone(), + key: note.key.clone(), + text: note_text, + importance: note.importance, + confidence: note.confidence, + status: note.status.clone(), + updated_at: note.updated_at, + expires_at: note.expires_at, + source_ref, + structured, + }; + + results.push(SearchDetailsResult { note_id, note: Some(note_response), error: None }); + + if args.record_hits_enabled && hit_seen.insert(note_id) { + hits.push(HitItem { + note_id, + chunk_id: session_item.chunk_id, + rank: session_item.rank, + final_score: session_item.final_score, + }); + } + } + + (results, hits) +} + +pub(super) fn build_timeline_by_day( + search_session_id: Uuid, + expires_at: OffsetDateTime, + items: &[SearchSessionItemRecord], +) -> Result { + let mut grouped: BTreeMap> = BTreeMap::new(); + + for item in items { + let date = item.updated_at.date().to_string(); + + grouped.entry(date).or_default().push(item.to_index_item()); + } + + let mut groups = Vec::with_capacity(grouped.len()); + + for (date, mut items) in grouped.into_iter().rev() { + items.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| b.final_score.partial_cmp(&a.final_score).unwrap_or(Ordering::Equal)) + }); + groups.push(SearchTimelineGroup { date, items }); + } + + Ok(SearchTimelineResponse { search_session_id, expires_at, groups }) +} + +pub(super) fn build_summary(raw: &str, max_chars: usize) -> String { + let normalized = normalize_whitespace(raw); + + truncate_chars(&normalized, max_chars) +} + +pub(super) fn resolve_read_scopes(cfg: &Config, profile: &str) -> Result> { + match profile { + "private_only" => Ok(cfg.scopes.read_profiles.private_only.clone()), + "private_plus_project" => Ok(cfg.scopes.read_profiles.private_plus_project.clone()), + "all_scopes" => Ok(cfg.scopes.read_profiles.all_scopes.clone()), + _ => Err(Error::InvalidRequest { message: "Unknown read_profile.".to_string() }), + } +} + +pub(super) fn validate_search_session_access( + session: &SearchSession, + tenant_id: &str, + project_id: &str, + agent_id: &str, +) -> Result<()> { + if session.tenant_id != tenant_id + || session.project_id != project_id + || session.agent_id != agent_id + { + return Err(Error::InvalidRequest { message: "Unknown search_session_id.".to_string() }); + } + + Ok(()) +} + +fn apply_payload_level_to_search_details_text( + raw_text: &str, + structured: Option<&StructuredFields>, + payload_level: PayloadLevel, + max_note_chars: usize, +) -> String { + match payload_level { + PayloadLevel::L0 => build_summary(raw_text, max_note_chars), + PayloadLevel::L1 => { + let candidate_text = structured + .and_then(|item| item.summary.as_deref()) + .filter(|summary| !summary.trim().is_empty()) + .unwrap_or(raw_text); + + build_summary(candidate_text, max_note_chars) + }, + PayloadLevel::L2 => raw_text.to_string(), + } +} + +fn normalize_whitespace(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut prev_space = false; + + for ch in raw.chars() { + if ch.is_whitespace() { + if !prev_space { + out.push(' '); + + prev_space = true; + } + + continue; + } + + out.push(ch); + + prev_space = false; + } + + out.trim().to_string() +} + +fn truncate_chars(raw: &str, max_chars: usize) -> String { + if raw.chars().count() <= max_chars { + return raw.to_string(); + } + + const TRUNCATION_MARKER: &str = "..."; + + let marker_chars = TRUNCATION_MARKER.chars().count(); + + if max_chars <= marker_chars { + return TRUNCATION_MARKER.chars().take(max_chars).collect(); + } + + let truncated_chars = max_chars - marker_chars; + let mut out = String::with_capacity(max_chars); + + for (idx, ch) in raw.chars().enumerate() { + if idx >= truncated_chars { + break; + } + + out.push(ch); + } + + out.push_str(TRUNCATION_MARKER); + + out +} + +fn validate_note_access( + note: &MemoryNote, + session: &SearchSession, + allowed_scopes: &[String], + shared_grants: &HashSet, + now: OffsetDateTime, +) -> Option { + if note.status != "active" { + return Some(SearchDetailsError { + code: "NOTE_INACTIVE".to_string(), + message: "Note is not active.".to_string(), + }); + } + if note.expires_at.map(|ts| ts <= now).unwrap_or(false) { + return Some(SearchDetailsError { + code: "NOTE_EXPIRED".to_string(), + message: "Note is expired.".to_string(), + }); + } + if !allowed_scopes.iter().any(|scope| scope == ¬e.scope) { + return Some(SearchDetailsError { + code: "SCOPE_DENIED".to_string(), + message: "Note scope is not allowed for this read_profile.".to_string(), + }); + } + if !access::note_read_allowed( + note, + session.agent_id.as_str(), + allowed_scopes, + shared_grants, + now, + ) { + return Some(SearchDetailsError { + code: "SCOPE_DENIED".to_string(), + message: "Note scope is not allowed for this read_profile.".to_string(), + }); + } + + None +} diff --git a/packages/elf-service/src/progressive_search/service.rs b/packages/elf-service/src/progressive_search/service.rs new file mode 100644 index 00000000..4a90d0ad --- /dev/null +++ b/packages/elf-service/src/progressive_search/service.rs @@ -0,0 +1,357 @@ +use std::collections::{HashMap, hash_set::HashSet}; + +use sqlx; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +use crate::{ + ElfService, Error, PayloadLevel, Result, SearchRequest, + access::{self, ORG_PROJECT_ID}, + progressive_search::{ + details::{self, SearchDetailsBuildArgs}, + storage::{self}, + types::{ + NewSearchSession, SESSION_SLIDING_TTL_HOURS, SearchDetailsRequest, + SearchDetailsResponse, SearchIndexItem, SearchIndexPlannedResponse, + SearchIndexResponse, SearchSessionGetRequest, SearchSessionGetResponse, + SearchSessionItemRecord, SearchSessionMode, SearchSessionizePath, + SearchSessionizedOutput, SearchTimelineGroup, SearchTimelineRequest, + SearchTimelineResponse, + }, + }, + structured_fields, +}; +use elf_storage::models::MemoryNote; + +impl ElfService { + /// Runs the default progressive-search path and returns indexed results. + pub async fn search(&self, req: SearchRequest) -> Result { + let response = self.search_planned(req).await?; + + Ok(SearchIndexResponse { + trace_id: response.trace_id, + search_session_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + }) + } + + /// Runs quick-find search and stores a quick session without a query plan. + pub async fn search_quick(&self, req: SearchRequest) -> Result { + self.search_sessionized(req, SearchSessionizePath::Quick).await.map(|output| output.index) + } + + /// Runs planned search and stores a session with a query plan. + pub async fn search_planned(&self, req: SearchRequest) -> Result { + let output = self.search_sessionized(req, SearchSessionizePath::Planned).await?; + let query_plan = output.query_plan.ok_or_else(|| Error::Storage { + message: "Planned search response is missing query_plan.".to_string(), + })?; + + Ok(SearchIndexPlannedResponse { + trace_id: output.index.trace_id, + search_session_id: output.index.search_session_id, + expires_at: output.index.expires_at, + items: output.index.items, + trajectory_summary: output.index.trajectory_summary, + query_plan, + }) + } + + async fn search_sessionized( + &self, + req: SearchRequest, + path: SearchSessionizePath, + ) -> Result { + let top_k = req.top_k.unwrap_or(self.cfg.memory.top_k).max(1); + let candidate_k = req.candidate_k.unwrap_or(self.cfg.memory.candidate_k).max(top_k); + let mut raw_req = req.clone(); + + raw_req.top_k = Some(candidate_k); + raw_req.record_hits = Some(false); + + let (trace_id, raw_items, trajectory_summary, query_plan) = match path { + SearchSessionizePath::Quick => { + let raw = self.search_raw_quick(raw_req).await?; + + (raw.trace_id, raw.items, raw.trajectory_summary, None) + }, + SearchSessionizePath::Planned => { + let raw = self.search_raw_planned(raw_req).await?; + + (raw.trace_id, raw.items, raw.trajectory_summary, Some(raw.query_plan)) + }, + }; + let now = OffsetDateTime::now_utc(); + let expires_at = now + Duration::hours(SESSION_SLIDING_TTL_HOURS); + let search_session_id = Uuid::new_v4(); + let note_ids: Vec = raw_items.iter().map(|item| item.note_id).collect(); + let structured_by_note = + structured_fields::fetch_structured_fields(&self.db.pool, ¬e_ids).await?; + let mut items = Vec::with_capacity(raw_items.len()); + + for (idx, item) in raw_items.iter().enumerate() { + let summary = structured_by_note + .get(&item.note_id) + .and_then(|value| value.summary.clone()) + .unwrap_or_else(|| { + details::build_summary(&item.snippet, self.cfg.memory.max_note_chars as usize) + }); + + items.push(SearchSessionItemRecord { + rank: idx as u32 + 1, + note_id: item.note_id, + chunk_id: item.chunk_id, + final_score: item.final_score, + updated_at: item.updated_at, + expires_at: item.expires_at, + r#type: item.r#type.clone(), + key: item.key.clone(), + scope: item.scope.clone(), + importance: item.importance, + confidence: item.confidence, + summary, + }); + } + + storage::store_search_session( + &self.db.pool, + NewSearchSession { + search_session_id, + trace_id, + tenant_id: &req.tenant_id, + project_id: &req.project_id, + agent_id: &req.agent_id, + read_profile: &req.read_profile, + query: &req.query, + mode: SearchSessionMode::from(path), + query_plan: query_plan.as_ref(), + trajectory_summary: trajectory_summary.as_ref(), + items: &items, + created_at: now, + expires_at, + }, + ) + .await?; + + let response_items: Vec = + items.into_iter().take(top_k as usize).map(|item| item.to_index_item()).collect(); + + Ok(SearchSessionizedOutput { + index: SearchIndexResponse { + trace_id, + search_session_id, + expires_at, + items: response_items, + trajectory_summary, + }, + query_plan, + }) + } + + /// Reloads a stored search session and optionally extends its TTL. + pub async fn search_session_get( + &self, + req: SearchSessionGetRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let now = OffsetDateTime::now_utc(); + let session = + storage::load_search_session(&self.db.pool, req.search_session_id, now).await?; + + details::validate_search_session_access(&session, tenant_id, project_id, agent_id)?; + + let touch = req.touch.unwrap_or(true); + let expires_at = if touch { + storage::touch_search_session(&self.db.pool, &session, now).await? + } else { + session.expires_at + }; + let top_k = req.top_k.unwrap_or(self.cfg.memory.top_k).max(1); + let items: Vec = session + .items + .into_iter() + .take(top_k as usize) + .map(|item| item.to_index_item()) + .collect(); + + Ok(SearchSessionGetResponse { + trace_id: session.trace_id, + search_session_id: session.search_session_id, + expires_at, + items, + mode: session.mode, + query_plan: session.query_plan, + trajectory_summary: session.trajectory_summary, + }) + } + + /// Reprojects a stored search session into timeline groups. + pub async fn search_timeline( + &self, + req: SearchTimelineRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let now = OffsetDateTime::now_utc(); + let session = + storage::load_search_session(&self.db.pool, req.search_session_id, now).await?; + + details::validate_search_session_access(&session, tenant_id, project_id, agent_id)?; + + let expires_at = storage::touch_search_session(&self.db.pool, &session, now).await?; + let payload_level = req.payload_level; + let group_by = req.group_by.unwrap_or_else(|| { + if payload_level == PayloadLevel::L0 { "none".to_string() } else { "day".to_string() } + }); + + match group_by.as_str() { + "day" => details::build_timeline_by_day( + session.search_session_id, + expires_at, + &session.items, + ), + "none" => Ok(SearchTimelineResponse { + search_session_id: session.search_session_id, + expires_at, + groups: vec![SearchTimelineGroup { + date: "all".to_string(), + items: session + .items + .iter() + .map(SearchSessionItemRecord::to_index_item) + .collect(), + }], + }), + _ => Err(Error::InvalidRequest { + message: "group_by must be one of: day, none.".to_string(), + }), + } + } + + /// Materializes selected note details out of a stored search session. + pub async fn search_details(&self, req: SearchDetailsRequest) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let now = OffsetDateTime::now_utc(); + let session = + storage::load_search_session(&self.db.pool, req.search_session_id, now).await?; + + details::validate_search_session_access(&session, tenant_id, project_id, agent_id)?; + + let expires_at = storage::touch_search_session(&self.db.pool, &session, now).await?; + let mut by_note_id: HashMap = HashMap::new(); + + for item in &session.items { + by_note_id.insert(item.note_id, item.clone()); + } + + let mut requested_in_session = Vec::new(); + let mut seen = HashSet::new(); + + for note_id in &req.note_ids { + if by_note_id.contains_key(note_id) && seen.insert(*note_id) { + requested_in_session.push(*note_id); + } + } + + let mut notes_by_id = HashMap::new(); + + if !requested_in_session.is_empty() { + let rows: Vec = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = ANY($1::uuid[]) + AND tenant_id = $2 + AND ( + project_id = $3 + OR (project_id = $4 AND scope = 'org_shared') + )", + ) + .bind(requested_in_session.as_slice()) + .bind(session.tenant_id.as_str()) + .bind(session.project_id.as_str()) + .bind(ORG_PROJECT_ID) + .fetch_all(&self.db.pool) + .await?; + + for note in rows { + notes_by_id.insert(note.note_id, note); + } + } + + let structured_by_note = if req.payload_level == PayloadLevel::L0 { + HashMap::new() + } else { + structured_fields::fetch_structured_fields( + &self.db.pool, + requested_in_session.as_slice(), + ) + .await? + }; + let allowed_scopes = details::resolve_read_scopes(&self.cfg, &session.read_profile)?; + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + session.tenant_id.as_str(), + session.project_id.as_str(), + agent_id, + allowed_scopes.iter().any(|scope| scope == "org_shared"), + ) + .await?; + let record_hits = req.record_hits.unwrap_or(true); + let details_args = SearchDetailsBuildArgs { + session_items_by_note_id: &by_note_id, + notes_by_id: ¬es_by_id, + structured_by_note: &structured_by_note, + session: &session, + shared_grants: &shared_grants, + allowed_scopes: &allowed_scopes, + now, + record_hits_enabled: record_hits, + payload_level: req.payload_level, + max_note_chars: self.cfg.memory.max_note_chars as usize, + }; + let (results, hits) = details::build_search_details_results(req.note_ids, details_args); + + if !hits.is_empty() { + let mut tx = self.db.pool.begin().await?; + + storage::record_detail_hits(&mut *tx, &session.query, &hits, now).await?; + + tx.commit().await?; + } + + Ok(SearchDetailsResponse { + search_session_id: session.search_session_id, + expires_at, + results, + }) + } +} diff --git a/packages/elf-service/src/progressive_search/storage.rs b/packages/elf-service/src/progressive_search/storage.rs new file mode 100644 index 00000000..ac5db23e --- /dev/null +++ b/packages/elf-service/src/progressive_search/storage.rs @@ -0,0 +1,273 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + str::FromStr, +}; + +use serde_json; +use sqlx::PgExecutor; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +use crate::{ + Error, Result, + progressive_search::types::{ + HitItem, NewSearchSession, SESSION_ABSOLUTE_TTL_HOURS, SESSION_SLIDING_TTL_HOURS, + SearchSession, SearchSessionItemRecord, SearchSessionMode, SearchSessionRow, + }, +}; +use elf_domain::english_gate; + +pub(super) async fn store_search_session<'e, E>( + executor: E, + session: NewSearchSession<'_>, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + let items_json = serde_json::to_value(session.items).map_err(|err| Error::Storage { + message: format!("Failed to encode search session items: {err}"), + })?; + let query_plan_json = + session.query_plan.map(serde_json::to_value).transpose().map_err(|err| Error::Storage { + message: format!("Failed to encode search session query plan: {err}"), + })?; + let trajectory_summary_json = + session.trajectory_summary.map(serde_json::to_value).transpose().map_err(|err| { + Error::Storage { + message: format!("Failed to encode search session trajectory summary: {err}"), + } + })?; + + sqlx::query( + "\ +INSERT INTO search_sessions ( + search_session_id, + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + mode, + trajectory_summary, + query_plan, + items, + created_at, + expires_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + ) + .bind(session.search_session_id) + .bind(session.trace_id) + .bind(session.tenant_id.trim()) + .bind(session.project_id.trim()) + .bind(session.agent_id.trim()) + .bind(session.read_profile) + .bind(session.query) + .bind(session.mode.as_str()) + .bind(trajectory_summary_json) + .bind(query_plan_json) + .bind(items_json) + .bind(session.created_at) + .bind(session.expires_at) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn load_search_session<'e, E>( + executor: E, + search_session_id: Uuid, + now: OffsetDateTime, +) -> Result +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, SearchSessionRow>( + "\ +SELECT + search_session_id, + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + mode, + trajectory_summary, + query_plan, + items, + created_at, + expires_at +FROM search_sessions +WHERE search_session_id = $1", + ) + .bind(search_session_id) + .fetch_optional(executor) + .await?; + let Some(row) = row else { + return Err(Error::InvalidRequest { message: "Unknown search_session_id.".to_string() }); + }; + let expires_at: OffsetDateTime = row.expires_at; + + if expires_at <= now { + return Err(Error::InvalidRequest { message: "Search session expired.".to_string() }); + } + + let items: Vec = serde_json::from_value(row.items).map_err(|err| { + Error::Storage { message: format!("Failed to decode search session items: {err}") } + })?; + let mode = SearchSessionMode::from_str(row.mode.as_str())?; + let query_plan = match row.query_plan { + Some(value) => Some(serde_json::from_value(value).map_err(|err| Error::Storage { + message: format!("Failed to decode search session query_plan: {err}"), + })?), + None => None, + }; + let trajectory_summary = match row.trajectory_summary { + Some(value) => Some(serde_json::from_value(value).map_err(|err| Error::Storage { + message: format!("Failed to decode search session trajectory summary: {err}"), + })?), + None => None, + }; + + Ok(SearchSession { + search_session_id: row.search_session_id, + trace_id: row.trace_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + read_profile: row.read_profile, + query: row.query, + items, + mode, + trajectory_summary, + query_plan, + created_at: row.created_at, + expires_at, + }) +} + +pub(super) async fn touch_search_session<'e, E>( + executor: E, + session: &SearchSession, + now: OffsetDateTime, +) -> Result +where + E: PgExecutor<'e>, +{ + let absolute_expires_at = session.created_at + Duration::hours(SESSION_ABSOLUTE_TTL_HOURS); + let sliding_expires_at = now + Duration::hours(SESSION_SLIDING_TTL_HOURS); + let touched = if sliding_expires_at < absolute_expires_at { + sliding_expires_at + } else { + absolute_expires_at + }; + + if touched <= session.expires_at { + return Ok(session.expires_at); + } + + sqlx::query( + "UPDATE search_sessions SET expires_at = $1 WHERE search_session_id = $2 AND expires_at < $1", + ) + .bind(touched) + .bind(session.search_session_id) + .execute(executor) + .await?; + + Ok(touched) +} + +pub(super) async fn record_detail_hits<'e, E>( + executor: E, + query: &str, + items: &[HitItem], + now: OffsetDateTime, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + if !english_gate::is_english_natural_language(query) { + return Err(Error::NonEnglishInput { field: "$.query".to_string() }); + } + + let query_hash = hash_query(query); + let mut hit_ids = Vec::with_capacity(items.len()); + let mut note_ids = Vec::with_capacity(items.len()); + let mut chunk_ids = Vec::with_capacity(items.len()); + let mut ranks = Vec::with_capacity(items.len()); + let mut final_scores = Vec::with_capacity(items.len()); + + for item in items { + let rank = i32::try_from(item.rank).map_err(|_| Error::InvalidRequest { + message: "Search session rank is out of range.".to_string(), + })?; + + hit_ids.push(Uuid::new_v4()); + note_ids.push(item.note_id); + chunk_ids.push(item.chunk_id); + ranks.push(rank); + final_scores.push(item.final_score); + } + + sqlx::query( + "\ +WITH hits AS ( + SELECT * + FROM unnest( + $1::uuid[], + $2::uuid[], + $3::uuid[], + $4::int4[], + $5::real[] +) AS t(hit_id, note_id, chunk_id, rank, final_score) +), +updated AS ( +UPDATE memory_notes +SET + hit_count = hit_count + 1, + last_hit_at = $6 +WHERE note_id = ANY($2) +) +INSERT INTO memory_hits ( + hit_id, + note_id, + chunk_id, + query_hash, + rank, + final_score, + ts +) +SELECT + hit_id, + note_id, + chunk_id, + $7, + rank, + final_score, + $6 +FROM hits", + ) + .bind(&hit_ids) + .bind(¬e_ids) + .bind(&chunk_ids) + .bind(&ranks) + .bind(&final_scores) + .bind(now) + .bind(query_hash.as_str()) + .execute(executor) + .await?; + + Ok(()) +} + +fn hash_query(query: &str) -> String { + let mut hasher = DefaultHasher::new(); + + Hash::hash(query, &mut hasher); + + format!("{:x}", hasher.finish()) +} diff --git a/packages/elf-service/src/progressive_search/types.rs b/packages/elf-service/src/progressive_search/types.rs new file mode 100644 index 00000000..f19efe32 --- /dev/null +++ b/packages/elf-service/src/progressive_search/types.rs @@ -0,0 +1,345 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{NoteFetchResponse, PayloadLevel, QueryPlan, SearchTrajectorySummary}; + +pub(super) const SESSION_SLIDING_TTL_HOURS: i64 = 6; +pub(super) const SESSION_ABSOLUTE_TTL_HOURS: i64 = 24; + +/// Lightweight session-storable search hit used by progressive-search APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchIndexItem { + /// Note identifier. + pub note_id: Uuid, + /// Note type discriminator. + pub r#type: String, + /// Optional application-defined key. + pub key: Option, + /// Scope key for the note. + pub scope: String, + /// Importance score. + pub importance: f32, + /// Confidence score. + pub confidence: f32, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// Optional expiry timestamp. + pub expires_at: Option, + /// Final ranked score. + pub final_score: f32, + /// Short display summary. + pub summary: String, +} + +/// Response payload for initial indexed search results. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchIndexResponse { + /// Search trace identifier. + pub trace_id: Uuid, + /// Search session identifier used for follow-up requests. + pub search_session_id: Uuid, + #[serde(with = "crate::time_serde")] + /// Session expiry timestamp. + pub expires_at: OffsetDateTime, + /// Stored search hits. + pub items: Vec, + /// Optional condensed explain output. + pub trajectory_summary: Option, +} + +/// Search-session mode used by progressive-search APIs. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SearchSessionMode { + /// Quick-find session without a stored query plan. + QuickFind, + /// Planned-search session with a stored query plan. + PlannedSearch, +} +impl SearchSessionMode { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::QuickFind => "quick_find", + Self::PlannedSearch => "planned_search", + } + } +} + +impl FromStr for SearchSessionMode { + type Err = crate::Error; + + fn from_str(value: &str) -> std::result::Result { + match value { + "quick_find" => Ok(Self::QuickFind), + "planned_search" => Ok(Self::PlannedSearch), + _ => Err(crate::Error::Storage { + message: format!("Unknown search session mode: {value}"), + }), + } + } +} + +impl From for SearchSessionMode { + fn from(path: SearchSessionizePath) -> Self { + match path { + SearchSessionizePath::Quick => Self::QuickFind, + SearchSessionizePath::Planned => Self::PlannedSearch, + } + } +} + +/// Response payload for reloading a stored search session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchSessionGetResponse { + /// Search trace identifier. + pub trace_id: Uuid, + /// Search session identifier. + pub search_session_id: Uuid, + #[serde(with = "crate::time_serde")] + /// Session expiry timestamp. + pub expires_at: OffsetDateTime, + /// Stored hits after trimming to the requested limit. + pub items: Vec, + /// Session mode. + pub mode: SearchSessionMode, + /// Stored query plan for planned-search sessions. + pub query_plan: Option, + /// Optional condensed explain output. + pub trajectory_summary: Option, +} + +/// Planned-search variant of the indexed search response. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchIndexPlannedResponse { + /// Search trace identifier. + pub trace_id: Uuid, + /// Search session identifier. + pub search_session_id: Uuid, + #[serde(with = "crate::time_serde")] + /// Session expiry timestamp. + pub expires_at: OffsetDateTime, + /// Stored hits. + pub items: Vec, + /// Optional condensed explain output. + pub trajectory_summary: Option, + /// Stored query plan for the session. + pub query_plan: QueryPlan, +} + +/// Request payload for reloading a search session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchSessionGetRequest { + /// Tenant that owns the session. + pub tenant_id: String, + /// Project that owns the session. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Search session identifier. + pub search_session_id: Uuid, + #[serde(default)] + /// Desired payload-detail level. + pub payload_level: PayloadLevel, + /// Optional limit on returned items. + pub top_k: Option, + /// When true, extends the sliding session TTL. + pub touch: Option, +} + +/// Request payload for timeline projection of a search session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTimelineRequest { + /// Tenant that owns the session. + pub tenant_id: String, + /// Project that owns the session. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Search session identifier. + pub search_session_id: Uuid, + /// Desired payload-detail level. + pub payload_level: PayloadLevel, + /// Optional timeline grouping mode. + pub group_by: Option, +} + +/// One timeline bucket for a search session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTimelineGroup { + /// Group key, usually a day string. + pub date: String, + /// Items that belong to the group. + pub items: Vec, +} + +/// Response payload for timeline projection. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTimelineResponse { + /// Search session identifier. + pub search_session_id: Uuid, + #[serde(with = "crate::time_serde")] + /// Session expiry timestamp. + pub expires_at: OffsetDateTime, + /// Timeline groups. + pub groups: Vec, +} + +/// Request payload for materializing details from a search session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchDetailsRequest { + /// Tenant that owns the session. + pub tenant_id: String, + /// Project that owns the session. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Search session identifier. + pub search_session_id: Uuid, + #[serde(default)] + /// Desired payload-detail level. + pub payload_level: PayloadLevel, + /// Requested subset of note identifiers. + pub note_ids: Vec, + /// When true, records note-hit metrics for returned details. + pub record_hits: Option, +} + +/// Per-note error payload for detail materialization. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchDetailsError { + /// Machine-readable error code. + pub code: String, + /// Human-readable error message. + pub message: String, +} + +/// Per-note detail result for a search session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchDetailsResult { + /// Requested note identifier. + pub note_id: Uuid, + /// Materialized note payload, when loading succeeded. + pub note: Option, + /// Per-note failure, when loading failed. + pub error: Option, +} + +/// Response payload for detail materialization. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchDetailsResponse { + /// Search session identifier. + pub search_session_id: Uuid, + #[serde(with = "crate::time_serde")] + /// Session expiry timestamp. + pub expires_at: OffsetDateTime, + /// Per-note results. + pub results: Vec, +} + +pub(super) struct HitItem { + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + pub(super) rank: u32, + pub(super) final_score: f32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SearchSessionizePath { + Quick, + Planned, +} + +pub(super) struct SearchSessionizedOutput { + pub(super) index: SearchIndexResponse, + pub(super) query_plan: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct SearchSessionItemRecord { + pub(super) rank: u32, + pub(super) note_id: Uuid, + pub(super) chunk_id: Uuid, + pub(super) final_score: f32, + #[serde(with = "crate::time_serde")] + pub(super) updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + pub(super) expires_at: Option, + pub(super) r#type: String, + pub(super) key: Option, + pub(super) scope: String, + pub(super) importance: f32, + pub(super) confidence: f32, + pub(super) summary: String, +} +impl SearchSessionItemRecord { + pub(super) fn to_index_item(&self) -> SearchIndexItem { + SearchIndexItem { + note_id: self.note_id, + r#type: self.r#type.clone(), + key: self.key.clone(), + scope: self.scope.clone(), + importance: self.importance, + confidence: self.confidence, + updated_at: self.updated_at, + expires_at: self.expires_at, + final_score: self.final_score, + summary: self.summary.clone(), + } + } +} + +pub(super) struct SearchSession { + pub(super) search_session_id: Uuid, + pub(super) trace_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) query: String, + pub(super) mode: SearchSessionMode, + pub(super) trajectory_summary: Option, + pub(super) query_plan: Option, + pub(super) items: Vec, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +#[derive(FromRow)] +pub(super) struct SearchSessionRow { + pub(super) search_session_id: Uuid, + pub(super) trace_id: Uuid, + pub(super) tenant_id: String, + pub(super) project_id: String, + pub(super) agent_id: String, + pub(super) read_profile: String, + pub(super) query: String, + pub(super) mode: String, + pub(super) trajectory_summary: Option, + pub(super) query_plan: Option, + pub(super) items: Value, + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} + +pub(super) struct NewSearchSession<'a> { + pub(super) search_session_id: Uuid, + pub(super) trace_id: Uuid, + pub(super) tenant_id: &'a str, + pub(super) project_id: &'a str, + pub(super) agent_id: &'a str, + pub(super) read_profile: &'a str, + pub(super) query: &'a str, + pub(super) mode: SearchSessionMode, + pub(super) trajectory_summary: Option<&'a SearchTrajectorySummary>, + pub(super) query_plan: Option<&'a QueryPlan>, + pub(super) items: &'a [SearchSessionItemRecord], + pub(super) created_at: OffsetDateTime, + pub(super) expires_at: OffsetDateTime, +} diff --git a/packages/elf-service/src/provenance.rs b/packages/elf-service/src/provenance.rs index f0e925c5..cffc775d 100644 --- a/packages/elf-service/src/provenance.rs +++ b/packages/elf-service/src/provenance.rs @@ -1,1144 +1,16 @@ //! Provenance inspection APIs. -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use serde_json::{self, Value}; -use sqlx::{FromRow, PgPool}; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ElfService, Error, Result}; -use elf_storage::models::MemoryNote; - -const NOTE_PROVENANCE_BUNDLE_SCHEMA_V1: &str = "elf.note_provenance_bundle/v1"; -const NOTE_PROVENANCE_INGEST_DECISIONS_LIMIT: i64 = 100; -const NOTE_PROVENANCE_NOTE_VERSIONS_LIMIT: i64 = 100; -const NOTE_PROVENANCE_OUTBOX_LIMIT: i64 = 100; -const NOTE_PROVENANCE_RECENT_TRACES_LIMIT: i64 = 20; -const NOTE_PROVENANCE_HISTORY_LIMIT: i64 = 200; -const MEMORY_HISTORY_SCHEMA_V1: &str = "elf.memory_history/v1"; - -/// Request payload for note provenance lookup. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceGetRequest { - /// Tenant that owns the note. - pub tenant_id: String, - /// Project that owns the note. - pub project_id: String, - /// Identifier of the note to inspect. - pub note_id: Uuid, -} - -/// Request payload for memory-history lookup. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MemoryHistoryGetRequest { - /// Tenant that owns the memory. - pub tenant_id: String, - /// Project that owns the memory. - pub project_id: String, - /// Identifier of the note to inspect. - pub note_id: Uuid, -} - -/// Timeline response for one memory. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MemoryHistoryResponse { - /// History schema identifier. - pub schema: String, - /// Inspected note identifier. - pub note_id: Uuid, - /// Chronological memory events. - pub events: Vec, -} - -/// Full provenance bundle for one note. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceBundleResponse { - /// Provenance bundle schema identifier. - pub schema: String, - /// Current persisted note snapshot. - pub note: NoteProvenanceNote, - /// Recorded ingestion decisions for the note. - pub ingest_decisions: Vec, - /// Version-history rows for the note. - pub note_versions: Vec, - /// Indexing outbox history for the note. - pub indexing_outbox: Vec, - /// Recent search traces that referenced the note. - pub recent_traces: Vec, - /// Chronological memory event timeline for the note. - pub history: Vec, -} - -/// Current note snapshot returned by provenance APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceNote { - /// Note identifier. - pub note_id: Uuid, - /// Tenant that owns the note. - pub tenant_id: String, - /// Project that owns the note. - pub project_id: String, - /// Agent that wrote the note. - pub agent_id: String, - /// Scope key for the note. - pub scope: String, - /// Note type discriminator. - pub r#type: String, - /// Optional application-defined key. - pub key: Option, - /// Note body text. - pub text: String, - /// Importance score. - pub importance: f32, - /// Confidence score. - pub confidence: f32, - /// Lifecycle status. - pub status: String, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// Optional expiry timestamp. - pub expires_at: Option, - /// Structured source reference metadata. - pub source_ref: Value, - /// Embedding version associated with the note. - pub embedding_version: String, - /// Search hit counter. - pub hit_count: i64, - #[serde(with = "crate::time_serde::option")] - /// Timestamp of the most recent hit. - pub last_hit_at: Option, -} -impl From for NoteProvenanceNote { - fn from(note: MemoryNote) -> Self { - Self { - note_id: note.note_id, - tenant_id: note.tenant_id, - project_id: note.project_id, - agent_id: note.agent_id, - scope: note.scope, - r#type: note.r#type, - key: note.key, - text: note.text, - importance: note.importance, - confidence: note.confidence, - status: note.status, - created_at: note.created_at, - updated_at: note.updated_at, - expires_at: note.expires_at, - source_ref: note.source_ref, - embedding_version: note.embedding_version, - hit_count: note.hit_count, - last_hit_at: note.last_hit_at, - } - } -} - -/// One recorded ingestion decision for a note. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceIngestDecision { - /// Decision identifier. - pub decision_id: Uuid, - /// Tenant that owns the decision record. - pub tenant_id: String, - /// Project that owns the decision record. - pub project_id: String, - /// Agent that triggered the ingestion decision. - pub agent_id: String, - /// Scope key evaluated by the decision. - pub scope: String, - /// Pipeline name that produced the decision. - pub pipeline: String, - /// Note type discriminator under evaluation. - pub note_type: String, - /// Optional application-defined key under evaluation. - pub note_key: Option, - /// Note identifier, when a note was persisted or matched. - pub note_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Note version produced by this decision, when applicable. - pub note_version_id: Option, - /// Pre-policy base decision. - pub base_decision: String, - /// Final policy decision. - pub policy_decision: String, - /// Persistence operation that followed the decision. - pub note_op: String, - /// Machine-readable reason code, if any. - pub reason_code: Option, - /// Structured diagnostic details. - pub details: Value, - #[serde(with = "crate::time_serde")] - /// Decision timestamp. - pub ts: OffsetDateTime, -} -impl From for NoteProvenanceIngestDecision { - fn from(row: NoteIngestDecisionRow) -> Self { - Self { - decision_id: row.decision_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - scope: row.scope, - pipeline: row.pipeline, - note_type: row.note_type, - note_key: row.note_key, - note_id: row.note_id, - note_version_id: row.note_version_id, - base_decision: row.base_decision, - policy_decision: row.policy_decision, - note_op: row.note_op, - reason_code: row.reason_code, - details: row.details, - ts: row.ts, - } - } -} - -/// One version-history row for a note. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceNoteVersion { - /// Version row identifier. - pub version_id: Uuid, - /// Note identifier. - pub note_id: Uuid, - /// Operation recorded in the version row. - pub op: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Snapshot before the operation, when available. - pub prev_snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Snapshot after the operation, when available. - pub new_snapshot: Option, - /// Human-readable reason for the change. - pub reason: String, - /// Actor that performed the change. - pub actor: String, - #[serde(with = "crate::time_serde")] - /// Version timestamp. - pub ts: OffsetDateTime, -} -impl From for NoteProvenanceNoteVersion { - fn from(row: NoteVersionRow) -> Self { - Self { - version_id: row.version_id, - note_id: row.note_id, - op: row.op, - prev_snapshot: row.prev_snapshot, - new_snapshot: row.new_snapshot, - reason: row.reason, - actor: row.actor, - ts: row.ts, - } - } -} - -/// One indexing-outbox row for a note. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceIndexingOutbox { - /// Outbox identifier. - pub outbox_id: Uuid, - /// Note identifier. - pub note_id: Uuid, - /// Requested indexing operation. - pub op: String, - /// Embedding version targeted by the job. - pub embedding_version: String, - /// Current outbox status. - pub status: String, - /// Number of attempts already made. - pub attempts: i32, - #[serde(skip_serializing_if = "Option::is_none")] - /// Most recent failure text, if any. - pub last_error: Option, - #[serde(with = "crate::time_serde")] - /// Earliest time the job may be claimed again. - pub available_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Creation timestamp. - pub created_at: OffsetDateTime, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} -impl From for NoteProvenanceIndexingOutbox { - fn from(row: NoteIndexingOutboxRow) -> Self { - Self { - outbox_id: row.outbox_id, - note_id: row.note_id, - op: row.op, - embedding_version: row.embedding_version, - status: row.status, - attempts: row.attempts, - last_error: row.last_error, - available_at: row.available_at, - created_at: row.created_at, - updated_at: row.updated_at, - } - } -} - -/// Recent search trace that referenced the note. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct NoteProvenanceRecentTrace { - /// Search trace identifier. - pub trace_id: Uuid, - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent that ran the search. - pub agent_id: String, - /// Read profile used for the trace. - pub read_profile: String, - /// Search query text. - pub query: String, - #[serde(with = "crate::time_serde")] - /// Trace creation timestamp. - pub created_at: OffsetDateTime, -} - -/// One normalized memory-history event. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MemoryHistoryEvent { - /// Stable event identifier within its source table. - pub event_id: String, - /// Normalized event type. - pub event_type: String, - /// Subject kind for the event. - pub subject_type: String, - /// Inspected note identifier. - pub note_id: Uuid, - /// Durable source table behind the event. - pub source_table: String, - /// Source row identifier when available. - pub source_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Related note version, when an ingest decision produced a version row. - pub related_note_version_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Related ingest decision, when a version or history event was caused by ingestion. - pub related_decision_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Related consolidation proposal, when a derived memory proposal references the note. - pub related_proposal_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Actor that caused the event, when available. - pub actor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Source operation string. - pub op: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Machine-readable reason code, when available. - pub reason_code: Option, - /// Human-readable one-line event summary. - pub summary: String, - /// Source-specific event details. - pub details: Value, - #[serde(with = "crate::time_serde")] - /// Event timestamp. - pub ts: OffsetDateTime, -} - -#[derive(Clone, Debug)] -struct ValidatedNoteProvenanceRequest { - tenant_id: String, - project_id: String, - note_id: Uuid, -} - -#[derive(FromRow)] -struct NoteIngestDecisionRow { - decision_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - scope: String, - pipeline: String, - note_type: String, - note_key: Option, - note_id: Option, - note_version_id: Option, - base_decision: String, - policy_decision: String, - note_op: String, - reason_code: Option, - details: Value, - ts: OffsetDateTime, -} - -#[derive(FromRow)] -struct NoteVersionRow { - version_id: Uuid, - note_id: Uuid, - op: String, - prev_snapshot: Option, - new_snapshot: Option, - reason: String, - actor: String, - ts: OffsetDateTime, -} - -#[derive(FromRow)] -struct NoteIndexingOutboxRow { - outbox_id: Uuid, - note_id: Uuid, - op: String, - embedding_version: String, - status: String, - attempts: i32, - last_error: Option, - available_at: OffsetDateTime, - created_at: OffsetDateTime, - updated_at: OffsetDateTime, -} - -#[derive(FromRow)] -struct NoteRecentTraceRow { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - created_at: OffsetDateTime, -} - -#[derive(FromRow)] -struct NoteDerivedProposalRow { - proposal_id: Uuid, - run_id: Uuid, - agent_id: String, - proposal_kind: String, - apply_intent: String, - review_state: String, - source_refs: Value, - source_snapshot: Value, - lineage: Value, - diff: Value, - confidence: f32, - target_ref: Value, - proposed_payload: Value, - created_at: OffsetDateTime, -} - -#[derive(FromRow)] -struct NoteProposalReviewRow { - review_id: Uuid, - proposal_id: Uuid, - run_id: Uuid, - reviewer_agent_id: String, - action: String, - from_review_state: String, - to_review_state: String, - review_comment: Option, - created_at: OffsetDateTime, - proposal_kind: String, - apply_intent: String, - diff: Value, -} - -impl ElfService { - /// Loads the current note plus recent provenance tables as one bundle. - pub async fn note_provenance_get( - &self, - req: NoteProvenanceGetRequest, - ) -> Result { - let req = validate_note_provenance_request(req)?; - let note = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 - AND tenant_id = $2 - AND project_id = $3", - ) - .bind(req.note_id) - .bind(&req.tenant_id) - .bind(&req.project_id) - .fetch_optional(&self.db.pool) - .await?; - let Some(note_row) = note else { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - }; - let ingest_decisions = load_ingest_decisions(&self.db.pool, &req).await?; - let note_versions = - load_note_versions(&self.db.pool, &req.tenant_id, &req.project_id, req.note_id).await?; - let indexing_outbox = - load_indexing_outbox(&self.db.pool, &req.tenant_id, &req.project_id, req.note_id) - .await?; - let recent_traces = load_recent_traces_for_note( - &self.db.pool, - &req.tenant_id, - &req.project_id, - req.note_id, - ) - .await?; - let history = load_memory_history_events(&self.db.pool, &req, ¬e_row).await?; - - Ok(NoteProvenanceBundleResponse { - schema: NOTE_PROVENANCE_BUNDLE_SCHEMA_V1.to_string(), - note: NoteProvenanceNote::from(note_row), - ingest_decisions, - note_versions, - indexing_outbox, - recent_traces, - history, - }) - } - - /// Loads the normalized memory-history timeline for one note. - pub async fn memory_history_get( - &self, - req: MemoryHistoryGetRequest, - ) -> Result { - let req = validate_note_provenance_request(NoteProvenanceGetRequest { - tenant_id: req.tenant_id, - project_id: req.project_id, - note_id: req.note_id, - })?; - let note_row = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 - AND tenant_id = $2 - AND project_id = $3", - ) - .bind(req.note_id) - .bind(&req.tenant_id) - .bind(&req.project_id) - .fetch_optional(&self.db.pool) - .await?; - let Some(note_row) = note_row else { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - }; - let events = load_memory_history_events(&self.db.pool, &req, ¬e_row).await?; - - Ok(MemoryHistoryResponse { - schema: MEMORY_HISTORY_SCHEMA_V1.to_string(), - note_id: req.note_id, - events, - }) - } -} - -fn validate_note_provenance_request( - req: NoteProvenanceGetRequest, -) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id and project_id are required.".to_string(), - }); - } - - Ok(ValidatedNoteProvenanceRequest { - tenant_id: tenant_id.to_string(), - project_id: project_id.to_string(), - note_id: req.note_id, - }) -} - -fn to_recent_trace(item: NoteRecentTraceRow) -> NoteProvenanceRecentTrace { - NoteProvenanceRecentTrace { - trace_id: item.trace_id, - tenant_id: item.tenant_id, - project_id: item.project_id, - agent_id: item.agent_id, - read_profile: item.read_profile, - query: item.query, - created_at: item.created_at, - } -} - -fn version_history_event( - version: &NoteProvenanceNoteVersion, - decision: Option<&&NoteProvenanceIngestDecision>, -) -> MemoryHistoryEvent { - let event_type = version_event_type(version.op.as_str(), version.reason.as_str()); - let related_decision_id = decision.map(|decision| decision.decision_id); - let details = serde_json::json!({ - "reason": version.reason, - "prev_snapshot": version.prev_snapshot, - "new_snapshot": version.new_snapshot, - "ingest_decision": decision.map(|decision| serde_json::json!({ - "decision_id": decision.decision_id, - "pipeline": decision.pipeline, - "base_decision": decision.base_decision, - "policy_decision": decision.policy_decision, - "note_op": decision.note_op, - "reason_code": decision.reason_code, - })), - }); - - MemoryHistoryEvent { - event_id: format!("memory_note_versions:{}", version.version_id), - event_type: event_type.to_string(), - subject_type: "note".to_string(), - note_id: version.note_id, - source_table: "memory_note_versions".to_string(), - source_id: Some(version.version_id), - related_note_version_id: Some(version.version_id), - related_decision_id, - related_proposal_id: None, - actor: Some(version.actor.clone()), - op: Some(version.op.clone()), - reason_code: None, - summary: version_summary(event_type, version.reason.as_str()), - details, - ts: version.ts, - } -} - -fn decision_history_event( - note_id: Uuid, - decision: &NoteProvenanceIngestDecision, -) -> MemoryHistoryEvent { - let event_type = decision_event_type(decision); - let details = serde_json::json!({ - "pipeline": decision.pipeline, - "note_type": decision.note_type, - "note_key": decision.note_key, - "base_decision": decision.base_decision, - "policy_decision": decision.policy_decision, - "note_op": decision.note_op, - "details": decision.details, - }); - - MemoryHistoryEvent { - event_id: format!("memory_ingest_decisions:{}", decision.decision_id), - event_type: event_type.to_string(), - subject_type: "note".to_string(), - note_id, - source_table: "memory_ingest_decisions".to_string(), - source_id: Some(decision.decision_id), - related_note_version_id: decision.note_version_id, - related_decision_id: Some(decision.decision_id), - related_proposal_id: None, - actor: Some(decision.agent_id.clone()), - op: Some(decision.note_op.clone()), - reason_code: decision.reason_code.clone(), - summary: decision_summary(event_type, decision), - details, - ts: decision.ts, - } -} - -fn expire_history_event(note: &MemoryNote, expires_at: OffsetDateTime) -> MemoryHistoryEvent { - MemoryHistoryEvent { - event_id: format!("memory_notes:{}:expire:{expires_at}", note.note_id), - event_type: "expire".to_string(), - subject_type: "note".to_string(), - note_id: note.note_id, - source_table: "memory_notes".to_string(), - source_id: Some(note.note_id), - related_note_version_id: None, - related_decision_id: None, - related_proposal_id: None, - actor: Some(note.agent_id.clone()), - op: Some("EXPIRE".to_string()), - reason_code: None, - summary: "Note reached its persisted expires_at timestamp.".to_string(), - details: serde_json::json!({ - "status": note.status, - "expires_at": expires_at, - }), - ts: expires_at, - } -} - -fn derived_proposal_history_event( - note_id: Uuid, - proposal: NoteDerivedProposalRow, -) -> MemoryHistoryEvent { - MemoryHistoryEvent { - event_id: format!("consolidation_proposals:{}", proposal.proposal_id), - event_type: "derived".to_string(), - subject_type: "note".to_string(), - note_id, - source_table: "consolidation_proposals".to_string(), - source_id: Some(proposal.proposal_id), - related_note_version_id: None, - related_decision_id: None, - related_proposal_id: Some(proposal.proposal_id), - actor: Some(proposal.agent_id), - op: Some(proposal.apply_intent.clone()), - reason_code: None, - summary: format!( - "Derived proposal '{}' was created with review_state '{}'.", - proposal.proposal_kind, proposal.review_state - ), - details: serde_json::json!({ - "run_id": proposal.run_id, - "proposal_kind": proposal.proposal_kind, - "apply_intent": proposal.apply_intent, - "review_state": proposal.review_state, - "source_refs": proposal.source_refs, - "source_snapshot": proposal.source_snapshot, - "lineage": proposal.lineage, - "diff": proposal.diff, - "confidence": proposal.confidence, - "target_ref": proposal.target_ref, - "proposed_payload": proposal.proposed_payload, - }), - ts: proposal.created_at, - } -} - -fn proposal_review_history_event( - note_id: Uuid, - review: NoteProposalReviewRow, -) -> MemoryHistoryEvent { - let event_type = proposal_review_event_type(review.action.as_str()); - - MemoryHistoryEvent { - event_id: format!("consolidation_proposal_reviews:{}", review.review_id), - event_type: event_type.to_string(), - subject_type: "note".to_string(), - note_id, - source_table: "consolidation_proposal_reviews".to_string(), - source_id: Some(review.review_id), - related_note_version_id: None, - related_decision_id: None, - related_proposal_id: Some(review.proposal_id), - actor: Some(review.reviewer_agent_id), - op: Some(review.action.clone()), - reason_code: None, - summary: format!( - "Proposal review action '{}' moved '{}' from '{}' to '{}'.", - review.action, review.proposal_kind, review.from_review_state, review.to_review_state - ), - details: serde_json::json!({ - "proposal_id": review.proposal_id, - "run_id": review.run_id, - "proposal_kind": review.proposal_kind, - "apply_intent": review.apply_intent, - "from_review_state": review.from_review_state, - "to_review_state": review.to_review_state, - "review_comment": review.review_comment, - "diff": review.diff, - }), - ts: review.created_at, - } -} - -fn should_emit_decision_event(decision: &NoteProvenanceIngestDecision) -> bool { - if matches!(decision.note_op.as_str(), "NONE" | "REJECTED") { - return true; - } - - decision.note_version_id.is_none() -} - -fn version_event_type(op: &str, reason: &str) -> &'static str { - let reason = reason.to_ascii_lowercase(); - - match op { - "ADD" => "add", - "UPDATE" => "update", - "DELETE" if reason.contains("expire") => "expire", - "DELETE" => "delete", - "PUBLISH" | "UNPUBLISH" => "related", - "DEPRECATE" => "superseded", - "RESTORE" => "restored", - "INVALIDATE" => "invalidated", - _ => "related", - } -} - -fn decision_event_type(decision: &NoteProvenanceIngestDecision) -> &'static str { - if decision.policy_decision == "reject" || decision.note_op == "REJECTED" { - return "reject"; - } - if decision.policy_decision == "ignore" || decision.note_op == "NONE" { - return "ignore"; - } - - match decision.note_op.as_str() { - "ADD" => "add", - "UPDATE" => "update", - "DELETE" => "delete", - _ => "related", - } -} - -fn proposal_review_event_type(action: &str) -> &'static str { - match action { - "apply" => "applied", - "discard" => "reject", - "defer" => "defer", - "approve" => "related", - _ => "related", - } -} - -fn version_summary(event_type: &str, reason: &str) -> String { - match event_type { - "add" => format!("Note was added by {reason}."), - "update" => format!("Note was updated by {reason}."), - "delete" => format!("Note was deleted by {reason}."), - "expire" => format!("Note expired through {reason}."), - "superseded" => format!("Note was superseded by {reason}."), - "restored" => format!("Note was restored by {reason}."), - "invalidated" => format!("Note was invalidated by {reason}."), - _ => format!("Note recorded related transition {reason}."), - } -} - -fn decision_summary(event_type: &str, decision: &NoteProvenanceIngestDecision) -> String { - let reason = decision.reason_code.as_deref().unwrap_or("no_reason_code"); - - match event_type { - "ignore" => format!("Ingestion ignored candidate memory with {reason}."), - "reject" => format!("Ingestion rejected candidate memory with {reason}."), - _ => format!( - "Ingestion recorded {} decision for operation {}.", - decision.policy_decision, decision.note_op - ), - } -} - -async fn load_ingest_decisions( - pool: &PgPool, - req: &ValidatedNoteProvenanceRequest, -) -> Result> { - let rows: Vec = sqlx::query_as::<_, NoteIngestDecisionRow>( - "\ -SELECT - decision_id, - tenant_id, - project_id, - agent_id, - scope, - pipeline, - note_type, - note_key, - note_id, - note_version_id, - base_decision, - policy_decision, - note_op, - reason_code, - details, - ts -FROM memory_ingest_decisions -WHERE note_id = $1 AND tenant_id = $2 AND project_id = $3 -ORDER BY ts DESC -LIMIT $4", - ) - .bind(req.note_id) - .bind(&req.tenant_id) - .bind(&req.project_id) - .bind(NOTE_PROVENANCE_INGEST_DECISIONS_LIMIT) - .fetch_all(pool) - .await?; - - Ok(rows.into_iter().map(NoteProvenanceIngestDecision::from).collect()) -} - -async fn load_note_versions( - pool: &PgPool, - tenant_id: &str, - project_id: &str, - note_id: Uuid, -) -> Result> { - let rows: Vec = sqlx::query_as::<_, NoteVersionRow>( - "\ -SELECT - memory_note_versions.version_id, - memory_note_versions.note_id, - memory_note_versions.op, - memory_note_versions.prev_snapshot, - memory_note_versions.new_snapshot, - memory_note_versions.reason, - memory_note_versions.actor, - memory_note_versions.ts -FROM memory_note_versions -JOIN memory_notes n ON n.note_id = memory_note_versions.note_id -WHERE memory_note_versions.note_id = $1 - AND n.tenant_id = $2 - AND n.project_id = $3 -ORDER BY memory_note_versions.ts DESC -LIMIT $4", - ) - .bind(note_id) - .bind(tenant_id) - .bind(project_id) - .bind(NOTE_PROVENANCE_NOTE_VERSIONS_LIMIT) - .fetch_all(pool) - .await?; - - Ok(rows.into_iter().map(NoteProvenanceNoteVersion::from).collect()) -} - -async fn load_indexing_outbox( - pool: &PgPool, - tenant_id: &str, - project_id: &str, - note_id: Uuid, -) -> Result> { - let rows: Vec = sqlx::query_as::<_, NoteIndexingOutboxRow>( - "\ -SELECT - indexing_outbox.outbox_id, - indexing_outbox.note_id, - indexing_outbox.op, - indexing_outbox.embedding_version, - indexing_outbox.status, - indexing_outbox.attempts, - indexing_outbox.last_error, - indexing_outbox.available_at, - indexing_outbox.created_at, - indexing_outbox.updated_at -FROM indexing_outbox -JOIN memory_notes n ON n.note_id = indexing_outbox.note_id -WHERE indexing_outbox.note_id = $1 - AND n.tenant_id = $2 - AND n.project_id = $3 -ORDER BY indexing_outbox.updated_at DESC -LIMIT $4", - ) - .bind(note_id) - .bind(tenant_id) - .bind(project_id) - .bind(NOTE_PROVENANCE_OUTBOX_LIMIT) - .fetch_all(pool) - .await?; - - Ok(rows.into_iter().map(NoteProvenanceIndexingOutbox::from).collect()) -} - -async fn load_recent_traces_for_note( - pool: &PgPool, - tenant_id: &str, - project_id: &str, - note_id: Uuid, -) -> Result> { - let rows: Vec = sqlx::query_as::<_, NoteRecentTraceRow>( - "\ -SELECT - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - created_at -FROM search_traces -WHERE tenant_id = $1 - AND project_id = $2 - AND trace_id IN (SELECT DISTINCT trace_id FROM search_trace_items WHERE note_id = $3) -ORDER BY created_at DESC, trace_id DESC -LIMIT $4", - ) - .bind(tenant_id) - .bind(project_id) - .bind(note_id) - .bind(NOTE_PROVENANCE_RECENT_TRACES_LIMIT) - .fetch_all(pool) - .await?; - - Ok(rows.into_iter().map(to_recent_trace).collect()) -} - -async fn load_memory_history_events( - pool: &PgPool, - req: &ValidatedNoteProvenanceRequest, - note: &MemoryNote, -) -> Result> { - let decisions = load_ingest_decisions(pool, req).await?; - let versions = load_note_versions(pool, &req.tenant_id, &req.project_id, req.note_id).await?; - let proposal_ref = serde_json::json!([{ "kind": "note", "id": req.note_id }]); - let target_ref = serde_json::json!({ "kind": "note", "id": req.note_id }); - let proposals = load_derived_proposals_for_note(pool, req, &proposal_ref, &target_ref).await?; - let reviews = load_proposal_reviews_for_note(pool, req, &proposal_ref, &target_ref).await?; - let mut decision_by_version = HashMap::new(); - - for decision in &decisions { - if let Some(version_id) = decision.note_version_id { - decision_by_version.insert(version_id, decision); - } - } - - let mut events = Vec::new(); - - for version in &versions { - events.push(version_history_event(version, decision_by_version.get(&version.version_id))); - } - for decision in &decisions { - if should_emit_decision_event(decision) { - events.push(decision_history_event(req.note_id, decision)); - } - } - - if let Some(expires_at) = note.expires_at - && expires_at <= OffsetDateTime::now_utc() - && !events.iter().any(|event| event.event_type == "expire") - { - events.push(expire_history_event(note, expires_at)); - } - - for proposal in proposals { - events.push(derived_proposal_history_event(req.note_id, proposal)); - } - for review in reviews { - events.push(proposal_review_history_event(req.note_id, review)); - } - - events.sort_by(|left, right| { - left.ts.cmp(&right.ts).then_with(|| left.event_id.cmp(&right.event_id)) - }); - - let history_limit = NOTE_PROVENANCE_HISTORY_LIMIT as usize; - - if events.len() > history_limit { - let drop_count = events.len() - history_limit; - - events.drain(0..drop_count); - } - - Ok(events) -} - -async fn load_derived_proposals_for_note( - pool: &PgPool, - req: &ValidatedNoteProvenanceRequest, - proposal_ref: &Value, - target_ref: &Value, -) -> Result> { - let rows = sqlx::query_as::<_, NoteDerivedProposalRow>( - "\ -SELECT - proposal_id, - run_id, - agent_id, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - created_at -FROM consolidation_proposals -WHERE tenant_id = $1 - AND project_id = $2 - AND (source_refs @> $3 OR target_ref @> $4) -ORDER BY created_at DESC, proposal_id DESC -LIMIT $5", - ) - .bind(&req.tenant_id) - .bind(&req.project_id) - .bind(proposal_ref) - .bind(target_ref) - .bind(NOTE_PROVENANCE_HISTORY_LIMIT) - .fetch_all(pool) - .await?; - - Ok(rows) -} - -async fn load_proposal_reviews_for_note( - pool: &PgPool, - req: &ValidatedNoteProvenanceRequest, - proposal_ref: &Value, - target_ref: &Value, -) -> Result> { - let rows = sqlx::query_as::<_, NoteProposalReviewRow>( - "\ -SELECT - reviews.review_id, - reviews.proposal_id, - reviews.run_id, - reviews.reviewer_agent_id, - reviews.action, - reviews.from_review_state, - reviews.to_review_state, - reviews.review_comment, - reviews.created_at, - proposals.proposal_kind, - proposals.apply_intent, - proposals.diff -FROM consolidation_proposal_reviews reviews -JOIN consolidation_proposals proposals - ON proposals.proposal_id = reviews.proposal_id -WHERE reviews.tenant_id = $1 - AND reviews.project_id = $2 - AND (proposals.source_refs @> $3 OR proposals.target_ref @> $4) -ORDER BY reviews.created_at DESC, reviews.review_id DESC -LIMIT $5", - ) - .bind(&req.tenant_id) - .bind(&req.project_id) - .bind(proposal_ref) - .bind(target_ref) - .bind(NOTE_PROVENANCE_HISTORY_LIMIT) - .fetch_all(pool) - .await?; - - Ok(rows) -} - -#[cfg(test)] -mod tests { - use uuid::Uuid; - - use crate::provenance::{self, Error, NoteProvenanceGetRequest}; - - #[test] - fn normalize_note_provenance_request_trims_ids() { - let request = NoteProvenanceGetRequest { - tenant_id: " tenant-a ".to_string(), - project_id: " project-a\n".to_string(), - note_id: Uuid::new_v4(), - }; - let result = - provenance::validate_note_provenance_request(request).expect("expected valid request"); - - assert_eq!(result.tenant_id, "tenant-a"); - assert_eq!(result.project_id, "project-a"); - } - - #[test] - fn note_provenance_request_requires_tenant_and_project() { - let missing_tenant = NoteProvenanceGetRequest { - tenant_id: " ".to_string(), - project_id: "project-a".to_string(), - note_id: Uuid::new_v4(), - }; - let empty_project = NoteProvenanceGetRequest { - tenant_id: "tenant-a".to_string(), - project_id: " ".to_string(), - note_id: Uuid::new_v4(), - }; - let first = provenance::validate_note_provenance_request(missing_tenant) - .expect_err("expected tenant validation error"); - let second = provenance::validate_note_provenance_request(empty_project) - .expect_err("expected project validation error"); - - match first { - Error::InvalidRequest { message } => { - assert!(message.contains("tenant_id")); - }, - _ => panic!("tenant validation should produce InvalidRequest"), - } - match second { - Error::InvalidRequest { message } => { - assert!(message.contains("tenant_id") || message.contains("project_id")); - }, - _ => panic!("project validation should produce InvalidRequest"), - } - } -} +mod history; +mod loaders; +mod service; +mod types; +mod validation; + +pub use types::{ + MemoryHistoryEvent, MemoryHistoryGetRequest, MemoryHistoryResponse, + NoteProvenanceBundleResponse, NoteProvenanceGetRequest, NoteProvenanceIndexingOutbox, + NoteProvenanceIngestDecision, NoteProvenanceNote, NoteProvenanceNoteVersion, + NoteProvenanceRecentTrace, +}; + +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/provenance/history.rs b/packages/elf-service/src/provenance/history.rs new file mode 100644 index 00000000..71b2755f --- /dev/null +++ b/packages/elf-service/src/provenance/history.rs @@ -0,0 +1,259 @@ +use serde_json; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::provenance::types::{ + MemoryHistoryEvent, NoteProvenanceIngestDecision, NoteProvenanceNoteVersion, + rows::{NoteDerivedProposalRow, NoteProposalReviewRow}, +}; +use elf_storage::models::MemoryNote; + +pub(super) fn version_history_event( + version: &NoteProvenanceNoteVersion, + decision: Option<&&NoteProvenanceIngestDecision>, +) -> MemoryHistoryEvent { + let event_type = version_event_type(version.op.as_str(), version.reason.as_str()); + let related_decision_id = decision.map(|decision| decision.decision_id); + let details = serde_json::json!({ + "reason": version.reason, + "prev_snapshot": version.prev_snapshot, + "new_snapshot": version.new_snapshot, + "ingest_decision": decision.map(|decision| serde_json::json!({ + "decision_id": decision.decision_id, + "pipeline": decision.pipeline, + "base_decision": decision.base_decision, + "policy_decision": decision.policy_decision, + "note_op": decision.note_op, + "reason_code": decision.reason_code, + })), + }); + + MemoryHistoryEvent { + event_id: format!("memory_note_versions:{}", version.version_id), + event_type: event_type.to_string(), + subject_type: "note".to_string(), + note_id: version.note_id, + source_table: "memory_note_versions".to_string(), + source_id: Some(version.version_id), + related_note_version_id: Some(version.version_id), + related_decision_id, + related_proposal_id: None, + actor: Some(version.actor.clone()), + op: Some(version.op.clone()), + reason_code: None, + summary: version_summary(event_type, version.reason.as_str()), + details, + ts: version.ts, + } +} + +pub(super) fn decision_history_event( + note_id: Uuid, + decision: &NoteProvenanceIngestDecision, +) -> MemoryHistoryEvent { + let event_type = decision_event_type(decision); + let details = serde_json::json!({ + "pipeline": decision.pipeline, + "note_type": decision.note_type, + "note_key": decision.note_key, + "base_decision": decision.base_decision, + "policy_decision": decision.policy_decision, + "note_op": decision.note_op, + "details": decision.details, + }); + + MemoryHistoryEvent { + event_id: format!("memory_ingest_decisions:{}", decision.decision_id), + event_type: event_type.to_string(), + subject_type: "note".to_string(), + note_id, + source_table: "memory_ingest_decisions".to_string(), + source_id: Some(decision.decision_id), + related_note_version_id: decision.note_version_id, + related_decision_id: Some(decision.decision_id), + related_proposal_id: None, + actor: Some(decision.agent_id.clone()), + op: Some(decision.note_op.clone()), + reason_code: decision.reason_code.clone(), + summary: decision_summary(event_type, decision), + details, + ts: decision.ts, + } +} + +pub(super) fn expire_history_event( + note: &MemoryNote, + expires_at: OffsetDateTime, +) -> MemoryHistoryEvent { + MemoryHistoryEvent { + event_id: format!("memory_notes:{}:expire:{expires_at}", note.note_id), + event_type: "expire".to_string(), + subject_type: "note".to_string(), + note_id: note.note_id, + source_table: "memory_notes".to_string(), + source_id: Some(note.note_id), + related_note_version_id: None, + related_decision_id: None, + related_proposal_id: None, + actor: Some(note.agent_id.clone()), + op: Some("EXPIRE".to_string()), + reason_code: None, + summary: "Note reached its persisted expires_at timestamp.".to_string(), + details: serde_json::json!({ + "status": note.status, + "expires_at": expires_at, + }), + ts: expires_at, + } +} + +pub(super) fn derived_proposal_history_event( + note_id: Uuid, + proposal: NoteDerivedProposalRow, +) -> MemoryHistoryEvent { + MemoryHistoryEvent { + event_id: format!("consolidation_proposals:{}", proposal.proposal_id), + event_type: "derived".to_string(), + subject_type: "note".to_string(), + note_id, + source_table: "consolidation_proposals".to_string(), + source_id: Some(proposal.proposal_id), + related_note_version_id: None, + related_decision_id: None, + related_proposal_id: Some(proposal.proposal_id), + actor: Some(proposal.agent_id), + op: Some(proposal.apply_intent.clone()), + reason_code: None, + summary: format!( + "Derived proposal '{}' was created with review_state '{}'.", + proposal.proposal_kind, proposal.review_state + ), + details: serde_json::json!({ + "run_id": proposal.run_id, + "proposal_kind": proposal.proposal_kind, + "apply_intent": proposal.apply_intent, + "review_state": proposal.review_state, + "source_refs": proposal.source_refs, + "source_snapshot": proposal.source_snapshot, + "lineage": proposal.lineage, + "diff": proposal.diff, + "confidence": proposal.confidence, + "target_ref": proposal.target_ref, + "proposed_payload": proposal.proposed_payload, + }), + ts: proposal.created_at, + } +} + +pub(super) fn proposal_review_history_event( + note_id: Uuid, + review: NoteProposalReviewRow, +) -> MemoryHistoryEvent { + let event_type = proposal_review_event_type(review.action.as_str()); + + MemoryHistoryEvent { + event_id: format!("consolidation_proposal_reviews:{}", review.review_id), + event_type: event_type.to_string(), + subject_type: "note".to_string(), + note_id, + source_table: "consolidation_proposal_reviews".to_string(), + source_id: Some(review.review_id), + related_note_version_id: None, + related_decision_id: None, + related_proposal_id: Some(review.proposal_id), + actor: Some(review.reviewer_agent_id), + op: Some(review.action.clone()), + reason_code: None, + summary: format!( + "Proposal review action '{}' moved '{}' from '{}' to '{}'.", + review.action, review.proposal_kind, review.from_review_state, review.to_review_state + ), + details: serde_json::json!({ + "proposal_id": review.proposal_id, + "run_id": review.run_id, + "proposal_kind": review.proposal_kind, + "apply_intent": review.apply_intent, + "from_review_state": review.from_review_state, + "to_review_state": review.to_review_state, + "review_comment": review.review_comment, + "diff": review.diff, + }), + ts: review.created_at, + } +} + +pub(super) fn should_emit_decision_event(decision: &NoteProvenanceIngestDecision) -> bool { + if matches!(decision.note_op.as_str(), "NONE" | "REJECTED") { + return true; + } + + decision.note_version_id.is_none() +} + +fn version_event_type(op: &str, reason: &str) -> &'static str { + let reason = reason.to_ascii_lowercase(); + + match op { + "ADD" => "add", + "UPDATE" => "update", + "DELETE" if reason.contains("expire") => "expire", + "DELETE" => "delete", + "PUBLISH" | "UNPUBLISH" => "related", + "DEPRECATE" => "superseded", + "RESTORE" => "restored", + "INVALIDATE" => "invalidated", + _ => "related", + } +} + +fn decision_event_type(decision: &NoteProvenanceIngestDecision) -> &'static str { + if decision.policy_decision == "reject" || decision.note_op == "REJECTED" { + return "reject"; + } + if decision.policy_decision == "ignore" || decision.note_op == "NONE" { + return "ignore"; + } + + match decision.note_op.as_str() { + "ADD" => "add", + "UPDATE" => "update", + "DELETE" => "delete", + _ => "related", + } +} + +fn proposal_review_event_type(action: &str) -> &'static str { + match action { + "apply" => "applied", + "discard" => "reject", + "defer" => "defer", + "approve" => "related", + _ => "related", + } +} + +fn version_summary(event_type: &str, reason: &str) -> String { + match event_type { + "add" => format!("Note was added by {reason}."), + "update" => format!("Note was updated by {reason}."), + "delete" => format!("Note was deleted by {reason}."), + "expire" => format!("Note expired through {reason}."), + "superseded" => format!("Note was superseded by {reason}."), + "restored" => format!("Note was restored by {reason}."), + "invalidated" => format!("Note was invalidated by {reason}."), + _ => format!("Note recorded related transition {reason}."), + } +} + +fn decision_summary(event_type: &str, decision: &NoteProvenanceIngestDecision) -> String { + let reason = decision.reason_code.as_deref().unwrap_or("no_reason_code"); + + match event_type { + "ignore" => format!("Ingestion ignored candidate memory with {reason}."), + "reject" => format!("Ingestion rejected candidate memory with {reason}."), + _ => format!( + "Ingestion recorded {} decision for operation {}.", + decision.policy_decision, decision.note_op + ), + } +} diff --git a/packages/elf-service/src/provenance/loaders.rs b/packages/elf-service/src/provenance/loaders.rs new file mode 100644 index 00000000..c6706dcc --- /dev/null +++ b/packages/elf-service/src/provenance/loaders.rs @@ -0,0 +1,327 @@ +use std::collections::HashMap; + +use serde_json::Value; +use sqlx::PgPool; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Result, + provenance::{ + history::{self}, + types::{ + MemoryHistoryEvent, NoteProvenanceIndexingOutbox, NoteProvenanceIngestDecision, + NoteProvenanceNoteVersion, NoteProvenanceRecentTrace, + constants::{ + NOTE_PROVENANCE_HISTORY_LIMIT, NOTE_PROVENANCE_INGEST_DECISIONS_LIMIT, + NOTE_PROVENANCE_NOTE_VERSIONS_LIMIT, NOTE_PROVENANCE_OUTBOX_LIMIT, + NOTE_PROVENANCE_RECENT_TRACES_LIMIT, + }, + requests::ValidatedNoteProvenanceRequest, + rows::{ + NoteDerivedProposalRow, NoteIndexingOutboxRow, NoteIngestDecisionRow, + NoteProposalReviewRow, NoteRecentTraceRow, NoteVersionRow, + }, + }, + }, +}; +use elf_storage::models::MemoryNote; + +pub(super) async fn load_ingest_decisions( + pool: &PgPool, + req: &ValidatedNoteProvenanceRequest, +) -> Result> { + let rows: Vec = sqlx::query_as::<_, NoteIngestDecisionRow>( + "\ +SELECT + decision_id, + tenant_id, + project_id, + agent_id, + scope, + pipeline, + note_type, + note_key, + note_id, + note_version_id, + base_decision, + policy_decision, + note_op, + reason_code, + details, + ts +FROM memory_ingest_decisions +WHERE note_id = $1 AND tenant_id = $2 AND project_id = $3 +ORDER BY ts DESC +LIMIT $4", + ) + .bind(req.note_id) + .bind(&req.tenant_id) + .bind(&req.project_id) + .bind(NOTE_PROVENANCE_INGEST_DECISIONS_LIMIT) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(NoteProvenanceIngestDecision::from).collect()) +} + +pub(super) async fn load_note_versions( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + note_id: Uuid, +) -> Result> { + let rows: Vec = sqlx::query_as::<_, NoteVersionRow>( + "\ +SELECT + memory_note_versions.version_id, + memory_note_versions.note_id, + memory_note_versions.op, + memory_note_versions.prev_snapshot, + memory_note_versions.new_snapshot, + memory_note_versions.reason, + memory_note_versions.actor, + memory_note_versions.ts +FROM memory_note_versions +JOIN memory_notes n ON n.note_id = memory_note_versions.note_id +WHERE memory_note_versions.note_id = $1 + AND n.tenant_id = $2 + AND n.project_id = $3 +ORDER BY memory_note_versions.ts DESC +LIMIT $4", + ) + .bind(note_id) + .bind(tenant_id) + .bind(project_id) + .bind(NOTE_PROVENANCE_NOTE_VERSIONS_LIMIT) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(NoteProvenanceNoteVersion::from).collect()) +} + +pub(super) async fn load_indexing_outbox( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + note_id: Uuid, +) -> Result> { + let rows: Vec = sqlx::query_as::<_, NoteIndexingOutboxRow>( + "\ +SELECT + indexing_outbox.outbox_id, + indexing_outbox.note_id, + indexing_outbox.op, + indexing_outbox.embedding_version, + indexing_outbox.status, + indexing_outbox.attempts, + indexing_outbox.last_error, + indexing_outbox.available_at, + indexing_outbox.created_at, + indexing_outbox.updated_at +FROM indexing_outbox +JOIN memory_notes n ON n.note_id = indexing_outbox.note_id +WHERE indexing_outbox.note_id = $1 + AND n.tenant_id = $2 + AND n.project_id = $3 +ORDER BY indexing_outbox.updated_at DESC +LIMIT $4", + ) + .bind(note_id) + .bind(tenant_id) + .bind(project_id) + .bind(NOTE_PROVENANCE_OUTBOX_LIMIT) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(NoteProvenanceIndexingOutbox::from).collect()) +} + +pub(super) async fn load_recent_traces_for_note( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + note_id: Uuid, +) -> Result> { + let rows: Vec = sqlx::query_as::<_, NoteRecentTraceRow>( + "\ +SELECT + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + created_at +FROM search_traces +WHERE tenant_id = $1 + AND project_id = $2 + AND trace_id IN (SELECT DISTINCT trace_id FROM search_trace_items WHERE note_id = $3) +ORDER BY created_at DESC, trace_id DESC +LIMIT $4", + ) + .bind(tenant_id) + .bind(project_id) + .bind(note_id) + .bind(NOTE_PROVENANCE_RECENT_TRACES_LIMIT) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(to_recent_trace).collect()) +} + +pub(super) async fn load_memory_history_events( + pool: &PgPool, + req: &ValidatedNoteProvenanceRequest, + note: &MemoryNote, +) -> Result> { + let decisions = load_ingest_decisions(pool, req).await?; + let versions = load_note_versions(pool, &req.tenant_id, &req.project_id, req.note_id).await?; + let proposal_ref = serde_json::json!([{ "kind": "note", "id": req.note_id }]); + let target_ref = serde_json::json!({ "kind": "note", "id": req.note_id }); + let proposals = load_derived_proposals_for_note(pool, req, &proposal_ref, &target_ref).await?; + let reviews = load_proposal_reviews_for_note(pool, req, &proposal_ref, &target_ref).await?; + let mut decision_by_version = HashMap::new(); + + for decision in &decisions { + if let Some(version_id) = decision.note_version_id { + decision_by_version.insert(version_id, decision); + } + } + + let mut events = Vec::new(); + + for version in &versions { + events.push(history::version_history_event( + version, + decision_by_version.get(&version.version_id), + )); + } + for decision in &decisions { + if history::should_emit_decision_event(decision) { + events.push(history::decision_history_event(req.note_id, decision)); + } + } + + if let Some(expires_at) = note.expires_at + && expires_at <= OffsetDateTime::now_utc() + && !events.iter().any(|event| event.event_type == "expire") + { + events.push(history::expire_history_event(note, expires_at)); + } + + for proposal in proposals { + events.push(history::derived_proposal_history_event(req.note_id, proposal)); + } + for review in reviews { + events.push(history::proposal_review_history_event(req.note_id, review)); + } + + events.sort_by(|left, right| { + left.ts.cmp(&right.ts).then_with(|| left.event_id.cmp(&right.event_id)) + }); + + let history_limit = NOTE_PROVENANCE_HISTORY_LIMIT as usize; + + if events.len() > history_limit { + let drop_count = events.len() - history_limit; + + events.drain(0..drop_count); + } + + Ok(events) +} + +pub(super) async fn load_derived_proposals_for_note( + pool: &PgPool, + req: &ValidatedNoteProvenanceRequest, + proposal_ref: &Value, + target_ref: &Value, +) -> Result> { + let rows = sqlx::query_as::<_, NoteDerivedProposalRow>( + "\ +SELECT + proposal_id, + run_id, + agent_id, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + created_at +FROM consolidation_proposals +WHERE tenant_id = $1 + AND project_id = $2 + AND (source_refs @> $3 OR target_ref @> $4) +ORDER BY created_at DESC, proposal_id DESC +LIMIT $5", + ) + .bind(&req.tenant_id) + .bind(&req.project_id) + .bind(proposal_ref) + .bind(target_ref) + .bind(NOTE_PROVENANCE_HISTORY_LIMIT) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub(super) async fn load_proposal_reviews_for_note( + pool: &PgPool, + req: &ValidatedNoteProvenanceRequest, + proposal_ref: &Value, + target_ref: &Value, +) -> Result> { + let rows = sqlx::query_as::<_, NoteProposalReviewRow>( + "\ +SELECT + reviews.review_id, + reviews.proposal_id, + reviews.run_id, + reviews.reviewer_agent_id, + reviews.action, + reviews.from_review_state, + reviews.to_review_state, + reviews.review_comment, + reviews.created_at, + proposals.proposal_kind, + proposals.apply_intent, + proposals.diff +FROM consolidation_proposal_reviews reviews +JOIN consolidation_proposals proposals + ON proposals.proposal_id = reviews.proposal_id +WHERE reviews.tenant_id = $1 + AND reviews.project_id = $2 + AND (proposals.source_refs @> $3 OR proposals.target_ref @> $4) +ORDER BY reviews.created_at DESC, reviews.review_id DESC +LIMIT $5", + ) + .bind(&req.tenant_id) + .bind(&req.project_id) + .bind(proposal_ref) + .bind(target_ref) + .bind(NOTE_PROVENANCE_HISTORY_LIMIT) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +fn to_recent_trace(item: NoteRecentTraceRow) -> NoteProvenanceRecentTrace { + NoteProvenanceRecentTrace { + trace_id: item.trace_id, + tenant_id: item.tenant_id, + project_id: item.project_id, + agent_id: item.agent_id, + read_profile: item.read_profile, + query: item.query, + created_at: item.created_at, + } +} diff --git a/packages/elf-service/src/provenance/service.rs b/packages/elf-service/src/provenance/service.rs new file mode 100644 index 00000000..5a3ef572 --- /dev/null +++ b/packages/elf-service/src/provenance/service.rs @@ -0,0 +1,107 @@ +use crate::{ + ElfService, Error, Result, + provenance::{ + loaders::{self}, + types::{ + MemoryHistoryGetRequest, MemoryHistoryResponse, NoteProvenanceBundleResponse, + NoteProvenanceGetRequest, NoteProvenanceNote, + constants::{MEMORY_HISTORY_SCHEMA_V1, NOTE_PROVENANCE_BUNDLE_SCHEMA_V1}, + }, + validation, + }, +}; +use elf_storage::models::MemoryNote; + +impl ElfService { + /// Loads the current note plus recent provenance tables as one bundle. + pub async fn note_provenance_get( + &self, + req: NoteProvenanceGetRequest, + ) -> Result { + let req = validation::validate_note_provenance_request(req)?; + let note = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 + AND tenant_id = $2 + AND project_id = $3", + ) + .bind(req.note_id) + .bind(&req.tenant_id) + .bind(&req.project_id) + .fetch_optional(&self.db.pool) + .await?; + let Some(note_row) = note else { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + }; + let ingest_decisions = loaders::load_ingest_decisions(&self.db.pool, &req).await?; + let note_versions = loaders::load_note_versions( + &self.db.pool, + &req.tenant_id, + &req.project_id, + req.note_id, + ) + .await?; + let indexing_outbox = loaders::load_indexing_outbox( + &self.db.pool, + &req.tenant_id, + &req.project_id, + req.note_id, + ) + .await?; + let recent_traces = loaders::load_recent_traces_for_note( + &self.db.pool, + &req.tenant_id, + &req.project_id, + req.note_id, + ) + .await?; + let history = loaders::load_memory_history_events(&self.db.pool, &req, ¬e_row).await?; + + Ok(NoteProvenanceBundleResponse { + schema: NOTE_PROVENANCE_BUNDLE_SCHEMA_V1.to_string(), + note: NoteProvenanceNote::from(note_row), + ingest_decisions, + note_versions, + indexing_outbox, + recent_traces, + history, + }) + } + + /// Loads the normalized memory-history timeline for one note. + pub async fn memory_history_get( + &self, + req: MemoryHistoryGetRequest, + ) -> Result { + let req = validation::validate_note_provenance_request(NoteProvenanceGetRequest { + tenant_id: req.tenant_id, + project_id: req.project_id, + note_id: req.note_id, + })?; + let note_row = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 + AND tenant_id = $2 + AND project_id = $3", + ) + .bind(req.note_id) + .bind(&req.tenant_id) + .bind(&req.project_id) + .fetch_optional(&self.db.pool) + .await?; + let Some(note_row) = note_row else { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + }; + let events = loaders::load_memory_history_events(&self.db.pool, &req, ¬e_row).await?; + + Ok(MemoryHistoryResponse { + schema: MEMORY_HISTORY_SCHEMA_V1.to_string(), + note_id: req.note_id, + events, + }) + } +} diff --git a/packages/elf-service/src/provenance/tests.rs b/packages/elf-service/src/provenance/tests.rs new file mode 100644 index 00000000..878b2bf6 --- /dev/null +++ b/packages/elf-service/src/provenance/tests.rs @@ -0,0 +1,51 @@ +use uuid::Uuid; + +use crate::{ + Error, + provenance::{types::NoteProvenanceGetRequest, validation}, +}; + +#[test] +fn normalize_note_provenance_request_trims_ids() { + let request = NoteProvenanceGetRequest { + tenant_id: " tenant-a ".to_string(), + project_id: " project-a\n".to_string(), + note_id: Uuid::new_v4(), + }; + let result = + validation::validate_note_provenance_request(request).expect("expected valid request"); + + assert_eq!(result.tenant_id, "tenant-a"); + assert_eq!(result.project_id, "project-a"); +} + +#[test] +fn note_provenance_request_requires_tenant_and_project() { + let missing_tenant = NoteProvenanceGetRequest { + tenant_id: " ".to_string(), + project_id: "project-a".to_string(), + note_id: Uuid::new_v4(), + }; + let empty_project = NoteProvenanceGetRequest { + tenant_id: "tenant-a".to_string(), + project_id: " ".to_string(), + note_id: Uuid::new_v4(), + }; + let first = validation::validate_note_provenance_request(missing_tenant) + .expect_err("expected tenant validation error"); + let second = validation::validate_note_provenance_request(empty_project) + .expect_err("expected project validation error"); + + match first { + Error::InvalidRequest { message } => { + assert!(message.contains("tenant_id")); + }, + _ => panic!("tenant validation should produce InvalidRequest"), + } + match second { + Error::InvalidRequest { message } => { + assert!(message.contains("tenant_id") || message.contains("project_id")); + }, + _ => panic!("project validation should produce InvalidRequest"), + } +} diff --git a/packages/elf-service/src/provenance/types.rs b/packages/elf-service/src/provenance/types.rs new file mode 100644 index 00000000..249ceb22 --- /dev/null +++ b/packages/elf-service/src/provenance/types.rs @@ -0,0 +1,17 @@ +pub(in crate::provenance) mod constants; +pub(in crate::provenance) mod requests; +pub(in crate::provenance) mod rows; + +mod events; +mod notes; +mod responses; + +pub use self::{ + events::MemoryHistoryEvent, + notes::{ + NoteProvenanceIndexingOutbox, NoteProvenanceIngestDecision, NoteProvenanceNote, + NoteProvenanceNoteVersion, NoteProvenanceRecentTrace, + }, + requests::{MemoryHistoryGetRequest, NoteProvenanceGetRequest}, + responses::{MemoryHistoryResponse, NoteProvenanceBundleResponse}, +}; diff --git a/packages/elf-service/src/provenance/types/constants.rs b/packages/elf-service/src/provenance/types/constants.rs new file mode 100644 index 00000000..eed584f4 --- /dev/null +++ b/packages/elf-service/src/provenance/types/constants.rs @@ -0,0 +1,8 @@ +pub(in crate::provenance) const NOTE_PROVENANCE_BUNDLE_SCHEMA_V1: &str = + "elf.note_provenance_bundle/v1"; +pub(in crate::provenance) const NOTE_PROVENANCE_INGEST_DECISIONS_LIMIT: i64 = 100; +pub(in crate::provenance) const NOTE_PROVENANCE_NOTE_VERSIONS_LIMIT: i64 = 100; +pub(in crate::provenance) const NOTE_PROVENANCE_OUTBOX_LIMIT: i64 = 100; +pub(in crate::provenance) const NOTE_PROVENANCE_RECENT_TRACES_LIMIT: i64 = 20; +pub(in crate::provenance) const NOTE_PROVENANCE_HISTORY_LIMIT: i64 = 200; +pub(in crate::provenance) const MEMORY_HISTORY_SCHEMA_V1: &str = "elf.memory_history/v1"; diff --git a/packages/elf-service/src/provenance/types/events.rs b/packages/elf-service/src/provenance/types/events.rs new file mode 100644 index 00000000..d691cea9 --- /dev/null +++ b/packages/elf-service/src/provenance/types/events.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +/// One normalized memory-history event. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MemoryHistoryEvent { + /// Stable event identifier within its source table. + pub event_id: String, + /// Normalized event type. + pub event_type: String, + /// Subject kind for the event. + pub subject_type: String, + /// Inspected note identifier. + pub note_id: Uuid, + /// Durable source table behind the event. + pub source_table: String, + /// Source row identifier when available. + pub source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Related note version, when an ingest decision produced a version row. + pub related_note_version_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Related ingest decision, when a version or history event was caused by ingestion. + pub related_decision_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Related consolidation proposal, when a derived memory proposal references the note. + pub related_proposal_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Actor that caused the event, when available. + pub actor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Source operation string. + pub op: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Machine-readable reason code, when available. + pub reason_code: Option, + /// Human-readable one-line event summary. + pub summary: String, + /// Source-specific event details. + pub details: Value, + #[serde(with = "crate::time_serde")] + /// Event timestamp. + pub ts: OffsetDateTime, +} diff --git a/packages/elf-service/src/provenance/types/notes.rs b/packages/elf-service/src/provenance/types/notes.rs new file mode 100644 index 00000000..ed5de168 --- /dev/null +++ b/packages/elf-service/src/provenance/types/notes.rs @@ -0,0 +1,242 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::provenance::types::rows::{ + NoteIndexingOutboxRow, NoteIngestDecisionRow, NoteVersionRow, +}; +use elf_storage::models::MemoryNote; + +/// Current note snapshot returned by provenance APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceNote { + /// Note identifier. + pub note_id: Uuid, + /// Tenant that owns the note. + pub tenant_id: String, + /// Project that owns the note. + pub project_id: String, + /// Agent that wrote the note. + pub agent_id: String, + /// Scope key for the note. + pub scope: String, + /// Note type discriminator. + pub r#type: String, + /// Optional application-defined key. + pub key: Option, + /// Note body text. + pub text: String, + /// Importance score. + pub importance: f32, + /// Confidence score. + pub confidence: f32, + /// Lifecycle status. + pub status: String, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// Optional expiry timestamp. + pub expires_at: Option, + /// Structured source reference metadata. + pub source_ref: Value, + /// Embedding version associated with the note. + pub embedding_version: String, + /// Search hit counter. + pub hit_count: i64, + #[serde(with = "crate::time_serde::option")] + /// Timestamp of the most recent hit. + pub last_hit_at: Option, +} +impl From for NoteProvenanceNote { + fn from(note: MemoryNote) -> Self { + Self { + note_id: note.note_id, + tenant_id: note.tenant_id, + project_id: note.project_id, + agent_id: note.agent_id, + scope: note.scope, + r#type: note.r#type, + key: note.key, + text: note.text, + importance: note.importance, + confidence: note.confidence, + status: note.status, + created_at: note.created_at, + updated_at: note.updated_at, + expires_at: note.expires_at, + source_ref: note.source_ref, + embedding_version: note.embedding_version, + hit_count: note.hit_count, + last_hit_at: note.last_hit_at, + } + } +} + +/// One recorded ingestion decision for a note. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceIngestDecision { + /// Decision identifier. + pub decision_id: Uuid, + /// Tenant that owns the decision record. + pub tenant_id: String, + /// Project that owns the decision record. + pub project_id: String, + /// Agent that triggered the ingestion decision. + pub agent_id: String, + /// Scope key evaluated by the decision. + pub scope: String, + /// Pipeline name that produced the decision. + pub pipeline: String, + /// Note type discriminator under evaluation. + pub note_type: String, + /// Optional application-defined key under evaluation. + pub note_key: Option, + /// Note identifier, when a note was persisted or matched. + pub note_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Note version produced by this decision, when applicable. + pub note_version_id: Option, + /// Pre-policy base decision. + pub base_decision: String, + /// Final policy decision. + pub policy_decision: String, + /// Persistence operation that followed the decision. + pub note_op: String, + /// Machine-readable reason code, if any. + pub reason_code: Option, + /// Structured diagnostic details. + pub details: Value, + #[serde(with = "crate::time_serde")] + /// Decision timestamp. + pub ts: OffsetDateTime, +} +impl From for NoteProvenanceIngestDecision { + fn from(row: NoteIngestDecisionRow) -> Self { + Self { + decision_id: row.decision_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + scope: row.scope, + pipeline: row.pipeline, + note_type: row.note_type, + note_key: row.note_key, + note_id: row.note_id, + note_version_id: row.note_version_id, + base_decision: row.base_decision, + policy_decision: row.policy_decision, + note_op: row.note_op, + reason_code: row.reason_code, + details: row.details, + ts: row.ts, + } + } +} + +/// One version-history row for a note. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceNoteVersion { + /// Version row identifier. + pub version_id: Uuid, + /// Note identifier. + pub note_id: Uuid, + /// Operation recorded in the version row. + pub op: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Snapshot before the operation, when available. + pub prev_snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Snapshot after the operation, when available. + pub new_snapshot: Option, + /// Human-readable reason for the change. + pub reason: String, + /// Actor that performed the change. + pub actor: String, + #[serde(with = "crate::time_serde")] + /// Version timestamp. + pub ts: OffsetDateTime, +} +impl From for NoteProvenanceNoteVersion { + fn from(row: NoteVersionRow) -> Self { + Self { + version_id: row.version_id, + note_id: row.note_id, + op: row.op, + prev_snapshot: row.prev_snapshot, + new_snapshot: row.new_snapshot, + reason: row.reason, + actor: row.actor, + ts: row.ts, + } + } +} + +/// One indexing-outbox row for a note. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceIndexingOutbox { + /// Outbox identifier. + pub outbox_id: Uuid, + /// Note identifier. + pub note_id: Uuid, + /// Requested indexing operation. + pub op: String, + /// Embedding version targeted by the job. + pub embedding_version: String, + /// Current outbox status. + pub status: String, + /// Number of attempts already made. + pub attempts: i32, + #[serde(skip_serializing_if = "Option::is_none")] + /// Most recent failure text, if any. + pub last_error: Option, + #[serde(with = "crate::time_serde")] + /// Earliest time the job may be claimed again. + pub available_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Creation timestamp. + pub created_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} +impl From for NoteProvenanceIndexingOutbox { + fn from(row: NoteIndexingOutboxRow) -> Self { + Self { + outbox_id: row.outbox_id, + note_id: row.note_id, + op: row.op, + embedding_version: row.embedding_version, + status: row.status, + attempts: row.attempts, + last_error: row.last_error, + available_at: row.available_at, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +/// Recent search trace that referenced the note. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceRecentTrace { + /// Search trace identifier. + pub trace_id: Uuid, + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent that ran the search. + pub agent_id: String, + /// Read profile used for the trace. + pub read_profile: String, + /// Search query text. + pub query: String, + #[serde(with = "crate::time_serde")] + /// Trace creation timestamp. + pub created_at: OffsetDateTime, +} diff --git a/packages/elf-service/src/provenance/types/requests.rs b/packages/elf-service/src/provenance/types/requests.rs new file mode 100644 index 00000000..40356eb9 --- /dev/null +++ b/packages/elf-service/src/provenance/types/requests.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Request payload for note provenance lookup. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceGetRequest { + /// Tenant that owns the note. + pub tenant_id: String, + /// Project that owns the note. + pub project_id: String, + /// Identifier of the note to inspect. + pub note_id: Uuid, +} + +/// Request payload for memory-history lookup. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MemoryHistoryGetRequest { + /// Tenant that owns the memory. + pub tenant_id: String, + /// Project that owns the memory. + pub project_id: String, + /// Identifier of the note to inspect. + pub note_id: Uuid, +} + +#[derive(Clone, Debug)] +pub(in crate::provenance) struct ValidatedNoteProvenanceRequest { + pub(in crate::provenance) tenant_id: String, + pub(in crate::provenance) project_id: String, + pub(in crate::provenance) note_id: Uuid, +} diff --git a/packages/elf-service/src/provenance/types/responses.rs b/packages/elf-service/src/provenance/types/responses.rs new file mode 100644 index 00000000..b96a9d9f --- /dev/null +++ b/packages/elf-service/src/provenance/types/responses.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::provenance::types::{ + events::MemoryHistoryEvent, + notes::{ + NoteProvenanceIndexingOutbox, NoteProvenanceIngestDecision, NoteProvenanceNote, + NoteProvenanceNoteVersion, NoteProvenanceRecentTrace, + }, +}; + +/// Timeline response for one memory. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MemoryHistoryResponse { + /// History schema identifier. + pub schema: String, + /// Inspected note identifier. + pub note_id: Uuid, + /// Chronological memory events. + pub events: Vec, +} + +/// Full provenance bundle for one note. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteProvenanceBundleResponse { + /// Provenance bundle schema identifier. + pub schema: String, + /// Current persisted note snapshot. + pub note: NoteProvenanceNote, + /// Recorded ingestion decisions for the note. + pub ingest_decisions: Vec, + /// Version-history rows for the note. + pub note_versions: Vec, + /// Indexing outbox history for the note. + pub indexing_outbox: Vec, + /// Recent search traces that referenced the note. + pub recent_traces: Vec, + /// Chronological memory event timeline for the note. + pub history: Vec, +} diff --git a/packages/elf-service/src/provenance/types/rows.rs b/packages/elf-service/src/provenance/types/rows.rs new file mode 100644 index 00000000..bc7ad41c --- /dev/null +++ b/packages/elf-service/src/provenance/types/rows.rs @@ -0,0 +1,95 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(FromRow)] +pub(in crate::provenance) struct NoteIngestDecisionRow { + pub(in crate::provenance) decision_id: Uuid, + pub(in crate::provenance) tenant_id: String, + pub(in crate::provenance) project_id: String, + pub(in crate::provenance) agent_id: String, + pub(in crate::provenance) scope: String, + pub(in crate::provenance) pipeline: String, + pub(in crate::provenance) note_type: String, + pub(in crate::provenance) note_key: Option, + pub(in crate::provenance) note_id: Option, + pub(in crate::provenance) note_version_id: Option, + pub(in crate::provenance) base_decision: String, + pub(in crate::provenance) policy_decision: String, + pub(in crate::provenance) note_op: String, + pub(in crate::provenance) reason_code: Option, + pub(in crate::provenance) details: Value, + pub(in crate::provenance) ts: OffsetDateTime, +} + +#[derive(FromRow)] +pub(in crate::provenance) struct NoteVersionRow { + pub(in crate::provenance) version_id: Uuid, + pub(in crate::provenance) note_id: Uuid, + pub(in crate::provenance) op: String, + pub(in crate::provenance) prev_snapshot: Option, + pub(in crate::provenance) new_snapshot: Option, + pub(in crate::provenance) reason: String, + pub(in crate::provenance) actor: String, + pub(in crate::provenance) ts: OffsetDateTime, +} + +#[derive(FromRow)] +pub(in crate::provenance) struct NoteIndexingOutboxRow { + pub(in crate::provenance) outbox_id: Uuid, + pub(in crate::provenance) note_id: Uuid, + pub(in crate::provenance) op: String, + pub(in crate::provenance) embedding_version: String, + pub(in crate::provenance) status: String, + pub(in crate::provenance) attempts: i32, + pub(in crate::provenance) last_error: Option, + pub(in crate::provenance) available_at: OffsetDateTime, + pub(in crate::provenance) created_at: OffsetDateTime, + pub(in crate::provenance) updated_at: OffsetDateTime, +} + +#[derive(FromRow)] +pub(in crate::provenance) struct NoteRecentTraceRow { + pub(in crate::provenance) trace_id: Uuid, + pub(in crate::provenance) tenant_id: String, + pub(in crate::provenance) project_id: String, + pub(in crate::provenance) agent_id: String, + pub(in crate::provenance) read_profile: String, + pub(in crate::provenance) query: String, + pub(in crate::provenance) created_at: OffsetDateTime, +} + +#[derive(FromRow)] +pub(in crate::provenance) struct NoteDerivedProposalRow { + pub(in crate::provenance) proposal_id: Uuid, + pub(in crate::provenance) run_id: Uuid, + pub(in crate::provenance) agent_id: String, + pub(in crate::provenance) proposal_kind: String, + pub(in crate::provenance) apply_intent: String, + pub(in crate::provenance) review_state: String, + pub(in crate::provenance) source_refs: Value, + pub(in crate::provenance) source_snapshot: Value, + pub(in crate::provenance) lineage: Value, + pub(in crate::provenance) diff: Value, + pub(in crate::provenance) confidence: f32, + pub(in crate::provenance) target_ref: Value, + pub(in crate::provenance) proposed_payload: Value, + pub(in crate::provenance) created_at: OffsetDateTime, +} + +#[derive(FromRow)] +pub(in crate::provenance) struct NoteProposalReviewRow { + pub(in crate::provenance) review_id: Uuid, + pub(in crate::provenance) proposal_id: Uuid, + pub(in crate::provenance) run_id: Uuid, + pub(in crate::provenance) reviewer_agent_id: String, + pub(in crate::provenance) action: String, + pub(in crate::provenance) from_review_state: String, + pub(in crate::provenance) to_review_state: String, + pub(in crate::provenance) review_comment: Option, + pub(in crate::provenance) created_at: OffsetDateTime, + pub(in crate::provenance) proposal_kind: String, + pub(in crate::provenance) apply_intent: String, + pub(in crate::provenance) diff: Value, +} diff --git a/packages/elf-service/src/provenance/validation.rs b/packages/elf-service/src/provenance/validation.rs new file mode 100644 index 00000000..d6be0de3 --- /dev/null +++ b/packages/elf-service/src/provenance/validation.rs @@ -0,0 +1,23 @@ +use crate::{ + Error, Result, + provenance::types::{NoteProvenanceGetRequest, requests::ValidatedNoteProvenanceRequest}, +}; + +pub(super) fn validate_note_provenance_request( + req: NoteProvenanceGetRequest, +) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id and project_id are required.".to_string(), + }); + } + + Ok(ValidatedNoteProvenanceRequest { + tenant_id: tenant_id.to_string(), + project_id: project_id.to_string(), + note_id: req.note_id, + }) +} diff --git a/packages/elf-service/src/providers.rs b/packages/elf-service/src/providers.rs new file mode 100644 index 00000000..3d98b66d --- /dev/null +++ b/packages/elf-service/src/providers.rs @@ -0,0 +1,123 @@ +use std::{future::Future, pin::Pin, sync::Arc}; + +use serde_json::Value; + +use crate::{Error, Result}; +use elf_config::{EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; +use elf_providers::{embedding, extractor, rerank}; + +/// Boxed future type used by provider traits. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Embedding provider contract used by the service layer. +pub trait EmbeddingProvider +where + Self: Send + Sync, +{ + /// Embeds one or more texts into dense vectors. + fn embed<'a>( + &'a self, + cfg: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, Result>>>; +} + +/// Rerank provider contract used by the service layer. +pub trait RerankProvider +where + Self: Send + Sync, +{ + /// Scores candidate documents for one query. + fn rerank<'a>( + &'a self, + cfg: &'a ProviderConfig, + query: &'a str, + docs: &'a [String], + ) -> BoxFuture<'a, Result>>; +} + +/// Extractor provider contract used by the service layer. +pub trait ExtractorProvider +where + Self: Send + Sync, +{ + /// Extracts structured JSON output from a message transcript. + fn extract<'a>( + &'a self, + cfg: &'a LlmProviderConfig, + messages: &'a [Value], + ) -> BoxFuture<'a, Result>; +} + +/// Provider bundle used by `ElfService`. +#[derive(Clone)] +pub struct Providers { + /// Dense embedding provider implementation. + pub embedding: Arc, + /// Rerank provider implementation. + pub rerank: Arc, + /// Structured extraction provider implementation. + pub extractor: Arc, +} +impl Providers { + /// Builds a provider bundle from explicit provider implementations. + pub fn new( + embedding: Arc, + rerank: Arc, + extractor: Arc, + ) -> Self { + Self { embedding, rerank, extractor } + } +} + +impl Default for Providers { + fn default() -> Self { + let provider = Arc::new(DefaultProviders); + + Self { embedding: provider.clone(), rerank: provider.clone(), extractor: provider } + } +} + +struct DefaultProviders; +impl EmbeddingProvider for DefaultProviders { + fn embed<'a>( + &'a self, + cfg: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, Result>>> { + Box::pin(async move { + embedding::embed(cfg, texts) + .await + .map_err(|err| Error::Provider { message: err.to_string() }) + }) + } +} + +impl RerankProvider for DefaultProviders { + fn rerank<'a>( + &'a self, + cfg: &'a ProviderConfig, + query: &'a str, + docs: &'a [String], + ) -> BoxFuture<'a, Result>> { + Box::pin(async move { + rerank::rerank(cfg, query, docs) + .await + .map_err(|err| Error::Provider { message: err.to_string() }) + }) + } +} + +impl ExtractorProvider for DefaultProviders { + fn extract<'a>( + &'a self, + cfg: &'a LlmProviderConfig, + messages: &'a [Value], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + extractor::extract(cfg, messages) + .await + .map_err(|err| Error::Provider { message: err.to_string() }) + }) + } +} diff --git a/packages/elf-service/src/recall_debug.rs b/packages/elf-service/src/recall_debug.rs index e3ff6358..5733c40b 100644 --- a/packages/elf-service/src/recall_debug.rs +++ b/packages/elf-service/src/recall_debug.rs @@ -1,5 +1,18 @@ //! Cross-layer recall/debug panel readback. +mod helpers; +mod layers; +mod replay; +mod sources; +mod trace; +mod types; + +pub use types::{ + ELF_RECALL_DEBUG_PANEL_SCHEMA_V1, ELF_RECALL_TRACE_SCHEMA_V1, RecallDebugLayer, + RecallDebugPanelRequest, RecallDebugPanelRequestEcho, RecallDebugPanelResponse, + RecallDebugPanelSummary, RecallDebugRow, RecallTrace, RecallTraceEntry, RecallTraceSummary, +}; + use std::collections::{BTreeMap, BTreeSet, HashSet}; use serde::{Deserialize, Serialize}; @@ -12,2005 +25,25 @@ use crate::{ GraphQueryPredicateRef, GraphReportRequest, KnowledgePageSearchItem, KnowledgePageSearchRequest, Result, SearchExplainItem, SearchTrace, SearchTrajectoryStage, TraceBundleGetRequest, - access::{self, ORG_PROJECT_ID, SharedSpaceGrantKey}, - search::{self, TraceBundleMode, TraceReplayCandidate}, + access::{ORG_PROJECT_ID, SharedSpaceGrantKey}, + search::{TraceBundleMode, TraceReplayCandidate}, }; use elf_storage::models::MemoryNote; - -/// Schema identifier for recall/debug panel responses. -pub const ELF_RECALL_DEBUG_PANEL_SCHEMA_V1: &str = "elf.recall_debug_panel/v1"; -/// Schema identifier for deterministic recall trace projections. -pub const ELF_RECALL_TRACE_SCHEMA_V1: &str = "elf.recall_trace/v1"; - -const DEFAULT_RECALL_DEBUG_LIMIT: u32 = 25; -const MAX_RECALL_DEBUG_LIMIT: u32 = 100; -const MAX_RECALL_DEBUG_DOCS_LIMIT: u32 = 32; - -/// Request payload for the cross-layer recall/debug panel. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RecallDebugPanelRequest { - /// Tenant that owns the readback. - pub tenant_id: String, - /// Project that owns the readback. - pub project_id: String, - /// Agent requesting the readback. - pub agent_id: String, - /// Read profile used for memory, document, and graph visibility. - pub read_profile: String, - /// Optional search trace anchor for memory selected/dropped rows. - pub trace_id: Option, - /// Shared query used when docs_query or knowledge_query are omitted. - pub query: Option, - /// Optional Source Library query. - pub docs_query: Option, - /// Optional Knowledge Workspace page query. - pub knowledge_query: Option, - /// Optional graph subject selector. - pub graph_subject: Option, - /// Optional graph predicate selector. - pub graph_predicate: Option, - /// Whether to include Dreaming review queue proposals. Omitted means not requested. - pub include_dreaming: Option, - /// Maximum rows per layer. - pub limit: Option, - #[serde(skip)] - /// Whether project-scoped trace anchors are allowed for an admin mirror request. - pub allow_project_trace_debug: bool, -} - -/// Cross-layer recall/debug panel response. -#[derive(Clone, Debug, Serialize)] -pub struct RecallDebugPanelResponse { - /// Response schema identifier. - pub schema: String, - #[serde(with = "crate::time_serde")] - /// Panel generation timestamp. - pub generated_at: OffsetDateTime, - /// Echo of the effective anchors used for this response. - pub request: RecallDebugPanelRequestEcho, - /// Aggregate panel summary. - pub summary: RecallDebugPanelSummary, - /// Deterministic flat trace projection for agents and fixture assertions. - pub recall_trace: RecallTrace, - /// Cross-layer rows grouped by source layer. - pub layers: Vec, -} - -/// Deterministic flat recall trace over all requested layers. -#[derive(Clone, Debug, Serialize)] -pub struct RecallTrace { - /// Trace schema identifier. - pub schema: String, - /// Aggregate trace counters. - pub summary: RecallTraceSummary, - /// Stable trace entries in layer and row order. - pub entries: Vec, -} - -/// Aggregate counters for a recall trace. -#[derive(Clone, Debug, Default, Serialize)] -pub struct RecallTraceSummary { - /// Number of trace entries. - pub entry_count: usize, - /// Entries whose row selection state is selected. - pub selected_count: usize, - /// Entries whose row selection state is dropped. - pub dropped_count: usize, - /// Entries whose freshness state indicates stale or non-current evidence. - pub stale_count: usize, - /// Entries representing blocked layers. - pub blocked_count: usize, - /// Entries representing layers that were not requested. - pub not_requested_count: usize, - /// Entries that require raw SQL for diagnosis. - pub raw_sql_needed_count: usize, - /// Entries with a replay command or deterministic artifact path. - pub replay_command_count: usize, -} - -/// One compact recall trace entry. -#[derive(Clone, Debug, Serialize)] -pub struct RecallTraceEntry { - /// Layer identifier. - pub layer: String, - /// Primary trace state for compact assertions. - pub context_state: String, - /// Original row selection state or layer evidence class. - pub selection_state: String, - /// Authority layer that owns the context. - pub authority_layer: String, - /// Freshness or temporal state. - pub freshness_state: String, - /// Stable identifiers for replay or hydration. - pub item_ref: Value, - /// Source refs or source snapshots supporting the context. - pub source_refs: Value, - /// Optional score. - pub score: Option, - /// Optional rank. - pub rank: Option, - /// Compact policy or stage reason for the state. - pub policy_reason: Option, - /// Replay command or deterministic artifact path. - pub replay_command: Option, - /// Layer or row evidence class. - pub evidence_class: String, - /// Whether raw SQL is required to diagnose this entry. - pub raw_sql_needed: bool, -} - -/// Stable request echo for panel responses. -#[derive(Clone, Debug, Serialize)] -pub struct RecallDebugPanelRequestEcho { - /// Search trace anchor used for memory rows. - pub trace_id: Option, - /// Effective Source Library query. - pub docs_query: Option, - /// Effective Knowledge Workspace query. - pub knowledge_query: Option, - /// Whether a graph subject was supplied. - pub graph_subject_supplied: bool, - /// Whether Dreaming proposals were included. - pub include_dreaming: bool, - /// Effective row cap per layer. - pub limit: u32, -} - -/// Aggregate panel counters. -#[derive(Clone, Debug, Default, Serialize)] -pub struct RecallDebugPanelSummary { - /// Number of returned layers. - pub layer_count: usize, - /// Total returned row count. - pub row_count: usize, - /// Rows selected by a retrieval or review stage. - pub selected_count: usize, - /// Rows dropped by a retrieval or review stage. - pub dropped_count: usize, - /// Rows available for inspection but not selected/dropped. - pub available_count: usize, - /// Layers skipped because no anchor was supplied. - pub not_requested_layer_count: usize, - /// Layers that require follow-up before they can prove a debug claim. - pub incomplete_layer_count: usize, - /// Rows or layers that require raw SQL to inspect. - pub raw_sql_needed_count: usize, - /// Rows with a replay command or deterministic artifact path. - pub replay_command_count: usize, - /// Evidence-class counts across layers. - pub evidence_class_counts: BTreeMap, -} - -/// One recall/debug source layer. -#[derive(Clone, Debug, Serialize)] -pub struct RecallDebugLayer { - /// Layer identifier. - pub layer: String, - /// Evidence class for this layer. - pub evidence_class: String, - /// Human-readable layer summary. - pub summary: String, - /// Query or object anchor used by the layer. - pub anchor: Option, - /// Number of returned rows. - pub row_count: usize, - /// Selected rows in this layer. - pub selected_count: usize, - /// Dropped rows in this layer. - pub dropped_count: usize, - /// Available review/inspection rows in this layer. - pub available_count: usize, - /// Whether raw SQL is needed to inspect this layer. - pub raw_sql_needed: bool, - /// Whether the layer includes replay commands or deterministic artifact paths. - pub replayable: bool, - /// Compact layer-level debug artifacts. - pub debug_artifacts: Value, - /// Returned layer rows. - pub rows: Vec, -} - -/// One item in the recall/debug panel. -#[derive(Clone, Debug, Serialize)] -pub struct RecallDebugRow { - /// Layer identifier. - pub layer: String, - /// Stable item reference. - pub item_ref: Value, - /// Selection state such as selected, dropped, available, or reviewable. - pub selection_state: String, - /// Authority layer that owns the row. - pub authority_layer: String, - /// Freshness or temporal state. - pub freshness_state: String, - /// Source refs or source snapshots backing the row. - pub source_refs: Value, - /// Optional final score. - pub score: Option, - /// Optional rank within the layer. - pub rank: Option, - /// Short selection rationale. - pub rationale: Option, - /// Stage reason for selected/dropped status. - pub stage_reason: Option, - /// Replay command or deterministic artifact path when available. - pub replay_command: Option, - /// Row-level evidence class. - pub evidence_class: String, - /// Layer-specific debug artifacts. - pub debug_artifacts: Value, -} - -#[derive(Clone, Debug)] -struct NoteDebugSourceRow { - status: String, - source_ref: Value, - updated_at: OffsetDateTime, -} - -impl ElfService { - /// Builds a cross-layer recall/debug panel from existing readback surfaces. - pub async fn recall_debug_panel( - &self, - req: RecallDebugPanelRequest, - ) -> Result { - let limit = - req.limit.unwrap_or(DEFAULT_RECALL_DEBUG_LIMIT).clamp(1, MAX_RECALL_DEBUG_LIMIT); - let docs_query = req - .docs_query - .clone() - .or_else(|| req.query.clone()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let knowledge_query = req - .knowledge_query - .clone() - .or_else(|| req.query.clone()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let include_dreaming = req.include_dreaming == Some(true); - let mut layers = Vec::new(); - - layers.push(self.recall_memory_layer(&req, limit).await.unwrap_or_else(|err| { - blocked_layer( - "memory_notes", - req.trace_id.map(|trace_id| trace_id.to_string()), - "Requested memory trace bundle could not be read.", - &err, - ) - })); - layers.push( - self.recall_docs_layer(&req, docs_query.as_deref(), limit).await.unwrap_or_else( - |err| { - blocked_layer( - "source_documents", - docs_query.clone(), - "Requested Source Library document search could not be read.", - &err, - ) - }, - ), - ); - layers.push( - self.recall_knowledge_layer(&req, knowledge_query.as_deref(), limit) - .await - .unwrap_or_else(|err| { - blocked_layer( - "knowledge_pages", - knowledge_query.clone(), - "Requested Knowledge Workspace page search could not be read.", - &err, - ) - }), - ); - layers.push(self.recall_graph_layer(&req, limit).await.unwrap_or_else(|err| { - blocked_layer( - "graph_facts", - req.graph_subject.as_ref().and_then(json_anchor), - "Requested graph report could not be read.", - &err, - ) - })); - layers.push( - self.recall_dreaming_layer(&req, include_dreaming, limit).await.unwrap_or_else(|err| { - blocked_layer( - "dreaming_proposals", - Some("include_dreaming=true".to_string()), - "Requested Dreaming review queue could not be read.", - &err, - ) - }), - ); - - let summary = summarize_layers(&layers); - let recall_trace = build_recall_trace(&layers); - - Ok(RecallDebugPanelResponse { - schema: ELF_RECALL_DEBUG_PANEL_SCHEMA_V1.to_string(), - generated_at: OffsetDateTime::now_utc(), - request: RecallDebugPanelRequestEcho { - trace_id: req.trace_id, - docs_query, - knowledge_query, - graph_subject_supplied: req.graph_subject.is_some(), - include_dreaming, - limit, - }, - summary, - recall_trace, - layers, - }) - } - - async fn recall_memory_layer( - &self, - req: &RecallDebugPanelRequest, - limit: u32, - ) -> Result { - let Some(trace_id) = req.trace_id else { - return Ok(not_requested_layer( - "memory_notes", - "Supply trace_id to show selected and dropped Memory Note candidates.", - )); - }; - - if !req.allow_project_trace_debug { - self.ensure_public_recall_trace_allowed(req, trace_id).await?; - } - - let bundle = self - .trace_bundle_get(TraceBundleGetRequest { - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - agent_id: req.agent_id.clone(), - trace_id, - mode: TraceBundleMode::Bounded, - stage_items_limit: Some(limit), - candidates_limit: Some(limit.saturating_mul(4).min(400)), - }) - .await?; - let selected_note_ids = - bundle.items.iter().map(|item| item.note_id).collect::>(); - let selected_candidate_keys = - bundle.items.iter().filter_map(search_item_candidate_key).collect::>(); - let candidate_note_ids = - bundle.candidates.as_ref().into_iter().flatten().map(|candidate| candidate.note_id); - let all_note_ids = - selected_note_ids.iter().copied().chain(candidate_note_ids).collect::>(); - let source_refs = self - .load_memory_note_debug_sources(req, all_note_ids.iter().copied().collect()) - .await?; - let replay_command = format!("elf_admin_trace_bundle_get trace_id={trace_id} mode=bounded"); - let visible_items = bundle - .items - .iter() - .filter(|item| source_refs.contains_key(&item.note_id)) - .collect::>(); - let dropped_candidates = bundle - .candidates - .as_deref() - .unwrap_or_default() - .iter() - .filter(|candidate| !candidate_is_selected(&selected_candidate_keys, candidate)) - .filter(|candidate| source_refs.contains_key(&candidate.note_id)) - .collect::>(); - let compact_replay = serde_json::json!({ - "compact_replay": memory_compact_replay_artifact( - &bundle.trace, - bundle.stages.as_slice(), - bundle.candidates.as_deref().unwrap_or_default(), - visible_items.as_slice(), - &selected_candidate_keys, - &source_refs, - replay_command.as_str(), - ), - }); - let selected_cap = if !dropped_candidates.is_empty() && limit > 1 { - limit as usize - 1 - } else { - limit as usize - }; - let mut rows = Vec::new(); - - for item in visible_items.iter().take(selected_cap) { - let source = source_refs.get(&item.note_id); - - rows.push(RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({ - "trace_id": trace_id, - "result_handle": item.result_handle, - "note_id": item.note_id, - "chunk_id": item.chunk_id, - }), - selection_state: "selected".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: freshness_from_note_source(source), - source_refs: source_ref_from_note_source(source), - score: Some(item.explain.ranking.final_score), - rank: Some(item.rank), - rationale: Some("final ranked search result".to_string()), - stage_reason: last_stage_name(bundle.stages.as_slice()) - .or_else(|| Some("final_ranking".to_string())), - replay_command: Some(replay_command.clone()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({ - "ranking_explain": item.explain, - "note_updated_at": source.map(|row| row.updated_at), - }), - }); - } - - let dropped_cap = limit.saturating_sub(rows.len() as u32) as usize; - - for candidate in dropped_candidates.into_iter().take(dropped_cap) { - rows.push(candidate_debug_row( - trace_id, - candidate, - source_refs.get(&candidate.note_id), - replay_command.as_str(), - )); - } - - Ok(layer_from_rows_with_artifacts( - "memory_notes", - "pass", - Some(trace_id.to_string()), - "Search trace bundle with selected results and replay candidates.", - rows, - compact_replay, - )) - } - - async fn ensure_public_recall_trace_allowed( - &self, - req: &RecallDebugPanelRequest, - trace_id: Uuid, - ) -> Result<()> { - let row: Option<(i64,)> = sqlx::query_as( - "\ -SELECT 1 -FROM search_traces -WHERE trace_id = $1 - AND tenant_id = $2 - AND project_id = $3 - AND agent_id = $4 - AND read_profile = $5", - ) - .bind(trace_id) - .bind(req.tenant_id.trim()) - .bind(req.project_id.trim()) - .bind(req.agent_id.trim()) - .bind(req.read_profile.trim()) - .fetch_optional(&self.db.pool) - .await?; - - if row.is_some() { - Ok(()) - } else { - Err(Error::InvalidRequest { - message: "Unknown trace_id for this recall context.".to_string(), - }) - } - } - - async fn recall_docs_layer( - &self, - req: &RecallDebugPanelRequest, - docs_query: Option<&str>, - limit: u32, - ) -> Result { - let Some(query) = docs_query else { - return Ok(not_requested_layer( - "source_documents", - "Supply query or docs_query to show Source Library document candidates.", - )); - }; - let effective_limit = limit.min(MAX_RECALL_DEBUG_DOCS_LIMIT); - let response = self - .docs_search_l0(DocsSearchL0Request { - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - caller_agent_id: req.agent_id.clone(), - read_profile: req.read_profile.clone(), - query: query.to_string(), - scope: None, - status: Some("active".to_string()), - doc_type: None, - sparse_mode: None, - domain: None, - repo: None, - agent_id: None, - thread_id: None, - updated_after: None, - updated_before: None, - ts_gte: None, - ts_lte: None, - top_k: Some(effective_limit), - candidate_k: Some(effective_limit.saturating_mul(3).max(effective_limit)), - explain: Some(true), - }) - .await?; - let rows = response - .items - .into_iter() - .enumerate() - .map(|(index, item)| RecallDebugRow { - layer: "source_documents".to_string(), - item_ref: serde_json::json!({ - "trace_id": response.trace_id, - "doc_id": item.doc_id, - "chunk_id": item.chunk_id, - "pointer": item.pointer, - }), - selection_state: "selected".to_string(), - authority_layer: "source_library".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([{ - "schema": "source_ref/v1", - "resolver": "elf_doc_ext/v1", - "doc_id": item.doc_id, - "chunk_id": item.chunk_id, - "content_hash": item.content_hash, - "chunk_hash": item.chunk_hash, - "doc_updated_at": item.updated_at, - }]), - score: Some(item.score), - rank: Some(index as u32 + 1), - rationale: Some("docs_search_l0 selected chunk".to_string()), - stage_reason: response - .trajectory - .as_ref() - .and_then(|trajectory| trajectory.stages.last()) - .map(|stage| stage.stage_name.clone()) - .or(Some("docs_search_l0".to_string())), - replay_command: Some(format!("elf_docs_search_l0 query={query:?} explain=true")), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({ - "doc_type": item.doc_type, - "scope": item.scope, - "snippet": item.snippet, - "trajectory": response.trajectory, - "requested_limit": limit, - "effective_limit": effective_limit, - }), - }) - .collect(); - let summary = if effective_limit < limit { - format!( - "Source Library search rows selected by docs_search_l0; effective docs cap is {effective_limit}." - ) - } else { - "Source Library search rows selected by docs_search_l0.".to_string() - }; - - Ok(layer_from_rows("source_documents", "pass", Some(query.to_string()), &summary, rows)) - } - - async fn recall_knowledge_layer( - &self, - req: &RecallDebugPanelRequest, - knowledge_query: Option<&str>, - limit: u32, - ) -> Result { - let Some(query) = knowledge_query else { - return Ok(not_requested_layer( - "knowledge_pages", - "Supply query or knowledge_query to show Knowledge Workspace page candidates.", - )); - }; - let response = self - .knowledge_pages_search(KnowledgePageSearchRequest { - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - agent_id: req.agent_id.clone(), - read_profile: req.read_profile.clone(), - query: query.to_string(), - page_kind: None, - limit: Some(limit), - }) - .await?; - let rows = response - .items - .into_iter() - .enumerate() - .map(|(index, item)| RecallDebugRow { - layer: "knowledge_pages".to_string(), - item_ref: serde_json::json!({ - "page_id": item.page_id, - "section_id": item.section_id, - "page_kind": item.page_kind, - "page_key": item.page_key, - }), - selection_state: "selected".to_string(), - authority_layer: "derived_knowledge_page".to_string(), - freshness_state: knowledge_freshness(&item), - source_refs: serde_json::json!({ - "source_coverage": item.source_coverage, - "section_source_ref_count": item.source_ref_count, - "citation_count": item.citation_count, - "source_refs": item.source_refs, - }), - score: None, - rank: Some(index as u32 + 1), - rationale: Some("knowledge_pages_search selected section".to_string()), - stage_reason: Some("knowledge_page_search".to_string()), - replay_command: Some(format!( - "elf_recall_debug_panel knowledge_query={query:?} layer=knowledge_pages" - )), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({ - "title": item.title, - "heading": item.heading, - "lint_summary": item.lint_summary, - "trust_state": item.trust_state, - "repair_guidance": item.repair_guidance, - "snippet": item.snippet, - }), - }) - .collect(); - - Ok(layer_from_rows_with_artifacts( - "knowledge_pages", - "pass", - Some(query.to_string()), - "Knowledge Workspace sections selected by page search.", - rows, - serde_json::json!({}), - )) - } - - async fn recall_graph_layer( - &self, - req: &RecallDebugPanelRequest, - limit: u32, - ) -> Result { - let Some(subject) = req.graph_subject.clone() else { - return Ok(not_requested_layer( - "graph_facts", - "Supply graph_subject to show graph fact candidates and temporal status.", - )); - }; - let response = self - .graph_report(GraphReportRequest { - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - agent_id: req.agent_id.clone(), - read_profile: req.read_profile.clone(), - subject, - predicate: req.graph_predicate.clone(), - scopes: None, - as_of: None, - limit: Some(limit), - explain: Some(true), - }) - .await?; - let subject_anchor = response.subject.canonical.clone(); - let replay_command = graph_replay_command(&subject_anchor, req.graph_predicate.as_ref()); - let rows = response - .facts - .into_iter() - .enumerate() - .map(|(index, fact)| RecallDebugRow { - layer: "graph_facts".to_string(), - item_ref: serde_json::json!({ - "fact_id": fact.fact_id, - "subject": subject_anchor, - "predicate": fact.predicate, - "object": fact.object, - }), - selection_state: "available".to_string(), - authority_layer: "graph_fact".to_string(), - freshness_state: graph_temporal_status(fact.temporal_status), - source_refs: serde_json::json!({ - "evidence_note_ids": fact.evidence_note_ids, - "supersedes_fact_ids": fact.supersedes_fact_ids, - "superseded_by_fact_ids": fact.superseded_by_fact_ids, - }), - score: None, - rank: Some(index as u32 + 1), - rationale: Some("graph_report returned source-backed fact".to_string()), - stage_reason: Some(fact.status_markers.join(",")), - replay_command: Some(replay_command.clone()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({ - "scope": fact.scope, - "actor": fact.actor, - "valid_from": fact.valid_from, - "valid_to": fact.valid_to, - "status_markers": fact.status_markers, - }), - }) - .collect(); - - Ok(layer_from_rows_with_artifacts( - "graph_facts", - "pass", - Some(subject_anchor), - "Graph facts from source-backed graph report.", - rows, - serde_json::json!({}), - )) - } - - async fn recall_dreaming_layer( - &self, - req: &RecallDebugPanelRequest, - include_dreaming: bool, - limit: u32, - ) -> Result { - if !include_dreaming { - return Ok(not_requested_layer( - "dreaming_proposals", - "Set include_dreaming=true to show reviewable Dreaming proposals.", - )); - } - - let response = self - .dreaming_review_queue(DreamingReviewQueueRequest { - tenant_id: req.tenant_id.clone(), - project_id: req.project_id.clone(), - run_id: None, - review_state: None, - limit: Some(limit), - }) - .await?; - let rows = response - .items - .into_iter() - .enumerate() - .map(|(index, item)| RecallDebugRow { - layer: "dreaming_proposals".to_string(), - item_ref: serde_json::json!({ - "proposal_id": item.proposal_id, - "run_id": item.run_id, - "queue_variant": item.queue_variant, - "target_ref": item.target_ref, - }), - selection_state: "reviewable".to_string(), - authority_layer: "reviewable_dreaming_proposal".to_string(), - freshness_state: item.review_state.clone(), - source_refs: serde_json::json!({ - "source_refs": item.source_refs, - "source_snapshot": item.source_snapshot, - "affected_refs": item.affected_refs, - }), - score: Some(item.confidence), - rank: Some(index as u32 + 1), - rationale: Some(item.policy.reason.clone()), - stage_reason: Some(format!( - "review_state={}, auto_apply_allowed={}", - item.review_state, item.policy.auto_apply_allowed - )), - replay_command: Some("elf_dreaming_review_queue limit=".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({ - "policy": item.policy, - "unsupported_claim_flags": item.unsupported_claim_flags, - "contradiction_markers": item.contradiction_markers, - "staleness_markers": item.staleness_markers, - "diff": item.diff, - "review_audit": item.review_audit, - }), - }) - .collect(); - - Ok(layer_from_rows_with_artifacts( - "dreaming_proposals", - "pass", - None, - "Dreaming review queue proposals available for reviewer action.", - rows, - serde_json::json!({}), - )) - } - - async fn load_memory_note_debug_sources( - &self, - req: &RecallDebugPanelRequest, - note_ids: Vec, - ) -> Result> { - if note_ids.is_empty() { - return Ok(BTreeMap::new()); - } - - let rows = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes - WHERE tenant_id = $1 - AND note_id = ANY($3::uuid[]) - AND ( - project_id = $2 - OR (project_id = $4 AND scope = 'org_shared') - )", - ) - .bind(req.tenant_id.as_str()) - .bind(req.project_id.as_str()) - .bind(note_ids) - .bind(ORG_PROJECT_ID) - .fetch_all(&self.db.pool) - .await?; - - if req.allow_project_trace_debug { - return Ok(rows.into_iter().map(note_debug_source_pair).collect()); - } - - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, req.read_profile.trim())?; - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - req.tenant_id.trim(), - req.project_id.trim(), - req.agent_id.trim(), - org_shared_allowed, - ) - .await?; - let now = OffsetDateTime::now_utc(); - - Ok(rows - .into_iter() - .filter(|note| { - note_debug_read_allowed( - note, - req.agent_id.trim(), - &allowed_scopes, - &shared_grants, - now, - ) - }) - .map(note_debug_source_pair) - .collect()) - } -} - -fn note_debug_source_pair(note: MemoryNote) -> (Uuid, NoteDebugSourceRow) { - ( - note.note_id, - NoteDebugSourceRow { - status: note.status, - source_ref: note.source_ref, - updated_at: note.updated_at, - }, - ) -} - -fn note_debug_read_allowed( - note: &MemoryNote, - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, - now: OffsetDateTime, -) -> bool { - if note.status != "active" || note.expires_at.is_some_and(|expires_at| expires_at <= now) { - return false; - } - if !allowed_scopes.iter().any(|scope| scope == ¬e.scope) { - return false; - } - if note.scope == "agent_private" { - return note.agent_id == requester_agent_id; - } - if !matches!(note.scope.as_str(), "project_shared" | "org_shared") { - return false; - } - if note.agent_id == requester_agent_id { - return true; - } - - shared_grants.contains(&SharedSpaceGrantKey { - scope: note.scope.clone(), - space_owner_agent_id: note.agent_id.clone(), - }) -} - -fn candidate_debug_row( - trace_id: Uuid, - candidate: &TraceReplayCandidate, - source: Option<&NoteDebugSourceRow>, - replay_command: &str, -) -> RecallDebugRow { - let selected_by_diversity = candidate.diversity_selected.unwrap_or(false); - let skipped_reason = candidate.diversity_skipped_reason.clone().or_else(|| { - if selected_by_diversity { - candidate.diversity_selected_reason.clone() - } else { - Some("not_in_final_top_k".to_string()) - } - }); - - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({ - "trace_id": trace_id, - "note_id": candidate.note_id, - "chunk_id": candidate.chunk_id, - "chunk_index": candidate.chunk_index, - }), - selection_state: "dropped".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: freshness_from_note_source(source), - source_refs: source_ref_from_note_source(source), - score: candidate.retrieval_score, - rank: Some(candidate.retrieval_rank), - rationale: Some( - "candidate captured for replay but not selected in final result set".to_string(), - ), - stage_reason: skipped_reason, - replay_command: Some(replay_command.to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({ - "snippet": candidate.snippet, - "rerank_score": candidate.rerank_score, - "note_scope": candidate.note_scope, - "diversity_selected": candidate.diversity_selected, - "diversity_selected_rank": candidate.diversity_selected_rank, - "diversity_nearest_selected_note_id": candidate.diversity_nearest_selected_note_id, - "diversity_similarity": candidate.diversity_similarity, - "diversity_mmr_score": candidate.diversity_mmr_score, - "diversity_missing_embedding": candidate.diversity_missing_embedding, - }), - } -} - -fn memory_compact_replay_artifact( - trace: &SearchTrace, - stages: &[SearchTrajectoryStage], - candidates: &[TraceReplayCandidate], - selected_items: &[&SearchExplainItem], - selected_candidate_keys: &BTreeSet<(Uuid, Uuid)>, - source_refs: &BTreeMap, - replay_command: &str, -) -> Value { - serde_json::json!({ - "schema": "elf.recall_debug.compact_replay/v1", - "trace_id": trace.trace_id, - "query": trace.query, - "replay_command": replay_command, - "controls": compact_replay_controls(trace), - "stage_movement": compact_stage_movement(stages), - "candidate_replay": compact_candidate_replay(candidates, selected_candidate_keys, source_refs), - "selected_context": compact_selected_context(selected_items, source_refs), - "authority": { - "source_refs_visible": true, - "policy_reasons_visible": true, - "raw_sql_needed": false, - }, - }) -} - -fn compact_replay_controls(trace: &SearchTrace) -> Value { - serde_json::json!({ - "top_k": trace.top_k, - "candidate_count": trace.candidate_count, - "expansion_mode": trace.expansion_mode, - "expanded_query_count": trace.expanded_queries.len(), - "expanded_queries": trace.expanded_queries, - "allowed_scopes": trace.allowed_scopes, - "search": compact_pointer(&trace.config_snapshot, "/search"), - "ranking": { - "policy_id": compact_pointer(&trace.config_snapshot, "/ranking/policy_id"), - "blend": compact_pointer(&trace.config_snapshot, "/ranking/blend"), - "diversity": compact_pointer(&trace.config_snapshot, "/ranking/diversity"), - "retrieval_sources": compact_pointer(&trace.config_snapshot, "/ranking/retrieval_sources"), - "override": compact_pointer(&trace.config_snapshot, "/ranking/override"), - }, - }) -} - -fn compact_stage_movement(stages: &[SearchTrajectoryStage]) -> Vec { - stages - .iter() - .map(|stage| { - serde_json::json!({ - "stage_order": stage.stage_order, - "stage_name": stage.stage_name, - "item_count": stage.items.len(), - "stats": compact_pointer(&stage.stage_payload, "/stats"), - "decisions": compact_pointer(&stage.stage_payload, "/decisions"), - "filter_impact": compact_pointer(&stage.stage_payload, "/filter_impact"), - }) - }) - .collect() -} - -fn compact_candidate_replay( - candidates: &[TraceReplayCandidate], - selected_candidate_keys: &BTreeSet<(Uuid, Uuid)>, - source_refs: &BTreeMap, -) -> Value { - let rerank_ranks = candidate_rerank_ranks(candidates); - let rows = candidates - .iter() - .map(|candidate| { - let key = candidate_identity(candidate.note_id, candidate.chunk_id); - let rerank_rank = rerank_ranks.get(&key).copied(); - let selection_state = - if selected_candidate_keys.contains(&key) { "selected" } else { "dropped" }; - let stage_reason = candidate_stage_reason(candidate, selection_state); - let source_ref = - source_refs.get(&candidate.note_id).map(|source| source.source_ref.clone()); - - serde_json::json!({ - "note_id": candidate.note_id, - "chunk_id": candidate.chunk_id, - "source_ref": source_ref, - "source_ref_available": source_ref.is_some(), - "retrieval_rank": candidate.retrieval_rank, - "rerank_rank": rerank_rank, - "rerank_delta": rerank_rank.map(|rank| candidate.retrieval_rank as i64 - i64::from(rank)), - "rerank_score": candidate.rerank_score, - "retrieval_score": candidate.retrieval_score, - "selection_state": selection_state, - "stage_reason": stage_reason, - "policy_reason": stage_reason, - "note_scope": candidate.note_scope, - "diversity_selected": candidate.diversity_selected, - "diversity_skipped_reason": candidate.diversity_skipped_reason, - }) - }) - .collect::>(); - let selected_count = rows - .iter() - .filter(|row| row.get("selection_state").and_then(Value::as_str) == Some("selected")) - .count(); - - serde_json::json!({ - "candidate_count": candidates.len(), - "selected_count": selected_count, - "dropped_count": rows.len().saturating_sub(selected_count), - "rows": rows, - }) -} - -fn candidate_rerank_ranks(candidates: &[TraceReplayCandidate]) -> BTreeMap<(Uuid, Uuid), u32> { - let mut ordered = candidates.iter().collect::>(); - - ordered.sort_by(|a, b| { - b.rerank_score - .total_cmp(&a.rerank_score) - .then_with(|| a.retrieval_rank.cmp(&b.retrieval_rank)) - .then_with(|| a.note_id.cmp(&b.note_id)) - .then_with(|| a.chunk_id.cmp(&b.chunk_id)) - }); - - ordered - .into_iter() - .enumerate() - .map(|(index, candidate)| { - (candidate_identity(candidate.note_id, candidate.chunk_id), index as u32 + 1) - }) - .collect() -} - -fn candidate_stage_reason(candidate: &TraceReplayCandidate, selection_state: &str) -> String { - if selection_state == "selected" { - candidate.diversity_selected_reason.clone().unwrap_or_else(|| "selection.final".to_string()) - } else { - candidate - .diversity_skipped_reason - .clone() - .unwrap_or_else(|| "not_in_final_top_k".to_string()) - } -} - -fn compact_selected_context( - selected_items: &[&SearchExplainItem], - source_refs: &BTreeMap, -) -> Vec { - selected_items - .iter() - .map(|item| { - let source = source_refs.get(&item.note_id); - - serde_json::json!({ - "result_handle": item.result_handle, - "note_id": item.note_id, - "chunk_id": item.chunk_id, - "source_ref": source.map(|row| row.source_ref.clone()), - "source_ref_available": source.is_some(), - "freshness_state": freshness_from_note_source(source), - "final_rank": item.rank, - "final_score": item.explain.ranking.final_score, - "policy_id": item.explain.ranking.policy_id, - "policy_reason": "final ranked search result", - "ranking_terms": item - .explain - .ranking - .terms - .iter() - .map(|term| serde_json::json!({ - "name": term.name, - "value": term.value, - })) - .collect::>(), - "relation_context_count": item - .explain - .relation_context - .as_ref() - .map(Vec::len) - .unwrap_or_default(), - }) - }) - .collect() -} - -fn compact_pointer(value: &Value, pointer: &str) -> Value { - value.pointer(pointer).cloned().unwrap_or(Value::Null) -} - -fn summarize_layers(layers: &[RecallDebugLayer]) -> RecallDebugPanelSummary { - let mut summary = RecallDebugPanelSummary { layer_count: layers.len(), ..Default::default() }; - - for layer in layers { - summary.row_count += layer.row_count; - summary.selected_count += layer.selected_count; - summary.dropped_count += layer.dropped_count; - summary.available_count += layer.available_count; - - if layer.evidence_class == "not_requested" { - summary.not_requested_layer_count += 1; - } - if matches!(layer.evidence_class.as_str(), "incomplete" | "blocked" | "wrong_result") { - summary.incomplete_layer_count += 1; - } - if layer.raw_sql_needed { - summary.raw_sql_needed_count += 1; - } - - summary.replay_command_count += layer - .rows - .iter() - .filter(|row| row.replay_command.as_ref().is_some_and(|value| !value.is_empty())) - .count(); - *summary.evidence_class_counts.entry(layer.evidence_class.clone()).or_default() += 1; - } - - summary -} - -fn build_recall_trace(layers: &[RecallDebugLayer]) -> RecallTrace { - let mut entries = Vec::new(); - - for layer in layers { - if layer.rows.is_empty() { - if matches!( - layer.evidence_class.as_str(), - "blocked" | "not_requested" | "incomplete" | "wrong_result" - ) { - entries.push(layer_trace_entry(layer)); - } - - continue; - } - - entries.extend(layer.rows.iter().map(row_trace_entry)); - } - - let summary = summarize_trace_entries(&entries); - - RecallTrace { schema: ELF_RECALL_TRACE_SCHEMA_V1.to_string(), summary, entries } -} - -fn summarize_trace_entries(entries: &[RecallTraceEntry]) -> RecallTraceSummary { - let mut summary = RecallTraceSummary { entry_count: entries.len(), ..Default::default() }; - - for entry in entries { - match entry.selection_state.as_str() { - "selected" => summary.selected_count += 1, - "dropped" => summary.dropped_count += 1, - "blocked" => summary.blocked_count += 1, - "not_requested" => summary.not_requested_count += 1, - _ => {}, - } - - if entry.context_state == "stale" || stale_freshness_state(&entry.freshness_state) { - summary.stale_count += 1; - } - if entry.raw_sql_needed { - summary.raw_sql_needed_count += 1; - } - if entry.replay_command.as_ref().is_some_and(|value| !value.is_empty()) { - summary.replay_command_count += 1; - } - } - - summary -} - -fn layer_trace_entry(layer: &RecallDebugLayer) -> RecallTraceEntry { - let context_state = match layer.evidence_class.as_str() { - "not_requested" => "not_requested", - "blocked" => "blocked", - "incomplete" => "incomplete", - "wrong_result" => "wrong_result", - _ => "available", - }; - - RecallTraceEntry { - layer: layer.layer.clone(), - context_state: context_state.to_string(), - selection_state: layer.evidence_class.clone(), - authority_layer: layer.layer.clone(), - freshness_state: layer.evidence_class.clone(), - item_ref: serde_json::json!({ - "layer": layer.layer.clone(), - "anchor": layer.anchor.clone(), - }), - source_refs: serde_json::json!([]), - score: None, - rank: None, - policy_reason: Some(layer.summary.clone()), - replay_command: None, - evidence_class: layer.evidence_class.clone(), - raw_sql_needed: layer.raw_sql_needed, - } -} - -fn row_trace_entry(row: &RecallDebugRow) -> RecallTraceEntry { - let context_state = if stale_freshness_state(&row.freshness_state) { - "stale" - } else { - row.selection_state.as_str() - }; - - RecallTraceEntry { - layer: row.layer.clone(), - context_state: context_state.to_string(), - selection_state: row.selection_state.clone(), - authority_layer: row.authority_layer.clone(), - freshness_state: row.freshness_state.clone(), - item_ref: row.item_ref.clone(), - source_refs: row.source_refs.clone(), - score: row.score, - rank: row.rank, - policy_reason: row.stage_reason.clone().or_else(|| row.rationale.clone()), - replay_command: row.replay_command.clone(), - evidence_class: row.evidence_class.clone(), - raw_sql_needed: false, - } -} - -fn stale_freshness_state(freshness_state: &str) -> bool { - matches!( - freshness_state, - "stale" - | "deprecated" - | "deleted" - | "superseded" - | "tombstoned" - | "historical" - | "archived" - | "lint_warning" - | "lint_error" - ) -} - -fn layer_from_rows( - layer: &str, - evidence_class: &str, - anchor: Option, - summary: &str, - rows: Vec, -) -> RecallDebugLayer { - layer_from_rows_with_artifacts( - layer, - evidence_class, - anchor, - summary, - rows, - serde_json::json!({}), - ) -} - -fn layer_from_rows_with_artifacts( - layer: &str, - evidence_class: &str, - anchor: Option, - summary: &str, - rows: Vec, - debug_artifacts: Value, -) -> RecallDebugLayer { - let selected_count = rows.iter().filter(|row| row.selection_state == "selected").count(); - let dropped_count = rows.iter().filter(|row| row.selection_state == "dropped").count(); - let available_count = rows - .iter() - .filter(|row| matches!(row.selection_state.as_str(), "available" | "reviewable")) - .count(); - let replayable = rows.iter().any(|row| row.replay_command.is_some()); - - RecallDebugLayer { - layer: layer.to_string(), - evidence_class: evidence_class.to_string(), - summary: summary.to_string(), - anchor, - row_count: rows.len(), - selected_count, - dropped_count, - available_count, - raw_sql_needed: false, - replayable, - debug_artifacts, - rows, - } -} - -fn not_requested_layer(layer: &str, summary: &str) -> RecallDebugLayer { - RecallDebugLayer { - layer: layer.to_string(), - evidence_class: "not_requested".to_string(), - summary: summary.to_string(), - anchor: None, - row_count: 0, - selected_count: 0, - dropped_count: 0, - available_count: 0, - raw_sql_needed: false, - replayable: false, - debug_artifacts: serde_json::json!({}), - rows: Vec::new(), - } -} - -fn blocked_layer( - layer: &str, - anchor: Option, - summary: &str, - err: &Error, -) -> RecallDebugLayer { - RecallDebugLayer { - layer: layer.to_string(), - evidence_class: "blocked".to_string(), - summary: format!("{summary} error_class={}", public_error_class(err)), - anchor, - row_count: 0, - selected_count: 0, - dropped_count: 0, - available_count: 0, - raw_sql_needed: false, - replayable: false, - debug_artifacts: serde_json::json!({}), - rows: Vec::new(), - } -} - -fn public_error_class(err: &Error) -> &'static str { - match err { - Error::NonEnglishInput { .. } => "validation_non_english_input", - Error::InvalidRequest { .. } => "validation_invalid_request", - Error::ScopeDenied { .. } => "scope_denied", - Error::NotFound { .. } => "not_found", - Error::Conflict { .. } => "conflict", - Error::Provider { .. } => "provider_unavailable", - Error::Storage { .. } => "storage_unavailable", - Error::Qdrant { .. } => "vector_store_unavailable", - } -} - -fn json_anchor(value: &T) -> Option -where - T: Serialize + ?Sized, -{ - serde_json::to_value(value).ok().map(|value| value.to_string()) -} - -fn search_item_candidate_key(item: &SearchExplainItem) -> Option<(Uuid, Uuid)> { - item.chunk_id.map(|chunk_id| candidate_identity(item.note_id, chunk_id)) -} - -fn candidate_identity(note_id: Uuid, chunk_id: Uuid) -> (Uuid, Uuid) { - (note_id, chunk_id) -} - -fn candidate_is_selected( - selected_candidate_keys: &BTreeSet<(Uuid, Uuid)>, - candidate: &TraceReplayCandidate, -) -> bool { - selected_candidate_keys.contains(&candidate_identity(candidate.note_id, candidate.chunk_id)) -} - -fn graph_replay_command(subject: &str, predicate: Option<&GraphQueryPredicateRef>) -> String { - if let Some(predicate) = predicate.and_then(json_anchor) { - format!("elf_graph_report subject={subject} predicate={predicate} explain=true") - } else { - format!("elf_graph_report subject={subject} explain=true") - } -} - -fn freshness_from_note_source(source: Option<&NoteDebugSourceRow>) -> String { - source.map(|row| row.status.clone()).unwrap_or_else(|| "unknown".to_string()) -} - -fn source_ref_from_note_source(source: Option<&NoteDebugSourceRow>) -> Value { - source.map(|row| serde_json::json!([row.source_ref])).unwrap_or_else(|| serde_json::json!([])) -} - -fn last_stage_name(stages: &[SearchTrajectoryStage]) -> Option { - stages.last().map(|stage| stage.stage_name.clone()) -} - -fn knowledge_freshness(item: &KnowledgePageSearchItem) -> String { - if item.lint_summary.error_count > 0 { - "lint_error".to_string() - } else if item.lint_summary.warning_count > 0 { - "lint_warning".to_string() - } else if item.trust_state != "clean" { - item.trust_state.clone() - } else { - item.status.clone() - } -} - -fn graph_temporal_status(status: crate::RelationTemporalStatus) -> String { - match status { - crate::RelationTemporalStatus::Future => "future", - crate::RelationTemporalStatus::Current => "current", - crate::RelationTemporalStatus::Historical => "historical", - } - .to_string() -} - +use helpers::{ + candidate_identity, candidate_is_selected, freshness_from_note_source, graph_replay_command, + graph_temporal_status, json_anchor, knowledge_freshness, last_stage_name, public_error_class, + search_item_candidate_key, source_ref_from_note_source, +}; +use replay::{candidate_debug_row, memory_compact_replay_artifact}; +use sources::{note_debug_read_allowed, note_debug_source_pair}; +use trace::{ + blocked_layer, build_recall_trace, layer_from_rows, layer_from_rows_with_artifacts, + not_requested_layer, summarize_layers, +}; +use types::{ + DEFAULT_RECALL_DEBUG_LIMIT, MAX_RECALL_DEBUG_DOCS_LIMIT, MAX_RECALL_DEBUG_LIMIT, + NoteDebugSourceRow, +}; #[cfg(test)] -mod tests { - use std::collections::{BTreeMap, HashSet}; - - use time::OffsetDateTime; - - use crate::{ - RecallDebugRow, - access::SharedSpaceGrantKey, - recall_debug::{self, BTreeSet, Error, NoteDebugSourceRow, Uuid}, - search::{SearchTrace, SearchTrajectoryStage, TraceReplayCandidate}, - }; - use elf_storage::models::MemoryNote; - - #[test] - fn summary_preserves_not_requested_and_replay_counts() { - let layers = vec![ - recall_debug::not_requested_layer("graph_facts", "missing graph subject"), - recall_debug::layer_from_rows( - "memory_notes", - "pass", - Some("trace".to_string()), - "trace rows", - vec![ - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "n1"}), - selection_state: "selected".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([]), - score: Some(1.0), - rank: Some(1), - rationale: None, - stage_reason: None, - replay_command: Some("elf_admin_trace_bundle_get".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "n2"}), - selection_state: "dropped".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([]), - score: Some(0.5), - rank: Some(2), - rationale: None, - stage_reason: Some("not_in_final_top_k".to_string()), - replay_command: Some("elf_admin_trace_bundle_get".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - ], - ), - ]; - let summary = recall_debug::summarize_layers(&layers); - - assert_eq!(summary.layer_count, 2); - assert_eq!(summary.row_count, 2); - assert_eq!(summary.selected_count, 1); - assert_eq!(summary.dropped_count, 1); - assert_eq!(summary.not_requested_layer_count, 1); - assert_eq!(summary.replay_command_count, 2); - assert_eq!(summary.evidence_class_counts.get("pass"), Some(&1)); - assert_eq!(summary.evidence_class_counts.get("not_requested"), Some(&1)); - } - - #[test] - fn not_requested_layers_never_require_raw_sql() { - let layer = recall_debug::not_requested_layer("source_documents", "missing query"); - - assert_eq!(layer.evidence_class, "not_requested"); - assert_eq!(layer.row_count, 0); - assert!(!layer.raw_sql_needed); - assert!(!layer.replayable); - } - - #[test] - fn blocked_layers_are_counted_as_incomplete_evidence() { - let layer = recall_debug::blocked_layer( - "source_documents", - Some("alpha".to_string()), - "docs search failed", - &Error::Storage { message: "database unavailable".to_string() }, - ); - let summary = recall_debug::summarize_layers(&[layer]); - - assert_eq!(summary.layer_count, 1); - assert_eq!(summary.incomplete_layer_count, 1); - assert_eq!(summary.evidence_class_counts.get("blocked"), Some(&1)); - } - - #[test] - fn blocked_layer_does_not_expose_raw_backend_errors() { - let layer = recall_debug::blocked_layer( - "graph_facts", - None, - "graph report failed", - &Error::Storage { message: "password=secret host=db.internal".to_string() }, - ); - - assert!(layer.summary.contains("error_class=storage_unavailable")); - assert!(!layer.summary.contains("password=secret")); - assert!(!layer.summary.contains("db.internal")); - } - - #[test] - fn selected_candidate_filter_is_chunk_level() { - let note_id = Uuid::new_v4(); - let selected_chunk_id = Uuid::new_v4(); - let dropped_chunk_id = Uuid::new_v4(); - let selected = - BTreeSet::from([recall_debug::candidate_identity(note_id, selected_chunk_id)]); - - assert!(selected.contains(&recall_debug::candidate_identity(note_id, selected_chunk_id))); - assert!(!selected.contains(&recall_debug::candidate_identity(note_id, dropped_chunk_id))); - } - - fn compact_replay_trace(trace_id: Uuid, now: OffsetDateTime) -> SearchTrace { - SearchTrace { - trace_id, - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "release handoff".to_string(), - expansion_mode: "dynamic".to_string(), - expanded_queries: vec!["release handoff".to_string(), "owner transfer".to_string()], - allowed_scopes: vec!["agent_private".to_string(), "project_shared".to_string()], - candidate_count: 2, - top_k: 1, - config_snapshot: serde_json::json!({ - "search": { - "expansion": { - "mode": "dynamic", - "max_queries": 3, - "include_original": true - } - }, - "ranking": { - "policy_id": "ranking_v2:test", - "blend": { - "enabled": true - }, - "diversity": { - "enabled": true - }, - "retrieval_sources": { - "fusion_weight": 1.0 - }, - "override": { - "blend": { - "enabled": false - } - } - } - }), - created_at: now, - trace_version: 3, - } - } - - fn compact_replay_stages() -> Vec { - vec![ - SearchTrajectoryStage { - stage_order: 2, - stage_name: "recall.candidates".to_string(), - stage_payload: serde_json::json!({ - "stats": { - "candidate_count_before_filter": 2, - "candidate_count_after_filter": 2 - } - }), - items: Vec::new(), - }, - SearchTrajectoryStage { - stage_order: 4, - stage_name: "rerank.score".to_string(), - stage_payload: serde_json::json!({ - "stats": { - "reranked_count": 2 - }, - "decisions": { - "blend_enabled": true, - "diversity_enabled": true - } - }), - items: Vec::new(), - }, - ] - } - - fn compact_replay_selected_candidate( - note_id: Uuid, - chunk_id: Uuid, - now: OffsetDateTime, - ) -> TraceReplayCandidate { - TraceReplayCandidate { - note_id, - chunk_id, - chunk_index: 0, - snippet: "selected".to_string(), - retrieval_rank: 2, - retrieval_score: Some(0.4), - rerank_score: 0.9, - note_scope: "project_shared".to_string(), - note_importance: 0.7, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: Some(true), - diversity_selected_rank: Some(1), - diversity_selected_reason: Some("mmr".to_string()), - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: Some(0.8), - diversity_missing_embedding: Some(false), - } - } - - fn compact_replay_dropped_candidate( - note_id: Uuid, - chunk_id: Uuid, - selected_note_id: Uuid, - now: OffsetDateTime, - ) -> TraceReplayCandidate { - TraceReplayCandidate { - note_id, - chunk_id, - chunk_index: 0, - snippet: "dropped".to_string(), - retrieval_rank: 1, - retrieval_score: Some(0.8), - rerank_score: 0.1, - note_scope: "project_shared".to_string(), - note_importance: 0.3, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: Some(false), - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: Some("not_in_final_top_k".to_string()), - diversity_nearest_selected_note_id: Some(selected_note_id), - diversity_similarity: Some(0.92), - diversity_mmr_score: Some(0.1), - diversity_missing_embedding: Some(false), - } - } - - fn compact_replay_source_refs( - selected_note_id: Uuid, - dropped_note_id: Uuid, - now: OffsetDateTime, - ) -> BTreeMap { - BTreeMap::from([ - ( - selected_note_id, - NoteDebugSourceRow { - status: "active".to_string(), - source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "selected"}}), - updated_at: now, - }, - ), - ( - dropped_note_id, - NoteDebugSourceRow { - status: "active".to_string(), - source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "dropped"}}), - updated_at: now, - }, - ), - ]) - } - - fn assert_compact_replay_artifact(artifact: &serde_json::Value) { - assert_eq!( - artifact.pointer("/schema").and_then(serde_json::Value::as_str), - Some("elf.recall_debug.compact_replay/v1") - ); - assert_eq!( - artifact.pointer("/controls/top_k").and_then(serde_json::Value::as_u64), - Some(1) - ); - assert_eq!( - artifact.pointer("/controls/expanded_query_count").and_then(serde_json::Value::as_u64), - Some(2) - ); - assert_eq!( - artifact.pointer("/controls/ranking/policy_id").and_then(serde_json::Value::as_str), - Some("ranking_v2:test") - ); - assert_eq!( - artifact.pointer("/stage_movement/1/stage_name").and_then(serde_json::Value::as_str), - Some("rerank.score") - ); - assert_eq!( - artifact - .pointer("/candidate_replay/selected_count") - .and_then(serde_json::Value::as_u64), - Some(1) - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/selection_state") - .and_then(serde_json::Value::as_str), - Some("selected") - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/source_ref_available") - .and_then(serde_json::Value::as_bool), - Some(true) - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/rerank_delta") - .and_then(serde_json::Value::as_i64), - Some(1) - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/policy_reason") - .and_then(serde_json::Value::as_str), - Some("mmr") - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/1/selection_state") - .and_then(serde_json::Value::as_str), - Some("dropped") - ); - assert_eq!( - artifact.pointer("/authority/raw_sql_needed").and_then(serde_json::Value::as_bool), - Some(false) - ); - } - - #[test] - fn compact_replay_artifact_exposes_controls_stage_movement_and_rerank_effects() { - let trace_id = Uuid::new_v4(); - let selected_note_id = Uuid::new_v4(); - let selected_chunk_id = Uuid::new_v4(); - let dropped_note_id = Uuid::new_v4(); - let dropped_chunk_id = Uuid::new_v4(); - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let candidates = vec![ - compact_replay_selected_candidate(selected_note_id, selected_chunk_id, now), - compact_replay_dropped_candidate( - dropped_note_id, - dropped_chunk_id, - selected_note_id, - now, - ), - ]; - let selected = - BTreeSet::from([recall_debug::candidate_identity(selected_note_id, selected_chunk_id)]); - let source_refs = compact_replay_source_refs(selected_note_id, dropped_note_id, now); - let artifact = recall_debug::memory_compact_replay_artifact( - &compact_replay_trace(trace_id, now), - compact_replay_stages().as_slice(), - candidates.as_slice(), - &[], - &selected, - &source_refs, - "elf_admin_trace_bundle_get trace_id= mode=bounded", - ); - - assert_compact_replay_artifact(&artifact); - } - - #[test] - fn debug_note_readability_requires_current_note_and_scope_access() { - let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; - let shared_grants = HashSet::new(); - let now = OffsetDateTime::now_utc(); - let mut note = note_for_debug_visibility("owner-agent", "agent_private", "active"); - - assert!(recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "other-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.status = "deleted".to_string(); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.status = "deprecated".to_string(); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.status = "active".to_string(); - note.expires_at = Some(now); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.expires_at = None; - note.scope = "project_shared".to_string(); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "other-agent", - &allowed_scopes, - &shared_grants, - now - )); - - let shared_grants = HashSet::from([SharedSpaceGrantKey { - scope: "project_shared".to_string(), - space_owner_agent_id: "owner-agent".to_string(), - }]); - - assert!(recall_debug::note_debug_read_allowed( - ¬e, - "other-agent", - &allowed_scopes, - &shared_grants, - now - )); - } - - #[test] - fn recall_trace_flattens_stale_and_dropped_context() { - let layers = vec![ - recall_debug::layer_from_rows( - "memory_notes", - "pass", - Some("trace".to_string()), - "trace rows", - vec![ - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "selected-stale"}), - selection_state: "selected".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "deprecated".to_string(), - source_refs: serde_json::json!([{"schema": "source_ref/v1"}]), - score: Some(0.9), - rank: Some(1), - rationale: Some("selected but stale".to_string()), - stage_reason: Some("status=deprecated".to_string()), - replay_command: Some("elf_trace".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "dropped"}), - selection_state: "dropped".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([]), - score: Some(0.4), - rank: Some(4), - rationale: Some("candidate not narrated".to_string()), - stage_reason: Some("not_in_final_top_k".to_string()), - replay_command: Some("elf_trace".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - ], - ), - recall_debug::not_requested_layer("graph_facts", "missing graph subject"), - ]; - let trace = recall_debug::build_recall_trace(&layers); - - assert_eq!(trace.schema, "elf.recall_trace/v1"); - assert_eq!(trace.summary.entry_count, 3); - assert_eq!(trace.summary.selected_count, 1); - assert_eq!(trace.summary.dropped_count, 1); - assert_eq!(trace.summary.stale_count, 1); - assert_eq!(trace.summary.not_requested_count, 1); - assert_eq!(trace.summary.replay_command_count, 2); - assert_eq!(trace.entries[0].context_state, "stale"); - assert_eq!(trace.entries[0].policy_reason.as_deref(), Some("status=deprecated")); - assert_eq!(trace.entries[1].context_state, "dropped"); - assert_eq!(trace.entries[1].policy_reason.as_deref(), Some("not_in_final_top_k")); - assert_eq!(trace.entries[2].context_state, "not_requested"); - } - - #[test] - fn recall_trace_counts_blocked_layers_without_backend_details() { - let layer = recall_debug::blocked_layer( - "source_documents", - Some("alpha".to_string()), - "docs search failed", - &Error::Storage { message: "password=secret host=db.internal".to_string() }, - ); - let trace = recall_debug::build_recall_trace(&[layer]); - - assert_eq!(trace.summary.blocked_count, 1); - assert_eq!(trace.entries[0].context_state, "blocked"); - assert_eq!(trace.entries[0].selection_state, "blocked"); - assert!( - trace.entries[0] - .policy_reason - .as_deref() - .is_some_and(|reason| reason.contains("error_class=storage_unavailable")) - ); - assert!( - trace.entries[0] - .policy_reason - .as_deref() - .is_some_and(|reason| !reason.contains("password=secret")) - ); - } - - fn note_for_debug_visibility(agent_id: &str, scope: &str, status: &str) -> MemoryNote { - let now = OffsetDateTime::now_utc(); - - MemoryNote { - note_id: Uuid::new_v4(), - tenant_id: "tenant-a".to_string(), - project_id: "project-a".to_string(), - agent_id: agent_id.to_string(), - scope: scope.to_string(), - r#type: "fact".to_string(), - key: None, - text: "Fact: debug visibility test note.".to_string(), - importance: 0.7, - confidence: 0.9, - status: status.to_string(), - created_at: now, - updated_at: now, - expires_at: None, - embedding_version: "test:v1".to_string(), - source_ref: serde_json::json!({"schema": "source_ref/v1"}), - hit_count: 0, - last_hit_at: None, - } - } -} +#[path = "recall_debug/tests.rs"] +mod tests; diff --git a/packages/elf-service/src/recall_debug/helpers.rs b/packages/elf-service/src/recall_debug/helpers.rs new file mode 100644 index 00000000..a9765363 --- /dev/null +++ b/packages/elf-service/src/recall_debug/helpers.rs @@ -0,0 +1,83 @@ +use crate::recall_debug::{ + BTreeSet, Error, GraphQueryPredicateRef, KnowledgePageSearchItem, NoteDebugSourceRow, + SearchExplainItem, SearchTrajectoryStage, Serialize, TraceReplayCandidate, Uuid, Value, +}; + +pub(super) fn public_error_class(err: &Error) -> &'static str { + match err { + Error::NonEnglishInput { .. } => "validation_non_english_input", + Error::InvalidRequest { .. } => "validation_invalid_request", + Error::ScopeDenied { .. } => "scope_denied", + Error::NotFound { .. } => "not_found", + Error::Conflict { .. } => "conflict", + Error::Provider { .. } => "provider_unavailable", + Error::Storage { .. } => "storage_unavailable", + Error::Qdrant { .. } => "vector_store_unavailable", + } +} + +pub(super) fn json_anchor(value: &T) -> Option +where + T: Serialize + ?Sized, +{ + serde_json::to_value(value).ok().map(|value| value.to_string()) +} + +pub(super) fn search_item_candidate_key(item: &SearchExplainItem) -> Option<(Uuid, Uuid)> { + item.chunk_id.map(|chunk_id| candidate_identity(item.note_id, chunk_id)) +} + +pub(super) fn candidate_identity(note_id: Uuid, chunk_id: Uuid) -> (Uuid, Uuid) { + (note_id, chunk_id) +} + +pub(super) fn candidate_is_selected( + selected_candidate_keys: &BTreeSet<(Uuid, Uuid)>, + candidate: &TraceReplayCandidate, +) -> bool { + selected_candidate_keys.contains(&candidate_identity(candidate.note_id, candidate.chunk_id)) +} + +pub(super) fn graph_replay_command( + subject: &str, + predicate: Option<&GraphQueryPredicateRef>, +) -> String { + if let Some(predicate) = predicate.and_then(json_anchor) { + format!("elf_graph_report subject={subject} predicate={predicate} explain=true") + } else { + format!("elf_graph_report subject={subject} explain=true") + } +} + +pub(super) fn freshness_from_note_source(source: Option<&NoteDebugSourceRow>) -> String { + source.map(|row| row.status.clone()).unwrap_or_else(|| "unknown".to_string()) +} + +pub(super) fn source_ref_from_note_source(source: Option<&NoteDebugSourceRow>) -> Value { + source.map(|row| serde_json::json!([row.source_ref])).unwrap_or_else(|| serde_json::json!([])) +} + +pub(super) fn last_stage_name(stages: &[SearchTrajectoryStage]) -> Option { + stages.last().map(|stage| stage.stage_name.clone()) +} + +pub(super) fn knowledge_freshness(item: &KnowledgePageSearchItem) -> String { + if item.lint_summary.error_count > 0 { + "lint_error".to_string() + } else if item.lint_summary.warning_count > 0 { + "lint_warning".to_string() + } else if item.trust_state != "clean" { + item.trust_state.clone() + } else { + item.status.clone() + } +} + +pub(super) fn graph_temporal_status(status: crate::RelationTemporalStatus) -> String { + match status { + crate::RelationTemporalStatus::Future => "future", + crate::RelationTemporalStatus::Current => "current", + crate::RelationTemporalStatus::Historical => "historical", + } + .to_string() +} diff --git a/packages/elf-service/src/recall_debug/layers.rs b/packages/elf-service/src/recall_debug/layers.rs new file mode 100644 index 00000000..2ddc2141 --- /dev/null +++ b/packages/elf-service/src/recall_debug/layers.rs @@ -0,0 +1,118 @@ +mod docs; +mod dreaming; +mod graph; +mod knowledge; +mod memory; + +use crate::{ + access, + recall_debug::{ + self, BTreeMap, BTreeSet, DEFAULT_RECALL_DEBUG_LIMIT, DocsSearchL0Request, + DreamingReviewQueueRequest, ELF_RECALL_DEBUG_PANEL_SCHEMA_V1, ElfService, Error, + GraphReportRequest, KnowledgePageSearchRequest, MAX_RECALL_DEBUG_DOCS_LIMIT, + MAX_RECALL_DEBUG_LIMIT, MemoryNote, NoteDebugSourceRow, ORG_PROJECT_ID, OffsetDateTime, + RecallDebugLayer, RecallDebugPanelRequest, RecallDebugPanelRequestEcho, + RecallDebugPanelResponse, RecallDebugRow, Result, TraceBundleGetRequest, TraceBundleMode, + Uuid, candidate_debug_row, candidate_is_selected, freshness_from_note_source, + graph_replay_command, graph_temporal_status, json_anchor, knowledge_freshness, + last_stage_name, layer_from_rows, layer_from_rows_with_artifacts, + memory_compact_replay_artifact, not_requested_layer, note_debug_read_allowed, + note_debug_source_pair, search_item_candidate_key, source_ref_from_note_source, + }, + search, +}; + +impl ElfService { + /// Builds a cross-layer recall/debug panel from existing readback surfaces. + pub async fn recall_debug_panel( + &self, + req: RecallDebugPanelRequest, + ) -> Result { + let limit = + req.limit.unwrap_or(DEFAULT_RECALL_DEBUG_LIMIT).clamp(1, MAX_RECALL_DEBUG_LIMIT); + let docs_query = req + .docs_query + .clone() + .or_else(|| req.query.clone()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let knowledge_query = req + .knowledge_query + .clone() + .or_else(|| req.query.clone()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let include_dreaming = req.include_dreaming == Some(true); + let mut layers = Vec::new(); + + layers.push(self.recall_memory_layer(&req, limit).await.unwrap_or_else(|err| { + recall_debug::blocked_layer( + "memory_notes", + req.trace_id.map(|trace_id| trace_id.to_string()), + "Requested memory trace bundle could not be read.", + &err, + ) + })); + layers.push( + self.recall_docs_layer(&req, docs_query.as_deref(), limit).await.unwrap_or_else( + |err| { + recall_debug::blocked_layer( + "source_documents", + docs_query.clone(), + "Requested Source Library document search could not be read.", + &err, + ) + }, + ), + ); + layers.push( + self.recall_knowledge_layer(&req, knowledge_query.as_deref(), limit) + .await + .unwrap_or_else(|err| { + recall_debug::blocked_layer( + "knowledge_pages", + knowledge_query.clone(), + "Requested Knowledge Workspace page search could not be read.", + &err, + ) + }), + ); + layers.push(self.recall_graph_layer(&req, limit).await.unwrap_or_else(|err| { + recall_debug::blocked_layer( + "graph_facts", + req.graph_subject.as_ref().and_then(json_anchor), + "Requested graph report could not be read.", + &err, + ) + })); + layers.push( + self.recall_dreaming_layer(&req, include_dreaming, limit).await.unwrap_or_else(|err| { + recall_debug::blocked_layer( + "dreaming_proposals", + Some("include_dreaming=true".to_string()), + "Requested Dreaming review queue could not be read.", + &err, + ) + }), + ); + + let summary = recall_debug::summarize_layers(&layers); + let recall_trace = recall_debug::build_recall_trace(&layers); + + Ok(RecallDebugPanelResponse { + schema: ELF_RECALL_DEBUG_PANEL_SCHEMA_V1.to_string(), + generated_at: OffsetDateTime::now_utc(), + request: RecallDebugPanelRequestEcho { + trace_id: req.trace_id, + docs_query, + knowledge_query, + graph_subject_supplied: req.graph_subject.is_some(), + include_dreaming, + limit, + }, + summary, + recall_trace, + layers, + }) + } +} diff --git a/packages/elf-service/src/recall_debug/layers/docs.rs b/packages/elf-service/src/recall_debug/layers/docs.rs new file mode 100644 index 00000000..a059ac7d --- /dev/null +++ b/packages/elf-service/src/recall_debug/layers/docs.rs @@ -0,0 +1,105 @@ +use crate::recall_debug::layers::{ + self, DocsSearchL0Request, ElfService, MAX_RECALL_DEBUG_DOCS_LIMIT, RecallDebugLayer, + RecallDebugPanelRequest, RecallDebugRow, Result, +}; + +impl ElfService { + pub(super) async fn recall_docs_layer( + &self, + req: &RecallDebugPanelRequest, + docs_query: Option<&str>, + limit: u32, + ) -> Result { + let Some(query) = docs_query else { + return Ok(layers::not_requested_layer( + "source_documents", + "Supply query or docs_query to show Source Library document candidates.", + )); + }; + let effective_limit = limit.min(MAX_RECALL_DEBUG_DOCS_LIMIT); + let response = self + .docs_search_l0(DocsSearchL0Request { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + caller_agent_id: req.agent_id.clone(), + read_profile: req.read_profile.clone(), + query: query.to_string(), + scope: None, + status: Some("active".to_string()), + doc_type: None, + sparse_mode: None, + domain: None, + repo: None, + agent_id: None, + thread_id: None, + updated_after: None, + updated_before: None, + ts_gte: None, + ts_lte: None, + top_k: Some(effective_limit), + candidate_k: Some(effective_limit.saturating_mul(3).max(effective_limit)), + explain: Some(true), + }) + .await?; + let rows = response + .items + .into_iter() + .enumerate() + .map(|(index, item)| RecallDebugRow { + layer: "source_documents".to_string(), + item_ref: serde_json::json!({ + "trace_id": response.trace_id, + "doc_id": item.doc_id, + "chunk_id": item.chunk_id, + "pointer": item.pointer, + }), + selection_state: "selected".to_string(), + authority_layer: "source_library".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([{ + "schema": "source_ref/v1", + "resolver": "elf_doc_ext/v1", + "doc_id": item.doc_id, + "chunk_id": item.chunk_id, + "content_hash": item.content_hash, + "chunk_hash": item.chunk_hash, + "doc_updated_at": item.updated_at, + }]), + score: Some(item.score), + rank: Some(index as u32 + 1), + rationale: Some("docs_search_l0 selected chunk".to_string()), + stage_reason: response + .trajectory + .as_ref() + .and_then(|trajectory| trajectory.stages.last()) + .map(|stage| stage.stage_name.clone()) + .or(Some("docs_search_l0".to_string())), + replay_command: Some(format!("elf_docs_search_l0 query={query:?} explain=true")), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({ + "doc_type": item.doc_type, + "scope": item.scope, + "snippet": item.snippet, + "trajectory": response.trajectory, + "requested_limit": limit, + "effective_limit": effective_limit, + }), + }) + .collect(); + let summary = if effective_limit < limit { + format!( + "Source Library search rows selected by docs_search_l0; effective docs cap is {effective_limit}." + ) + } else { + "Source Library search rows selected by docs_search_l0.".to_string() + }; + + Ok(layers::layer_from_rows( + "source_documents", + "pass", + Some(query.to_string()), + &summary, + rows, + )) + } +} diff --git a/packages/elf-service/src/recall_debug/layers/dreaming.rs b/packages/elf-service/src/recall_debug/layers/dreaming.rs new file mode 100644 index 00000000..f786c71c --- /dev/null +++ b/packages/elf-service/src/recall_debug/layers/dreaming.rs @@ -0,0 +1,78 @@ +use crate::recall_debug::layers::{ + self, DreamingReviewQueueRequest, ElfService, RecallDebugLayer, RecallDebugPanelRequest, + RecallDebugRow, Result, +}; + +impl ElfService { + pub(super) async fn recall_dreaming_layer( + &self, + req: &RecallDebugPanelRequest, + include_dreaming: bool, + limit: u32, + ) -> Result { + if !include_dreaming { + return Ok(layers::not_requested_layer( + "dreaming_proposals", + "Set include_dreaming=true to show reviewable Dreaming proposals.", + )); + } + + let response = self + .dreaming_review_queue(DreamingReviewQueueRequest { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + run_id: None, + review_state: None, + limit: Some(limit), + }) + .await?; + let rows = response + .items + .into_iter() + .enumerate() + .map(|(index, item)| RecallDebugRow { + layer: "dreaming_proposals".to_string(), + item_ref: serde_json::json!({ + "proposal_id": item.proposal_id, + "run_id": item.run_id, + "queue_variant": item.queue_variant, + "target_ref": item.target_ref, + }), + selection_state: "reviewable".to_string(), + authority_layer: "reviewable_dreaming_proposal".to_string(), + freshness_state: item.review_state.clone(), + source_refs: serde_json::json!({ + "source_refs": item.source_refs, + "source_snapshot": item.source_snapshot, + "affected_refs": item.affected_refs, + }), + score: Some(item.confidence), + rank: Some(index as u32 + 1), + rationale: Some(item.policy.reason.clone()), + stage_reason: Some(format!( + "review_state={}, auto_apply_allowed={}", + item.review_state, item.policy.auto_apply_allowed + )), + replay_command: Some("elf_dreaming_review_queue limit=".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({ + "policy": item.policy, + "unsupported_claim_flags": item.unsupported_claim_flags, + "contradiction_markers": item.contradiction_markers, + "staleness_markers": item.staleness_markers, + "diff": item.diff, + "review_audit": item.review_audit, + }), + }) + .collect(); + + Ok(layers::layer_from_rows_with_artifacts( + "dreaming_proposals", + "pass", + None, + "Dreaming review queue proposals available for reviewer action.", + rows, + serde_json::json!({}), + )) + } +} diff --git a/packages/elf-service/src/recall_debug/layers/graph.rs b/packages/elf-service/src/recall_debug/layers/graph.rs new file mode 100644 index 00000000..c99b0d51 --- /dev/null +++ b/packages/elf-service/src/recall_debug/layers/graph.rs @@ -0,0 +1,80 @@ +use crate::recall_debug::layers::{ + self, ElfService, GraphReportRequest, RecallDebugLayer, RecallDebugPanelRequest, + RecallDebugRow, Result, +}; + +impl ElfService { + pub(super) async fn recall_graph_layer( + &self, + req: &RecallDebugPanelRequest, + limit: u32, + ) -> Result { + let Some(subject) = req.graph_subject.clone() else { + return Ok(layers::not_requested_layer( + "graph_facts", + "Supply graph_subject to show graph fact candidates and temporal status.", + )); + }; + let response = self + .graph_report(GraphReportRequest { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + agent_id: req.agent_id.clone(), + read_profile: req.read_profile.clone(), + subject, + predicate: req.graph_predicate.clone(), + scopes: None, + as_of: None, + limit: Some(limit), + explain: Some(true), + }) + .await?; + let subject_anchor = response.subject.canonical.clone(); + let replay_command = + layers::graph_replay_command(&subject_anchor, req.graph_predicate.as_ref()); + let rows = response + .facts + .into_iter() + .enumerate() + .map(|(index, fact)| RecallDebugRow { + layer: "graph_facts".to_string(), + item_ref: serde_json::json!({ + "fact_id": fact.fact_id, + "subject": subject_anchor, + "predicate": fact.predicate, + "object": fact.object, + }), + selection_state: "available".to_string(), + authority_layer: "graph_fact".to_string(), + freshness_state: layers::graph_temporal_status(fact.temporal_status), + source_refs: serde_json::json!({ + "evidence_note_ids": fact.evidence_note_ids, + "supersedes_fact_ids": fact.supersedes_fact_ids, + "superseded_by_fact_ids": fact.superseded_by_fact_ids, + }), + score: None, + rank: Some(index as u32 + 1), + rationale: Some("graph_report returned source-backed fact".to_string()), + stage_reason: Some(fact.status_markers.join(",")), + replay_command: Some(replay_command.clone()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({ + "scope": fact.scope, + "actor": fact.actor, + "valid_from": fact.valid_from, + "valid_to": fact.valid_to, + "status_markers": fact.status_markers, + }), + }) + .collect(); + + Ok(layers::layer_from_rows_with_artifacts( + "graph_facts", + "pass", + Some(subject_anchor), + "Graph facts from source-backed graph report.", + rows, + serde_json::json!({}), + )) + } +} diff --git a/packages/elf-service/src/recall_debug/layers/knowledge.rs b/packages/elf-service/src/recall_debug/layers/knowledge.rs new file mode 100644 index 00000000..815cd4fc --- /dev/null +++ b/packages/elf-service/src/recall_debug/layers/knowledge.rs @@ -0,0 +1,79 @@ +use crate::recall_debug::layers::{ + self, ElfService, KnowledgePageSearchRequest, RecallDebugLayer, RecallDebugPanelRequest, + RecallDebugRow, Result, +}; + +impl ElfService { + pub(super) async fn recall_knowledge_layer( + &self, + req: &RecallDebugPanelRequest, + knowledge_query: Option<&str>, + limit: u32, + ) -> Result { + let Some(query) = knowledge_query else { + return Ok(layers::not_requested_layer( + "knowledge_pages", + "Supply query or knowledge_query to show Knowledge Workspace page candidates.", + )); + }; + let response = self + .knowledge_pages_search(KnowledgePageSearchRequest { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + agent_id: req.agent_id.clone(), + read_profile: req.read_profile.clone(), + query: query.to_string(), + page_kind: None, + limit: Some(limit), + }) + .await?; + let rows = response + .items + .into_iter() + .enumerate() + .map(|(index, item)| RecallDebugRow { + layer: "knowledge_pages".to_string(), + item_ref: serde_json::json!({ + "page_id": item.page_id, + "section_id": item.section_id, + "page_kind": item.page_kind, + "page_key": item.page_key, + }), + selection_state: "selected".to_string(), + authority_layer: "derived_knowledge_page".to_string(), + freshness_state: layers::knowledge_freshness(&item), + source_refs: serde_json::json!({ + "source_coverage": item.source_coverage, + "section_source_ref_count": item.source_ref_count, + "citation_count": item.citation_count, + "source_refs": item.source_refs, + }), + score: None, + rank: Some(index as u32 + 1), + rationale: Some("knowledge_pages_search selected section".to_string()), + stage_reason: Some("knowledge_page_search".to_string()), + replay_command: Some(format!( + "elf_recall_debug_panel knowledge_query={query:?} layer=knowledge_pages" + )), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({ + "title": item.title, + "heading": item.heading, + "lint_summary": item.lint_summary, + "trust_state": item.trust_state, + "repair_guidance": item.repair_guidance, + "snippet": item.snippet, + }), + }) + .collect(); + + Ok(layers::layer_from_rows_with_artifacts( + "knowledge_pages", + "pass", + Some(query.to_string()), + "Knowledge Workspace sections selected by page search.", + rows, + serde_json::json!({}), + )) + } +} diff --git a/packages/elf-service/src/recall_debug/layers/memory.rs b/packages/elf-service/src/recall_debug/layers/memory.rs new file mode 100644 index 00000000..f0057fc5 --- /dev/null +++ b/packages/elf-service/src/recall_debug/layers/memory.rs @@ -0,0 +1,219 @@ +use crate::recall_debug::layers::{ + self, BTreeMap, BTreeSet, ElfService, Error, MemoryNote, NoteDebugSourceRow, ORG_PROJECT_ID, + OffsetDateTime, RecallDebugLayer, RecallDebugPanelRequest, RecallDebugRow, Result, + TraceBundleGetRequest, TraceBundleMode, Uuid, access, memory_compact_replay_artifact, + note_debug_source_pair, search, search_item_candidate_key, +}; + +impl ElfService { + pub(super) async fn recall_memory_layer( + &self, + req: &RecallDebugPanelRequest, + limit: u32, + ) -> Result { + let Some(trace_id) = req.trace_id else { + return Ok(layers::not_requested_layer( + "memory_notes", + "Supply trace_id to show selected and dropped Memory Note candidates.", + )); + }; + + if !req.allow_project_trace_debug { + self.ensure_public_recall_trace_allowed(req, trace_id).await?; + } + + let bundle = self + .trace_bundle_get(TraceBundleGetRequest { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + agent_id: req.agent_id.clone(), + trace_id, + mode: TraceBundleMode::Bounded, + stage_items_limit: Some(limit), + candidates_limit: Some(limit.saturating_mul(4).min(400)), + }) + .await?; + let selected_note_ids = + bundle.items.iter().map(|item| item.note_id).collect::>(); + let selected_candidate_keys = + bundle.items.iter().filter_map(search_item_candidate_key).collect::>(); + let candidate_note_ids = + bundle.candidates.as_ref().into_iter().flatten().map(|candidate| candidate.note_id); + let all_note_ids = + selected_note_ids.iter().copied().chain(candidate_note_ids).collect::>(); + let source_refs = self + .load_memory_note_debug_sources(req, all_note_ids.iter().copied().collect()) + .await?; + let replay_command = format!("elf_admin_trace_bundle_get trace_id={trace_id} mode=bounded"); + let visible_items = bundle + .items + .iter() + .filter(|item| source_refs.contains_key(&item.note_id)) + .collect::>(); + let dropped_candidates = bundle + .candidates + .as_deref() + .unwrap_or_default() + .iter() + .filter(|candidate| !layers::candidate_is_selected(&selected_candidate_keys, candidate)) + .filter(|candidate| source_refs.contains_key(&candidate.note_id)) + .collect::>(); + let compact_replay = serde_json::json!({ + "compact_replay": memory_compact_replay_artifact( + &bundle.trace, + bundle.stages.as_slice(), + bundle.candidates.as_deref().unwrap_or_default(), + visible_items.as_slice(), + &selected_candidate_keys, + &source_refs, + replay_command.as_str(), + ), + }); + let selected_cap = if !dropped_candidates.is_empty() && limit > 1 { + limit as usize - 1 + } else { + limit as usize + }; + let mut rows = Vec::new(); + + for item in visible_items.iter().take(selected_cap) { + let source = source_refs.get(&item.note_id); + + rows.push(RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({ + "trace_id": trace_id, + "result_handle": item.result_handle, + "note_id": item.note_id, + "chunk_id": item.chunk_id, + }), + selection_state: "selected".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: layers::freshness_from_note_source(source), + source_refs: layers::source_ref_from_note_source(source), + score: Some(item.explain.ranking.final_score), + rank: Some(item.rank), + rationale: Some("final ranked search result".to_string()), + stage_reason: layers::last_stage_name(bundle.stages.as_slice()) + .or_else(|| Some("final_ranking".to_string())), + replay_command: Some(replay_command.clone()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({ + "ranking_explain": item.explain, + "note_updated_at": source.map(|row| row.updated_at), + }), + }); + } + + let dropped_cap = limit.saturating_sub(rows.len() as u32) as usize; + + for candidate in dropped_candidates.into_iter().take(dropped_cap) { + rows.push(layers::candidate_debug_row( + trace_id, + candidate, + source_refs.get(&candidate.note_id), + replay_command.as_str(), + )); + } + + Ok(layers::layer_from_rows_with_artifacts( + "memory_notes", + "pass", + Some(trace_id.to_string()), + "Search trace bundle with selected results and replay candidates.", + rows, + compact_replay, + )) + } + + async fn ensure_public_recall_trace_allowed( + &self, + req: &RecallDebugPanelRequest, + trace_id: Uuid, + ) -> Result<()> { + let row: Option<(i64,)> = sqlx::query_as( + "\ +SELECT 1 +FROM search_traces +WHERE trace_id = $1 + AND tenant_id = $2 + AND project_id = $3 + AND agent_id = $4 + AND read_profile = $5", + ) + .bind(trace_id) + .bind(req.tenant_id.trim()) + .bind(req.project_id.trim()) + .bind(req.agent_id.trim()) + .bind(req.read_profile.trim()) + .fetch_optional(&self.db.pool) + .await?; + + if row.is_some() { + Ok(()) + } else { + Err(Error::InvalidRequest { + message: "Unknown trace_id for this recall context.".to_string(), + }) + } + } + + async fn load_memory_note_debug_sources( + &self, + req: &RecallDebugPanelRequest, + note_ids: Vec, + ) -> Result> { + if note_ids.is_empty() { + return Ok(BTreeMap::new()); + } + + let rows = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes + WHERE tenant_id = $1 + AND note_id = ANY($3::uuid[]) + AND ( + project_id = $2 + OR (project_id = $4 AND scope = 'org_shared') + )", + ) + .bind(req.tenant_id.as_str()) + .bind(req.project_id.as_str()) + .bind(note_ids) + .bind(ORG_PROJECT_ID) + .fetch_all(&self.db.pool) + .await?; + + if req.allow_project_trace_debug { + return Ok(rows.into_iter().map(note_debug_source_pair).collect()); + } + + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, req.read_profile.trim())?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + req.tenant_id.trim(), + req.project_id.trim(), + req.agent_id.trim(), + org_shared_allowed, + ) + .await?; + let now = OffsetDateTime::now_utc(); + + Ok(rows + .into_iter() + .filter(|note| { + layers::note_debug_read_allowed( + note, + req.agent_id.trim(), + &allowed_scopes, + &shared_grants, + now, + ) + }) + .map(note_debug_source_pair) + .collect()) + } +} diff --git a/packages/elf-service/src/recall_debug/replay.rs b/packages/elf-service/src/recall_debug/replay.rs new file mode 100644 index 00000000..c483d09f --- /dev/null +++ b/packages/elf-service/src/recall_debug/replay.rs @@ -0,0 +1,247 @@ +use crate::recall_debug::{ + self, BTreeMap, BTreeSet, NoteDebugSourceRow, RecallDebugRow, SearchExplainItem, SearchTrace, + SearchTrajectoryStage, TraceReplayCandidate, Uuid, Value, +}; + +pub(super) fn candidate_debug_row( + trace_id: Uuid, + candidate: &TraceReplayCandidate, + source: Option<&NoteDebugSourceRow>, + replay_command: &str, +) -> RecallDebugRow { + let selected_by_diversity = candidate.diversity_selected.unwrap_or(false); + let skipped_reason = candidate.diversity_skipped_reason.clone().or_else(|| { + if selected_by_diversity { + candidate.diversity_selected_reason.clone() + } else { + Some("not_in_final_top_k".to_string()) + } + }); + + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({ + "trace_id": trace_id, + "note_id": candidate.note_id, + "chunk_id": candidate.chunk_id, + "chunk_index": candidate.chunk_index, + }), + selection_state: "dropped".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: recall_debug::freshness_from_note_source(source), + source_refs: recall_debug::source_ref_from_note_source(source), + score: candidate.retrieval_score, + rank: Some(candidate.retrieval_rank), + rationale: Some( + "candidate captured for replay but not selected in final result set".to_string(), + ), + stage_reason: skipped_reason, + replay_command: Some(replay_command.to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({ + "snippet": candidate.snippet, + "rerank_score": candidate.rerank_score, + "note_scope": candidate.note_scope, + "diversity_selected": candidate.diversity_selected, + "diversity_selected_rank": candidate.diversity_selected_rank, + "diversity_nearest_selected_note_id": candidate.diversity_nearest_selected_note_id, + "diversity_similarity": candidate.diversity_similarity, + "diversity_mmr_score": candidate.diversity_mmr_score, + "diversity_missing_embedding": candidate.diversity_missing_embedding, + }), + } +} + +pub(super) fn memory_compact_replay_artifact( + trace: &SearchTrace, + stages: &[SearchTrajectoryStage], + candidates: &[TraceReplayCandidate], + selected_items: &[&SearchExplainItem], + selected_candidate_keys: &BTreeSet<(Uuid, Uuid)>, + source_refs: &BTreeMap, + replay_command: &str, +) -> Value { + serde_json::json!({ + "schema": "elf.recall_debug.compact_replay/v1", + "trace_id": trace.trace_id, + "query": trace.query, + "replay_command": replay_command, + "controls": compact_replay_controls(trace), + "stage_movement": compact_stage_movement(stages), + "candidate_replay": compact_candidate_replay(candidates, selected_candidate_keys, source_refs), + "selected_context": compact_selected_context(selected_items, source_refs), + "authority": { + "source_refs_visible": true, + "policy_reasons_visible": true, + "raw_sql_needed": false, + }, + }) +} + +pub(super) fn compact_replay_controls(trace: &SearchTrace) -> Value { + serde_json::json!({ + "top_k": trace.top_k, + "candidate_count": trace.candidate_count, + "expansion_mode": trace.expansion_mode, + "expanded_query_count": trace.expanded_queries.len(), + "expanded_queries": trace.expanded_queries, + "allowed_scopes": trace.allowed_scopes, + "search": compact_pointer(&trace.config_snapshot, "/search"), + "ranking": { + "policy_id": compact_pointer(&trace.config_snapshot, "/ranking/policy_id"), + "blend": compact_pointer(&trace.config_snapshot, "/ranking/blend"), + "diversity": compact_pointer(&trace.config_snapshot, "/ranking/diversity"), + "retrieval_sources": compact_pointer(&trace.config_snapshot, "/ranking/retrieval_sources"), + "override": compact_pointer(&trace.config_snapshot, "/ranking/override"), + }, + }) +} + +pub(super) fn compact_stage_movement(stages: &[SearchTrajectoryStage]) -> Vec { + stages + .iter() + .map(|stage| { + serde_json::json!({ + "stage_order": stage.stage_order, + "stage_name": stage.stage_name, + "item_count": stage.items.len(), + "stats": compact_pointer(&stage.stage_payload, "/stats"), + "decisions": compact_pointer(&stage.stage_payload, "/decisions"), + "filter_impact": compact_pointer(&stage.stage_payload, "/filter_impact"), + }) + }) + .collect() +} + +pub(super) fn compact_candidate_replay( + candidates: &[TraceReplayCandidate], + selected_candidate_keys: &BTreeSet<(Uuid, Uuid)>, + source_refs: &BTreeMap, +) -> Value { + let rerank_ranks = candidate_rerank_ranks(candidates); + let rows = candidates + .iter() + .map(|candidate| { + let key = recall_debug::candidate_identity(candidate.note_id, candidate.chunk_id); + let rerank_rank = rerank_ranks.get(&key).copied(); + let selection_state = + if selected_candidate_keys.contains(&key) { "selected" } else { "dropped" }; + let stage_reason = candidate_stage_reason(candidate, selection_state); + let source_ref = + source_refs.get(&candidate.note_id).map(|source| source.source_ref.clone()); + + serde_json::json!({ + "note_id": candidate.note_id, + "chunk_id": candidate.chunk_id, + "source_ref": source_ref, + "source_ref_available": source_ref.is_some(), + "retrieval_rank": candidate.retrieval_rank, + "rerank_rank": rerank_rank, + "rerank_delta": rerank_rank.map(|rank| candidate.retrieval_rank as i64 - i64::from(rank)), + "rerank_score": candidate.rerank_score, + "retrieval_score": candidate.retrieval_score, + "selection_state": selection_state, + "stage_reason": stage_reason, + "policy_reason": stage_reason, + "note_scope": candidate.note_scope, + "diversity_selected": candidate.diversity_selected, + "diversity_skipped_reason": candidate.diversity_skipped_reason, + }) + }) + .collect::>(); + let selected_count = rows + .iter() + .filter(|row| row.get("selection_state").and_then(Value::as_str) == Some("selected")) + .count(); + + serde_json::json!({ + "candidate_count": candidates.len(), + "selected_count": selected_count, + "dropped_count": rows.len().saturating_sub(selected_count), + "rows": rows, + }) +} + +pub(super) fn candidate_rerank_ranks( + candidates: &[TraceReplayCandidate], +) -> BTreeMap<(Uuid, Uuid), u32> { + let mut ordered = candidates.iter().collect::>(); + + ordered.sort_by(|a, b| { + b.rerank_score + .total_cmp(&a.rerank_score) + .then_with(|| a.retrieval_rank.cmp(&b.retrieval_rank)) + .then_with(|| a.note_id.cmp(&b.note_id)) + .then_with(|| a.chunk_id.cmp(&b.chunk_id)) + }); + + ordered + .into_iter() + .enumerate() + .map(|(index, candidate)| { + ( + recall_debug::candidate_identity(candidate.note_id, candidate.chunk_id), + index as u32 + 1, + ) + }) + .collect() +} + +pub(super) fn candidate_stage_reason( + candidate: &TraceReplayCandidate, + selection_state: &str, +) -> String { + if selection_state == "selected" { + candidate.diversity_selected_reason.clone().unwrap_or_else(|| "selection.final".to_string()) + } else { + candidate + .diversity_skipped_reason + .clone() + .unwrap_or_else(|| "not_in_final_top_k".to_string()) + } +} + +pub(super) fn compact_selected_context( + selected_items: &[&SearchExplainItem], + source_refs: &BTreeMap, +) -> Vec { + selected_items + .iter() + .map(|item| { + let source = source_refs.get(&item.note_id); + + serde_json::json!({ + "result_handle": item.result_handle, + "note_id": item.note_id, + "chunk_id": item.chunk_id, + "source_ref": source.map(|row| row.source_ref.clone()), + "source_ref_available": source.is_some(), + "freshness_state": recall_debug::freshness_from_note_source(source), + "final_rank": item.rank, + "final_score": item.explain.ranking.final_score, + "policy_id": item.explain.ranking.policy_id, + "policy_reason": "final ranked search result", + "ranking_terms": item + .explain + .ranking + .terms + .iter() + .map(|term| serde_json::json!({ + "name": term.name, + "value": term.value, + })) + .collect::>(), + "relation_context_count": item + .explain + .relation_context + .as_ref() + .map(Vec::len) + .unwrap_or_default(), + }) + }) + .collect() +} + +pub(super) fn compact_pointer(value: &Value, pointer: &str) -> Value { + value.pointer(pointer).cloned().unwrap_or(Value::Null) +} diff --git a/packages/elf-service/src/recall_debug/sources.rs b/packages/elf-service/src/recall_debug/sources.rs new file mode 100644 index 00000000..b144b1b7 --- /dev/null +++ b/packages/elf-service/src/recall_debug/sources.rs @@ -0,0 +1,43 @@ +use crate::recall_debug::{ + HashSet, MemoryNote, NoteDebugSourceRow, OffsetDateTime, SharedSpaceGrantKey, Uuid, +}; + +pub(super) fn note_debug_source_pair(note: MemoryNote) -> (Uuid, NoteDebugSourceRow) { + ( + note.note_id, + NoteDebugSourceRow { + status: note.status, + source_ref: note.source_ref, + updated_at: note.updated_at, + }, + ) +} + +pub(super) fn note_debug_read_allowed( + note: &MemoryNote, + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, + now: OffsetDateTime, +) -> bool { + if note.status != "active" || note.expires_at.is_some_and(|expires_at| expires_at <= now) { + return false; + } + if !allowed_scopes.iter().any(|scope| scope == ¬e.scope) { + return false; + } + if note.scope == "agent_private" { + return note.agent_id == requester_agent_id; + } + if !matches!(note.scope.as_str(), "project_shared" | "org_shared") { + return false; + } + if note.agent_id == requester_agent_id { + return true; + } + + shared_grants.contains(&SharedSpaceGrantKey { + scope: note.scope.clone(), + space_owner_agent_id: note.agent_id.clone(), + }) +} diff --git a/packages/elf-service/src/recall_debug/tests.rs b/packages/elf-service/src/recall_debug/tests.rs new file mode 100644 index 00000000..0d13b2c5 --- /dev/null +++ b/packages/elf-service/src/recall_debug/tests.rs @@ -0,0 +1,550 @@ +use std::collections::{BTreeMap, HashSet}; + +use serde_json::Value; +use time::OffsetDateTime; + +use crate::{ + RecallDebugRow, + access::SharedSpaceGrantKey, + recall_debug::{self, BTreeSet, Error, NoteDebugSourceRow, Uuid}, + search::{SearchTrace, SearchTrajectoryStage, TraceReplayCandidate}, +}; +use elf_storage::models::MemoryNote; + +#[test] +fn summary_preserves_not_requested_and_replay_counts() { + let layers = vec![ + recall_debug::not_requested_layer("graph_facts", "missing graph subject"), + recall_debug::layer_from_rows( + "memory_notes", + "pass", + Some("trace".to_string()), + "trace rows", + vec![ + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "n1"}), + selection_state: "selected".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([]), + score: Some(1.0), + rank: Some(1), + rationale: None, + stage_reason: None, + replay_command: Some("elf_admin_trace_bundle_get".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "n2"}), + selection_state: "dropped".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([]), + score: Some(0.5), + rank: Some(2), + rationale: None, + stage_reason: Some("not_in_final_top_k".to_string()), + replay_command: Some("elf_admin_trace_bundle_get".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + ], + ), + ]; + let summary = recall_debug::summarize_layers(&layers); + + assert_eq!(summary.layer_count, 2); + assert_eq!(summary.row_count, 2); + assert_eq!(summary.selected_count, 1); + assert_eq!(summary.dropped_count, 1); + assert_eq!(summary.not_requested_layer_count, 1); + assert_eq!(summary.replay_command_count, 2); + assert_eq!(summary.evidence_class_counts.get("pass"), Some(&1)); + assert_eq!(summary.evidence_class_counts.get("not_requested"), Some(&1)); +} + +#[test] +fn not_requested_layers_never_require_raw_sql() { + let layer = recall_debug::not_requested_layer("source_documents", "missing query"); + + assert_eq!(layer.evidence_class, "not_requested"); + assert_eq!(layer.row_count, 0); + assert!(!layer.raw_sql_needed); + assert!(!layer.replayable); +} + +#[test] +fn blocked_layers_are_counted_as_incomplete_evidence() { + let layer = recall_debug::blocked_layer( + "source_documents", + Some("alpha".to_string()), + "docs search failed", + &Error::Storage { message: "database unavailable".to_string() }, + ); + let summary = recall_debug::summarize_layers(&[layer]); + + assert_eq!(summary.layer_count, 1); + assert_eq!(summary.incomplete_layer_count, 1); + assert_eq!(summary.evidence_class_counts.get("blocked"), Some(&1)); +} + +#[test] +fn blocked_layer_does_not_expose_raw_backend_errors() { + let layer = recall_debug::blocked_layer( + "graph_facts", + None, + "graph report failed", + &Error::Storage { message: "password=secret host=db.internal".to_string() }, + ); + + assert!(layer.summary.contains("error_class=storage_unavailable")); + assert!(!layer.summary.contains("password=secret")); + assert!(!layer.summary.contains("db.internal")); +} + +#[test] +fn selected_candidate_filter_is_chunk_level() { + let note_id = Uuid::new_v4(); + let selected_chunk_id = Uuid::new_v4(); + let dropped_chunk_id = Uuid::new_v4(); + let selected = BTreeSet::from([recall_debug::candidate_identity(note_id, selected_chunk_id)]); + + assert!(selected.contains(&recall_debug::candidate_identity(note_id, selected_chunk_id))); + assert!(!selected.contains(&recall_debug::candidate_identity(note_id, dropped_chunk_id))); +} + +fn compact_replay_trace(trace_id: Uuid, now: OffsetDateTime) -> SearchTrace { + SearchTrace { + trace_id, + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "release handoff".to_string(), + expansion_mode: "dynamic".to_string(), + expanded_queries: vec!["release handoff".to_string(), "owner transfer".to_string()], + allowed_scopes: vec!["agent_private".to_string(), "project_shared".to_string()], + candidate_count: 2, + top_k: 1, + config_snapshot: serde_json::json!({ + "search": { + "expansion": { + "mode": "dynamic", + "max_queries": 3, + "include_original": true + } + }, + "ranking": { + "policy_id": "ranking_v2:test", + "blend": { + "enabled": true + }, + "diversity": { + "enabled": true + }, + "retrieval_sources": { + "fusion_weight": 1.0 + }, + "override": { + "blend": { + "enabled": false + } + } + } + }), + created_at: now, + trace_version: 3, + } +} + +fn compact_replay_stages() -> Vec { + vec![ + SearchTrajectoryStage { + stage_order: 2, + stage_name: "recall.candidates".to_string(), + stage_payload: serde_json::json!({ + "stats": { + "candidate_count_before_filter": 2, + "candidate_count_after_filter": 2 + } + }), + items: Vec::new(), + }, + SearchTrajectoryStage { + stage_order: 4, + stage_name: "rerank.score".to_string(), + stage_payload: serde_json::json!({ + "stats": { + "reranked_count": 2 + }, + "decisions": { + "blend_enabled": true, + "diversity_enabled": true + } + }), + items: Vec::new(), + }, + ] +} + +fn compact_replay_selected_candidate( + note_id: Uuid, + chunk_id: Uuid, + now: OffsetDateTime, +) -> TraceReplayCandidate { + TraceReplayCandidate { + note_id, + chunk_id, + chunk_index: 0, + snippet: "selected".to_string(), + retrieval_rank: 2, + retrieval_score: Some(0.4), + rerank_score: 0.9, + note_scope: "project_shared".to_string(), + note_importance: 0.7, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: Some(true), + diversity_selected_rank: Some(1), + diversity_selected_reason: Some("mmr".to_string()), + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: Some(0.8), + diversity_missing_embedding: Some(false), + } +} + +fn compact_replay_dropped_candidate( + note_id: Uuid, + chunk_id: Uuid, + selected_note_id: Uuid, + now: OffsetDateTime, +) -> TraceReplayCandidate { + TraceReplayCandidate { + note_id, + chunk_id, + chunk_index: 0, + snippet: "dropped".to_string(), + retrieval_rank: 1, + retrieval_score: Some(0.8), + rerank_score: 0.1, + note_scope: "project_shared".to_string(), + note_importance: 0.3, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: Some(false), + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: Some("not_in_final_top_k".to_string()), + diversity_nearest_selected_note_id: Some(selected_note_id), + diversity_similarity: Some(0.92), + diversity_mmr_score: Some(0.1), + diversity_missing_embedding: Some(false), + } +} + +fn compact_replay_source_refs( + selected_note_id: Uuid, + dropped_note_id: Uuid, + now: OffsetDateTime, +) -> BTreeMap { + BTreeMap::from([ + ( + selected_note_id, + NoteDebugSourceRow { + status: "active".to_string(), + source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "selected"}}), + updated_at: now, + }, + ), + ( + dropped_note_id, + NoteDebugSourceRow { + status: "active".to_string(), + source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "dropped"}}), + updated_at: now, + }, + ), + ]) +} + +fn assert_compact_replay_artifact(artifact: &Value) { + assert_eq!( + artifact.pointer("/schema").and_then(serde_json::Value::as_str), + Some("elf.recall_debug.compact_replay/v1") + ); + assert_eq!(artifact.pointer("/controls/top_k").and_then(serde_json::Value::as_u64), Some(1)); + assert_eq!( + artifact.pointer("/controls/expanded_query_count").and_then(serde_json::Value::as_u64), + Some(2) + ); + assert_eq!( + artifact.pointer("/controls/ranking/policy_id").and_then(serde_json::Value::as_str), + Some("ranking_v2:test") + ); + assert_eq!( + artifact.pointer("/stage_movement/1/stage_name").and_then(serde_json::Value::as_str), + Some("rerank.score") + ); + assert_eq!( + artifact.pointer("/candidate_replay/selected_count").and_then(serde_json::Value::as_u64), + Some(1) + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/selection_state") + .and_then(serde_json::Value::as_str), + Some("selected") + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/source_ref_available") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/rerank_delta") + .and_then(serde_json::Value::as_i64), + Some(1) + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/policy_reason") + .and_then(serde_json::Value::as_str), + Some("mmr") + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/1/selection_state") + .and_then(serde_json::Value::as_str), + Some("dropped") + ); + assert_eq!( + artifact.pointer("/authority/raw_sql_needed").and_then(serde_json::Value::as_bool), + Some(false) + ); +} + +#[test] +fn compact_replay_artifact_exposes_controls_stage_movement_and_rerank_effects() { + let trace_id = Uuid::new_v4(); + let selected_note_id = Uuid::new_v4(); + let selected_chunk_id = Uuid::new_v4(); + let dropped_note_id = Uuid::new_v4(); + let dropped_chunk_id = Uuid::new_v4(); + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let candidates = vec![ + compact_replay_selected_candidate(selected_note_id, selected_chunk_id, now), + compact_replay_dropped_candidate(dropped_note_id, dropped_chunk_id, selected_note_id, now), + ]; + let selected = + BTreeSet::from([recall_debug::candidate_identity(selected_note_id, selected_chunk_id)]); + let source_refs = compact_replay_source_refs(selected_note_id, dropped_note_id, now); + let artifact = recall_debug::memory_compact_replay_artifact( + &compact_replay_trace(trace_id, now), + compact_replay_stages().as_slice(), + candidates.as_slice(), + &[], + &selected, + &source_refs, + "elf_admin_trace_bundle_get trace_id= mode=bounded", + ); + + assert_compact_replay_artifact(&artifact); +} + +#[test] +fn debug_note_readability_requires_current_note_and_scope_access() { + let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; + let shared_grants = HashSet::new(); + let now = OffsetDateTime::now_utc(); + let mut note = note_for_debug_visibility("owner-agent", "agent_private", "active"); + + assert!(recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "other-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.status = "deleted".to_string(); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.status = "deprecated".to_string(); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.status = "active".to_string(); + note.expires_at = Some(now); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.expires_at = None; + note.scope = "project_shared".to_string(); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "other-agent", + &allowed_scopes, + &shared_grants, + now + )); + + let shared_grants = HashSet::from([SharedSpaceGrantKey { + scope: "project_shared".to_string(), + space_owner_agent_id: "owner-agent".to_string(), + }]); + + assert!(recall_debug::note_debug_read_allowed( + ¬e, + "other-agent", + &allowed_scopes, + &shared_grants, + now + )); +} + +#[test] +fn recall_trace_flattens_stale_and_dropped_context() { + let layers = vec![ + recall_debug::layer_from_rows( + "memory_notes", + "pass", + Some("trace".to_string()), + "trace rows", + vec![ + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "selected-stale"}), + selection_state: "selected".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "deprecated".to_string(), + source_refs: serde_json::json!([{"schema": "source_ref/v1"}]), + score: Some(0.9), + rank: Some(1), + rationale: Some("selected but stale".to_string()), + stage_reason: Some("status=deprecated".to_string()), + replay_command: Some("elf_trace".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "dropped"}), + selection_state: "dropped".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([]), + score: Some(0.4), + rank: Some(4), + rationale: Some("candidate not narrated".to_string()), + stage_reason: Some("not_in_final_top_k".to_string()), + replay_command: Some("elf_trace".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + ], + ), + recall_debug::not_requested_layer("graph_facts", "missing graph subject"), + ]; + let trace = recall_debug::build_recall_trace(&layers); + + assert_eq!(trace.schema, "elf.recall_trace/v1"); + assert_eq!(trace.summary.entry_count, 3); + assert_eq!(trace.summary.selected_count, 1); + assert_eq!(trace.summary.dropped_count, 1); + assert_eq!(trace.summary.stale_count, 1); + assert_eq!(trace.summary.not_requested_count, 1); + assert_eq!(trace.summary.replay_command_count, 2); + assert_eq!(trace.entries[0].context_state, "stale"); + assert_eq!(trace.entries[0].policy_reason.as_deref(), Some("status=deprecated")); + assert_eq!(trace.entries[1].context_state, "dropped"); + assert_eq!(trace.entries[1].policy_reason.as_deref(), Some("not_in_final_top_k")); + assert_eq!(trace.entries[2].context_state, "not_requested"); +} + +#[test] +fn recall_trace_counts_blocked_layers_without_backend_details() { + let layer = recall_debug::blocked_layer( + "source_documents", + Some("alpha".to_string()), + "docs search failed", + &Error::Storage { message: "password=secret host=db.internal".to_string() }, + ); + let trace = recall_debug::build_recall_trace(&[layer]); + + assert_eq!(trace.summary.blocked_count, 1); + assert_eq!(trace.entries[0].context_state, "blocked"); + assert_eq!(trace.entries[0].selection_state, "blocked"); + assert!( + trace.entries[0] + .policy_reason + .as_deref() + .is_some_and(|reason| reason.contains("error_class=storage_unavailable")) + ); + assert!( + trace.entries[0] + .policy_reason + .as_deref() + .is_some_and(|reason| !reason.contains("password=secret")) + ); +} + +fn note_for_debug_visibility(agent_id: &str, scope: &str, status: &str) -> MemoryNote { + let now = OffsetDateTime::now_utc(); + + MemoryNote { + note_id: Uuid::new_v4(), + tenant_id: "tenant-a".to_string(), + project_id: "project-a".to_string(), + agent_id: agent_id.to_string(), + scope: scope.to_string(), + r#type: "fact".to_string(), + key: None, + text: "Fact: debug visibility test note.".to_string(), + importance: 0.7, + confidence: 0.9, + status: status.to_string(), + created_at: now, + updated_at: now, + expires_at: None, + embedding_version: "test:v1".to_string(), + source_ref: serde_json::json!({"schema": "source_ref/v1"}), + hit_count: 0, + last_hit_at: None, + } +} diff --git a/packages/elf-service/src/recall_debug/trace.rs b/packages/elf-service/src/recall_debug/trace.rs new file mode 100644 index 00000000..e8e1eeff --- /dev/null +++ b/packages/elf-service/src/recall_debug/trace.rs @@ -0,0 +1,239 @@ +use crate::recall_debug::{ + self, ELF_RECALL_TRACE_SCHEMA_V1, Error, RecallDebugLayer, RecallDebugPanelSummary, + RecallDebugRow, RecallTrace, RecallTraceEntry, RecallTraceSummary, Value, +}; + +pub(super) fn summarize_layers(layers: &[RecallDebugLayer]) -> RecallDebugPanelSummary { + let mut summary = RecallDebugPanelSummary { layer_count: layers.len(), ..Default::default() }; + + for layer in layers { + summary.row_count += layer.row_count; + summary.selected_count += layer.selected_count; + summary.dropped_count += layer.dropped_count; + summary.available_count += layer.available_count; + + if layer.evidence_class == "not_requested" { + summary.not_requested_layer_count += 1; + } + if matches!(layer.evidence_class.as_str(), "incomplete" | "blocked" | "wrong_result") { + summary.incomplete_layer_count += 1; + } + if layer.raw_sql_needed { + summary.raw_sql_needed_count += 1; + } + + summary.replay_command_count += layer + .rows + .iter() + .filter(|row| row.replay_command.as_ref().is_some_and(|value| !value.is_empty())) + .count(); + *summary.evidence_class_counts.entry(layer.evidence_class.clone()).or_default() += 1; + } + + summary +} + +pub(super) fn build_recall_trace(layers: &[RecallDebugLayer]) -> RecallTrace { + let mut entries = Vec::new(); + + for layer in layers { + if layer.rows.is_empty() { + if matches!( + layer.evidence_class.as_str(), + "blocked" | "not_requested" | "incomplete" | "wrong_result" + ) { + entries.push(layer_trace_entry(layer)); + } + + continue; + } + + entries.extend(layer.rows.iter().map(row_trace_entry)); + } + + let summary = summarize_trace_entries(&entries); + + RecallTrace { schema: ELF_RECALL_TRACE_SCHEMA_V1.to_string(), summary, entries } +} + +pub(super) fn summarize_trace_entries(entries: &[RecallTraceEntry]) -> RecallTraceSummary { + let mut summary = RecallTraceSummary { entry_count: entries.len(), ..Default::default() }; + + for entry in entries { + match entry.selection_state.as_str() { + "selected" => summary.selected_count += 1, + "dropped" => summary.dropped_count += 1, + "blocked" => summary.blocked_count += 1, + "not_requested" => summary.not_requested_count += 1, + _ => {}, + } + + if entry.context_state == "stale" || stale_freshness_state(&entry.freshness_state) { + summary.stale_count += 1; + } + if entry.raw_sql_needed { + summary.raw_sql_needed_count += 1; + } + if entry.replay_command.as_ref().is_some_and(|value| !value.is_empty()) { + summary.replay_command_count += 1; + } + } + + summary +} + +pub(super) fn layer_trace_entry(layer: &RecallDebugLayer) -> RecallTraceEntry { + let context_state = match layer.evidence_class.as_str() { + "not_requested" => "not_requested", + "blocked" => "blocked", + "incomplete" => "incomplete", + "wrong_result" => "wrong_result", + _ => "available", + }; + + RecallTraceEntry { + layer: layer.layer.clone(), + context_state: context_state.to_string(), + selection_state: layer.evidence_class.clone(), + authority_layer: layer.layer.clone(), + freshness_state: layer.evidence_class.clone(), + item_ref: serde_json::json!({ + "layer": layer.layer.clone(), + "anchor": layer.anchor.clone(), + }), + source_refs: serde_json::json!([]), + score: None, + rank: None, + policy_reason: Some(layer.summary.clone()), + replay_command: None, + evidence_class: layer.evidence_class.clone(), + raw_sql_needed: layer.raw_sql_needed, + } +} + +pub(super) fn row_trace_entry(row: &RecallDebugRow) -> RecallTraceEntry { + let context_state = if stale_freshness_state(&row.freshness_state) { + "stale" + } else { + row.selection_state.as_str() + }; + + RecallTraceEntry { + layer: row.layer.clone(), + context_state: context_state.to_string(), + selection_state: row.selection_state.clone(), + authority_layer: row.authority_layer.clone(), + freshness_state: row.freshness_state.clone(), + item_ref: row.item_ref.clone(), + source_refs: row.source_refs.clone(), + score: row.score, + rank: row.rank, + policy_reason: row.stage_reason.clone().or_else(|| row.rationale.clone()), + replay_command: row.replay_command.clone(), + evidence_class: row.evidence_class.clone(), + raw_sql_needed: false, + } +} + +pub(super) fn stale_freshness_state(freshness_state: &str) -> bool { + matches!( + freshness_state, + "stale" + | "deprecated" + | "deleted" + | "superseded" + | "tombstoned" + | "historical" + | "archived" + | "lint_warning" + | "lint_error" + ) +} + +pub(super) fn layer_from_rows( + layer: &str, + evidence_class: &str, + anchor: Option, + summary: &str, + rows: Vec, +) -> RecallDebugLayer { + layer_from_rows_with_artifacts( + layer, + evidence_class, + anchor, + summary, + rows, + serde_json::json!({}), + ) +} + +pub(super) fn layer_from_rows_with_artifacts( + layer: &str, + evidence_class: &str, + anchor: Option, + summary: &str, + rows: Vec, + debug_artifacts: Value, +) -> RecallDebugLayer { + let selected_count = rows.iter().filter(|row| row.selection_state == "selected").count(); + let dropped_count = rows.iter().filter(|row| row.selection_state == "dropped").count(); + let available_count = rows + .iter() + .filter(|row| matches!(row.selection_state.as_str(), "available" | "reviewable")) + .count(); + let replayable = rows.iter().any(|row| row.replay_command.is_some()); + + RecallDebugLayer { + layer: layer.to_string(), + evidence_class: evidence_class.to_string(), + summary: summary.to_string(), + anchor, + row_count: rows.len(), + selected_count, + dropped_count, + available_count, + raw_sql_needed: false, + replayable, + debug_artifacts, + rows, + } +} + +pub(super) fn not_requested_layer(layer: &str, summary: &str) -> RecallDebugLayer { + RecallDebugLayer { + layer: layer.to_string(), + evidence_class: "not_requested".to_string(), + summary: summary.to_string(), + anchor: None, + row_count: 0, + selected_count: 0, + dropped_count: 0, + available_count: 0, + raw_sql_needed: false, + replayable: false, + debug_artifacts: serde_json::json!({}), + rows: Vec::new(), + } +} + +pub(super) fn blocked_layer( + layer: &str, + anchor: Option, + summary: &str, + err: &Error, +) -> RecallDebugLayer { + RecallDebugLayer { + layer: layer.to_string(), + evidence_class: "blocked".to_string(), + summary: format!("{summary} error_class={}", recall_debug::public_error_class(err)), + anchor, + row_count: 0, + selected_count: 0, + dropped_count: 0, + available_count: 0, + raw_sql_needed: false, + replayable: false, + debug_artifacts: serde_json::json!({}), + rows: Vec::new(), + } +} diff --git a/packages/elf-service/src/recall_debug/types.rs b/packages/elf-service/src/recall_debug/types.rs new file mode 100644 index 00000000..c5fdec4d --- /dev/null +++ b/packages/elf-service/src/recall_debug/types.rs @@ -0,0 +1,235 @@ +use crate::recall_debug::{ + BTreeMap, Deserialize, GraphQueryEntityRef, GraphQueryPredicateRef, OffsetDateTime, Serialize, + Uuid, Value, +}; + +/// Schema identifier for recall/debug panel responses. +pub const ELF_RECALL_DEBUG_PANEL_SCHEMA_V1: &str = "elf.recall_debug_panel/v1"; +/// Schema identifier for deterministic recall trace projections. +pub const ELF_RECALL_TRACE_SCHEMA_V1: &str = "elf.recall_trace/v1"; + +pub(super) const DEFAULT_RECALL_DEBUG_LIMIT: u32 = 25; +pub(super) const MAX_RECALL_DEBUG_LIMIT: u32 = 100; +pub(super) const MAX_RECALL_DEBUG_DOCS_LIMIT: u32 = 32; + +/// Request payload for the cross-layer recall/debug panel. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RecallDebugPanelRequest { + /// Tenant that owns the readback. + pub tenant_id: String, + /// Project that owns the readback. + pub project_id: String, + /// Agent requesting the readback. + pub agent_id: String, + /// Read profile used for memory, document, and graph visibility. + pub read_profile: String, + /// Optional search trace anchor for memory selected/dropped rows. + pub trace_id: Option, + /// Shared query used when docs_query or knowledge_query are omitted. + pub query: Option, + /// Optional Source Library query. + pub docs_query: Option, + /// Optional Knowledge Workspace page query. + pub knowledge_query: Option, + /// Optional graph subject selector. + pub graph_subject: Option, + /// Optional graph predicate selector. + pub graph_predicate: Option, + /// Whether to include Dreaming review queue proposals. Omitted means not requested. + pub include_dreaming: Option, + /// Maximum rows per layer. + pub limit: Option, + #[serde(skip)] + /// Whether project-scoped trace anchors are allowed for an admin mirror request. + pub allow_project_trace_debug: bool, +} + +/// Cross-layer recall/debug panel response. +#[derive(Clone, Debug, Serialize)] +pub struct RecallDebugPanelResponse { + /// Response schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Panel generation timestamp. + pub generated_at: OffsetDateTime, + /// Echo of the effective anchors used for this response. + pub request: RecallDebugPanelRequestEcho, + /// Aggregate panel summary. + pub summary: RecallDebugPanelSummary, + /// Deterministic flat trace projection for agents and fixture assertions. + pub recall_trace: RecallTrace, + /// Cross-layer rows grouped by source layer. + pub layers: Vec, +} + +/// Deterministic flat recall trace over all requested layers. +#[derive(Clone, Debug, Serialize)] +pub struct RecallTrace { + /// Trace schema identifier. + pub schema: String, + /// Aggregate trace counters. + pub summary: RecallTraceSummary, + /// Stable trace entries in layer and row order. + pub entries: Vec, +} + +/// Aggregate counters for a recall trace. +#[derive(Clone, Debug, Default, Serialize)] +pub struct RecallTraceSummary { + /// Number of trace entries. + pub entry_count: usize, + /// Entries whose row selection state is selected. + pub selected_count: usize, + /// Entries whose row selection state is dropped. + pub dropped_count: usize, + /// Entries whose freshness state indicates stale or non-current evidence. + pub stale_count: usize, + /// Entries representing blocked layers. + pub blocked_count: usize, + /// Entries representing layers that were not requested. + pub not_requested_count: usize, + /// Entries that require raw SQL for diagnosis. + pub raw_sql_needed_count: usize, + /// Entries with a replay command or deterministic artifact path. + pub replay_command_count: usize, +} + +/// One compact recall trace entry. +#[derive(Clone, Debug, Serialize)] +pub struct RecallTraceEntry { + /// Layer identifier. + pub layer: String, + /// Primary trace state for compact assertions. + pub context_state: String, + /// Original row selection state or layer evidence class. + pub selection_state: String, + /// Authority layer that owns the context. + pub authority_layer: String, + /// Freshness or temporal state. + pub freshness_state: String, + /// Stable identifiers for replay or hydration. + pub item_ref: Value, + /// Source refs or source snapshots supporting the context. + pub source_refs: Value, + /// Optional score. + pub score: Option, + /// Optional rank. + pub rank: Option, + /// Compact policy or stage reason for the state. + pub policy_reason: Option, + /// Replay command or deterministic artifact path. + pub replay_command: Option, + /// Layer or row evidence class. + pub evidence_class: String, + /// Whether raw SQL is required to diagnose this entry. + pub raw_sql_needed: bool, +} + +/// Stable request echo for panel responses. +#[derive(Clone, Debug, Serialize)] +pub struct RecallDebugPanelRequestEcho { + /// Search trace anchor used for memory rows. + pub trace_id: Option, + /// Effective Source Library query. + pub docs_query: Option, + /// Effective Knowledge Workspace query. + pub knowledge_query: Option, + /// Whether a graph subject was supplied. + pub graph_subject_supplied: bool, + /// Whether Dreaming proposals were included. + pub include_dreaming: bool, + /// Effective row cap per layer. + pub limit: u32, +} + +/// Aggregate panel counters. +#[derive(Clone, Debug, Default, Serialize)] +pub struct RecallDebugPanelSummary { + /// Number of returned layers. + pub layer_count: usize, + /// Total returned row count. + pub row_count: usize, + /// Rows selected by a retrieval or review stage. + pub selected_count: usize, + /// Rows dropped by a retrieval or review stage. + pub dropped_count: usize, + /// Rows available for inspection but not selected/dropped. + pub available_count: usize, + /// Layers skipped because no anchor was supplied. + pub not_requested_layer_count: usize, + /// Layers that require follow-up before they can prove a debug claim. + pub incomplete_layer_count: usize, + /// Rows or layers that require raw SQL to inspect. + pub raw_sql_needed_count: usize, + /// Rows with a replay command or deterministic artifact path. + pub replay_command_count: usize, + /// Evidence-class counts across layers. + pub evidence_class_counts: BTreeMap, +} + +/// One recall/debug source layer. +#[derive(Clone, Debug, Serialize)] +pub struct RecallDebugLayer { + /// Layer identifier. + pub layer: String, + /// Evidence class for this layer. + pub evidence_class: String, + /// Human-readable layer summary. + pub summary: String, + /// Query or object anchor used by the layer. + pub anchor: Option, + /// Number of returned rows. + pub row_count: usize, + /// Selected rows in this layer. + pub selected_count: usize, + /// Dropped rows in this layer. + pub dropped_count: usize, + /// Available review/inspection rows in this layer. + pub available_count: usize, + /// Whether raw SQL is needed to inspect this layer. + pub raw_sql_needed: bool, + /// Whether the layer includes replay commands or deterministic artifact paths. + pub replayable: bool, + /// Compact layer-level debug artifacts. + pub debug_artifacts: Value, + /// Returned layer rows. + pub rows: Vec, +} + +/// One item in the recall/debug panel. +#[derive(Clone, Debug, Serialize)] +pub struct RecallDebugRow { + /// Layer identifier. + pub layer: String, + /// Stable item reference. + pub item_ref: Value, + /// Selection state such as selected, dropped, available, or reviewable. + pub selection_state: String, + /// Authority layer that owns the row. + pub authority_layer: String, + /// Freshness or temporal state. + pub freshness_state: String, + /// Source refs or source snapshots backing the row. + pub source_refs: Value, + /// Optional final score. + pub score: Option, + /// Optional rank within the layer. + pub rank: Option, + /// Short selection rationale. + pub rationale: Option, + /// Stage reason for selected/dropped status. + pub stage_reason: Option, + /// Replay command or deterministic artifact path when available. + pub replay_command: Option, + /// Row-level evidence class. + pub evidence_class: String, + /// Layer-specific debug artifacts. + pub debug_artifacts: Value, +} + +#[derive(Clone, Debug)] +pub(super) struct NoteDebugSourceRow { + pub(super) status: String, + pub(super) source_ref: Value, + pub(super) updated_at: OffsetDateTime, +} diff --git a/packages/elf-service/src/search.rs b/packages/elf-service/src/search.rs index 8101de22..86365fb3 100644 --- a/packages/elf-service/src/search.rs +++ b/packages/elf-service/src/search.rs @@ -1,9 +1,43 @@ //! Search APIs and ranking explanations. +mod api; +mod cache; +mod db_helpers; mod filter; +mod finish; +mod helpers; +mod hits; +mod item_builders; +mod query_plan; mod ranking; +mod replay_helpers; +mod retrieval; +mod scoring_helpers; +mod service; +mod sql; +mod state; +mod structured; +mod trace; +mod trace_persistence; +mod trace_stages; +mod trajectory_loaders; pub use crate::ranking_explain_v2::{SearchRankingExplain, SearchRankingTerm}; +pub use api::{ + BlendRankingOverride, BlendSegmentOverride, DiversityRankingOverride, PayloadLevel, QueryPlan, + QueryPlanBlendSegment, QueryPlanBudget, QueryPlanDynamicGate, QueryPlanFusionPolicy, + QueryPlanIntent, QueryPlanRerankPolicy, QueryPlanRetrievalStage, QueryPlanRewrite, + QueryPlanStage, RankingRequestOverride, RecentTraceHeader, RetrievalSourcesRankingOverride, + SearchDiversityExplain, SearchExplain, SearchExplainItem, SearchExplainRelationContext, + SearchExplainRelationContextObject, SearchExplainRelationEntityRef, SearchExplainRequest, + SearchExplainResponse, SearchExplainTrajectory, SearchExplainTrajectoryMatch, + SearchExplainTrajectoryStage, SearchItem, SearchMatchExplain, SearchRawPlannedResponse, + SearchRequest, SearchResponse, SearchTrace, SearchTrajectoryResponse, SearchTrajectoryStage, + SearchTrajectoryStageItem, SearchTrajectorySummary, SearchTrajectorySummaryStage, + TraceBundleGetRequest, TraceBundleMode, TraceBundleResponse, TraceGetRequest, TraceGetResponse, + TraceRecentCursor, TraceRecentListRequest, TraceRecentListResponse, TraceReplayCandidate, + TraceReplayContext, TraceReplayItem, TraceTrajectoryGetRequest, +}; use std::{ cmp::Ordering, @@ -15,18 +49,20 @@ use qdrant_client::qdrant::{ Condition, Document, Filter, Fusion, MinShould, PrefetchQueryBuilder, Query, QueryPointsBuilder, ScoredPoint, }; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde::{Deserialize, Serialize}; use serde_json::Value; -use sqlx::{FromRow, PgConnection, PgExecutor, PgPool, QueryBuilder, Row}; +use sqlx::{FromRow, PgConnection, PgExecutor, PgPool, QueryBuilder}; use time::{Duration, OffsetDateTime}; use uuid::Uuid; use crate::{ ElfService, Result, - access::{self, ORG_PROJECT_ID}, + access::ORG_PROJECT_ID, graph::RelationTemporalStatus, - ranking_explain_v2::{self, SEARCH_RANKING_EXPLAIN_SCHEMA_V2, TraceTermsArgs}, + ranking_explain_v2::{SEARCH_RANKING_EXPLAIN_SCHEMA_V2, TraceTermsArgs}, }; +use cache::{fetch_cache_payload, store_cache_payload}; +use db_helpers::{fetch_chunks_by_pair, fetch_note_vectors_for_diversity}; use elf_config::{Config, SearchCache}; use elf_domain::english_gate; use elf_storage::{ @@ -34,7368 +70,155 @@ use elf_storage::{ qdrant::{BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME}, }; use filter::{SearchFilter, SearchFilterImpact}; +use helpers::{ + apply_payload_level_to_search_item, build_search_filter, build_trajectory_summary_from_stages, + raw_search_path_label, sorted_unique_strings, validate_search_request_inputs, +}; +use hits::record_hits; +use item_builders::{build_search_item_and_trace_item, build_trace_candidate_record}; use ranking::{ NormalizationKind, ResolvedBlendPolicy, ResolvedDiversityPolicy, ResolvedRetrievalSourcesPolicy, }; +use replay_helpers::cmp_scored_replay; +use scoring_helpers::{score_chunk_candidate, select_best_scored_chunks}; +use sql::{ + DEFAULT_BOUNDED_CANDIDATES_LIMIT, DEFAULT_BOUNDED_STAGE_ITEMS_LIMIT, + DEFAULT_FULL_CANDIDATES_LIMIT, DEFAULT_FULL_STAGE_ITEMS_LIMIT, DEFAULT_RECENT_TRACES_LIMIT, + MAX_RECENT_TRACES_LIMIT, MAX_TRACE_BUNDLE_CANDIDATES_LIMIT, MAX_TRACE_BUNDLE_ITEMS_LIMIT, + RECENT_TRACES_SCHEMA_V1, RELATION_CONTEXT_SQL, SEARCH_FILTER_IMPACT_SCHEMA_V1, + SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, TRACE_BUNDLE_SCHEMA_V1, +}; +use state::{ + BestChunkForNoteRow, BuildQueryPlanArgs, BuildSearchItemArgs, BuildTraceArgs, CacheKind, + CachePayload, ChunkCandidate, ChunkMeta, ChunkRow, ChunkSnippet, DeterministicRankingTerms, + DiversityDecision, DynamicGateSummary, ExpansionCachePayload, ExpansionMode, ExpansionOutput, + FieldHit, FinishSearchArgs, FinishSearchPolicies, FinishSearchScoringResult, + MaybeDynamicSearchArgs, NoteMeta, NoteVectorRow, QueryEmbedding, QueryPlanStagesArgs, + RawSearchExecutionContext, RawSearchPath, RecursiveRetrievalArgs, RecursiveRetrievalResult, + RerankCacheCandidate, RerankCacheItem, RerankCachePayload, RetrievalSourceCandidates, + RetrievalSourceKind, ScoreCandidateCtx, ScoreSnippetArgs, ScoredChunk, ScoredReplay, + SearchExplainTraceRow, SearchRecentTraceRow, SearchRelationContextRow, SearchRetrievalArgs, + SearchRetrievalResult, SearchTraceBuilder, SearchTraceItemRow, SearchTraceRow, + StructuredFieldHitArgs, StructuredFieldHitRow, StructuredFieldRetrievalArgs, + StructuredFieldRetrievalResult, TraceCandidateRecord, TraceCandidateSnapshotRow, TraceContext, + TraceItemRecord, TracePayload, TraceRecord, TraceTrajectoryStageItemRecord, + TraceTrajectoryStageRecord, +}; +use structured::{build_structured_field_candidates, build_structured_field_matches}; +use trace_persistence::{enqueue_trace, persist_trace_inline}; +use trace_stages::{build_trace_audit, build_trace_trajectory_stages}; +use trajectory_loaders::{ + load_item_trajectory, load_trace_trajectory_stages, load_trace_trajectory_summary, +}; const TRACE_VERSION: i32 = 3; const MAX_MATCHED_TERMS: usize = 8; const MAX_TRAJECTORY_STAGE_ITEMS: usize = 256; const MAX_CANDIDATE_K: u32 = 1_024; -const QUERY_PLAN_SCHEMA: &str = "elf.search.query_plan"; -const QUERY_PLAN_VERSION: &str = "v1"; -const SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1: &str = "search_retrieval_trajectory/v1"; -const SEARCH_FILTER_IMPACT_SCHEMA_V1: &str = "search_filter_impact/v1"; -const RECENT_TRACES_SCHEMA_V1: &str = "elf.recent_traces/v1"; -const TRACE_BUNDLE_SCHEMA_V1: &str = "elf.trace_bundle/v1"; -const MAX_RECENT_TRACES_LIMIT: u32 = 200; -const DEFAULT_RECENT_TRACES_LIMIT: u32 = 50; -const DEFAULT_BOUNDED_STAGE_ITEMS_LIMIT: u32 = 64; -const DEFAULT_FULL_STAGE_ITEMS_LIMIT: u32 = 256; -const DEFAULT_BOUNDED_CANDIDATES_LIMIT: u32 = 0; -const DEFAULT_FULL_CANDIDATES_LIMIT: u32 = 200; -const MAX_TRACE_BUNDLE_ITEMS_LIMIT: u32 = 256; -const MAX_TRACE_BUNDLE_CANDIDATES_LIMIT: u32 = 1_000; -const RELATION_CONTEXT_SQL: &str = r#" -WITH selected_facts AS ( - SELECT DISTINCT ON (snc.selected_note_id, gf.fact_id) - snc.selected_note_id, - gf.fact_id, - gf.scope, - subject_entity.canonical AS subject_canonical, - subject_entity.kind AS subject_kind, - gf.predicate, - gf.object_entity_id, - object_entity.canonical AS object_canonical, - object_entity.kind AS object_kind, - gf.object_value, - gf.valid_from, - gf.valid_to, - (gf.valid_from <= $4 AND (gf.valid_to IS NULL OR gf.valid_to > $4)) AS is_current - FROM unnest($7::uuid[]) AS snc(selected_note_id) - JOIN memory_notes selected_note - ON selected_note.note_id = snc.selected_note_id - JOIN graph_fact_evidence gfe - ON gfe.note_id = snc.selected_note_id - JOIN graph_facts gf - ON gf.fact_id = gfe.fact_id - JOIN graph_entities subject_entity - ON subject_entity.entity_id = gf.subject_entity_id - AND subject_entity.tenant_id = $1 - AND subject_entity.project_id = $2 - LEFT JOIN graph_entities object_entity - ON object_entity.entity_id = gf.object_entity_id - AND object_entity.tenant_id = $1 - AND object_entity.project_id = $2 - WHERE gf.tenant_id = $1 - AND gf.project_id = $2 - AND selected_note.tenant_id = $1 - AND selected_note.project_id = $2 - AND selected_note.status = 'active' - AND ( - selected_note.expires_at IS NULL - OR selected_note.expires_at > $4 - ) - AND ( - ($5 AND selected_note.scope = 'agent_private' AND selected_note.agent_id = $3) - OR ( - selected_note.scope = ANY($6::text[]) - AND ( - selected_note.agent_id = $3 - OR concat(selected_note.scope, ':', selected_note.agent_id) = ANY($10::text[]) - ) - ) - ) - AND ( - ($5 AND gf.scope = 'agent_private' AND gf.agent_id = $3) - OR ( - gf.scope = ANY($6::text[]) - AND ( - gf.agent_id = $3 - OR concat(gf.scope, ':', gf.agent_id) = ANY($10::text[]) - ) - ) - ) - AND gf.valid_from <= $4 - ORDER BY - snc.selected_note_id, - gf.fact_id, - (gf.valid_from <= $4 AND (gf.valid_to IS NULL OR gf.valid_to > $4)) DESC, - gf.valid_from DESC, - gf.fact_id ASC -), -ranked_facts AS ( - SELECT - selected_note_id, - fact_id, - scope, - subject_canonical, - subject_kind, - predicate, - object_entity_id, - object_canonical, - object_kind, - object_value, - valid_from, - valid_to, - is_current, - ROW_NUMBER() OVER ( - PARTITION BY selected_note_id - ORDER BY is_current DESC, valid_from DESC, fact_id ASC - ) AS fact_rank - FROM selected_facts -), -bounded_facts AS ( - SELECT - selected_note_id, - fact_id, - scope, - subject_canonical, - subject_kind, - predicate, - object_entity_id, - object_canonical, - object_kind, - object_value, - valid_from, - valid_to, - is_current, - fact_rank - FROM ranked_facts - WHERE fact_rank <= $9 -), -evidence_ranked AS ( - SELECT - bf.selected_note_id, - bf.fact_id, - bf.scope, - bf.subject_canonical, - bf.subject_kind, - bf.predicate, - bf.object_entity_id, - bf.object_canonical, - bf.object_kind, - bf.object_value, - bf.valid_from, - bf.valid_to, - bf.is_current, - bf.fact_rank, - e.note_id AS evidence_note_id, - e.created_at AS evidence_created_at, - ROW_NUMBER() OVER ( - PARTITION BY bf.selected_note_id, bf.fact_id - ORDER BY e.created_at ASC, e.note_id ASC - ) AS evidence_rank - FROM bounded_facts bf - JOIN graph_fact_evidence e - ON e.fact_id = bf.fact_id - JOIN memory_notes evidence_note - ON evidence_note.note_id = e.note_id - AND evidence_note.tenant_id = $1 - AND evidence_note.project_id = $2 - AND evidence_note.status = 'active' - AND ( - evidence_note.expires_at IS NULL - OR evidence_note.expires_at > $4 - ) - AND ( - ($5 AND evidence_note.scope = 'agent_private' AND evidence_note.agent_id = $3) - OR ( - evidence_note.scope = ANY($6::text[]) - AND ( - evidence_note.agent_id = $3 - OR concat(evidence_note.scope, ':', evidence_note.agent_id) = ANY($10::text[]) - ) - ) - ) -), -fact_contexts AS ( - SELECT - selected_note_id, - fact_id, - scope, - subject_canonical, - subject_kind, - predicate, - object_entity_id, - object_canonical, - object_kind, - object_value, - valid_from, - valid_to, - is_current, - fact_rank, - ARRAY_AGG(evidence_note_id ORDER BY evidence_created_at ASC, evidence_note_id ASC) AS evidence_note_ids - FROM evidence_ranked - WHERE evidence_rank <= $8 - GROUP BY - selected_note_id, - fact_id, - scope, - subject_canonical, - subject_kind, - predicate, - object_entity_id, - object_canonical, - object_kind, - object_value, - valid_from, - valid_to, - is_current, - fact_rank -) -SELECT - selected_note_id AS note_id, - fact_id, - scope, - subject_canonical, - subject_kind, - predicate, - object_entity_id, - object_canonical, - object_kind, - object_value, - valid_from, - valid_to, - is_current, - evidence_note_ids -FROM fact_contexts -ORDER BY note_id, fact_rank -"#; - -/// Request payload for search APIs. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchRequest { - /// Tenant to search within. - pub tenant_id: String, - /// Project to search within. - pub project_id: String, - /// Agent requesting the search. - pub agent_id: String, - /// Optional auth token identifier used for role checks. - pub token_id: Option, - #[serde(default)] - /// Requested payload-detail level. - pub payload_level: PayloadLevel, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Search query text. - pub query: String, - /// Requested number of returned items. - pub top_k: Option, - /// Retrieval breadth before ranking and projection. - pub candidate_k: Option, - - /// Optional structured filter expression. - pub filter: Option, - /// When true, records note-hit metrics for returned items. - pub record_hits: Option, - /// Optional ranking-policy overrides. - pub ranking: Option, -} - -/// Ranking override bundle supplied on a search request. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RankingRequestOverride { - /// Blend-ranking override. - pub blend: Option, - /// Diversity-ranking override. - pub diversity: Option, - /// Retrieval-source weighting override. - pub retrieval_sources: Option, -} - -/// Blend-ranking override supplied on a search request. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct BlendRankingOverride { - /// Enables or disables blend ranking. - pub enabled: Option, - /// Override for rerank-score normalization. - pub rerank_normalization: Option, - /// Override for retrieval-score normalization. - pub retrieval_normalization: Option, - /// Override for blend segments. - pub segments: Option>, -} - -/// One blend segment override. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct BlendSegmentOverride { - /// Highest retrieval rank covered by the segment. - pub max_retrieval_rank: u32, - /// Retrieval weight applied within the segment. - pub retrieval_weight: f32, -} - -/// Diversity-ranking override supplied on a search request. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DiversityRankingOverride { - /// Enables or disables diversity selection. - pub enabled: Option, - /// Similarity threshold for duplicate suppression. - pub sim_threshold: Option, - /// MMR lambda value. - pub mmr_lambda: Option, - /// Maximum number of candidates to skip while selecting diverse results. - pub max_skips: Option, -} - -/// Retrieval-source weighting override supplied on a search request. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RetrievalSourcesRankingOverride { - /// Weight for fusion retrieval. - pub fusion_weight: Option, - /// Weight for structured-field retrieval. - pub structured_field_weight: Option, - /// Priority for fusion retrieval. - pub fusion_priority: Option, - /// Priority for structured-field retrieval. - pub structured_field_priority: Option, - /// Weight for recursive retrieval. - pub recursive_weight: Option, - /// Priority for recursive retrieval. - pub recursive_priority: Option, -} - -/// Full explanation attached to one search item. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplain { - /// Match-specific explanation. - pub r#match: SearchMatchExplain, - /// Ranking-term explanation. - pub ranking: SearchRankingExplain, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional relation-context snippets supporting the match. - pub relation_context: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional diversity-selection explanation. - pub diversity: Option, -} - -/// Relation-context row attached to a search explanation. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainRelationContext { - /// Fact identifier. - pub fact_id: Uuid, - /// Scope key for the fact. - pub scope: String, - /// Subject entity reference. - pub subject: SearchExplainRelationEntityRef, - /// Predicate surface. - pub predicate: String, - /// Object payload. - pub object: SearchExplainRelationContextObject, - #[serde(with = "crate::time_serde")] - /// Start of the fact validity window. - pub valid_from: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// End of the fact validity window, if superseded. - pub valid_to: Option, - #[serde(default)] - /// Temporal state for the fact relative to the search read timestamp. - pub temporal_status: RelationTemporalStatus, - #[serde(default)] - /// Evidence note identifiers supporting the fact. - pub evidence_note_ids: Vec, -} - -/// Lightweight entity reference used in search explanations. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainRelationEntityRef { - #[serde(skip_serializing_if = "Option::is_none")] - /// Canonical entity surface. - pub canonical: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional entity kind. - pub kind: Option, -} - -/// Object payload used in search explanation relation context. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainRelationContextObject { - #[serde(skip_serializing_if = "Option::is_none")] - /// Entity-shaped object value. - pub entity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Scalar object value. - pub value: Option, -} - -/// Match-level explanation for a search item. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchMatchExplain { - /// Query terms matched by the item. - pub matched_terms: Vec, - /// Fields that supplied the matches. - pub matched_fields: Vec, -} - -/// Diversity-selection explanation for a search item. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchDiversityExplain { - /// Whether diversity ranking was enabled. - pub enabled: bool, - /// Reason the item was selected. - pub selected_reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Reason the item was skipped, when applicable. - pub skipped_reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Nearest already selected note that influenced the decision. - pub nearest_selected_note_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Similarity to the nearest selected note. - pub similarity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// MMR score used by diversity selection. - pub mmr_score: Option, - #[serde(default)] - /// Whether the item lacked an embedding needed for diversity scoring. - pub missing_embedding: bool, -} - -/// One ranked search result item. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchItem { - /// Stable result-handle identifier for explain APIs. - pub result_handle: Uuid, - /// Note identifier. - pub note_id: Uuid, - /// Chunk identifier. - pub chunk_id: Uuid, - /// Zero-based chunk position. - pub chunk_index: i32, - /// Inclusive start byte offset of the snippet chunk. - pub start_offset: i32, - /// Exclusive end byte offset of the snippet chunk. - pub end_offset: i32, - /// Returned snippet text. - pub snippet: String, - /// Note type discriminator. - pub r#type: String, - /// Optional application-defined key. - pub key: Option, - /// Scope key for the note. - pub scope: String, - /// Importance score. - pub importance: f32, - /// Confidence score. - pub confidence: f32, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// Optional expiry timestamp. - pub expires_at: Option, - /// Final ranked score. - pub final_score: f32, - /// Structured source reference metadata. - pub source_ref: Value, - /// Item-level explanation payload. - pub explain: SearchExplain, -} - -/// Response payload for raw search results. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchResponse { - /// Search trace identifier. - pub trace_id: Uuid, - /// Ranked search items. - pub items: Vec, - /// Optional condensed explain output. - pub trajectory_summary: Option, -} - -/// Planned-search variant of the raw search response. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchRawPlannedResponse { - /// Search trace identifier. - pub trace_id: Uuid, - /// Ranked search items. - pub items: Vec, - /// Optional condensed explain output. - pub trajectory_summary: Option, - /// Query plan used for the search. - pub query_plan: QueryPlan, -} - -/// Query plan emitted by planned search. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlan { - /// Query-plan schema identifier. - pub schema: String, - /// Query-plan version string. - pub version: String, - /// Ordered planning stages. - pub stages: Vec, - /// Request intent snapshot. - pub intent: QueryPlanIntent, - /// Query rewrite output. - pub rewrite: QueryPlanRewrite, - /// Retrieval-stage plan. - pub retrieval_stages: Vec, - /// Fusion-policy snapshot. - pub fusion_policy: QueryPlanFusionPolicy, - /// Rerank-policy snapshot. - pub rerank_policy: QueryPlanRerankPolicy, - /// Budget snapshot. - pub budget: QueryPlanBudget, -} - -/// One stage in a query plan. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanStage { - /// Stage name. - pub name: String, - /// Free-form stage details. - pub details: Value, -} - -/// Request intent captured in a query plan. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanIntent { - /// Original search query text. - pub query: String, - /// Tenant to search within. - pub tenant_id: String, - /// Project to search within. - pub project_id: String, - /// Agent requesting the search. - pub agent_id: String, - /// Read profile used for the search. - pub read_profile: String, - /// Scopes allowed by the read profile. - pub allowed_scopes: Vec, -} - -/// Rewrite section of a query plan. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanRewrite { - /// Expansion mode label. - pub expansion_mode: String, - /// Expanded query strings. - pub expanded_queries: Vec, - /// Dynamic-gate summary. - pub dynamic_gate: QueryPlanDynamicGate, -} - -/// Dynamic-query-expansion gate summary. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanDynamicGate { - /// Whether the dynamic gate was considered. - pub considered: bool, - /// Whether the dynamic gate decided to expand. - pub should_expand: Option, - /// Candidate count observed by the gate. - pub observed_candidates: Option, - /// Top score observed by the gate. - pub observed_top_score: Option, - /// Minimum candidates threshold. - pub min_candidates: u32, - /// Minimum top-score threshold. - pub min_top_score: f32, -} - -/// Retrieval-stage entry in a query plan. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanRetrievalStage { - /// Stage name. - pub name: String, - /// Retrieval source label. - pub source: String, - /// Whether the stage is enabled. - pub enabled: bool, - /// Candidate limit for the stage. - pub candidate_limit: u32, -} - -/// Fusion-policy snapshot used during search. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanFusionPolicy { - /// Fusion strategy label. - pub strategy: String, - /// Weight for fusion retrieval. - pub fusion_weight: f32, - /// Weight for structured-field retrieval. - pub structured_field_weight: f32, - /// Weight for recursive retrieval. - pub recursive_weight: f32, - /// Priority for fusion retrieval. - pub fusion_priority: u32, - /// Priority for structured-field retrieval. - pub structured_field_priority: u32, - /// Priority for recursive retrieval. - pub recursive_priority: u32, -} - -/// One blend segment in the rerank policy. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanBlendSegment { - /// Highest retrieval rank covered by the segment. - pub max_retrieval_rank: u32, - /// Retrieval weight applied within the segment. - pub retrieval_weight: f32, -} - -/// Rerank-policy snapshot used during search. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanRerankPolicy { - /// Provider identifier. - pub provider_id: String, - /// Model identifier. - pub model: String, - /// Whether blend ranking was enabled. - pub blend_enabled: bool, - /// Rerank normalization label. - pub rerank_normalization: String, - /// Retrieval normalization label. - pub retrieval_normalization: String, - /// Blend segments used by the policy. - pub blend_segments: Vec, - /// Whether diversity ranking was enabled. - pub diversity_enabled: bool, - /// Diversity similarity threshold. - pub diversity_sim_threshold: f32, - /// Diversity MMR lambda. - pub diversity_mmr_lambda: f32, - /// Diversity max-skips limit. - pub diversity_max_skips: u32, -} - -/// Budget snapshot used during search. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct QueryPlanBudget { - /// Final top-k budget. - pub top_k: u32, - /// Candidate-k budget. - pub candidate_k: u32, - /// Prefilter candidate cap. - pub prefilter_max_candidates: u32, - /// Query-expansion cap. - pub expansion_max_queries: u32, - /// Whether ranking caches were enabled. - pub cache_enabled: bool, -} - -/// Request payload for loading one item-level explanation. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainRequest { - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent requesting the explain payload. - pub agent_id: String, - /// Result-handle identifier returned by search. - pub result_handle: Uuid, -} - -/// Search trace metadata persisted for one search run. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTrace { - /// Search trace identifier. - pub trace_id: Uuid, - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent that ran the search. - pub agent_id: String, - /// Read profile used for the search. - pub read_profile: String, - /// Search query text. - pub query: String, - /// Expansion mode label. - pub expansion_mode: String, - /// Expanded query strings. - pub expanded_queries: Vec, - /// Scopes allowed by the read profile. - pub allowed_scopes: Vec, - /// Candidate count observed by the search. - pub candidate_count: u32, - /// Top-k budget used by the search. - pub top_k: u32, - /// Config snapshot captured for the trace. - pub config_snapshot: Value, - #[serde(with = "crate::time_serde")] - /// Trace creation timestamp. - pub created_at: OffsetDateTime, - /// Trace schema version. - pub trace_version: i32, -} - -/// Condensed search-trajectory explanation. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTrajectorySummary { - /// Summary schema identifier. - pub schema: String, - /// Ordered summary stages. - pub stages: Vec, -} - -/// One stage in a condensed search trajectory. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTrajectorySummaryStage { - /// Zero-based stage order. - pub stage_order: u32, - /// Stable stage name. - pub stage_name: String, - /// Number of items after the stage. - pub item_count: u32, - /// Free-form stage statistics. - pub stats: Value, -} - -/// One full search-trajectory stage. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTrajectoryStage { - /// Zero-based stage order. - pub stage_order: u32, - /// Stable stage name. - pub stage_name: String, - /// Stage-level payload. - pub stage_payload: Value, - /// Item rows for the stage. - pub items: Vec, -} - -/// One item row inside a search-trajectory stage. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTrajectoryStageItem { - /// Stage-item identifier, when persisted. - pub item_id: Option, - /// Note identifier, when applicable. - pub note_id: Option, - /// Chunk identifier, when applicable. - pub chunk_id: Option, - /// Free-form per-item metrics. - pub metrics: Value, -} - -/// Full search-trajectory response. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchTrajectoryResponse { - /// Trace metadata. - pub trace: SearchTrace, - /// Condensed trajectory summary. - pub trajectory: SearchTrajectorySummary, - /// Full trajectory stages. - pub stages: Vec, -} - -/// Item-level explain trajectory. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainTrajectory { - /// Trajectory schema identifier. - pub schema: String, - /// Ordered explain stages. - pub stages: Vec, -} - -/// One stage in an item-level explain trajectory. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainTrajectoryStage { - /// Zero-based stage order. - pub stage_order: u32, - /// Stable stage name. - pub stage_name: String, - /// Stage-level payload. - pub stage_payload: Value, - /// Per-item metrics. - pub metrics: Value, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional match information for the selected item. - pub match_info: Option, -} - -/// Match reference for one explain trajectory stage. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainTrajectoryMatch { - /// Match kind label. - pub kind: String, - /// Stage-item identifier, when persisted. - pub item_id: Option, - /// Note identifier, when applicable. - pub note_id: Option, - /// Chunk identifier, when applicable. - pub chunk_id: Option, -} - -/// Explain payload for one ranked search item. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainItem { - /// Stable result-handle identifier. - pub result_handle: Uuid, - /// Note identifier. - pub note_id: Uuid, - /// Chunk identifier, when applicable. - pub chunk_id: Option, - /// 1-based final rank. - pub rank: u32, - /// Item-level explanation payload. - pub explain: SearchExplain, -} - -/// Response payload for item-level explanations. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SearchExplainResponse { - /// Trace metadata. - pub trace: SearchTrace, - /// Explained item payload. - pub item: SearchExplainItem, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional explain trajectory. - pub trajectory: Option, -} - -/// Request payload for listing recent traces. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceRecentListRequest { - /// Tenant that owns the traces. - pub tenant_id: String, - /// Project that owns the traces. - pub project_id: String, - /// Agent requesting the list. - pub agent_id: String, - - /// Maximum number of traces to return. - pub limit: Option, - - /// Cursor creation timestamp for pagination. - pub cursor_created_at: Option, - - /// Cursor trace identifier for pagination. - pub cursor_trace_id: Option, - - /// Optional agent filter. - pub agent_id_filter: Option, - - /// Optional read-profile filter. - pub read_profile: Option, - #[serde(with = "crate::time_serde::option")] - /// Optional lower bound for trace creation time. - pub created_after: Option, - #[serde(with = "crate::time_serde::option")] - /// Optional upper bound for trace creation time. - pub created_before: Option, -} - -/// Header row returned by recent-trace listing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RecentTraceHeader { - /// Trace identifier. - pub trace_id: Uuid, - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent that ran the trace. - pub agent_id: String, - /// Read profile used for the trace. - pub read_profile: String, - /// Search query text. - pub query: String, - #[serde(with = "crate::time_serde")] - /// Trace creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Pagination cursor returned by recent-trace listing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceRecentCursor { - #[serde(with = "crate::time_serde")] - /// Cursor creation timestamp. - pub created_at: OffsetDateTime, - /// Cursor trace identifier. - pub trace_id: Uuid, -} - -/// Response payload for recent-trace listing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceRecentListResponse { - /// Response schema identifier. - pub schema: String, - /// Returned trace headers. - pub traces: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Cursor for the next page, when more results remain. - pub next_cursor: Option, -} - -/// Request payload for loading a trace bundle. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceBundleGetRequest { - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent requesting the bundle. - pub agent_id: String, - /// Trace identifier. - pub trace_id: Uuid, - #[serde(default)] - /// Bundle mode controlling output size. - pub mode: TraceBundleMode, - - /// Optional cap for per-stage items. - pub stage_items_limit: Option, - - /// Optional cap for replay candidates. - pub candidates_limit: Option, -} - -/// Response payload for trace bundles. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceBundleResponse { - /// Response schema identifier. - pub schema: String, - #[serde(with = "crate::time_serde")] - /// Bundle generation timestamp. - pub generated_at: OffsetDateTime, - /// Trace metadata. - pub trace: SearchTrace, - /// Explained items from the trace. - pub items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional condensed trajectory summary. - pub trajectory_summary: Option, - /// Full trajectory stages. - pub stages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional replay candidates. - pub candidates: Option>, -} - -/// Request payload for loading trace metadata and items. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceGetRequest { - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent requesting the trace. - pub agent_id: String, - /// Trace identifier. - pub trace_id: Uuid, -} - -/// Request payload for loading full trajectory stages. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceTrajectoryGetRequest { - /// Tenant that owns the trace. - pub tenant_id: String, - /// Project that owns the trace. - pub project_id: String, - /// Agent requesting the trajectory. - pub agent_id: String, - /// Trace identifier. - pub trace_id: Uuid, -} - -/// Response payload for trace metadata and explained items. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceGetResponse { - /// Trace metadata. - pub trace: SearchTrace, - /// Explained items from the trace. - pub items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional condensed trajectory summary. - pub trajectory_summary: Option, -} - -/// Context needed to replay ranking against stored candidates. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceReplayContext { - /// Trace identifier. - pub trace_id: Uuid, - /// Search query text. - pub query: String, - /// Candidate count observed during the trace. - pub candidate_count: u32, - /// Top-k budget used during the trace. - pub top_k: u32, - #[serde(with = "crate::time_serde")] - /// Trace creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Candidate row used for replaying ranking offline. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceReplayCandidate { - /// Note identifier. - pub note_id: Uuid, - /// Chunk identifier. - pub chunk_id: Uuid, - /// Zero-based chunk position. - pub chunk_index: i32, - /// Candidate snippet text. - pub snippet: String, - /// 1-based retrieval rank. - pub retrieval_rank: u32, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional merged retrieval score captured before rerank. - pub retrieval_score: Option, - /// Raw rerank-model score. - pub rerank_score: f32, - /// Scope key for the note. - pub note_scope: String, - /// Note importance score. - pub note_importance: f32, - #[serde(with = "crate::time_serde")] - /// Note last-update timestamp. - pub note_updated_at: OffsetDateTime, - /// Note hit counter. - pub note_hit_count: i64, - #[serde(with = "crate::time_serde::option")] - /// Timestamp of the note's most recent hit. - pub note_last_hit_at: Option, - /// Whether the candidate was selected by diversity ranking. - pub diversity_selected: Option, - /// Final selected rank under diversity ranking. - pub diversity_selected_rank: Option, - /// Reason the candidate was selected by diversity ranking. - pub diversity_selected_reason: Option, - /// Reason the candidate was skipped by diversity ranking. - pub diversity_skipped_reason: Option, - /// Nearest selected note that influenced the diversity decision. - pub diversity_nearest_selected_note_id: Option, - /// Similarity to the nearest selected note. - pub diversity_similarity: Option, - /// MMR score used for diversity selection. - pub diversity_mmr_score: Option, - /// Whether the candidate lacked an embedding for diversity scoring. - pub diversity_missing_embedding: Option, -} - -/// Final replayed ranking item. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TraceReplayItem { - /// Note identifier. - pub note_id: Uuid, - /// Chunk identifier. - pub chunk_id: Uuid, - /// 1-based retrieval rank. - pub retrieval_rank: u32, - /// Final replayed score. - pub final_score: f32, - /// Recomputed explanation payload. - pub explain: SearchExplain, +pub(crate) fn resolve_read_profile_scopes(cfg: &Config, profile: &str) -> Result> { + ranking::resolve_scopes(cfg, profile) } -struct ScoreSnippetArgs<'a, 'k> { - query: &'a str, - snippet_items: Vec, - scope_context_boost_by_scope: &'a HashMap<&'k str, f32>, - det_query_tokens: &'a [String], - blend_policy: &'a ResolvedBlendPolicy, - cache_cfg: &'a SearchCache, - now: OffsetDateTime, - candidate_count: usize, - skip_rerank: bool, -} +/// Computes the stable ranking-policy identifier for a search configuration. +pub fn ranking_policy_id( + cfg: &Config, + ranking_override: Option<&RankingRequestOverride>, +) -> Result { + let blend_policy = ranking::resolve_blend_policy( + &cfg.ranking.blend, + ranking_override.and_then(|value| value.blend.as_ref()), + )?; + let diversity_policy = ranking::resolve_diversity_policy( + &cfg.ranking.diversity, + ranking_override.and_then(|value| value.diversity.as_ref()), + )?; + let retrieval_sources_policy = ranking::resolve_retrieval_sources_policy( + &cfg.ranking.retrieval_sources, + ranking_override.and_then(|value| value.retrieval_sources.as_ref()), + )?; + let snapshot = ranking::build_policy_snapshot( + cfg, + &blend_policy, + &diversity_policy, + &retrieval_sources_policy, + ranking_override, + ); + let hash = ranking::hash_policy_snapshot(&snapshot)?; + let prefix = &hash[..12.min(hash.len())]; -struct ScoreCandidateCtx<'a, 'k> { - cfg: &'a Config, - blend_policy: &'a ResolvedBlendPolicy, - scope_context_boost_by_scope: &'a HashMap<&'k str, f32>, - det_query_tokens: &'a [String], - now: OffsetDateTime, - total_rerank: u32, - total_retrieval: u32, + Ok(format!("ranking_v2:{prefix}")) } -struct MaybeDynamicSearchArgs<'a> { - path: RawSearchPath, - enabled: bool, - trace_id: Uuid, - query: &'a str, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - token_id: Option<&'a str>, - read_profile: &'a str, - allowed_scopes: &'a [String], - project_context_description: Option<&'a str>, - filter: &'a Filter, - service_filter: Option<&'a SearchFilter>, - candidate_k: u32, - requested_candidate_k: u32, - effective_candidate_k: u32, +/// Replays ranking against stored trace candidates and returns the final top-k items. +pub fn replay_ranking_from_candidates( + cfg: &Config, + trace: &TraceReplayContext, + ranking_override: Option<&RankingRequestOverride>, + candidates: &[TraceReplayCandidate], top_k: u32, - record_hits_enabled: bool, - ranking_override: Option<&'a RankingRequestOverride>, - retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, - payload_level: PayloadLevel, -} - -struct SearchRetrievalArgs<'a> { - query: &'a str, - expansion_mode: ExpansionMode, - project_context_description: Option<&'a str>, - filter: &'a Filter, - candidate_k: u32, - baseline_vector: Option<&'a Vec>, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - allowed_scopes: &'a [String], - retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, -} - -struct RecursiveRetrievalArgs<'a> { - query: &'a str, - query_vec: &'a [f32], - filter: &'a Filter, - candidate_k: u32, - retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, - seed_candidates: &'a [ChunkCandidate], -} - -struct SearchRetrievalResult { - expanded_queries: Vec, - candidates: Vec, - structured_matches: HashMap>, - recursive: Option, -} - -#[derive(Clone, Debug, Default)] -struct RecursiveRetrievalResult { - enabled: bool, - rounds_executed: u32, - scopes_seeded: usize, - scopes_queried: usize, - candidates_before: usize, - candidates_after: usize, - candidates_added: usize, - total_queries: u32, - stop_reason: Option, - candidates: Vec, -} - -#[derive(Clone, Debug)] -struct QueryEmbedding { - text: String, - vector: Vec, -} - -#[derive(Clone, Debug)] -struct ChunkCandidate { - chunk_id: Uuid, - note_id: Uuid, - chunk_index: i32, - retrieval_rank: u32, - retrieval_score: Option, - scope: Option, - updated_at: Option, - embedding_version: Option, -} - -#[derive(Clone, Debug)] -struct RerankCacheCandidate { - chunk_id: Uuid, - updated_at: OffsetDateTime, -} - -#[derive(Clone, Debug)] -struct NoteMeta { - note_id: Uuid, - note_type: String, - key: Option, - scope: String, - agent_id: String, - importance: f32, - confidence: f32, - updated_at: OffsetDateTime, - expires_at: Option, - source_ref: Value, - embedding_version: String, - hit_count: i64, - last_hit_at: Option, -} - -#[derive(Clone, Debug, FromRow)] -struct ChunkRow { - chunk_id: Uuid, - note_id: Uuid, - chunk_index: i32, - start_offset: i32, - end_offset: i32, - text: String, -} - -#[derive(Clone, Debug, FromRow)] -struct NoteVectorRow { - note_id: Uuid, - vec_text: String, -} - -#[derive(Clone, Debug, FromRow)] -struct SearchExplainTraceRow { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - expansion_mode: String, - expanded_queries: Value, - allowed_scopes: Value, - candidate_count: i32, - top_k: i32, - config_snapshot: Value, - trace_version: i32, - created_at: OffsetDateTime, - item_id: Uuid, - note_id: Uuid, - chunk_id: Option, - rank: i32, - explain: Value, -} - -#[derive(Clone, Debug, FromRow)] -struct SearchRelationContextRow { - note_id: Uuid, - fact_id: Uuid, - scope: String, - subject_canonical: Option, - subject_kind: Option, - predicate: String, - object_entity_id: Option, - object_canonical: Option, - object_kind: Option, - object_value: Option, - valid_from: OffsetDateTime, - valid_to: Option, - is_current: bool, - evidence_note_ids: Vec, -} - -#[derive(Clone, Debug, FromRow)] -struct SearchTraceRow { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - expansion_mode: String, - expanded_queries: Value, - allowed_scopes: Value, - candidate_count: i32, - top_k: i32, - config_snapshot: Value, - trace_version: i32, - created_at: OffsetDateTime, -} - -#[derive(Clone, Debug, FromRow)] -struct SearchTraceItemRow { - item_id: Uuid, - note_id: Uuid, - chunk_id: Option, - rank: i32, - explain: Value, -} - -#[derive(Clone, Debug, FromRow)] -struct SearchRecentTraceRow { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - created_at: OffsetDateTime, -} - -#[derive(Clone, Debug, FromRow)] -struct TraceCandidateSnapshotRow { - candidate_snapshot: Value, -} - -#[derive(Clone, Debug, FromRow)] -struct StructuredFieldHitRow { - note_id: Uuid, - field_kind: String, -} - -#[derive(Clone, Debug, FromRow)] -struct BestChunkForNoteRow { - note_id: Uuid, - chunk_id: Uuid, - chunk_index: i32, -} - -#[derive(Clone, Debug)] -struct ChunkMeta { - chunk_id: Uuid, - chunk_index: i32, - start_offset: i32, - end_offset: i32, -} - -#[derive(Clone, Debug)] -struct ChunkSnippet { - note: NoteMeta, - chunk: ChunkMeta, - snippet: String, - retrieval_rank: u32, - retrieval_score: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct ExpansionCachePayload { - queries: Vec, -} - -#[derive(Debug, Deserialize)] -struct ExpansionOutput { - queries: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RerankCacheItem { - chunk_id: Uuid, - updated_at: OffsetDateTime, - score: f32, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RerankCachePayload { - items: Vec, -} - -#[derive(Clone, Debug)] -struct CachePayload { - value: Value, - size_bytes: usize, -} - -#[derive(Clone, Debug)] -struct ScoredChunk { - item: ChunkSnippet, - final_score: f32, - rerank_score: f32, - rerank_rank: u32, - rerank_norm: f32, - retrieval_norm: f32, - blend_retrieval_weight: f32, - retrieval_term: f32, - rerank_term: f32, - tie_breaker_score: f32, - scope_context_boost: f32, - age_days: f32, - importance: f32, - deterministic_lexical_overlap_ratio: f32, - deterministic_lexical_bonus: f32, - deterministic_hit_count: i64, - deterministic_last_hit_age_days: Option, - deterministic_hit_boost: f32, - deterministic_decay_penalty: f32, -} +) -> Result> { + let query_tokens = ranking::tokenize_query(trace.query.as_str(), MAX_MATCHED_TERMS); + let scope_context_boost_by_scope = + ranking::build_scope_context_boost_by_scope(&query_tokens, cfg.context.as_ref()); + let det_query_tokens = structured::build_deterministic_query_tokens(cfg, trace.query.as_str()); + let blend_policy = ranking::resolve_blend_policy( + &cfg.ranking.blend, + ranking_override.and_then(|override_| override_.blend.as_ref()), + )?; + let diversity_policy = ranking::resolve_diversity_policy( + &cfg.ranking.diversity, + ranking_override.and_then(|override_| override_.diversity.as_ref()), + )?; + let policy_id = ranking_policy_id(cfg, ranking_override)?; + let now = trace.created_at; + let total_rerank = u32::try_from(candidates.len()).unwrap_or(1).max(1); + let total_retrieval = trace.candidate_count.max(1); + let rerank_ranks = ranking::build_rerank_ranks_for_replay(candidates); + let replay_diversity_decisions = ranking::extract_replay_diversity_decisions(candidates); + let score_ctx = ScoreCandidateCtx { + cfg, + blend_policy: &blend_policy, + scope_context_boost_by_scope: &scope_context_boost_by_scope, + det_query_tokens: det_query_tokens.as_slice(), + now, + total_rerank, + total_retrieval, + }; + let mut best_by_note: BTreeMap = BTreeMap::new(); -#[derive(Clone, Debug)] -struct DiversityDecision { - selected: bool, - selected_rank: Option, - selected_reason: String, - skipped_reason: Option, - nearest_selected_note_id: Option, - similarity: Option, - mmr_score: Option, - missing_embedding: bool, -} + for (candidate, rerank_rank) in candidates.iter().zip(rerank_ranks) { + let scored = replay_helpers::score_replay_candidate(&score_ctx, candidate, rerank_rank); + let replace = match best_by_note.get(&candidate.note_id) { + None => true, + Some(existing) => replay_helpers::should_replace_replay_best(existing, &scored), + }; -#[derive(Clone, Copy, Debug)] -struct DeterministicRankingTerms { - lexical_overlap_ratio: f32, - lexical_bonus: f32, - hit_count: i64, - last_hit_age_days: Option, - hit_boost: f32, - decay_penalty: f32, -} -impl Default for DeterministicRankingTerms { - fn default() -> Self { - Self { - lexical_overlap_ratio: 0.0, - lexical_bonus: 0.0, - hit_count: 0, - last_hit_age_days: None, - hit_boost: 0.0, - decay_penalty: 0.0, + if replace { + best_by_note.insert(candidate.note_id, scored); } } -} -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TracePayload { - trace: TraceRecord, - items: Vec, - #[serde(default)] - candidates: Vec, - #[serde(default)] - stages: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceRecord { - trace_id: Uuid, - tenant_id: String, - project_id: String, - agent_id: String, - read_profile: String, - query: String, - expansion_mode: String, - expanded_queries: Vec, - allowed_scopes: Vec, - candidate_count: u32, - top_k: u32, - config_snapshot: Value, - trace_version: i32, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} + let mut results: Vec = best_by_note.into_values().collect(); -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceItemRecord { - item_id: Uuid, - note_id: Uuid, - chunk_id: Option, - rank: u32, - final_score: f32, - explain: SearchExplain, -} + results.sort_by(cmp_scored_replay); -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceCandidateRecord { - candidate_id: Uuid, - note_id: Uuid, - chunk_id: Uuid, - chunk_index: i32, - snippet: String, - #[serde(default)] - candidate_snapshot: Value, - retrieval_rank: u32, - rerank_score: f32, - note_scope: String, - note_importance: f32, - note_updated_at: OffsetDateTime, - note_hit_count: i64, - note_last_hit_at: Option, - created_at: OffsetDateTime, - expires_at: OffsetDateTime, -} + let results = replay_helpers::apply_replay_diversity_selection( + results, + top_k, + diversity_policy.enabled, + &replay_diversity_decisions, + ); -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceTrajectoryStageRecord { - stage_id: Uuid, - stage_order: u32, - stage_name: String, - stage_payload: Value, - created_at: OffsetDateTime, - #[serde(default)] - items: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct TraceTrajectoryStageItemRecord { - id: Uuid, - item_id: Option, - note_id: Option, - chunk_id: Option, - metrics: Value, -} - -struct TraceContext<'a> { - trace_id: Uuid, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - read_profile: &'a str, - query: &'a str, - expansion_mode: ExpansionMode, - expanded_queries: Vec, - allowed_scopes: &'a [String], - candidate_count: usize, - top_k: u32, -} - -struct SearchTraceBuilder { - trace: TraceRecord, - items: Vec, - candidates: Vec, - stages: Vec, -} -impl SearchTraceBuilder { - fn new( - context: TraceContext<'_>, - config_snapshot: Value, - retention_days: i64, - now: OffsetDateTime, - ) -> Self { - let trace = TraceRecord { - trace_id: context.trace_id, - tenant_id: context.tenant_id.to_string(), - project_id: context.project_id.to_string(), - agent_id: context.agent_id.to_string(), - read_profile: context.read_profile.to_string(), - query: context.query.to_string(), - expansion_mode: ranking::expansion_mode_label(context.expansion_mode).to_string(), - expanded_queries: context.expanded_queries, - allowed_scopes: context.allowed_scopes.to_vec(), - candidate_count: context.candidate_count as u32, - top_k: context.top_k, - config_snapshot, - trace_version: TRACE_VERSION, - created_at: now, - expires_at: now + Duration::days(retention_days), - }; - - Self { trace, items: Vec::new(), candidates: Vec::new(), stages: Vec::new() } - } - - fn push_item(&mut self, item: TraceItemRecord) { - self.items.push(item); - } - - fn push_candidate(&mut self, candidate: TraceCandidateRecord) { - self.candidates.push(candidate); - } - - fn push_stage(&mut self, stage: TraceTrajectoryStageRecord) { - self.stages.push(stage); - } - - fn build(self) -> TracePayload { - TracePayload { - trace: self.trace, - items: self.items, - candidates: self.candidates, - stages: self.stages, - } - } -} - -struct FinishSearchArgs<'a> { - path: RawSearchPath, - trace_id: Uuid, - query: &'a str, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - token_id: Option<&'a str>, - read_profile: &'a str, - allowed_scopes: &'a [String], - expanded_queries: Vec, - expansion_mode: ExpansionMode, - candidates: Vec, - structured_matches: HashMap>, - recursive_retrieval: Option, - top_k: u32, - record_hits_enabled: bool, - ranking_override: Option, - filter: Option<&'a SearchFilter>, - requested_candidate_k: u32, - effective_candidate_k: u32, - payload_level: PayloadLevel, -} - -struct FinishSearchPolicies { - blend_policy: ResolvedBlendPolicy, - diversity_policy: ResolvedDiversityPolicy, - retrieval_sources_policy: ResolvedRetrievalSourcesPolicy, - policy_snapshot: Value, - policy_id: String, -} - -struct FinishSearchScoringResult { - query_tokens: Vec, - filtered_candidates: Vec, - scored_count: usize, - snippet_count: usize, - filtered_candidate_count: usize, - filter_impact: Option, - trace_candidates: Vec, - fused_results: Vec, - selected_results: Vec, - diversity_decisions: HashMap, - selected_count: usize, -} - -struct BuildTraceArgs<'a> { - path: RawSearchPath, - trace_id: Uuid, - query: &'a str, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - token_id: Option<&'a str>, - read_profile: &'a str, - expansion_mode: ExpansionMode, - expanded_queries: Vec, - allowed_scopes: &'a [String], - candidate_count: usize, - filtered_candidate_count: usize, - snippet_count: usize, - scored_count: usize, - fused_count: usize, - selected_count: usize, - top_k: u32, - query_tokens: &'a [String], - structured_matches: &'a HashMap>, - recursive_retrieval: Option<&'a RecursiveRetrievalResult>, - policies: &'a FinishSearchPolicies, - diversity_decisions: &'a HashMap, - recall_candidates: Vec, - fused_results: Vec, - selected_results: Vec, - relation_contexts: HashMap>, - trace_candidates: Vec, - now: OffsetDateTime, - ranking_override: &'a Option, - filter_impact: Option, - payload_level: PayloadLevel, -} - -struct BuildQueryPlanArgs<'a> { - path: RawSearchPath, - query: &'a str, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - read_profile: &'a str, - allowed_scopes: &'a [String], - expansion_mode: ExpansionMode, - expanded_queries: Vec, - top_k: u32, - candidate_k: u32, - retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, - recursive_enabled: bool, - policies: &'a FinishSearchPolicies, - dynamic_gate: DynamicGateSummary, -} - -struct RawSearchExecutionContext { - tenant_id: String, - project_id: String, - agent_id: String, - token_id: Option, - top_k: u32, - candidate_k: u32, - requested_candidate_k: u32, - effective_candidate_k: u32, - query: String, - read_profile: String, - payload_level: PayloadLevel, - filter: Option, - record_hits_enabled: bool, - ranking_override: Option, - retrieval_sources_policy: ResolvedRetrievalSourcesPolicy, - expansion_mode: ExpansionMode, - trace_id: Uuid, - project_context_description: Option, - allowed_scopes: Vec, - policies: FinishSearchPolicies, -} - -struct QueryPlanStagesArgs<'a> { - path: RawSearchPath, - query: &'a str, - read_profile: &'a str, - allowed_scope_count: usize, - rewrite: &'a QueryPlanRewrite, - retrieval_stages: &'a [QueryPlanRetrievalStage], - fusion_policy: &'a QueryPlanFusionPolicy, - rerank_policy: &'a QueryPlanRerankPolicy, - budget: &'a QueryPlanBudget, -} - -struct BuildSearchItemArgs<'a> { - cfg: &'a Config, - policy_id: &'a str, - blend_policy: &'a ResolvedBlendPolicy, - diversity_policy: &'a ResolvedDiversityPolicy, - diversity_decisions: &'a HashMap, - query_tokens: &'a [String], - structured_matches: &'a HashMap>, - relation_contexts: &'a HashMap>, - scored_chunk: ScoredChunk, - rank: u32, -} - -struct StructuredFieldRetrievalArgs<'a> { - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - allowed_scopes: &'a [String], - query_vec: &'a [f32], - candidate_k: u32, - now: OffsetDateTime, -} - -#[derive(Debug)] -struct FieldHit { - note_id: Uuid, - field_kind: String, -} - -struct StructuredFieldHitArgs<'a> { - embed_version: &'a str, - tenant_id: &'a str, - project_id: &'a str, - agent_id: &'a str, - now: OffsetDateTime, - vec_text: &'a str, - retrieval_limit: i64, - private_allowed: bool, - non_private_scopes: &'a [String], -} - -#[derive(Clone, Debug)] -struct StructuredFieldRetrievalResult { - candidates: Vec, - structured_matches: HashMap>, -} - -#[derive(Clone, Debug)] -struct RetrievalSourceCandidates { - source: RetrievalSourceKind, - candidates: Vec, -} - -#[derive(Clone, Debug)] -struct ScoredReplay { - note_id: Uuid, - chunk_id: Uuid, - retrieval_rank: u32, - final_score: f32, - rerank_score: f32, - rerank_rank: u32, - rerank_norm: f32, - retrieval_norm: f32, - blend_retrieval_weight: f32, - retrieval_term: f32, - rerank_term: f32, - tie_breaker_score: f32, - scope_context_boost: f32, - age_days: f32, - importance: f32, - note_scope: String, - deterministic_lexical_overlap_ratio: f32, - deterministic_lexical_bonus: f32, - deterministic_hit_count: i64, - deterministic_last_hit_age_days: Option, - deterministic_hit_boost: f32, - deterministic_decay_penalty: f32, -} - -#[derive(Clone, Debug, Default)] -struct DynamicGateSummary { - considered: bool, - should_expand: Option, - observed_candidates: Option, - observed_top_score: Option, -} - -/// Bundle-size mode for trace exports. -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -#[derive(Default)] -pub enum TraceBundleMode { - #[default] - /// Return the bounded default export. - Bounded, - /// Return the full export. - Full, -} - -/// Payload-detail level used by search and trace APIs. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum PayloadLevel { - #[default] - /// Level 0 payloads. - L0, - /// Level 1 payloads. - L1, - /// Level 2 payloads. - L2, -} -impl PayloadLevel { - fn as_str(self) -> &'static str { - match self { - Self::L0 => "l0", - Self::L1 => "l1", - Self::L2 => "l2", - } - } - - fn parse(raw: &str) -> Option { - match raw.to_ascii_lowercase().as_str() { - "l0" => Some(Self::L0), - "l1" => Some(Self::L1), - "l2" => Some(Self::L2), - _ => None, - } - } -} - -impl Serialize for PayloadLevel { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - self.as_str().serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for PayloadLevel { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let raw = String::deserialize(deserializer)?; - - Self::parse(&raw).ok_or_else(|| de::Error::custom("payload_level must be l0, l1, or l2")) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ExpansionMode { - Off, - Always, - Dynamic, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RawSearchPath { - Quick, - Planned, -} - -#[derive(Clone, Copy, Debug)] -enum CacheKind { - Expansion, - Rerank, -} -impl CacheKind { - fn as_str(self) -> &'static str { - match self { - Self::Expansion => "expansion", - Self::Rerank => "rerank", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -enum RetrievalSourceKind { - Fusion, - StructuredField, - Recursive, -} - -impl ElfService { - /// Runs the quick raw-search path and returns ranked items without a query plan. - pub async fn search_raw_quick(&self, req: SearchRequest) -> Result { - self.execute_search_raw_path(req, RawSearchPath::Quick).await.map(|response| { - SearchResponse { - trace_id: response.trace_id, - items: response.items, - trajectory_summary: response.trajectory_summary, - } - }) - } - - /// Runs the planned raw-search path and returns ranked items plus a query plan. - pub async fn search_raw_planned(&self, req: SearchRequest) -> Result { - self.execute_search_raw_path(req, RawSearchPath::Planned).await - } - - /// Runs the default raw-search path and returns ranked items. - pub async fn search_raw(&self, req: SearchRequest) -> Result { - self.search_raw_planned(req).await.map(|response| SearchResponse { - trace_id: response.trace_id, - items: response.items, - trajectory_summary: response.trajectory_summary, - }) - } - - async fn execute_search_raw_path( - &self, - req: SearchRequest, - path: RawSearchPath, - ) -> Result { - let context = self.prepare_raw_search_execution(req, path)?; - - if context.allowed_scopes.is_empty() { - return self.execute_search_raw_no_allowed_scopes(&context, path).await; - } - - let dynamic_gate_enabled = - path == RawSearchPath::Planned && context.expansion_mode == ExpansionMode::Dynamic; - - self.execute_search_raw_with_allowed_scopes(&context, path, dynamic_gate_enabled).await - } - - async fn execute_search_raw_no_allowed_scopes( - &self, - context: &RawSearchExecutionContext, - path: RawSearchPath, - ) -> Result { - let expanded_queries = vec![context.query.clone()]; - let response = self - .finish_search(FinishSearchArgs { - path, - trace_id: context.trace_id, - query: context.query.as_str(), - tenant_id: context.tenant_id.as_str(), - project_id: context.project_id.as_str(), - agent_id: context.agent_id.as_str(), - token_id: context.token_id.as_deref(), - read_profile: context.read_profile.as_str(), - allowed_scopes: &context.allowed_scopes, - expanded_queries: expanded_queries.clone(), - expansion_mode: context.expansion_mode, - candidates: Vec::new(), - structured_matches: HashMap::new(), - recursive_retrieval: None, - top_k: context.top_k, - record_hits_enabled: context.record_hits_enabled, - ranking_override: context.ranking_override.clone(), - payload_level: context.payload_level, - filter: context.filter.as_ref(), - requested_candidate_k: context.requested_candidate_k, - effective_candidate_k: context.effective_candidate_k, - }) - .await?; - - Ok(self.build_raw_planned_response( - context, - path, - response, - expanded_queries, - DynamicGateSummary::default(), - )) - } - - async fn execute_search_raw_with_allowed_scopes( - &self, - context: &RawSearchExecutionContext, - path: RawSearchPath, - dynamic_gate_enabled: bool, - ) -> Result { - let filter = build_search_filter( - context.tenant_id.as_str(), - context.project_id.as_str(), - context.agent_id.as_str(), - &context.allowed_scopes, - ); - let retrieval_candidate_k = if context.filter.is_some() { - context.effective_candidate_k - } else { - context.candidate_k - }; - let (baseline_vector, early_response, dynamic_gate) = self - .maybe_finish_dynamic_search(MaybeDynamicSearchArgs { - path, - enabled: dynamic_gate_enabled, - trace_id: context.trace_id, - query: context.query.as_str(), - tenant_id: context.tenant_id.as_str(), - project_id: context.project_id.as_str(), - agent_id: context.agent_id.as_str(), - token_id: context.token_id.as_deref(), - read_profile: context.read_profile.as_str(), - allowed_scopes: &context.allowed_scopes, - project_context_description: context.project_context_description.as_deref(), - filter: &filter, - service_filter: context.filter.as_ref(), - candidate_k: retrieval_candidate_k, - requested_candidate_k: context.requested_candidate_k, - effective_candidate_k: context.effective_candidate_k, - top_k: context.top_k, - record_hits_enabled: context.record_hits_enabled, - ranking_override: context.ranking_override.as_ref(), - retrieval_sources_policy: &context.retrieval_sources_policy, - payload_level: context.payload_level, - }) - .await?; - - if let Some(response) = early_response { - return Ok(self.build_raw_planned_response( - context, - path, - response, - vec![context.query.clone()], - dynamic_gate, - )); - } - - let retrieval = self - .retrieve_search_candidates(SearchRetrievalArgs { - query: context.query.as_str(), - expansion_mode: context.expansion_mode, - project_context_description: context.project_context_description.as_deref(), - filter: &filter, - candidate_k: retrieval_candidate_k, - baseline_vector: baseline_vector.as_ref(), - tenant_id: context.tenant_id.as_str(), - project_id: context.project_id.as_str(), - agent_id: context.agent_id.as_str(), - allowed_scopes: &context.allowed_scopes, - retrieval_sources_policy: &context.retrieval_sources_policy, - }) - .await?; - let expanded_queries = retrieval.expanded_queries.clone(); - let response = self - .finish_search(FinishSearchArgs { - path, - trace_id: context.trace_id, - query: context.query.as_str(), - tenant_id: context.tenant_id.as_str(), - project_id: context.project_id.as_str(), - agent_id: context.agent_id.as_str(), - token_id: context.token_id.as_deref(), - read_profile: context.read_profile.as_str(), - allowed_scopes: &context.allowed_scopes, - expanded_queries: retrieval.expanded_queries, - expansion_mode: context.expansion_mode, - candidates: retrieval.candidates, - structured_matches: retrieval.structured_matches, - recursive_retrieval: retrieval.recursive, - top_k: context.top_k, - record_hits_enabled: context.record_hits_enabled, - ranking_override: context.ranking_override.clone(), - payload_level: context.payload_level, - filter: context.filter.as_ref(), - requested_candidate_k: context.requested_candidate_k, - effective_candidate_k: context.effective_candidate_k, - }) - .await?; - - Ok(self.build_raw_planned_response(context, path, response, expanded_queries, dynamic_gate)) - } - - fn prepare_raw_search_execution( - &self, - req: SearchRequest, - path: RawSearchPath, - ) -> Result { - let tenant_id = req.tenant_id.trim().to_string(); - let project_id = req.project_id.trim().to_string(); - let agent_id = req.agent_id.trim().to_string(); - let token_id = req - .token_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); - - validate_search_request_inputs( - tenant_id.as_str(), - project_id.as_str(), - agent_id.as_str(), - req.query.as_str(), - )?; - - let top_k = req.top_k.unwrap_or(self.cfg.memory.top_k).max(1); - let candidate_k = req.candidate_k.unwrap_or(self.cfg.memory.candidate_k).max(top_k); - let requested_candidate_k = candidate_k; - let filter = req - .filter - .as_ref() - .map(SearchFilter::parse) - .transpose() - .map_err(|err| crate::Error::InvalidRequest { message: err.to_string() })?; - let effective_candidate_k = if filter.is_some() { - requested_candidate_k.saturating_mul(3).min(MAX_CANDIDATE_K).max(top_k) - } else { - requested_candidate_k - }; - let query = req.query; - let read_profile = req.read_profile; - let record_hits_enabled = req.record_hits.unwrap_or(false); - let ranking_override = req.ranking; - let retrieval_sources_policy = ranking::resolve_retrieval_sources_policy( - &self.cfg.ranking.retrieval_sources, - ranking_override.as_ref().and_then(|override_| override_.retrieval_sources.as_ref()), - )?; - let expansion_mode = match path { - RawSearchPath::Quick => ExpansionMode::Off, - RawSearchPath::Planned => ranking::resolve_expansion_mode(&self.cfg), - }; - let trace_id = Uuid::new_v4(); - let project_context_description = self - .resolve_project_context_description(tenant_id.as_str(), project_id.as_str()) - .map(|value| value.to_string()); - let allowed_scopes = ranking::resolve_scopes(&self.cfg, read_profile.as_str())?; - let policies = self.resolve_finish_search_policies(ranking_override.as_ref())?; - - Ok(RawSearchExecutionContext { - tenant_id, - project_id, - agent_id, - token_id, - top_k, - candidate_k, - requested_candidate_k, - effective_candidate_k, - filter, - query, - read_profile, - payload_level: req.payload_level, - record_hits_enabled, - ranking_override, - retrieval_sources_policy, - expansion_mode, - trace_id, - project_context_description, - allowed_scopes, - policies, - }) - } - - fn build_raw_planned_response( - &self, - context: &RawSearchExecutionContext, - path: RawSearchPath, - response: SearchResponse, - expanded_queries: Vec, - dynamic_gate: DynamicGateSummary, - ) -> SearchRawPlannedResponse { - let query_plan = self.build_query_plan(BuildQueryPlanArgs { - path, - query: context.query.as_str(), - tenant_id: context.tenant_id.as_str(), - project_id: context.project_id.as_str(), - agent_id: context.agent_id.as_str(), - read_profile: context.read_profile.as_str(), - allowed_scopes: &context.allowed_scopes, - expansion_mode: context.expansion_mode, - expanded_queries, - top_k: context.top_k, - candidate_k: context.candidate_k, - retrieval_sources_policy: &context.retrieval_sources_policy, - recursive_enabled: self.cfg.search.recursive.enabled, - policies: &context.policies, - dynamic_gate, - }); - - SearchRawPlannedResponse { - trace_id: response.trace_id, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan, - } - } - - async fn maybe_finish_dynamic_search( - &self, - args: MaybeDynamicSearchArgs<'_>, - ) -> Result<(Option>, Option, DynamicGateSummary)> { - if !args.enabled { - return Ok((None, None, DynamicGateSummary::default())); - } - - let query_vec = - self.embed_single_query(args.query, args.project_context_description).await?; - let baseline_points = self - .run_fusion_query( - &[QueryEmbedding { text: args.query.to_string(), vector: query_vec.clone() }], - args.filter, - args.candidate_k, - ) - .await?; - let top_score = baseline_points.first().map(|point| point.score).unwrap_or(0.0); - let fusion_candidates = ranking::collect_chunk_candidates( - &baseline_points, - self.cfg.search.prefilter.max_candidates, - args.candidate_k, - ); - let should_expand = ranking::should_expand_dynamic( - baseline_points.len(), - top_score, - &self.cfg.search.dynamic, - ); - let dynamic_gate = DynamicGateSummary { - considered: true, - should_expand: Some(should_expand), - observed_candidates: Some(baseline_points.len() as u32), - observed_top_score: Some(top_score), - }; - - if should_expand { - return Ok((Some(query_vec), None, dynamic_gate)); - } - - let StructuredFieldRetrievalResult { - candidates: structured_candidates, - structured_matches, - } = self - .retrieve_structured_field_candidates(StructuredFieldRetrievalArgs { - tenant_id: args.tenant_id, - project_id: args.project_id, - agent_id: args.agent_id, - allowed_scopes: args.allowed_scopes, - query_vec: query_vec.as_slice(), - candidate_k: args.candidate_k, - now: OffsetDateTime::now_utc(), - }) - .await?; - let mut seed_candidates = - Vec::with_capacity(fusion_candidates.len() + structured_candidates.len()); - - seed_candidates.extend_from_slice(fusion_candidates.as_slice()); - seed_candidates.extend_from_slice(structured_candidates.as_slice()); - - let recursive = self - .run_recursive_retrieval(RecursiveRetrievalArgs { - query: args.query, - query_vec: query_vec.as_slice(), - filter: args.filter, - candidate_k: args.candidate_k, - retrieval_sources_policy: args.retrieval_sources_policy, - seed_candidates: seed_candidates.as_slice(), - }) - .await?; - let mut retrieval_sources = vec![ - RetrievalSourceCandidates { - source: RetrievalSourceKind::Fusion, - candidates: fusion_candidates, - }, - RetrievalSourceCandidates { - source: RetrievalSourceKind::StructuredField, - candidates: structured_candidates, - }, - ]; - - if recursive.enabled { - retrieval_sources.push(RetrievalSourceCandidates { - source: RetrievalSourceKind::Recursive, - candidates: recursive.candidates.clone(), - }); - } - - let merged_candidates = ranking::merge_retrieval_candidates( - retrieval_sources, - args.retrieval_sources_policy, - args.candidate_k, - ); - let response = self - .finish_search(FinishSearchArgs { - path: args.path, - trace_id: args.trace_id, - query: args.query, - tenant_id: args.tenant_id, - project_id: args.project_id, - agent_id: args.agent_id, - token_id: args.token_id, - read_profile: args.read_profile, - allowed_scopes: args.allowed_scopes, - expanded_queries: vec![args.query.to_string()], - expansion_mode: ExpansionMode::Dynamic, - candidates: merged_candidates, - structured_matches, - recursive_retrieval: Some(recursive), - top_k: args.top_k, - record_hits_enabled: args.record_hits_enabled, - ranking_override: args.ranking_override.cloned(), - payload_level: args.payload_level, - filter: args.service_filter, - requested_candidate_k: args.requested_candidate_k, - effective_candidate_k: args.effective_candidate_k, - }) - .await?; - - Ok((Some(query_vec), Some(response), dynamic_gate)) - } - - async fn retrieve_search_candidates( - &self, - args: SearchRetrievalArgs<'_>, - ) -> Result { - let queries = match args.expansion_mode { - ExpansionMode::Off => vec![args.query.to_string()], - ExpansionMode::Always | ExpansionMode::Dynamic => self.expand_queries(args.query).await, - }; - let expanded_queries = queries.clone(); - let query_embeddings = self - .embed_queries( - queries.as_slice(), - args.query, - args.baseline_vector, - args.project_context_description, - ) - .await?; - let fusion_points = - self.run_fusion_query(&query_embeddings, args.filter, args.candidate_k).await?; - let fusion_candidates = ranking::collect_chunk_candidates( - &fusion_points, - self.cfg.search.prefilter.max_candidates, - args.candidate_k, - ); - let original_query_vec = query_embeddings - .iter() - .find(|embedded| embedded.text == args.query) - .map(|embedded| embedded.vector.clone()) - .unwrap_or_else(Vec::new); - let original_query_vec = if original_query_vec.is_empty() { - self.embed_single_query(args.query, args.project_context_description).await? - } else { - original_query_vec - }; - let StructuredFieldRetrievalResult { - candidates: structured_candidates, - structured_matches, - } = self - .retrieve_structured_field_candidates(StructuredFieldRetrievalArgs { - tenant_id: args.tenant_id, - project_id: args.project_id, - agent_id: args.agent_id, - allowed_scopes: args.allowed_scopes, - query_vec: original_query_vec.as_slice(), - candidate_k: args.candidate_k, - now: OffsetDateTime::now_utc(), - }) - .await?; - let mut seed_candidates = - Vec::with_capacity(fusion_candidates.len() + structured_candidates.len()); - - seed_candidates.extend_from_slice(fusion_candidates.as_slice()); - seed_candidates.extend_from_slice(structured_candidates.as_slice()); - - let recursive = self - .run_recursive_retrieval(RecursiveRetrievalArgs { - query: args.query, - query_vec: original_query_vec.as_slice(), - filter: args.filter, - candidate_k: args.candidate_k, - retrieval_sources_policy: args.retrieval_sources_policy, - seed_candidates: seed_candidates.as_slice(), - }) - .await?; - let mut retrieval_sources = vec![ - RetrievalSourceCandidates { - source: RetrievalSourceKind::Fusion, - candidates: fusion_candidates, - }, - RetrievalSourceCandidates { - source: RetrievalSourceKind::StructuredField, - candidates: structured_candidates, - }, - ]; - - if recursive.enabled { - retrieval_sources.push(RetrievalSourceCandidates { - source: RetrievalSourceKind::Recursive, - candidates: recursive.candidates.clone(), - }); - } - - let merged_candidates = ranking::merge_retrieval_candidates( - retrieval_sources, - args.retrieval_sources_policy, - args.candidate_k, - ); - - Ok(SearchRetrievalResult { - expanded_queries, - candidates: merged_candidates, - structured_matches, - recursive: Some(recursive), - }) - } - - async fn run_recursive_retrieval( - &self, - args: RecursiveRetrievalArgs<'_>, - ) -> Result { - let recursive_config = &self.cfg.search.recursive; - let mut result = RecursiveRetrievalResult { - enabled: recursive_config.enabled - && args.retrieval_sources_policy.recursive_weight > 0.0, - ..Default::default() - }; - - if !result.enabled { - result.stop_reason = Some("disabled".to_string()); - - return Ok(result); - } - if args.query_vec.is_empty() { - result.stop_reason = Some("missing_query_vector".to_string()); - - return Ok(result); - } - - let mut seed_scopes = HashSet::::new(); - - for candidate in args.seed_candidates { - if let Some(scope) = candidate.scope.as_deref() - && !scope.trim().is_empty() - { - seed_scopes.insert(scope.to_string()); - } - } - - result.scopes_seeded = seed_scopes.len(); - result.candidates_before = args.seed_candidates.len(); - - if seed_scopes.is_empty() { - result.stop_reason = Some("no_scope_seed".to_string()); - - return Ok(result); - } - - let max_depth = recursive_config.max_depth; - let max_children_per_node = - usize::try_from(recursive_config.max_children_per_node).unwrap_or(usize::MAX); - let max_nodes_per_scope = - usize::try_from(recursive_config.max_nodes_per_scope).unwrap_or(usize::MAX); - let max_total_nodes = - usize::try_from(recursive_config.max_total_nodes).unwrap_or(usize::MAX); - let child_query_embedding = - QueryEmbedding { text: args.query.to_string(), vector: args.query_vec.to_vec() }; - let per_query_candidate_k = - args.candidate_k.min(recursive_config.max_nodes_per_scope).max(1); - let (candidates, queried_scopes, rounds_executed, stop_reason) = self - .collect_recursive_candidates( - &args, - seed_scopes, - child_query_embedding, - max_depth, - max_children_per_node, - max_nodes_per_scope, - max_total_nodes, - per_query_candidate_k, - self.cfg.search.prefilter.max_candidates, - ) - .await?; - - result.scopes_queried = queried_scopes; - result.rounds_executed = rounds_executed; - result.total_queries = rounds_executed; - result.candidates = candidates; - result.candidates_added = result.candidates.len(); - result.candidates_after = result.candidates_before + result.candidates_added; - result.stop_reason = stop_reason.or(Some("converged".to_string())); - - Ok(result) - } - - #[allow(clippy::too_many_arguments)] - async fn collect_recursive_candidates( - &self, - args: &RecursiveRetrievalArgs<'_>, - seed_scopes: HashSet, - child_query_embedding: QueryEmbedding, - max_depth: u32, - max_children_per_node: usize, - max_nodes_per_scope: usize, - max_total_nodes: usize, - per_query_candidate_k: u32, - prefilter_max_candidates: u32, - ) -> Result<(Vec, usize, u32, Option)> { - let mut queued_scopes: VecDeque<(String, u32)> = VecDeque::new(); - let mut discovered_scopes = seed_scopes.clone(); - let mut recursion_candidates = Vec::::new(); - let mut seen_chunks = - args.seed_candidates.iter().map(|candidate| candidate.chunk_id).collect::>(); - let mut scope_counts: HashMap = HashMap::new(); - let mut queried_scopes = 0_usize; - let mut rounds_executed = 0_u32; - let mut stop_reason: Option = None; - - for scope in seed_scopes { - queued_scopes.push_back((scope, 1)); - } - - while let Some((scope, depth)) = queued_scopes.pop_front() { - if depth > max_depth { - stop_reason = Some("max_depth".to_string()); - - break; - } - - queried_scopes = queried_scopes.saturating_add(1); - rounds_executed = rounds_executed.saturating_add(1); - - let mut scoped_filter = args.filter.clone(); - - scoped_filter.must.push(Condition::matches("scope", scope.clone())); - - let recursive_points = self - .run_fusion_query( - slice::from_ref(&child_query_embedding), - &scoped_filter, - per_query_candidate_k, - ) - .await?; - let scope_query_limit = per_query_candidate_k.min(max_nodes_per_scope as u32); - let recursive_candidates_for_scope = ranking::collect_chunk_candidates( - &recursive_points, - prefilter_max_candidates.min(scope_query_limit), - scope_query_limit, - ); - let mut child_scopes = HashSet::::new(); - - for mut candidate in recursive_candidates_for_scope { - if recursion_candidates.len() >= max_total_nodes { - stop_reason = Some("max_total_nodes".to_string()); - - break; - } - - let scope_key = candidate.scope.clone().unwrap_or_else(|| scope.clone()); - let scope_count = scope_counts.entry(scope_key.clone()).or_default(); - - if (*scope_count as usize) >= max_nodes_per_scope { - continue; - } - if !seen_chunks.insert(candidate.chunk_id) { - continue; - } - - *scope_count = scope_count.saturating_add(1); - candidate.scope = Some(scope_key.clone()); - - recursion_candidates.push(candidate); - - if depth < max_depth - && child_scopes.len() < max_children_per_node - && !scope_key.is_empty() - && discovered_scopes.insert(scope_key.clone()) - { - child_scopes.insert(scope_key.clone()); - queued_scopes.push_back((scope_key.clone(), depth.saturating_add(1))); - } - } - - if stop_reason.is_some() { - break; - } - } - - Ok((recursion_candidates, queried_scopes, rounds_executed, stop_reason)) - } - - fn resolve_project_context_description<'a>( - &'a self, - tenant_id: &str, - project_id: &str, - ) -> Option<&'a str> { - let context = self.cfg.context.as_ref()?; - let descriptions = context.project_descriptions.as_ref()?; - let key = format!("{tenant_id}:{project_id}"); - let mut saw_non_english = false; - - if let Some(value) = descriptions.get(&key) { - let trimmed = value.trim(); - - if !trimmed.is_empty() { - if !english_gate::is_english_natural_language(trimmed) { - saw_non_english = true; - } else { - return Some(trimmed); - } - } - } - if let Some(value) = descriptions.get(project_id) { - let trimmed = value.trim(); - - if !trimmed.is_empty() { - if !english_gate::is_english_natural_language(trimmed) { - saw_non_english = true; - } else { - return Some(trimmed); - } - } - } - - if saw_non_english { - tracing::warn!( - tenant_id = %tenant_id, - project_id = %project_id, - "Project context description is non-English. Skipping context." - ); - } - - None - } - - /// Loads the explain payload for one result handle. - pub async fn search_explain(&self, req: SearchExplainRequest) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id and project_id are required.".to_string(), - }); - } - - let row = sqlx::query_as::<_, SearchExplainTraceRow>( - "\ -SELECT - t.trace_id, - t.tenant_id, - t.project_id, - t.agent_id, - t.read_profile, - t.query, - t.expansion_mode, - t.expanded_queries, - t.allowed_scopes, - t.candidate_count, - t.top_k, - t.config_snapshot, - t.trace_version, - t.created_at, - i.item_id, - i.note_id, - i.chunk_id, - i.rank, - i.explain -FROM search_trace_items i -JOIN search_traces t ON i.trace_id = t.trace_id - -WHERE i.item_id = $1 AND t.tenant_id = $2 AND t.project_id = $3", - ) - .bind(req.result_handle) - .bind(tenant_id) - .bind(project_id) - .fetch_optional(&self.db.pool) - .await?; - let Some(row) = row else { - return Err(crate::Error::InvalidRequest { - message: "Unknown result_handle or trace not yet persisted.".to_string(), - }); - }; - let expanded_queries: Vec = - ranking::decode_json(row.expanded_queries, "expanded_queries")?; - let allowed_scopes: Vec = - ranking::decode_json(row.allowed_scopes, "allowed_scopes")?; - let config_snapshot = row.config_snapshot; - let explain: SearchExplain = ranking::decode_json(row.explain, "explain")?; - let trace = SearchTrace { - trace_id: row.trace_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - read_profile: row.read_profile, - query: row.query, - expansion_mode: row.expansion_mode, - expanded_queries, - allowed_scopes, - candidate_count: row.candidate_count as u32, - top_k: row.top_k as u32, - config_snapshot, - created_at: row.created_at, - trace_version: row.trace_version, - }; - let item = SearchExplainItem { - result_handle: row.item_id, - note_id: row.note_id, - chunk_id: row.chunk_id, - rank: row.rank as u32, - explain, - }; - let trajectory = load_item_trajectory( - &self.db.pool, - row.trace_id, - row.item_id, - row.note_id, - row.chunk_id, - ) - .await?; - - Ok(SearchExplainResponse { trace, item, trajectory }) - } - - /// Loads trace metadata and explained items for one trace. - pub async fn trace_get(&self, req: TraceGetRequest) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - - if req.agent_id.trim().is_empty() { - return Err(crate::Error::InvalidRequest { - message: "agent_id is required.".to_string(), - }); - } - if tenant_id.is_empty() || project_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id and project_id are required.".to_string(), - }); - } - - let row = sqlx::query_as::<_, SearchTraceRow>( - "\ -SELECT - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - expansion_mode, - expanded_queries, - allowed_scopes, - candidate_count, - top_k, - config_snapshot, - trace_version, - created_at -FROM search_traces -WHERE trace_id = $1 AND tenant_id = $2 AND project_id = $3", - ) - .bind(req.trace_id) - .bind(tenant_id) - .bind(project_id) - .fetch_optional(&self.db.pool) - .await?; - let Some(row) = row else { - return Err(crate::Error::InvalidRequest { message: "Unknown trace_id.".to_string() }); - }; - let expanded_queries: Vec = - ranking::decode_json(row.expanded_queries, "expanded_queries")?; - let allowed_scopes: Vec = - ranking::decode_json(row.allowed_scopes, "allowed_scopes")?; - let config_snapshot = row.config_snapshot; - let trace = SearchTrace { - trace_id: row.trace_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - read_profile: row.read_profile, - query: row.query, - expansion_mode: row.expansion_mode, - expanded_queries, - allowed_scopes, - candidate_count: row.candidate_count as u32, - top_k: row.top_k as u32, - config_snapshot, - created_at: row.created_at, - trace_version: row.trace_version, - }; - let item_rows = sqlx::query_as::<_, SearchTraceItemRow>( - "\ -SELECT - item_id, - note_id, - chunk_id, - rank, - explain -FROM search_trace_items -WHERE trace_id = $1 -ORDER BY rank ASC", - ) - .bind(req.trace_id) - .fetch_all(&self.db.pool) - .await?; - let mut items = Vec::with_capacity(item_rows.len()); - - for row in item_rows { - let explain: SearchExplain = ranking::decode_json(row.explain, "explain")?; - - items.push(SearchExplainItem { - result_handle: row.item_id, - note_id: row.note_id, - chunk_id: row.chunk_id, - rank: row.rank as u32, - explain, - }); - } - - let trajectory_summary = load_trace_trajectory_summary(&self.db.pool, req.trace_id).await?; - - Ok(TraceGetResponse { trace, items, trajectory_summary }) - } - - /// Loads full trajectory stages for one trace. - pub async fn trace_trajectory_get( - &self, - req: TraceTrajectoryGetRequest, - ) -> Result { - let base = self - .trace_get(TraceGetRequest { - tenant_id: req.tenant_id, - project_id: req.project_id, - agent_id: req.agent_id, - trace_id: req.trace_id, - }) - .await?; - let stages = load_trace_trajectory_stages(&self.db.pool, req.trace_id).await?; - let trajectory = build_trajectory_summary_from_stages(stages.as_slice()); - - Ok(SearchTrajectoryResponse { trace: base.trace, trajectory, stages }) - } - - /// Lists recent traces with cursor-based pagination. - pub async fn trace_recent_list( - &self, - req: TraceRecentListRequest, - ) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let caller_agent_id = req.agent_id.trim(); - let cursor_created_at = req.cursor_created_at; - let cursor_trace_id = req.cursor_trace_id; - let agent_id_filter = req.agent_id_filter.map(|value| value.trim().to_string()); - let read_profile = req.read_profile.map(|value| value.trim().to_string()); - let limit = req.limit.unwrap_or(DEFAULT_RECENT_TRACES_LIMIT); - - if cursor_created_at.is_some() != cursor_trace_id.is_some() { - return Err(crate::Error::InvalidRequest { - message: "cursor_created_at and cursor_trace_id must be both set or both omitted." - .to_string(), - }); - } - if caller_agent_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "agent_id is required.".to_string(), - }); - } - if tenant_id.is_empty() || project_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id and project_id are required.".to_string(), - }); - } - if limit == 0 || limit > MAX_RECENT_TRACES_LIMIT { - return Err(crate::Error::InvalidRequest { - message: format!("limit must be between 1 and {MAX_RECENT_TRACES_LIMIT}."), - }); - } - - if let (Some(created_after), Some(created_before)) = (req.created_after, req.created_before) - && created_after >= created_before - { - return Err(crate::Error::InvalidRequest { - message: "created_after must be before created_before.".to_string(), - }); - } - - let agent_id_filter = agent_id_filter.as_deref(); - let read_profile = read_profile.as_deref(); - let fetch_limit = (limit + 1).min(MAX_RECENT_TRACES_LIMIT + 1); - let rows = sqlx::query_as::<_, SearchRecentTraceRow>( - "\ -SELECT - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - created_at -FROM search_traces -WHERE tenant_id = $1 - AND project_id = $2 - AND ($3::text IS NULL OR agent_id = $3) - AND ($4::text IS NULL OR read_profile = $4) - AND ($5::timestamptz IS NULL OR created_at > $5) - AND ($6::timestamptz IS NULL OR created_at < $6) - AND ($7::timestamptz IS NULL OR $8::uuid IS NULL OR (created_at, trace_id) < ($7, $8)) -ORDER BY created_at DESC, trace_id DESC -LIMIT $9 -", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id_filter) - .bind(read_profile) - .bind(req.created_after) - .bind(req.created_before) - .bind(cursor_created_at) - .bind(cursor_trace_id) - .bind(fetch_limit as i64) - .fetch_all(&self.db.pool) - .await?; - let next_cursor = if rows.len() > limit as usize { - let cursor_row = &rows[limit as usize - 1]; - - Some(TraceRecentCursor { - created_at: cursor_row.created_at, - trace_id: cursor_row.trace_id, - }) - } else { - None - }; - let mut response_rows = rows; - - response_rows.truncate(limit as usize); - - let mut traces = Vec::with_capacity(response_rows.len()); - - for row in response_rows { - traces.push(RecentTraceHeader { - trace_id: row.trace_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - read_profile: row.read_profile, - query: row.query, - created_at: row.created_at, - }); - } - - Ok(TraceRecentListResponse { - schema: RECENT_TRACES_SCHEMA_V1.to_string(), - traces, - next_cursor, - }) - } - - /// Loads a trace bundle with optional trajectory and replay candidates. - pub async fn trace_bundle_get( - &self, - req: TraceBundleGetRequest, - ) -> Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - - if req.agent_id.trim().is_empty() { - return Err(crate::Error::InvalidRequest { - message: "agent_id is required.".to_string(), - }); - } - if tenant_id.is_empty() || project_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id and project_id are required.".to_string(), - }); - } - - let base = self - .trace_get(TraceGetRequest { - tenant_id: tenant_id.to_string(), - project_id: project_id.to_string(), - agent_id: req.agent_id.trim().to_string(), - trace_id: req.trace_id, - }) - .await?; - let default_stage_items_limit = match req.mode { - TraceBundleMode::Bounded => DEFAULT_BOUNDED_STAGE_ITEMS_LIMIT, - TraceBundleMode::Full => DEFAULT_FULL_STAGE_ITEMS_LIMIT, - }; - let default_candidates_limit = match req.mode { - TraceBundleMode::Bounded => DEFAULT_BOUNDED_CANDIDATES_LIMIT, - TraceBundleMode::Full => DEFAULT_FULL_CANDIDATES_LIMIT, - }; - let stage_items_limit = req - .stage_items_limit - .unwrap_or(default_stage_items_limit) - .min(MAX_TRACE_BUNDLE_ITEMS_LIMIT); - let candidates_limit = req - .candidates_limit - .unwrap_or(default_candidates_limit) - .min(MAX_TRACE_BUNDLE_CANDIDATES_LIMIT); - let mut stages = load_trace_trajectory_stages(&self.db.pool, req.trace_id).await?; - - for stage in stages.iter_mut() { - stage.items.truncate(stage_items_limit as usize); - } - - let candidates = if candidates_limit == 0 { - None - } else { - let candidate_rows = sqlx::query_as::<_, TraceCandidateSnapshotRow>( - "\ -SELECT candidate_snapshot -FROM search_trace_candidates -WHERE trace_id = $1 -ORDER BY retrieval_rank ASC, candidate_id ASC -LIMIT $2 -", - ) - .bind(req.trace_id) - .bind(candidates_limit as i32) - .fetch_all(&self.db.pool) - .await?; - let mut candidates = Vec::with_capacity(candidate_rows.len()); - - for row in candidate_rows { - candidates - .push(ranking::decode_json(row.candidate_snapshot, "candidate_snapshot")?); - } - - if candidates.is_empty() { None } else { Some(candidates) } - }; - - Ok(TraceBundleResponse { - schema: TRACE_BUNDLE_SCHEMA_V1.to_string(), - generated_at: OffsetDateTime::now_utc(), - trace: base.trace, - items: base.items, - trajectory_summary: base.trajectory_summary, - stages, - candidates, - }) - } - - async fn embed_single_query( - &self, - query: &str, - project_context_description: Option<&str>, - ) -> Result> { - let input = ranking::build_dense_embedding_input(query, project_context_description); - let embeddings = self - .providers - .embedding - .embed(&self.cfg.providers.embedding, slice::from_ref(&input)) - .await?; - let query_vec = embeddings.into_iter().next().ok_or_else(|| crate::Error::Provider { - message: "Embedding provider returned no vectors.".to_string(), - })?; - - if query_vec.len() != self.cfg.storage.qdrant.vector_dim as usize { - return Err(crate::Error::Provider { - message: "Embedding vector dimension mismatch.".to_string(), - }); - } - - Ok(query_vec) - } - - async fn embed_queries( - &self, - queries: &[String], - original_query: &str, - baseline_vector: Option<&Vec>, - project_context_description: Option<&str>, - ) -> Result> { - let mut extra_queries = Vec::new(); - let mut extra_inputs = Vec::new(); - - for query in queries { - if baseline_vector.is_some() && query == original_query { - continue; - } - - extra_queries.push(query.clone()); - extra_inputs - .push(ranking::build_dense_embedding_input(query, project_context_description)); - } - - let mut embedded_iter = if extra_queries.is_empty() { - Vec::new().into_iter() - } else { - let embedded = self - .providers - .embedding - .embed(&self.cfg.providers.embedding, &extra_inputs) - .await?; - - if embedded.len() != extra_queries.len() { - return Err(crate::Error::Provider { - message: "Embedding provider returned mismatched vector count.".to_string(), - }); - } - - embedded.into_iter() - }; - let mut out = Vec::with_capacity(queries.len()); - - for query in queries { - let vector = if baseline_vector.is_some() && query == original_query { - baseline_vector - .ok_or_else(|| crate::Error::Provider { - message: "Embedding baseline vector is missing.".to_string(), - })? - .clone() - } else { - embedded_iter.next().ok_or_else(|| crate::Error::Provider { - message: "Embedding provider returned no vectors.".to_string(), - })? - }; - - if vector.len() != self.cfg.storage.qdrant.vector_dim as usize { - return Err(crate::Error::Provider { - message: "Embedding vector dimension mismatch.".to_string(), - }); - } - - out.push(QueryEmbedding { text: query.clone(), vector }); - } - - Ok(out) - } - - async fn run_fusion_query( - &self, - queries: &[QueryEmbedding], - filter: &Filter, - candidate_k: u32, - ) -> Result> { - let mut search = QueryPointsBuilder::new(self.qdrant.collection.clone()); - - for query in queries { - let dense_prefetch = PrefetchQueryBuilder::default() - .query(Query::new_nearest(query.vector.clone())) - .using(DENSE_VECTOR_NAME) - .filter(filter.clone()) - .limit(candidate_k as u64); - let bm25_prefetch = PrefetchQueryBuilder::default() - .query(Query::new_nearest(Document::new(query.text.clone(), BM25_MODEL))) - .using(BM25_VECTOR_NAME) - .filter(filter.clone()) - .limit(candidate_k as u64); - - search = search.add_prefetch(dense_prefetch).add_prefetch(bm25_prefetch); - } - - let search = search.with_payload(true).query(Fusion::Rrf).limit(candidate_k as u64); - let response = self - .qdrant - .client - .query(search) - .await - .map_err(|err| crate::Error::Qdrant { message: err.to_string() })?; - - Ok(response.result) - } - - async fn expand_queries(&self, query: &str) -> Vec { - let cfg = &self.cfg.search.expansion; - let cache_cfg = &self.cfg.search.cache; - let now = OffsetDateTime::now_utc(); - let cache_key = if cache_cfg.enabled { - match ranking::build_expansion_cache_key( - query, - cfg.max_queries, - cfg.include_original, - self.cfg.providers.llm_extractor.provider_id.as_str(), - self.cfg.providers.llm_extractor.model.as_str(), - self.cfg.providers.llm_extractor.temperature, - ) { - Ok(key) => Some(key), - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Expansion.as_str(), - "Cache key build failed." - ); - - None - }, - } - } else { - None - }; - - if let Some(key) = cache_key.as_ref() - && let Some(queries) = self.read_expansion_cache_queries(key, cache_cfg, now).await - { - return queries; - } - - let messages = - ranking::build_expansion_messages(query, cfg.max_queries, cfg.include_original); - let raw = match self - .providers - .extractor - .extract(&self.cfg.providers.llm_extractor, &messages) - .await - { - Ok(value) => value, - Err(err) => { - tracing::warn!(error = %err, "Query expansion failed; falling back to original query."); - - return vec![query.to_string()]; - }, - }; - let parsed: ExpansionOutput = match serde_json::from_value(raw) { - Ok(value) => value, - Err(err) => { - tracing::warn!(error = %err, "Query expansion returned invalid JSON; falling back to original query."); - - return vec![query.to_string()]; - }, - }; - let normalized = ranking::normalize_queries( - parsed.queries, - query, - cfg.include_original, - cfg.max_queries, - ); - let result = if normalized.is_empty() { vec![query.to_string()] } else { normalized }; - - if let Some(key) = cache_key { - self.store_expansion_cache_queries(&key, &result, cache_cfg).await; - } - - result - } - - async fn read_expansion_cache_queries( - &self, - key: &str, - cache_cfg: &SearchCache, - now: OffsetDateTime, - ) -> Option> { - match fetch_cache_payload(&self.db.pool, CacheKind::Expansion, key, now).await { - Ok(Some(payload)) => { - tracing::info!( - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = true, - payload_size = payload.size_bytes, - ttl_days = cache_cfg.expansion_ttl_days, - "Cache hit." - ); - - let cached: ExpansionCachePayload = match serde_json::from_value(payload.value) { - Ok(value) => value, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache payload decode failed." - ); - - ExpansionCachePayload { queries: Vec::new() } - }, - }; - - (!cached.queries.is_empty()).then_some(cached.queries) - }, - Ok(None) => { - tracing::info!( - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size = 0_u64, - ttl_days = cache_cfg.expansion_ttl_days, - "Cache miss." - ); - - None - }, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache read failed." - ); - - None - }, - } - } - - async fn store_expansion_cache_queries( - &self, - key: &str, - queries: &[String], - cache_cfg: &SearchCache, - ) { - let payload = ExpansionCachePayload { queries: queries.to_vec() }; - let payload_json = match serde_json::to_value(&payload) { - Ok(value) => value, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache payload encode failed." - ); - - return; - }, - }; - let stored_at = OffsetDateTime::now_utc(); - let expires_at = stored_at + Duration::days(cache_cfg.expansion_ttl_days); - - match store_cache_payload( - &self.db.pool, - CacheKind::Expansion, - key, - payload_json, - stored_at, - expires_at, - cache_cfg.max_payload_bytes, - ) - .await - { - Ok(Some(payload_size)) => { - tracing::info!( - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size, - ttl_days = cache_cfg.expansion_ttl_days, - "Cache stored." - ); - }, - Ok(None) => { - tracing::warn!( - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size = 0_u64, - ttl_days = cache_cfg.expansion_ttl_days, - "Cache payload skipped due to size." - ); - }, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Expansion.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache write failed." - ); - }, - } - } - - async fn retrieve_structured_field_candidates( - &self, - args: StructuredFieldRetrievalArgs<'_>, - ) -> Result { - let StructuredFieldRetrievalArgs { - tenant_id, - project_id, - agent_id, - allowed_scopes, - query_vec, - candidate_k, - now, - } = args; - - if query_vec.is_empty() { - return Ok(StructuredFieldRetrievalResult { - candidates: Vec::new(), - structured_matches: HashMap::new(), - }); - } - - let embed_version = crate::embedding_version(&self.cfg); - let vec_text = crate::vector_to_pg(query_vec); - let private_allowed = allowed_scopes.iter().any(|scope| scope == "agent_private"); - let non_private_scopes: Vec = - allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); - let retrieval_limit = i64::from(candidate_k.saturating_mul(4).clamp(16, 400)); - let rows = self - .fetch_structured_field_hits(StructuredFieldHitArgs { - embed_version: embed_version.as_str(), - tenant_id, - project_id, - agent_id, - now, - vec_text: vec_text.as_str(), - retrieval_limit, - private_allowed, - non_private_scopes: non_private_scopes.as_slice(), - }) - .await?; - let (ordered_note_ids, structured_matches_out) = build_structured_field_matches(rows); - - if ordered_note_ids.is_empty() { - return Ok(StructuredFieldRetrievalResult { - candidates: Vec::new(), - structured_matches: structured_matches_out, - }); - } - - let best_by_note = self - .fetch_best_chunks_for_notes( - embed_version.as_str(), - ordered_note_ids.as_slice(), - vec_text.as_str(), - ) - .await?; - let structured_candidates = build_structured_field_candidates( - candidate_k, - ordered_note_ids, - best_by_note, - embed_version.as_str(), - ); - - Ok(StructuredFieldRetrievalResult { - candidates: structured_candidates, - structured_matches: structured_matches_out, - }) - } - - async fn fetch_structured_field_hits( - &self, - args: StructuredFieldHitArgs<'_>, - ) -> Result> { - if args.private_allowed && args.non_private_scopes.is_empty() { - self.fetch_structured_field_hits_private_only(args).await - } else if !args.private_allowed { - self.fetch_structured_field_hits_non_private_only(args).await - } else { - self.fetch_structured_field_hits_mixed(args).await - } - } - - async fn fetch_structured_field_hits_private_only( - &self, - args: StructuredFieldHitArgs<'_>, - ) -> Result> { - let rows = sqlx::query_as::<_, StructuredFieldHitRow>( - "\ -SELECT - f.note_id, - f.field_kind -FROM memory_note_fields f -JOIN note_field_embeddings e - ON e.field_id = f.field_id - AND e.embedding_version = $1 -JOIN memory_notes n - ON n.note_id = f.note_id -WHERE n.tenant_id = $2 - AND n.project_id = $3 - AND n.status = 'active' - AND (n.expires_at IS NULL OR n.expires_at > $4) - AND n.scope = 'agent_private' - AND n.agent_id = $5 -ORDER BY e.vec <=> $6::text::vector ASC -LIMIT $7", - ) - .bind(args.embed_version) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.now) - .bind(args.agent_id) - .bind(args.vec_text) - .bind(args.retrieval_limit) - .fetch_all(&self.db.pool) - .await?; - - Ok(rows - .into_iter() - .map(|row| FieldHit { note_id: row.note_id, field_kind: row.field_kind }) - .collect()) - } - - async fn fetch_structured_field_hits_non_private_only( - &self, - args: StructuredFieldHitArgs<'_>, - ) -> Result> { - let rows = sqlx::query_as::<_, StructuredFieldHitRow>( - "\ -SELECT - f.note_id, - f.field_kind -FROM memory_note_fields f -JOIN note_field_embeddings e - ON e.field_id = f.field_id - AND e.embedding_version = $1 -JOIN memory_notes n - ON n.note_id = f.note_id -WHERE n.tenant_id = $2 - AND (n.project_id = $3 OR (n.project_id = $8 AND n.scope = 'org_shared')) - AND n.status = 'active' - AND (n.expires_at IS NULL OR n.expires_at > $4) - AND n.scope = ANY($5::text[]) -ORDER BY e.vec <=> $6::text::vector ASC -LIMIT $7", - ) - .bind(args.embed_version) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.now) - .bind(args.non_private_scopes) - .bind(args.vec_text) - .bind(args.retrieval_limit) - .bind(ORG_PROJECT_ID) - .fetch_all(&self.db.pool) - .await?; - - Ok(rows - .into_iter() - .map(|row| FieldHit { note_id: row.note_id, field_kind: row.field_kind }) - .collect()) - } - - async fn fetch_structured_field_hits_mixed( - &self, - args: StructuredFieldHitArgs<'_>, - ) -> Result> { - let rows = sqlx::query_as::<_, StructuredFieldHitRow>( - "\ -SELECT - f.note_id, - f.field_kind -FROM memory_note_fields f -JOIN note_field_embeddings e - ON e.field_id = f.field_id - AND e.embedding_version = $1 -JOIN memory_notes n - ON n.note_id = f.note_id -WHERE n.tenant_id = $2 - AND (n.project_id = $3 OR (n.project_id = $9 AND n.scope = 'org_shared')) - AND n.status = 'active' - AND (n.expires_at IS NULL OR n.expires_at > $4) - AND ( - (n.scope = 'agent_private' AND n.agent_id = $5) - OR n.scope = ANY($6::text[]) - ) -ORDER BY e.vec <=> $7::text::vector ASC -LIMIT $8", - ) - .bind(args.embed_version) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.now) - .bind(args.agent_id) - .bind(args.non_private_scopes) - .bind(args.vec_text) - .bind(args.retrieval_limit) - .bind(ORG_PROJECT_ID) - .fetch_all(&self.db.pool) - .await?; - - Ok(rows - .into_iter() - .map(|row| FieldHit { note_id: row.note_id, field_kind: row.field_kind }) - .collect()) - } - - async fn fetch_best_chunks_for_notes( - &self, - embed_version: &str, - ordered_note_ids: &[Uuid], - vec_text: &str, - ) -> Result> { - let best_chunks = sqlx::query_as::<_, BestChunkForNoteRow>( - "\ -SELECT DISTINCT ON (c.note_id) - c.note_id, - c.chunk_id, - c.chunk_index -FROM memory_note_chunks c -JOIN note_chunk_embeddings e - ON e.chunk_id = c.chunk_id - AND e.embedding_version = $1 -WHERE c.note_id = ANY($2::uuid[]) -ORDER BY c.note_id ASC, e.vec <=> $3::text::vector ASC", - ) - .bind(embed_version) - .bind(ordered_note_ids) - .bind(vec_text) - .fetch_all(&self.db.pool) - .await?; - let mut best_by_note = HashMap::new(); - - for row in best_chunks { - best_by_note.insert(row.note_id, (row.chunk_id, row.chunk_index)); - } - - Ok(best_by_note) - } - - async fn finish_search(&self, args: FinishSearchArgs<'_>) -> Result { - let now = OffsetDateTime::now_utc(); - let candidate_count = args.candidates.len(); - let candidate_note_ids: Vec = - args.candidates.iter().map(|candidate| candidate.note_id).collect(); - let policies = self.resolve_finish_search_policies(args.ranking_override.as_ref())?; - let note_meta = self - .fetch_note_meta_for_candidates( - args.tenant_id, - args.project_id, - args.agent_id, - args.allowed_scopes, - candidate_note_ids.as_slice(), - now, - ) - .await?; - let scoring = self - .build_finish_search_scoring( - args.query, - args.candidates, - ¬e_meta, - &policies, - args.top_k, - candidate_count, - args.filter, - args.requested_candidate_k, - args.effective_candidate_k, - now, - args.path == RawSearchPath::Quick, - ) - .await?; - let FinishSearchScoringResult { - query_tokens, - filtered_candidates, - scored_count, - snippet_count, - filtered_candidate_count, - filter_impact, - mut trace_candidates, - fused_results, - selected_results, - diversity_decisions, - selected_count, - } = scoring; - let relation_contexts = self - .build_relation_context_for_selected_results( - &selected_results, - args.tenant_id, - args.project_id, - args.agent_id, - args.allowed_scopes, - now, - ) - .await?; - - ranking::attach_diversity_decisions_to_trace_candidates( - &mut trace_candidates, - &diversity_decisions, - ); - - self.record_hits_if_enabled(args.record_hits_enabled, args.query, &selected_results, now) - .await?; - - let (items, trajectory_summary) = self - .build_items_and_write_trace(BuildTraceArgs { - path: args.path, - trace_id: args.trace_id, - query: args.query, - tenant_id: args.tenant_id, - project_id: args.project_id, - agent_id: args.agent_id, - token_id: args.token_id, - read_profile: args.read_profile, - expansion_mode: args.expansion_mode, - expanded_queries: args.expanded_queries, - allowed_scopes: args.allowed_scopes, - candidate_count, - filtered_candidate_count, - snippet_count, - scored_count, - fused_count: fused_results.len(), - selected_count, - top_k: args.top_k, - query_tokens: query_tokens.as_slice(), - structured_matches: &args.structured_matches, - policies: &policies, - diversity_decisions: &diversity_decisions, - recall_candidates: filtered_candidates, - fused_results, - selected_results, - relation_contexts, - trace_candidates, - recursive_retrieval: args.recursive_retrieval.as_ref(), - now, - ranking_override: &args.ranking_override, - filter_impact, - payload_level: args.payload_level, - }) - .await?; - - Ok(SearchResponse { - trace_id: args.trace_id, - items, - trajectory_summary: Some(trajectory_summary), - }) - } - - async fn build_items_and_write_trace( - &self, - args: BuildTraceArgs<'_>, - ) -> Result<(Vec, SearchTrajectorySummary)> { - let trace_id = args.trace_id; - let (items, trajectory_summary, trace_payload) = self.build_items_and_trace_payload(args); - - self.write_trace_payload(trace_id, trace_payload).await?; - - Ok((items, trajectory_summary)) - } - - #[allow(clippy::too_many_arguments)] - async fn build_finish_search_scoring( - &self, - query: &str, - candidates: Vec, - note_meta: &HashMap, - policies: &FinishSearchPolicies, - top_k: u32, - candidate_count: usize, - filter: Option<&SearchFilter>, - requested_candidate_k: u32, - effective_candidate_k: u32, - now: OffsetDateTime, - skip_rerank: bool, - ) -> Result { - let (filtered_candidates, filter_impact) = self.apply_filter_to_candidates( - candidates, - note_meta, - filter, - requested_candidate_k, - effective_candidate_k, - ); - let filtered_candidate_count = filtered_candidates.len(); - let snippet_items = self.build_snippet_items(&filtered_candidates, note_meta).await?; - let snippet_count = snippet_items.len(); - let query_tokens = ranking::tokenize_query(query, MAX_MATCHED_TERMS); - let scope_context_boost_by_scope = - ranking::build_scope_context_boost_by_scope(&query_tokens, self.cfg.context.as_ref()); - let det_query_tokens = build_deterministic_query_tokens(&self.cfg, query); - let scored = self - .score_snippet_items(ScoreSnippetArgs { - query, - snippet_items, - scope_context_boost_by_scope: &scope_context_boost_by_scope, - det_query_tokens: det_query_tokens.as_slice(), - blend_policy: &policies.blend_policy, - cache_cfg: &self.cfg.search.cache, - now, - candidate_count, - skip_rerank, - }) - .await?; - let scored_count = scored.len(); - let trace_candidates = self.build_trace_candidates(&scored, now); - let results = select_best_scored_chunks(scored); - let fused_results = results.clone(); - let (selected_results, diversity_decisions) = - self.apply_diversity_policy(results, top_k, &policies.diversity_policy).await?; - let selected_count = selected_results.len(); - - Ok(FinishSearchScoringResult { - query_tokens, - filtered_candidates, - scored_count, - snippet_count, - filtered_candidate_count, - filter_impact, - trace_candidates, - fused_results, - selected_results, - diversity_decisions, - selected_count, - }) - } - - fn apply_filter_to_candidates( - &self, - candidates: Vec, - note_meta: &HashMap, - filter: Option<&SearchFilter>, - requested_candidate_k: u32, - effective_candidate_k: u32, - ) -> (Vec, Option) { - let filtered_candidates: Vec = candidates - .into_iter() - .filter(|candidate| ranking::candidate_matches_note(note_meta, candidate)) - .collect(); - - match filter { - Some(filter) => { - let (candidates, filter_impact) = filter.eval( - filtered_candidates, - note_meta, - requested_candidate_k, - effective_candidate_k, - ); - - (candidates, Some(filter_impact)) - }, - None => (filtered_candidates, None), - } - } - - async fn build_relation_context_for_selected_results( - &self, - selected_results: &[ScoredChunk], - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], - now: OffsetDateTime, - ) -> Result>> { - if !self.cfg.search.graph_context.enabled { - return Ok(HashMap::new()); - } - - let selected_note_ids: Vec = - selected_results.iter().map(|chunk| chunk.item.note.note_id).collect(); - - if selected_note_ids.is_empty() { - return Ok(HashMap::new()); - } - - self.fetch_relation_contexts_for_notes( - selected_note_ids.as_slice(), - tenant_id, - project_id, - agent_id, - allowed_scopes, - now, - ) - .await - } - - fn resolve_finish_search_policies( - &self, - ranking_override: Option<&RankingRequestOverride>, - ) -> Result { - let blend_policy = ranking::resolve_blend_policy( - &self.cfg.ranking.blend, - ranking_override.and_then(|override_| override_.blend.as_ref()), - )?; - let diversity_policy = ranking::resolve_diversity_policy( - &self.cfg.ranking.diversity, - ranking_override.and_then(|override_| override_.diversity.as_ref()), - )?; - let retrieval_sources_policy = ranking::resolve_retrieval_sources_policy( - &self.cfg.ranking.retrieval_sources, - ranking_override.and_then(|override_| override_.retrieval_sources.as_ref()), - )?; - let policy_snapshot = ranking::build_policy_snapshot( - &self.cfg, - &blend_policy, - &diversity_policy, - &retrieval_sources_policy, - ranking_override, - ); - let policy_hash = ranking::hash_policy_snapshot(&policy_snapshot)?; - let policy_id = format!("ranking_v2:{}", &policy_hash[..12.min(policy_hash.len())]); - - Ok(FinishSearchPolicies { - blend_policy, - diversity_policy, - retrieval_sources_policy, - policy_snapshot, - policy_id, - }) - } - - fn build_query_plan(&self, args: BuildQueryPlanArgs<'_>) -> QueryPlan { - let allowed_scopes = sorted_unique_strings(args.allowed_scopes.to_vec()); - let expanded_queries = sorted_unique_strings(args.expanded_queries); - let retrieval_stages = self.build_query_plan_retrieval_stages( - args.candidate_k, - args.retrieval_sources_policy, - args.recursive_enabled, - ); - let rewrite = - self.build_query_plan_rewrite(args.expansion_mode, expanded_queries, args.dynamic_gate); - let fusion_policy = self.build_query_plan_fusion_policy(args.retrieval_sources_policy); - let rerank_policy = self.build_query_plan_rerank_policy(args.policies); - let budget = self.build_query_plan_budget(args.top_k, args.candidate_k); - let stages = Self::build_query_plan_stages(QueryPlanStagesArgs { - path: args.path, - query: args.query, - read_profile: args.read_profile, - allowed_scope_count: allowed_scopes.len(), - rewrite: &rewrite, - retrieval_stages: &retrieval_stages, - fusion_policy: &fusion_policy, - rerank_policy: &rerank_policy, - budget: &budget, - }); - - QueryPlan { - schema: QUERY_PLAN_SCHEMA.to_string(), - version: QUERY_PLAN_VERSION.to_string(), - stages, - intent: QueryPlanIntent { - query: args.query.to_string(), - tenant_id: args.tenant_id.to_string(), - project_id: args.project_id.to_string(), - agent_id: args.agent_id.to_string(), - read_profile: args.read_profile.to_string(), - allowed_scopes, - }, - rewrite, - retrieval_stages, - fusion_policy, - rerank_policy, - budget, - } - } - - fn build_query_plan_retrieval_stages( - &self, - candidate_k: u32, - retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, - recursive_enabled: bool, - ) -> Vec { - let mut stages = vec![ - QueryPlanRetrievalStage { - name: "fusion_dense_bm25".to_string(), - source: "qdrant_fusion".to_string(), - enabled: true, - candidate_limit: candidate_k, - }, - QueryPlanRetrievalStage { - name: "structured_field_vector".to_string(), - source: "postgres_vector".to_string(), - enabled: retrieval_sources_policy.structured_field_weight > 0.0, - candidate_limit: candidate_k, - }, - ]; - - if recursive_enabled { - stages.push(QueryPlanRetrievalStage { - name: "recursive_scope".to_string(), - source: "scope_graph".to_string(), - enabled: retrieval_sources_policy.recursive_weight > 0.0, - candidate_limit: candidate_k, - }); - } - - stages - } - - fn build_query_plan_rewrite( - &self, - expansion_mode: ExpansionMode, - expanded_queries: Vec, - dynamic_gate: DynamicGateSummary, - ) -> QueryPlanRewrite { - QueryPlanRewrite { - expansion_mode: ranking::expansion_mode_label(expansion_mode).to_string(), - expanded_queries, - dynamic_gate: QueryPlanDynamicGate { - considered: dynamic_gate.considered, - should_expand: dynamic_gate.should_expand, - observed_candidates: dynamic_gate.observed_candidates, - observed_top_score: dynamic_gate.observed_top_score, - min_candidates: self.cfg.search.dynamic.min_candidates, - min_top_score: self.cfg.search.dynamic.min_top_score, - }, - } - } - - fn build_query_plan_fusion_policy( - &self, - retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, - ) -> QueryPlanFusionPolicy { - QueryPlanFusionPolicy { - strategy: "weighted_merge".to_string(), - fusion_weight: retrieval_sources_policy.fusion_weight, - structured_field_weight: retrieval_sources_policy.structured_field_weight, - recursive_weight: retrieval_sources_policy.recursive_weight, - fusion_priority: retrieval_sources_policy.fusion_priority, - structured_field_priority: retrieval_sources_policy.structured_field_priority, - recursive_priority: retrieval_sources_policy.recursive_priority, - } - } - - fn build_query_plan_rerank_policy( - &self, - policies: &FinishSearchPolicies, - ) -> QueryPlanRerankPolicy { - QueryPlanRerankPolicy { - provider_id: self.cfg.providers.rerank.provider_id.clone(), - model: self.cfg.providers.rerank.model.clone(), - blend_enabled: policies.blend_policy.enabled, - rerank_normalization: policies.blend_policy.rerank_normalization.as_str().to_string(), - retrieval_normalization: policies - .blend_policy - .retrieval_normalization - .as_str() - .to_string(), - blend_segments: policies - .blend_policy - .segments - .iter() - .map(|segment| QueryPlanBlendSegment { - max_retrieval_rank: segment.max_retrieval_rank, - retrieval_weight: segment.retrieval_weight, - }) - .collect(), - diversity_enabled: policies.diversity_policy.enabled, - diversity_sim_threshold: policies.diversity_policy.sim_threshold, - diversity_mmr_lambda: policies.diversity_policy.mmr_lambda, - diversity_max_skips: policies.diversity_policy.max_skips, - } - } - - fn build_query_plan_budget(&self, top_k: u32, candidate_k: u32) -> QueryPlanBudget { - QueryPlanBudget { - top_k, - candidate_k, - prefilter_max_candidates: self.cfg.search.prefilter.max_candidates, - expansion_max_queries: self.cfg.search.expansion.max_queries, - cache_enabled: self.cfg.search.cache.enabled, - } - } - - fn build_query_plan_stages(args: QueryPlanStagesArgs<'_>) -> Vec { - vec![ - QueryPlanStage { - name: "intent".to_string(), - details: serde_json::json!({ - "path": raw_search_path_label(args.path), - "query": args.query, - "read_profile": args.read_profile, - "allowed_scope_count": args.allowed_scope_count, - }), - }, - QueryPlanStage { - name: "rewrite".to_string(), - details: serde_json::json!({ - "expansion_mode": args.rewrite.expansion_mode.as_str(), - "expanded_query_count": args.rewrite.expanded_queries.len(), - "dynamic_gate_considered": args.rewrite.dynamic_gate.considered, - "dynamic_gate_should_expand": args.rewrite.dynamic_gate.should_expand, - }), - }, - QueryPlanStage { - name: "retrieval".to_string(), - details: serde_json::json!({ - "stages": args.retrieval_stages, - }), - }, - QueryPlanStage { - name: "fusion".to_string(), - details: serde_json::json!({ - "strategy": args.fusion_policy.strategy.as_str(), - "fusion_weight": args.fusion_policy.fusion_weight, - "structured_field_weight": args.fusion_policy.structured_field_weight, - }), - }, - QueryPlanStage { - name: "rerank".to_string(), - details: serde_json::json!({ - "provider_id": args.rerank_policy.provider_id.as_str(), - "model": args.rerank_policy.model.as_str(), - "blend_enabled": args.rerank_policy.blend_enabled, - "diversity_enabled": args.rerank_policy.diversity_enabled, - }), - }, - QueryPlanStage { - name: "budget".to_string(), - details: serde_json::json!({ - "top_k": args.budget.top_k, - "candidate_k": args.budget.candidate_k, - "prefilter_max_candidates": args.budget.prefilter_max_candidates, - "expansion_max_queries": args.budget.expansion_max_queries, - "cache_enabled": args.budget.cache_enabled, - }), - }, - ] - } - - async fn score_snippet_items( - &self, - args: ScoreSnippetArgs<'_, '_>, - ) -> Result> { - let ScoreSnippetArgs { - query, - snippet_items, - scope_context_boost_by_scope, - det_query_tokens, - blend_policy, - cache_cfg, - now, - candidate_count, - skip_rerank, - } = args; - - if snippet_items.is_empty() { - return Ok(Vec::new()); - } - - let scores = if skip_rerank { - Self::build_quick_find_rerank_scores(&snippet_items) - } else { - self.rerank_snippet_items(query, snippet_items.as_slice(), cache_cfg, now).await? - }; - let rerank_ranks = ranking::build_rerank_ranks(&snippet_items, &scores); - let total_rerank = u32::try_from(scores.len()).unwrap_or(1).max(1); - let total_retrieval = u32::try_from(candidate_count).unwrap_or(1).max(1); - let score_ctx = ScoreCandidateCtx { - cfg: &self.cfg, - blend_policy, - scope_context_boost_by_scope, - det_query_tokens, - now, - total_rerank, - total_retrieval, - }; - let mut scored = Vec::with_capacity(snippet_items.len()); - - for ((item, rerank_score), rerank_rank) in - snippet_items.into_iter().zip(scores).zip(rerank_ranks) - { - scored.push(score_chunk_candidate(&score_ctx, item, rerank_score, rerank_rank)); - } - - Ok(scored) - } - - fn build_quick_find_rerank_scores(snippet_items: &[ChunkSnippet]) -> Vec { - let mut idxs: Vec = (0..snippet_items.len()).collect(); - - idxs.sort_by(|&a, &b| { - let ord = snippet_items[a].retrieval_rank.cmp(&snippet_items[b].retrieval_rank); - - if ord != Ordering::Equal { - return ord; - } - - let ord = snippet_items[a].chunk.chunk_index.cmp(&snippet_items[b].chunk.chunk_index); - - if ord != Ordering::Equal { - return ord; - } - - snippet_items[a].chunk.chunk_id.cmp(&snippet_items[b].chunk.chunk_id) - }); - - let total = idxs.len(); - - if total == 0 { - return Vec::new(); - } - - let mut scores = vec![0_f32; total]; - - for (rank, idx) in idxs.into_iter().enumerate() { - scores[idx] = 1.0 / (rank as f32 + 1.0); - } - - scores - } - - fn build_trace_candidates( - &self, - scored: &[ScoredChunk], - now: OffsetDateTime, - ) -> Vec { - if !self.cfg.search.explain.capture_candidates || scored.is_empty() { - return Vec::new(); - } - - let candidate_expires_at = - now + Duration::days(self.cfg.search.explain.candidate_retention_days); - - scored - .iter() - .map(|scored_chunk| { - build_trace_candidate_record(scored_chunk, now, candidate_expires_at) - }) - .collect() - } - - async fn apply_diversity_policy( - &self, - results: Vec, - top_k: u32, - diversity_policy: &ResolvedDiversityPolicy, - ) -> Result<(Vec, HashMap)> { - let note_vectors = if diversity_policy.enabled { - fetch_note_vectors_for_diversity(&self.db.pool, results.as_slice()).await? - } else { - HashMap::new() - }; - let (selected_results, diversity_decisions) = - ranking::select_diverse_results(results, top_k, diversity_policy, ¬e_vectors); - - Ok((selected_results, diversity_decisions)) - } - - async fn record_hits_if_enabled( - &self, - enabled: bool, - query: &str, - selected_results: &[ScoredChunk], - now: OffsetDateTime, - ) -> Result<()> { - if !enabled || selected_results.is_empty() { - return Ok(()); - } - - let mut tx = self.db.pool.begin().await?; - - record_hits(&mut *tx, query, selected_results, now).await?; - - tx.commit().await?; - - Ok(()) - } - - fn build_items_and_trace_payload( - &self, - args: BuildTraceArgs<'_>, - ) -> (Vec, SearchTrajectorySummary, TracePayload) { - let mut trajectory_stages = build_trace_trajectory_stages(&args); - let trace_context = TraceContext { - trace_id: args.trace_id, - tenant_id: args.tenant_id, - project_id: args.project_id, - agent_id: args.agent_id, - read_profile: args.read_profile, - query: args.query, - expansion_mode: args.expansion_mode, - expanded_queries: args.expanded_queries.clone(), - allowed_scopes: args.allowed_scopes, - candidate_count: args.candidate_count, - top_k: args.top_k, - }; - let mut config_snapshot = ranking::build_config_snapshot( - &self.cfg, - &args.policies.blend_policy, - &args.policies.diversity_policy, - &args.policies.retrieval_sources_policy, - args.ranking_override.as_ref(), - args.policies.policy_id.as_str(), - &args.policies.policy_snapshot, - ); - - if let Some(object) = config_snapshot.as_object_mut() { - object.insert("audit".to_string(), build_trace_audit(args.agent_id, args.token_id)); - } - - let mut items = Vec::with_capacity(args.selected_results.len()); - let mut trace_builder = SearchTraceBuilder::new( - trace_context, - config_snapshot, - self.cfg.search.explain.retention_days, - args.now, - ); - let mut final_stage_items = Vec::new(); - - for candidate in args.trace_candidates { - trace_builder.push_candidate(candidate); - } - for (idx, scored_chunk) in args.selected_results.into_iter().enumerate() { - let rank = idx as u32 + 1; - let (item, trace_item) = build_search_item_and_trace_item(BuildSearchItemArgs { - cfg: &self.cfg, - policy_id: args.policies.policy_id.as_str(), - blend_policy: &args.policies.blend_policy, - diversity_policy: &args.policies.diversity_policy, - diversity_decisions: args.diversity_decisions, - query_tokens: args.query_tokens, - structured_matches: args.structured_matches, - relation_contexts: &args.relation_contexts, - scored_chunk, - rank, - }); - let item = apply_payload_level_to_search_item(item, args.payload_level); - - final_stage_items.push(TraceTrajectoryStageItemRecord { - id: Uuid::new_v4(), - item_id: Some(item.result_handle), - note_id: Some(item.note_id), - chunk_id: Some(item.chunk_id), - metrics: serde_json::json!({ - "rank": rank, - "final_score": item.final_score, - }), - }); - items.push(item); - trace_builder.push_item(trace_item); - } - - if let Some(stage) = - trajectory_stages.iter_mut().find(|stage| stage.stage_name == "selection.final") - { - stage.items = final_stage_items; - } - - let trajectory_summary = build_trajectory_summary_from_stages( - &trajectory_stages - .iter() - .map(|stage| SearchTrajectoryStage { - stage_order: stage.stage_order, - stage_name: stage.stage_name.clone(), - stage_payload: stage.stage_payload.clone(), - items: stage - .items - .iter() - .map(|item| SearchTrajectoryStageItem { - item_id: item.item_id, - note_id: item.note_id, - chunk_id: item.chunk_id, - metrics: item.metrics.clone(), - }) - .collect(), - }) - .collect::>(), - ); - - for stage in trajectory_stages { - trace_builder.push_stage(stage); - } - - (items, trajectory_summary, trace_builder.build()) - } - - async fn write_trace_payload(&self, trace_id: Uuid, trace_payload: TracePayload) -> Result<()> { - match self.cfg.search.explain.write_mode.trim().to_ascii_lowercase().as_str() { - "inline" => { - let mut tx = self.db.pool.begin().await?; - - persist_trace_inline(&mut tx, trace_payload).await?; - - tx.commit().await?; - }, - _ => - if let Err(err) = enqueue_trace(&self.db.pool, trace_payload).await { - tracing::error!( - error = %err, - trace_id = %trace_id, - "Failed to enqueue search trace." - ); - }, - } - - Ok(()) - } - - async fn build_snippet_items( - &self, - filtered_candidates: &[ChunkCandidate], - note_meta: &HashMap, - ) -> Result> { - if filtered_candidates.is_empty() { - return Ok(Vec::new()); - } - - let pairs = ranking::collect_neighbor_pairs(filtered_candidates); - let chunk_rows = fetch_chunks_by_pair(&self.db.pool, &pairs).await?; - let mut chunk_by_id = HashMap::new(); - let mut chunk_by_note_index = HashMap::new(); - - for row in chunk_rows { - chunk_by_note_index.insert((row.note_id, row.chunk_index), row.clone()); - chunk_by_id.insert(row.chunk_id, row); - } - - let mut items = Vec::new(); - - for candidate in filtered_candidates { - let Some(chunk_row) = chunk_by_id.get(&candidate.chunk_id) else { - tracing::warn!( - chunk_id = %candidate.chunk_id, - "Chunk metadata missing for candidate." - ); - - continue; - }; - let snippet = ranking::stitch_snippet( - candidate.note_id, - chunk_row.chunk_index, - &chunk_by_note_index, - ); - - if snippet.is_empty() { - continue; - } - - let Some(note) = note_meta.get(&candidate.note_id) else { continue }; - let chunk = ChunkMeta { - chunk_id: chunk_row.chunk_id, - chunk_index: chunk_row.chunk_index, - start_offset: chunk_row.start_offset, - end_offset: chunk_row.end_offset, - }; - - items.push(ChunkSnippet { - note: note.clone(), - chunk, - snippet, - retrieval_rank: candidate.retrieval_rank, - retrieval_score: candidate.retrieval_score, - }); - } - - Ok(items) - } - - async fn rerank_snippet_items( - &self, - query: &str, - snippet_items: &[ChunkSnippet], - cache_cfg: &SearchCache, - now: OffsetDateTime, - ) -> Result> { - if snippet_items.is_empty() { - return Ok(Vec::new()); - } - - let (cache_candidates, signature) = Self::build_rerank_cache_signature(snippet_items); - let mut cache_key: Option = None; - let mut cached_scores: Option> = None; - - if cache_cfg.enabled { - match ranking::build_rerank_cache_key( - query, - self.cfg.providers.rerank.provider_id.as_str(), - self.cfg.providers.rerank.model.as_str(), - &signature, - ) { - Ok(key) => { - cache_key = Some(key.clone()); - cached_scores = self - .read_rerank_cache_scores(&key, cache_candidates.as_slice(), cache_cfg, now) - .await; - }, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Rerank.as_str(), - "Cache key build failed." - ); - }, - } - } - - if let Some(scores) = cached_scores { - return Ok(scores); - } - - let docs: Vec = snippet_items.iter().map(|item| item.snippet.clone()).collect(); - let scores = self.providers.rerank.rerank(&self.cfg.providers.rerank, query, &docs).await?; - - if scores.len() != snippet_items.len() { - return Err(crate::Error::Provider { - message: "Rerank provider returned mismatched score count.".to_string(), - }); - } - if cache_cfg.enabled - && let Some(key) = cache_key.as_ref() - && !cache_candidates.is_empty() - { - self.store_rerank_cache_scores( - key, - cache_candidates.as_slice(), - scores.as_slice(), - cache_cfg, - ) - .await; - } - - Ok(scores) - } - - fn build_rerank_cache_signature( - snippet_items: &[ChunkSnippet], - ) -> (Vec, Vec<(Uuid, OffsetDateTime)>) { - let candidates: Vec = snippet_items - .iter() - .map(|item| RerankCacheCandidate { - chunk_id: item.chunk.chunk_id, - updated_at: item.note.updated_at, - }) - .collect(); - let signature: Vec<(Uuid, OffsetDateTime)> = - candidates.iter().map(|candidate| (candidate.chunk_id, candidate.updated_at)).collect(); - - (candidates, signature) - } - - async fn read_rerank_cache_scores( - &self, - key: &str, - cache_candidates: &[RerankCacheCandidate], - cache_cfg: &SearchCache, - now: OffsetDateTime, - ) -> Option> { - match fetch_cache_payload(&self.db.pool, CacheKind::Rerank, key, now).await { - Ok(Some(payload)) => { - let decoded: RerankCachePayload = match serde_json::from_value(payload.value) { - Ok(value) => value, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache payload decode failed." - ); - - RerankCachePayload { items: Vec::new() } - }, - }; - - if let Some(scores) = ranking::build_cached_scores(&decoded, cache_candidates) { - tracing::info!( - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = true, - payload_size = payload.size_bytes, - ttl_days = cache_cfg.rerank_ttl_days, - "Cache hit." - ); - - Some(scores) - } else { - tracing::warn!( - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size = payload.size_bytes, - ttl_days = cache_cfg.rerank_ttl_days, - "Cache payload did not match candidates." - ); - - None - } - }, - Ok(None) => { - tracing::info!( - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size = 0_u64, - ttl_days = cache_cfg.rerank_ttl_days, - "Cache miss." - ); - - None - }, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache read failed." - ); - - None - }, - } - } - - async fn store_rerank_cache_scores( - &self, - key: &str, - cache_candidates: &[RerankCacheCandidate], - scores: &[f32], - cache_cfg: &SearchCache, - ) { - let payload = RerankCachePayload { - items: cache_candidates - .iter() - .zip(scores.iter()) - .map(|(candidate, score)| RerankCacheItem { - chunk_id: candidate.chunk_id, - updated_at: candidate.updated_at, - score: *score, - }) - .collect(), - }; - - match serde_json::to_value(&payload) { - Ok(payload_json) => { - let stored_at = OffsetDateTime::now_utc(); - let expires_at = stored_at + Duration::days(cache_cfg.rerank_ttl_days); - - match store_cache_payload( - &self.db.pool, - CacheKind::Rerank, - key, - payload_json, - stored_at, - expires_at, - cache_cfg.max_payload_bytes, - ) - .await - { - Ok(Some(payload_size)) => { - tracing::info!( - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size, - ttl_days = cache_cfg.rerank_ttl_days, - "Cache stored." - ); - }, - Ok(None) => { - tracing::warn!( - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - hit = false, - payload_size = 0_u64, - ttl_days = cache_cfg.rerank_ttl_days, - "Cache payload skipped due to size." - ); - }, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache write failed." - ); - }, - } - }, - Err(err) => { - tracing::warn!( - error = %err, - cache_kind = CacheKind::Rerank.as_str(), - cache_key_prefix = ranking::cache_key_prefix(key), - "Cache payload encode failed." - ); - }, - } - } - - async fn fetch_note_meta_for_candidates( - &self, - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], - candidate_note_ids: &[Uuid], - now: OffsetDateTime, - ) -> Result> { - if candidate_note_ids.is_empty() { - return Ok(HashMap::new()); - } - - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await?; - let notes: Vec = sqlx::query_as( - "\ -SELECT * -FROM memory_notes -WHERE note_id = ANY($1::uuid[]) - AND tenant_id = $2 - AND ( - project_id = $3 - OR (project_id = $4 AND scope = 'org_shared') - )", - ) - .bind(candidate_note_ids) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_all(&self.db.pool) - .await?; - let mut note_meta = HashMap::new(); - - for note in notes { - if !access::note_read_allowed(¬e, agent_id, allowed_scopes, &shared_grants, now) { - continue; - } - - note_meta.insert( - note.note_id, - NoteMeta { - note_id: note.note_id, - note_type: note.r#type, - key: note.key, - scope: note.scope, - agent_id: note.agent_id, - importance: note.importance, - confidence: note.confidence, - updated_at: note.updated_at, - expires_at: note.expires_at, - source_ref: note.source_ref, - embedding_version: note.embedding_version, - hit_count: note.hit_count, - last_hit_at: note.last_hit_at, - }, - ); - } - - Ok(note_meta) - } - - async fn fetch_relation_contexts_for_notes( - &self, - note_ids: &[Uuid], - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], - now: OffsetDateTime, - ) -> Result>> { - if note_ids.is_empty() { - return Ok(HashMap::new()); - } - - let private_allowed = allowed_scopes.iter().any(|scope| scope == "agent_private"); - let non_private_scopes: Vec = - allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &self.db.pool, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await?; - let shared_scope_keys = access::shared_scope_key_strings(&shared_grants); - let (max_evidence_notes_per_fact, max_facts_per_item) = self.relation_context_bounds(); - let rows = self - .fetch_relation_context_rows( - note_ids, - tenant_id, - project_id, - agent_id, - &non_private_scopes, - shared_scope_keys.as_slice(), - private_allowed, - now, - max_evidence_notes_per_fact, - max_facts_per_item, - ) - .await?; - - Ok(Self::group_relation_context_rows(rows)) - } - - fn relation_context_bounds(&self) -> (i32, i32) { - let max_evidence_notes_per_fact = - i32::try_from(self.cfg.search.graph_context.max_evidence_notes_per_fact) - .unwrap_or(i32::MAX); - let max_facts_per_item = - i32::try_from(self.cfg.search.graph_context.max_facts_per_item).unwrap_or(i32::MAX); - - (max_evidence_notes_per_fact, max_facts_per_item) - } - - #[allow(clippy::too_many_arguments)] - async fn fetch_relation_context_rows( - &self, - note_ids: &[Uuid], - tenant_id: &str, - project_id: &str, - agent_id: &str, - non_private_scopes: &[String], - shared_scope_keys: &[String], - private_allowed: bool, - now: OffsetDateTime, - max_evidence_notes_per_fact: i32, - max_facts_per_item: i32, - ) -> Result> { - Ok(sqlx::query_as::<_, SearchRelationContextRow>(RELATION_CONTEXT_SQL) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(now) - .bind(private_allowed) - .bind(non_private_scopes) - .bind(note_ids) - .bind(max_evidence_notes_per_fact) - .bind(max_facts_per_item) - .bind(shared_scope_keys) - .fetch_all(&self.db.pool) - .await?) - } - - fn group_relation_context_rows( - rows: Vec, - ) -> HashMap> { - let mut relation_context_by_note: HashMap> = - HashMap::new(); - - for row in rows { - if row.evidence_note_ids.is_empty() { - continue; - } - - let object = if row.object_entity_id.is_some() { - SearchExplainRelationContextObject { - entity: Some(SearchExplainRelationEntityRef { - canonical: row.object_canonical, - kind: row.object_kind, - }), - value: None, - } - } else { - SearchExplainRelationContextObject { entity: None, value: row.object_value } - }; - - relation_context_by_note.entry(row.note_id).or_default().push( - SearchExplainRelationContext { - fact_id: row.fact_id, - scope: row.scope, - subject: SearchExplainRelationEntityRef { - canonical: row.subject_canonical, - kind: row.subject_kind, - }, - predicate: row.predicate, - object, - valid_from: row.valid_from, - valid_to: row.valid_to, - temporal_status: if row.is_current { - RelationTemporalStatus::Current - } else { - RelationTemporalStatus::Historical - }, - evidence_note_ids: row.evidence_note_ids, - }, - ); - } - - relation_context_by_note - } -} - -pub(crate) fn resolve_read_profile_scopes(cfg: &Config, profile: &str) -> Result> { - ranking::resolve_scopes(cfg, profile) -} - -/// Computes the stable ranking-policy identifier for a search configuration. -pub fn ranking_policy_id( - cfg: &Config, - ranking_override: Option<&RankingRequestOverride>, -) -> Result { - let blend_policy = ranking::resolve_blend_policy( - &cfg.ranking.blend, - ranking_override.and_then(|value| value.blend.as_ref()), - )?; - let diversity_policy = ranking::resolve_diversity_policy( - &cfg.ranking.diversity, - ranking_override.and_then(|value| value.diversity.as_ref()), - )?; - let retrieval_sources_policy = ranking::resolve_retrieval_sources_policy( - &cfg.ranking.retrieval_sources, - ranking_override.and_then(|value| value.retrieval_sources.as_ref()), - )?; - let snapshot = ranking::build_policy_snapshot( - cfg, - &blend_policy, - &diversity_policy, - &retrieval_sources_policy, - ranking_override, - ); - let hash = ranking::hash_policy_snapshot(&snapshot)?; - let prefix = &hash[..12.min(hash.len())]; - - Ok(format!("ranking_v2:{prefix}")) -} - -/// Replays ranking against stored trace candidates and returns the final top-k items. -pub fn replay_ranking_from_candidates( - cfg: &Config, - trace: &TraceReplayContext, - ranking_override: Option<&RankingRequestOverride>, - candidates: &[TraceReplayCandidate], - top_k: u32, -) -> Result> { - let query_tokens = ranking::tokenize_query(trace.query.as_str(), MAX_MATCHED_TERMS); - let scope_context_boost_by_scope = - ranking::build_scope_context_boost_by_scope(&query_tokens, cfg.context.as_ref()); - let det_query_tokens = build_deterministic_query_tokens(cfg, trace.query.as_str()); - let blend_policy = ranking::resolve_blend_policy( - &cfg.ranking.blend, - ranking_override.and_then(|override_| override_.blend.as_ref()), - )?; - let diversity_policy = ranking::resolve_diversity_policy( - &cfg.ranking.diversity, - ranking_override.and_then(|override_| override_.diversity.as_ref()), - )?; - let policy_id = ranking_policy_id(cfg, ranking_override)?; - let now = trace.created_at; - let total_rerank = u32::try_from(candidates.len()).unwrap_or(1).max(1); - let total_retrieval = trace.candidate_count.max(1); - let rerank_ranks = ranking::build_rerank_ranks_for_replay(candidates); - let replay_diversity_decisions = ranking::extract_replay_diversity_decisions(candidates); - let score_ctx = ScoreCandidateCtx { - cfg, - blend_policy: &blend_policy, - scope_context_boost_by_scope: &scope_context_boost_by_scope, - det_query_tokens: det_query_tokens.as_slice(), - now, - total_rerank, - total_retrieval, - }; - let mut best_by_note: BTreeMap = BTreeMap::new(); - - for (candidate, rerank_rank) in candidates.iter().zip(rerank_ranks) { - let scored = score_replay_candidate(&score_ctx, candidate, rerank_rank); - let replace = match best_by_note.get(&candidate.note_id) { - None => true, - Some(existing) => should_replace_replay_best(existing, &scored), - }; - - if replace { - best_by_note.insert(candidate.note_id, scored); - } - } - - let mut results: Vec = best_by_note.into_values().collect(); - - results.sort_by(cmp_scored_replay); - - let results = apply_replay_diversity_selection( - results, - top_k, - diversity_policy.enabled, - &replay_diversity_decisions, - ); - - Ok(build_replay_items( - cfg, - &blend_policy, - &diversity_policy, - policy_id.as_str(), - &replay_diversity_decisions, - results, - )) -} - -fn apply_payload_level_to_search_item( - mut item: SearchItem, - payload_level: PayloadLevel, -) -> SearchItem { - if payload_level == PayloadLevel::L2 { - return item; - } - - item.source_ref = serde_json::json!({}); - - item -} - -fn validate_search_request_inputs( - tenant_id: &str, - project_id: &str, - agent_id: &str, - query: &str, -) -> Result<()> { - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(crate::Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - if !english_gate::is_english_natural_language(query) { - return Err(crate::Error::NonEnglishInput { field: "$.query".to_string() }); - } - - Ok(()) -} - -fn raw_search_path_label(path: RawSearchPath) -> &'static str { - match path { - RawSearchPath::Quick => "quick", - RawSearchPath::Planned => "planned", - } -} - -fn sorted_unique_strings(mut values: Vec) -> Vec { - values.sort(); - values.dedup(); - - values -} - -fn build_trajectory_summary_from_stages( - stages: &[SearchTrajectoryStage], -) -> SearchTrajectorySummary { - let summary_stages = stages - .iter() - .map(|stage| { - let stats = - stage.stage_payload.get("stats").cloned().unwrap_or_else(|| serde_json::json!({})); - - SearchTrajectorySummaryStage { - stage_order: stage.stage_order, - stage_name: stage.stage_name.clone(), - item_count: stage.items.len() as u32, - stats, - } - }) - .collect(); - - SearchTrajectorySummary { - schema: SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1.to_string(), - stages: summary_stages, - } -} - -fn build_search_filter( - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], -) -> Filter { - let private_scope = "agent_private".to_string(); - let non_private_scopes: Vec = - allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); - let mut scope_should_conditions = Vec::new(); - - if allowed_scopes.iter().any(|scope| scope == "agent_private") { - let private_filter = Filter::all([ - Condition::matches("scope", private_scope), - Condition::matches("agent_id", agent_id.to_string()), - ]); - - scope_should_conditions.push(Condition::from(private_filter)); - } - if !non_private_scopes.is_empty() { - scope_should_conditions.push(Condition::matches("scope", non_private_scopes)); - } - - let scope_min_should = if scope_should_conditions.is_empty() { - None - } else { - Some(MinShould { min_count: 1, conditions: scope_should_conditions }) - }; - let mut project_or_org_branches = vec![Condition::from(Filter { - must: vec![Condition::matches("project_id", project_id.to_string())], - should: Vec::new(), - must_not: Vec::new(), - min_should: scope_min_should, - })]; - - if allowed_scopes.iter().any(|scope| scope == "org_shared") { - let org_filter = Filter::all([ - Condition::matches("project_id", ORG_PROJECT_ID.to_string()), - Condition::matches("scope", "org_shared".to_string()), - ]); - - project_or_org_branches.push(Condition::from(org_filter)); - } - - Filter { - must: vec![ - Condition::matches("tenant_id", tenant_id.to_string()), - Condition::matches("status", "active".to_string()), - ], - should: Vec::new(), - must_not: Vec::new(), - min_should: Some(MinShould { min_count: 1, conditions: project_or_org_branches }), - } -} - -fn select_best_scored_chunks(scored: Vec) -> Vec { - let mut best_by_note: HashMap = HashMap::new(); - - for scored_item in scored { - let note_id = scored_item.item.note.note_id; - let replace = match best_by_note.get(¬e_id) { - Some(existing) => scored_item.final_score > existing.final_score, - None => true, - }; - - if replace { - best_by_note.insert(note_id, scored_item); - } - } - - let mut results: Vec = best_by_note.into_values().collect(); - - results.sort_by(cmp_scored_chunk); - - results -} - -fn cmp_scored_chunk(a: &ScoredChunk, b: &ScoredChunk) -> Ordering { - let ord = ranking::cmp_f32_desc(a.final_score, b.final_score); - - if ord != Ordering::Equal { - return ord; - } - - let ord = a.item.retrieval_rank.cmp(&b.item.retrieval_rank); - - if ord != Ordering::Equal { - return ord; - } - - let ord = a.item.note.note_id.cmp(&b.item.note.note_id); - - if ord != Ordering::Equal { - return ord; - } - - a.item.chunk.chunk_id.cmp(&b.item.chunk.chunk_id) -} - -fn score_chunk_candidate( - ctx: &ScoreCandidateCtx<'_, '_>, - item: ChunkSnippet, - rerank_score: f32, - rerank_rank: u32, -) -> ScoredChunk { - let importance = item.note.importance; - let retrieval_rank = item.retrieval_rank; - let age_days = (ctx.now - item.note.updated_at).as_seconds_f32() / 86_400.0; - let decay = if ctx.cfg.ranking.recency_tau_days > 0.0 { - (-age_days / ctx.cfg.ranking.recency_tau_days).exp() - } else { - 1.0 - }; - let base = (1.0 + 0.6 * importance) * decay; - let tie_breaker_score = ctx.cfg.ranking.tie_breaker_weight * base; - let scope_context_boost = - ctx.scope_context_boost_by_scope.get(item.note.scope.as_str()).copied().unwrap_or(0.0); - let rerank_norm = match ctx.blend_policy.rerank_normalization { - NormalizationKind::Rank => ranking::rank_normalize(rerank_rank, ctx.total_rerank), - }; - let retrieval_norm = match ctx.blend_policy.retrieval_normalization { - NormalizationKind::Rank => ranking::rank_normalize(retrieval_rank, ctx.total_retrieval), - }; - let blend_retrieval_weight = if ctx.blend_policy.enabled { - ranking::retrieval_weight_for_rank(retrieval_rank, &ctx.blend_policy.segments) - } else { - 0.0 - }; - let retrieval_term = blend_retrieval_weight * retrieval_norm; - let rerank_term = (1.0 - blend_retrieval_weight) * rerank_norm; - let det_terms = ranking::compute_deterministic_ranking_terms( - ctx.cfg, - ctx.det_query_tokens, - item.snippet.as_str(), - item.note.hit_count, - item.note.last_hit_at, - age_days, - ctx.now, - ); - let final_score = retrieval_term - + rerank_term - + tie_breaker_score - + scope_context_boost - + det_terms.lexical_bonus - + det_terms.hit_boost - + det_terms.decay_penalty; - - ScoredChunk { - item, - final_score, - rerank_score, - rerank_rank, - rerank_norm, - retrieval_norm, - blend_retrieval_weight, - retrieval_term, - rerank_term, - tie_breaker_score, - scope_context_boost, - age_days, - importance, - deterministic_lexical_overlap_ratio: det_terms.lexical_overlap_ratio, - deterministic_lexical_bonus: det_terms.lexical_bonus, - deterministic_hit_count: det_terms.hit_count, - deterministic_last_hit_age_days: det_terms.last_hit_age_days, - deterministic_hit_boost: det_terms.hit_boost, - deterministic_decay_penalty: det_terms.decay_penalty, - } -} - -fn build_trace_candidate_record( - scored_chunk: &ScoredChunk, - now: OffsetDateTime, - expires_at: OffsetDateTime, -) -> TraceCandidateRecord { - let note = &scored_chunk.item.note; - - TraceCandidateRecord { - candidate_id: Uuid::new_v4(), - note_id: note.note_id, - chunk_id: scored_chunk.item.chunk.chunk_id, - chunk_index: scored_chunk.item.chunk.chunk_index, - snippet: scored_chunk.item.snippet.clone(), - candidate_snapshot: serde_json::to_value(TraceReplayCandidate { - note_id: note.note_id, - chunk_id: scored_chunk.item.chunk.chunk_id, - chunk_index: scored_chunk.item.chunk.chunk_index, - snippet: scored_chunk.item.snippet.clone(), - retrieval_rank: scored_chunk.item.retrieval_rank, - retrieval_score: scored_chunk.item.retrieval_score, - rerank_score: scored_chunk.rerank_score, - note_scope: note.scope.clone(), - note_importance: note.importance, - note_updated_at: note.updated_at, - note_hit_count: note.hit_count, - note_last_hit_at: note.last_hit_at, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }) - .unwrap_or_else(|_| serde_json::json!({})), - retrieval_rank: scored_chunk.item.retrieval_rank, - rerank_score: scored_chunk.rerank_score, - note_scope: note.scope.clone(), - note_importance: note.importance, - note_updated_at: note.updated_at, - note_hit_count: note.hit_count, - note_last_hit_at: note.last_hit_at, - created_at: now, - expires_at, - } -} - -fn build_search_item_and_trace_item( - args: BuildSearchItemArgs<'_>, -) -> (SearchItem, TraceItemRecord) { - let (matched_terms, matched_fields) = ranking::match_terms_in_text( - args.query_tokens, - args.scored_chunk.item.snippet.as_str(), - args.scored_chunk.item.note.key.as_deref(), - MAX_MATCHED_TERMS, - ); - let matched_fields = ranking::merge_matched_fields( - matched_fields, - args.structured_matches.get(&args.scored_chunk.item.note.note_id), - ); - let trace_terms = ranking_explain_v2::build_trace_terms_v2(TraceTermsArgs { - cfg: args.cfg, - blend_enabled: args.blend_policy.enabled, - retrieval_normalization: args.blend_policy.retrieval_normalization.as_str(), - rerank_normalization: args.blend_policy.rerank_normalization.as_str(), - blend_retrieval_weight: args.scored_chunk.blend_retrieval_weight, - retrieval_rank: args.scored_chunk.item.retrieval_rank, - retrieval_norm: args.scored_chunk.retrieval_norm, - retrieval_term: args.scored_chunk.retrieval_term, - rerank_score: args.scored_chunk.rerank_score, - rerank_rank: args.scored_chunk.rerank_rank, - rerank_norm: args.scored_chunk.rerank_norm, - rerank_term: args.scored_chunk.rerank_term, - tie_breaker_score: args.scored_chunk.tie_breaker_score, - importance: args.scored_chunk.importance, - age_days: args.scored_chunk.age_days, - scope: args.scored_chunk.item.note.scope.as_str(), - scope_context_boost: args.scored_chunk.scope_context_boost, - deterministic_lexical_overlap_ratio: args.scored_chunk.deterministic_lexical_overlap_ratio, - deterministic_lexical_bonus: args.scored_chunk.deterministic_lexical_bonus, - deterministic_hit_count: args.scored_chunk.deterministic_hit_count, - deterministic_last_hit_age_days: args.scored_chunk.deterministic_last_hit_age_days, - deterministic_hit_boost: args.scored_chunk.deterministic_hit_boost, - deterministic_decay_penalty: args.scored_chunk.deterministic_decay_penalty, - }); - let response_terms = ranking_explain_v2::strip_term_inputs(&trace_terms); - let relation_context = - args.relation_contexts.get(&args.scored_chunk.item.note.note_id).cloned(); - let diversity = if args.diversity_policy.enabled { - args.diversity_decisions - .get(&args.scored_chunk.item.note.note_id) - .map(ranking::build_diversity_explain) - } else { - None - }; - let response_explain = SearchExplain { - r#match: SearchMatchExplain { - matched_terms: matched_terms.clone(), - matched_fields: matched_fields.clone(), - }, - ranking: SearchRankingExplain { - schema: SEARCH_RANKING_EXPLAIN_SCHEMA_V2.to_string(), - policy_id: args.policy_id.to_string(), - final_score: args.scored_chunk.final_score, - terms: response_terms, - }, - relation_context: relation_context.clone(), - diversity: diversity.clone(), - }; - let trace_explain = SearchExplain { - r#match: SearchMatchExplain { matched_terms, matched_fields }, - ranking: SearchRankingExplain { - schema: SEARCH_RANKING_EXPLAIN_SCHEMA_V2.to_string(), - policy_id: args.policy_id.to_string(), - final_score: args.scored_chunk.final_score, - terms: trace_terms, - }, - relation_context, - diversity, - }; - let result_handle = Uuid::new_v4(); - let note = &args.scored_chunk.item.note; - let chunk = &args.scored_chunk.item.chunk; - let item = SearchItem { - result_handle, - note_id: note.note_id, - chunk_id: chunk.chunk_id, - chunk_index: chunk.chunk_index, - start_offset: chunk.start_offset, - end_offset: chunk.end_offset, - snippet: args.scored_chunk.item.snippet.clone(), - r#type: note.note_type.clone(), - key: note.key.clone(), - scope: note.scope.clone(), - importance: note.importance, - confidence: note.confidence, - updated_at: note.updated_at, - expires_at: note.expires_at, - final_score: args.scored_chunk.final_score, - source_ref: note.source_ref.clone(), - explain: response_explain, - }; - let trace_item = TraceItemRecord { - item_id: result_handle, - note_id: note.note_id, - chunk_id: Some(chunk.chunk_id), - rank: args.rank, - final_score: args.scored_chunk.final_score, - explain: trace_explain, - }; - - (item, trace_item) -} - -fn build_structured_field_matches(rows: Vec) -> (Vec, HashMap>) { - let mut structured_matches: HashMap> = HashMap::new(); - let mut ordered_note_ids = Vec::new(); - let mut seen_notes = HashSet::new(); - - for row in rows { - let label = match row.field_kind.as_str() { - "summary" => "summary", - "fact" => "facts", - "concept" => "concepts", - _ => continue, - }; - - structured_matches.entry(row.note_id).or_default().insert(label.to_string()); - - if seen_notes.insert(row.note_id) { - ordered_note_ids.push(row.note_id); - } - } - - let mut structured_matches_out: HashMap> = HashMap::new(); - - for (note_id, fields) in structured_matches { - let mut fields: Vec = fields.into_iter().collect(); - - fields.sort(); - structured_matches_out.insert(note_id, fields); - } - - (ordered_note_ids, structured_matches_out) -} - -fn build_structured_field_candidates( - candidate_k: u32, - ordered_note_ids: Vec, - best_by_note: HashMap, - embed_version: &str, -) -> Vec { - let mut structured_candidates = Vec::new(); - let mut next_rank = 1_u32; - - for note_id in ordered_note_ids { - if structured_candidates.len() >= candidate_k as usize { - break; - } - - let Some((chunk_id, chunk_index)) = best_by_note.get(¬e_id) else { continue }; - - structured_candidates.push(ChunkCandidate { - chunk_id: *chunk_id, - note_id, - chunk_index: *chunk_index, - retrieval_rank: next_rank, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: Some(embed_version.to_string()), - }); - - next_rank = next_rank.saturating_add(1); - } - - structured_candidates -} - -fn build_deterministic_query_tokens(cfg: &Config, query: &str) -> Vec { - if cfg.ranking.deterministic.enabled - && cfg.ranking.deterministic.lexical.enabled - && cfg.ranking.deterministic.lexical.max_query_terms > 0 - { - ranking::tokenize_query(query, cfg.ranking.deterministic.lexical.max_query_terms as usize) - } else { - Vec::new() - } -} - -fn build_trace_audit(actor_id: &str, token_id: Option<&str>) -> Value { - match token_id.map(str::trim).filter(|value| !value.is_empty()) { - Some(token_id) => serde_json::json!({ "actor_id": actor_id, "token_id": token_id }), - None => serde_json::json!({ "actor_id": actor_id }), - } -} - -fn build_trace_trajectory_stages(args: &BuildTraceArgs<'_>) -> Vec { - let path_label = raw_search_path_label(args.path); - - vec![ - build_trace_rewrite_stage(args, path_label), - build_trace_recall_stage(args, path_label), - build_trace_fusion_stage(args, path_label), - build_trace_rerank_stage(args, path_label), - build_trace_final_stage(args, path_label), - ] -} - -fn build_trace_rewrite_stage( - args: &BuildTraceArgs<'_>, - path_label: &str, -) -> TraceTrajectoryStageRecord { - let expanded_queries = sorted_unique_strings(args.expanded_queries.clone()); - - TraceTrajectoryStageRecord { - stage_id: Uuid::new_v4(), - stage_order: 1, - stage_name: "rewrite.expansion".to_string(), - stage_payload: serde_json::json!({ - "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, - "path": path_label, - "inputs": { - "query": args.query, - "expansion_mode": ranking::expansion_mode_label(args.expansion_mode), - }, - "outputs": { - "expanded_queries": expanded_queries, - }, - "stats": { - "expanded_query_count": args.expanded_queries.len(), - }, - }), - created_at: args.now, - items: Vec::new(), - } -} - -fn build_trace_recall_stage( - args: &BuildTraceArgs<'_>, - path_label: &str, -) -> TraceTrajectoryStageRecord { - let mut stage_payload = serde_json::json!({ - "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, - "path": path_label, - "stats": { - "candidate_count_before_filter": args.candidate_count, - "candidate_count_after_filter": args.filtered_candidate_count, - "snippet_count": args.snippet_count, - }, - }); - - if let Some(filter_impact) = &args.filter_impact - && let Some(payload) = stage_payload.as_object_mut() - { - payload.insert("filter_impact".to_string(), filter_impact.to_stage_payload()); - } - if let Some(recursive_retrieval) = args.recursive_retrieval - && recursive_retrieval.enabled - && let Some(payload) = stage_payload.as_object_mut() - { - payload.insert( - "recursive".to_string(), - serde_json::json!({ - "enabled": true, - "scopes_seeded": recursive_retrieval.scopes_seeded, - "scopes_queried": recursive_retrieval.scopes_queried, - "candidates_before": recursive_retrieval.candidates_before, - "candidates_added": recursive_retrieval.candidates_added, - "candidates_after": recursive_retrieval.candidates_after, - "rounds_executed": recursive_retrieval.rounds_executed, - "total_queries": recursive_retrieval.total_queries, - "stop_reason": recursive_retrieval - .stop_reason - .clone() - .unwrap_or_else(|| "converged".to_string()), - }), - ); - } - - let items: Vec = args - .recall_candidates - .iter() - .take(MAX_TRAJECTORY_STAGE_ITEMS) - .map(|candidate| TraceTrajectoryStageItemRecord { - id: Uuid::new_v4(), - item_id: None, - note_id: Some(candidate.note_id), - chunk_id: Some(candidate.chunk_id), - metrics: serde_json::json!({ - "retrieval_rank": candidate.retrieval_rank, - "chunk_index": candidate.chunk_index, - }), - }) - .collect(); - - TraceTrajectoryStageRecord { - stage_id: Uuid::new_v4(), - stage_order: 2, - stage_name: "recall.candidates".to_string(), - stage_payload, - created_at: args.now, - items, - } -} - -fn build_trace_fusion_stage( - args: &BuildTraceArgs<'_>, - path_label: &str, -) -> TraceTrajectoryStageRecord { - let items: Vec = args - .fused_results - .iter() - .take(MAX_TRAJECTORY_STAGE_ITEMS) - .map(|scored| TraceTrajectoryStageItemRecord { - id: Uuid::new_v4(), - item_id: None, - note_id: Some(scored.item.note.note_id), - chunk_id: Some(scored.item.chunk.chunk_id), - metrics: serde_json::json!({ - "retrieval_rank": scored.item.retrieval_rank, - "final_score": scored.final_score, - }), - }) - .collect(); - - TraceTrajectoryStageRecord { - stage_id: Uuid::new_v4(), - stage_order: 3, - stage_name: "fusion.merge".to_string(), - stage_payload: serde_json::json!({ - "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, - "path": path_label, - "stats": { - "scored_count": args.scored_count, - "fused_count": args.fused_count, - }, - "decisions": { - "fusion_weight": args.policies.retrieval_sources_policy.fusion_weight, - "structured_field_weight": args.policies.retrieval_sources_policy.structured_field_weight, - "fusion_priority": args.policies.retrieval_sources_policy.fusion_priority, - "structured_field_priority": args.policies.retrieval_sources_policy.structured_field_priority, - }, - }), - created_at: args.now, - items, - } -} - -fn build_trace_rerank_stage( - args: &BuildTraceArgs<'_>, - path_label: &str, -) -> TraceTrajectoryStageRecord { - let items: Vec = args - .fused_results - .iter() - .take(MAX_TRAJECTORY_STAGE_ITEMS) - .map(|scored| TraceTrajectoryStageItemRecord { - id: Uuid::new_v4(), - item_id: None, - note_id: Some(scored.item.note.note_id), - chunk_id: Some(scored.item.chunk.chunk_id), - metrics: serde_json::json!({ - "rerank_score": scored.rerank_score, - "rerank_rank": scored.rerank_rank, - "rerank_norm": scored.rerank_norm, - "retrieval_norm": scored.retrieval_norm, - "final_score": scored.final_score, - }), - }) - .collect(); - - TraceTrajectoryStageRecord { - stage_id: Uuid::new_v4(), - stage_order: 4, - stage_name: "rerank.score".to_string(), - stage_payload: serde_json::json!({ - "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, - "path": path_label, - "stats": { - "reranked_count": args.scored_count, - }, - "decisions": { - "blend_enabled": args.policies.blend_policy.enabled, - "diversity_enabled": args.policies.diversity_policy.enabled, - }, - }), - created_at: args.now, - items, - } -} - -fn build_trace_final_stage( - args: &BuildTraceArgs<'_>, - path_label: &str, -) -> TraceTrajectoryStageRecord { - TraceTrajectoryStageRecord { - stage_id: Uuid::new_v4(), - stage_order: 5, - stage_name: "selection.final".to_string(), - stage_payload: serde_json::json!({ - "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, - "path": path_label, - "stats": { - "selected_count": args.selected_count, - "top_k": args.top_k, - }, - }), - created_at: args.now, - items: Vec::new(), - } -} - -fn score_replay_candidate( - ctx: &ScoreCandidateCtx<'_, '_>, - candidate: &TraceReplayCandidate, - rerank_rank: u32, -) -> ScoredReplay { - let importance = candidate.note_importance; - let retrieval_rank = candidate.retrieval_rank; - let age_days = (ctx.now - candidate.note_updated_at).as_seconds_f32() / 86_400.0; - let decay = if ctx.cfg.ranking.recency_tau_days > 0.0 { - (-age_days / ctx.cfg.ranking.recency_tau_days).exp() - } else { - 1.0 - }; - let base = (1.0 + 0.6 * importance) * decay; - let tie_breaker_score = ctx.cfg.ranking.tie_breaker_weight * base; - let scope_context_boost = - ctx.scope_context_boost_by_scope.get(candidate.note_scope.as_str()).copied().unwrap_or(0.0); - let rerank_norm = match ctx.blend_policy.rerank_normalization { - NormalizationKind::Rank => ranking::rank_normalize(rerank_rank, ctx.total_rerank), - }; - let retrieval_norm = match ctx.blend_policy.retrieval_normalization { - NormalizationKind::Rank => ranking::rank_normalize(retrieval_rank, ctx.total_retrieval), - }; - let blend_retrieval_weight = if ctx.blend_policy.enabled { - ranking::retrieval_weight_for_rank(retrieval_rank, &ctx.blend_policy.segments) - } else { - 0.0 - }; - let retrieval_term = blend_retrieval_weight * retrieval_norm; - let rerank_term = (1.0 - blend_retrieval_weight) * rerank_norm; - let det_terms = ranking::compute_deterministic_ranking_terms( - ctx.cfg, - ctx.det_query_tokens, - candidate.snippet.as_str(), - candidate.note_hit_count, - candidate.note_last_hit_at, - age_days, - ctx.now, - ); - let final_score = retrieval_term - + rerank_term - + tie_breaker_score - + scope_context_boost - + det_terms.lexical_bonus - + det_terms.hit_boost - + det_terms.decay_penalty; - - ScoredReplay { - note_id: candidate.note_id, - chunk_id: candidate.chunk_id, - retrieval_rank, - final_score, - rerank_score: candidate.rerank_score, - rerank_rank, - rerank_norm, - retrieval_norm, - blend_retrieval_weight, - retrieval_term, - rerank_term, - tie_breaker_score, - scope_context_boost, - age_days, - importance, - note_scope: candidate.note_scope.clone(), - deterministic_lexical_overlap_ratio: det_terms.lexical_overlap_ratio, - deterministic_lexical_bonus: det_terms.lexical_bonus, - deterministic_hit_count: det_terms.hit_count, - deterministic_last_hit_age_days: det_terms.last_hit_age_days, - deterministic_hit_boost: det_terms.hit_boost, - deterministic_decay_penalty: det_terms.decay_penalty, - } -} - -fn should_replace_replay_best(existing: &ScoredReplay, scored: &ScoredReplay) -> bool { - let ord = ranking::cmp_f32_desc(scored.final_score, existing.final_score); - - if ord != Ordering::Equal { - ord == Ordering::Less - } else { - scored.retrieval_rank < existing.retrieval_rank - } -} - -fn cmp_scored_replay(a: &ScoredReplay, b: &ScoredReplay) -> Ordering { - let ord = ranking::cmp_f32_desc(a.final_score, b.final_score); - - if ord != Ordering::Equal { - return ord; - } - - let ord = a.retrieval_rank.cmp(&b.retrieval_rank); - - if ord != Ordering::Equal { - return ord; - } - - let ord = a.note_id.cmp(&b.note_id); - - if ord != Ordering::Equal { - return ord; - } - - a.chunk_id.cmp(&b.chunk_id) -} - -fn apply_replay_diversity_selection( - mut results: Vec, - top_k: u32, - diversity_enabled: bool, - replay_diversity_decisions: &HashMap, -) -> Vec { - if diversity_enabled && !replay_diversity_decisions.is_empty() { - let mut selected: Vec = results - .iter() - .filter(|scored| { - replay_diversity_decisions - .get(&scored.note_id) - .map(|decision| decision.selected) - .unwrap_or(false) - }) - .cloned() - .collect(); - - selected.sort_by(|a, b| { - let rank_a = replay_diversity_decisions - .get(&a.note_id) - .and_then(|decision| decision.selected_rank) - .unwrap_or(u32::MAX); - let rank_b = replay_diversity_decisions - .get(&b.note_id) - .and_then(|decision| decision.selected_rank) - .unwrap_or(u32::MAX); - let ord = rank_a.cmp(&rank_b); - - if ord != Ordering::Equal { - return ord; - } - - a.note_id.cmp(&b.note_id) - }); - - if !selected.is_empty() { - results = selected; - } - } - - results.truncate(top_k.max(1) as usize); - - results -} - -fn build_replay_items( - cfg: &Config, - blend_policy: &ResolvedBlendPolicy, - diversity_policy: &ResolvedDiversityPolicy, - policy_id: &str, - replay_diversity_decisions: &HashMap, - results: Vec, -) -> Vec { - let mut out = Vec::with_capacity(results.len()); - - for scored in results { - let terms = ranking_explain_v2::build_trace_terms_v2(TraceTermsArgs { - cfg, - blend_enabled: blend_policy.enabled, - retrieval_normalization: blend_policy.retrieval_normalization.as_str(), - rerank_normalization: blend_policy.rerank_normalization.as_str(), - blend_retrieval_weight: scored.blend_retrieval_weight, - retrieval_rank: scored.retrieval_rank, - retrieval_norm: scored.retrieval_norm, - retrieval_term: scored.retrieval_term, - rerank_score: scored.rerank_score, - rerank_rank: scored.rerank_rank, - rerank_norm: scored.rerank_norm, - rerank_term: scored.rerank_term, - tie_breaker_score: scored.tie_breaker_score, - importance: scored.importance, - age_days: scored.age_days, - scope: scored.note_scope.as_str(), - scope_context_boost: scored.scope_context_boost, - deterministic_lexical_overlap_ratio: scored.deterministic_lexical_overlap_ratio, - deterministic_lexical_bonus: scored.deterministic_lexical_bonus, - deterministic_hit_count: scored.deterministic_hit_count, - deterministic_last_hit_age_days: scored.deterministic_last_hit_age_days, - deterministic_hit_boost: scored.deterministic_hit_boost, - deterministic_decay_penalty: scored.deterministic_decay_penalty, - }); - let explain = SearchExplain { - r#match: SearchMatchExplain { matched_terms: Vec::new(), matched_fields: Vec::new() }, - ranking: SearchRankingExplain { - schema: SEARCH_RANKING_EXPLAIN_SCHEMA_V2.to_string(), - policy_id: policy_id.to_string(), - final_score: scored.final_score, - terms, - }, - relation_context: None, - diversity: if diversity_policy.enabled { - replay_diversity_decisions - .get(&scored.note_id) - .map(ranking::build_diversity_explain) - } else { - None - }, - }; - - out.push(TraceReplayItem { - note_id: scored.note_id, - chunk_id: scored.chunk_id, - retrieval_rank: scored.retrieval_rank, - final_score: scored.final_score, - explain, - }); - } - - out -} - -async fn load_trace_trajectory_summary( - pool: &PgPool, - trace_id: Uuid, -) -> Result> { - let stages = load_trace_trajectory_stages(pool, trace_id).await?; - - if stages.is_empty() { - Ok(None) - } else { - Ok(Some(build_trajectory_summary_from_stages(stages.as_slice()))) - } -} - -async fn load_trace_trajectory_stages( - pool: &PgPool, - trace_id: Uuid, -) -> Result> { - let rows = sqlx::query( - "\ - SELECT - s.stage_id, - s.stage_order, - s.stage_name, - s.stage_payload, - i.item_id, - i.note_id, - i.chunk_id, - i.metrics -FROM search_trace_stages s -LEFT JOIN search_trace_stage_items i ON i.stage_id = s.stage_id -WHERE s.trace_id = $1 -ORDER BY s.stage_order ASC, i.item_id ASC NULLS LAST, i.note_id ASC NULLS LAST", - ) - .bind(trace_id) - .fetch_all(pool) - .await?; - let mut stages = Vec::new(); - let mut stage_pos_by_id: HashMap = HashMap::new(); - - for row in rows { - let stage_id: Uuid = row.try_get("stage_id")?; - let idx = if let Some(idx) = stage_pos_by_id.get(&stage_id).copied() { - idx - } else { - let stage_order: i32 = row.try_get("stage_order")?; - let stage_name: String = row.try_get("stage_name")?; - let stage_payload: Value = row.try_get("stage_payload")?; - let idx = stages.len(); - - stages.push(SearchTrajectoryStage { - stage_order: stage_order as u32, - stage_name, - stage_payload, - items: Vec::new(), - }); - stage_pos_by_id.insert(stage_id, idx); - - idx - }; - let item_metrics: Option = row.try_get("metrics")?; - - if let Some(metrics) = item_metrics { - stages[idx].items.push(SearchTrajectoryStageItem { - item_id: row.try_get("item_id")?, - note_id: row.try_get("note_id")?, - chunk_id: row.try_get("chunk_id")?, - metrics, - }); - } - } - - Ok(stages) -} - -async fn load_item_trajectory( - pool: &PgPool, - trace_id: Uuid, - item_id: Uuid, - note_id: Uuid, - trace_item_chunk_id: Option, -) -> Result> { - let rows = sqlx::query( - "\ -SELECT - s.stage_order, - s.stage_name, - s.stage_payload, - i.item_id, - i.note_id, - i.chunk_id, - i.metrics -FROM search_trace_stages s -LEFT JOIN search_trace_stage_items i - ON i.stage_id = s.stage_id - AND ( - i.item_id = $2 - OR ( - i.item_id IS NULL - AND i.note_id = $3 - AND ($4 IS NULL OR i.chunk_id = $4) - ) - ) -WHERE s.trace_id = $1 -ORDER BY s.stage_order ASC, i.item_id ASC NULLS LAST, i.note_id ASC NULLS LAST", - ) - .bind(trace_id) - .bind(item_id) - .bind(note_id) - .bind(trace_item_chunk_id) - .fetch_all(pool) - .await?; - - if rows.is_empty() { - return Ok(None); - } - - let mut stages = Vec::with_capacity(rows.len()); - let mut stage_pos_by_order: HashMap = HashMap::new(); - - for row in rows { - let stage_order: i32 = row.try_get("stage_order")?; - let stage_name: String = row.try_get("stage_name")?; - let stage_payload: Value = row.try_get("stage_payload")?; - let stage_order = stage_order as u32; - let idx = if let Some(idx) = stage_pos_by_order.get(&stage_order).copied() { - idx - } else { - let idx = stages.len(); - - stages.push(SearchExplainTrajectoryStage { - stage_order, - stage_name, - stage_payload, - metrics: serde_json::json!({}), - match_info: None, - }); - stage_pos_by_order.insert(stage_order, idx); - - idx - }; - let item_metrics: Option = row.try_get("metrics")?; - let matched_item_id: Option = row.try_get("item_id")?; - let matched_note_id: Option = row.try_get("note_id")?; - let matched_chunk_id: Option = row.try_get("chunk_id")?; - - if let Some(metrics) = item_metrics { - let match_kind = if matched_item_id.is_some() { - "item_id" - } else if trace_item_chunk_id.is_some() { - "note_chunk" - } else { - "note" - }; - - stages[idx].match_info = Some(SearchExplainTrajectoryMatch { - kind: match_kind.to_string(), - item_id: matched_item_id, - note_id: matched_note_id, - chunk_id: matched_chunk_id, - }); - stages[idx].metrics = metrics; - } - } - - Ok(Some(SearchExplainTrajectory { - schema: SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1.to_string(), - stages, - })) -} - -async fn fetch_chunks_by_pair<'e, E>(executor: E, pairs: &[(Uuid, i32)]) -> Result> -where - E: PgExecutor<'e>, -{ - if pairs.is_empty() { - return Ok(Vec::new()); - } - - let mut builder = QueryBuilder::new( - "SELECT chunk_id, note_id, chunk_index, start_offset, end_offset, text \ - FROM memory_note_chunks WHERE ", - ); - let mut separated = builder.separated(" OR "); - - for (note_id, chunk_index) in pairs { - separated.push("("); - separated - .push_unseparated("note_id = ") - .push_bind_unseparated(note_id) - .push_unseparated(" AND chunk_index = ") - .push_bind_unseparated(chunk_index) - .push_unseparated(")"); - } - - let query = builder.build_query_as(); - let rows = query.fetch_all(executor).await?; - - Ok(rows) -} - -async fn fetch_note_vectors_for_diversity<'e, E>( - executor: E, - scored: &[ScoredChunk], -) -> Result>> -where - E: PgExecutor<'e>, -{ - if scored.is_empty() { - return Ok(HashMap::new()); - } - - let mut note_ids = Vec::new(); - let mut embedding_versions = Vec::new(); - let mut seen = HashSet::new(); - - for scored_chunk in scored { - let note_id = scored_chunk.item.note.note_id; - - if seen.insert(note_id) { - note_ids.push(note_id); - embedding_versions.push(scored_chunk.item.note.embedding_version.clone()); - } - } - - let rows = sqlx::query_as::<_, NoteVectorRow>( - "\ -WITH expected AS ( - SELECT * - FROM unnest($1::uuid[], $2::text[]) AS t(note_id, embedding_version) -) -SELECT - e.note_id, - n.vec::text AS vec_text -FROM expected e -JOIN note_embeddings n - ON n.note_id = e.note_id - AND n.embedding_version = e.embedding_version", - ) - .bind(note_ids.as_slice()) - .bind(embedding_versions.as_slice()) - .fetch_all(executor) - .await?; - let mut out = HashMap::new(); - - for row in rows { - let vec = crate::parse_pg_vector(row.vec_text.as_str())?; - - out.insert(row.note_id, vec); - } - - Ok(out) -} - -async fn enqueue_trace<'e, E>(executor: E, payload: TracePayload) -> Result<()> -where - E: PgExecutor<'e>, -{ - let now = OffsetDateTime::now_utc(); - let payload_json = serde_json::to_value(&payload).map_err(|err| crate::Error::Storage { - message: format!("Failed to encode search trace payload: {err}"), - })?; - - sqlx::query( - "\ -INSERT INTO search_trace_outbox ( - outbox_id, - trace_id, - status, - attempts, - last_error, - available_at, - payload, - created_at, - updated_at -) -VALUES ($1, $2, 'PENDING', 0, NULL, $3, $4, $3, $3)", - ) - .bind(Uuid::new_v4()) - .bind(payload.trace.trace_id) - .bind(now) - .bind(payload_json) - .execute(executor) - .await?; - - Ok(()) -} - -async fn persist_trace_inline(executor: &mut PgConnection, payload: TracePayload) -> Result<()> { - let trace = payload.trace; - let items = payload.items; - let candidates = payload.candidates; - let stages = payload.stages; - let trace_id = trace.trace_id; - - persist_trace_inline_header(executor, &trace).await?; - persist_trace_inline_items(executor, trace_id, items).await?; - persist_trace_inline_stages(executor, trace_id, stages).await?; - persist_trace_inline_candidates(executor, trace_id, candidates).await?; - - Ok(()) -} - -async fn persist_trace_inline_stages( - executor: &mut PgConnection, - trace_id: Uuid, - stages: Vec, -) -> Result<()> { - if stages.is_empty() { - return Ok(()); - } - - let mut item_records = Vec::new(); - let mut stage_builder = QueryBuilder::new( - "\ -INSERT INTO search_trace_stages ( - stage_id, - trace_id, - stage_order, - stage_name, - stage_payload, - created_at -) ", - ); - - stage_builder.push_values(stages, |mut b, stage| { - for item in stage.items { - item_records.push((stage.stage_id, item)); - } - - b.push_bind(stage.stage_id) - .push_bind(trace_id) - .push_bind(stage.stage_order as i32) - .push_bind(stage.stage_name) - .push_bind(stage.stage_payload) - .push_bind(stage.created_at); - }); - stage_builder.push(" ON CONFLICT (stage_id) DO NOTHING"); - stage_builder.build().execute(&mut *executor).await?; - - if item_records.is_empty() { - return Ok(()); - } - - let mut item_builder = QueryBuilder::new( - "\ -INSERT INTO search_trace_stage_items ( - id, - stage_id, - item_id, - note_id, - chunk_id, - metrics -) ", - ); - - item_builder.push_values(item_records, |mut b, (stage_id, item)| { - b.push_bind(item.id) - .push_bind(stage_id) - .push_bind(item.item_id) - .push_bind(item.note_id) - .push_bind(item.chunk_id) - .push_bind(item.metrics); - }); - item_builder.push(" ON CONFLICT (id) DO NOTHING"); - item_builder.build().execute(executor).await?; - - Ok(()) -} - -async fn persist_trace_inline_header( - executor: &mut PgConnection, - trace: &TraceRecord, -) -> Result<()> { - let expanded_queries_json = serde_json::to_value(&trace.expanded_queries).map_err(|err| { - crate::Error::Storage { message: format!("Failed to encode expanded_queries: {err}") } - })?; - let allowed_scopes_json = serde_json::to_value(&trace.allowed_scopes).map_err(|err| { - crate::Error::Storage { message: format!("Failed to encode allowed_scopes: {err}") } - })?; - - sqlx::query( - "\ -INSERT INTO search_traces ( - trace_id, - tenant_id, - project_id, - agent_id, - read_profile, - query, - expansion_mode, - expanded_queries, - allowed_scopes, - candidate_count, - top_k, - config_snapshot, - trace_version, - created_at, - expires_at -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15 -) - ON CONFLICT (trace_id) DO NOTHING", - ) - .bind(trace.trace_id) - .bind(trace.tenant_id.as_str()) - .bind(trace.project_id.as_str()) - .bind(trace.agent_id.as_str()) - .bind(trace.read_profile.as_str()) - .bind(trace.query.as_str()) - .bind(trace.expansion_mode.as_str()) - .bind(expanded_queries_json) - .bind(allowed_scopes_json) - .bind(trace.candidate_count as i32) - .bind(trace.top_k as i32) - .bind(trace.config_snapshot.clone()) - .bind(trace.trace_version) - .bind(trace.created_at) - .bind(trace.expires_at) - .execute(executor) - .await?; - - Ok(()) -} - -async fn persist_trace_inline_items( - executor: &mut PgConnection, - trace_id: Uuid, - items: Vec, -) -> Result<()> { - if items.is_empty() { - return Ok(()); - } - - let mut builder = QueryBuilder::new( - "\ -INSERT INTO search_trace_items ( - item_id, - trace_id, - note_id, - chunk_id, - rank, - final_score, - explain -) ", - ); - - builder.push_values(items, |mut b, item| { - let explain_json = - serde_json::to_value(item.explain).expect("SearchExplain must be JSON-serializable."); - - b.push_bind(item.item_id) - .push_bind(trace_id) - .push_bind(item.note_id) - .push_bind(item.chunk_id) - .push_bind(item.rank as i32) - .push_bind(item.final_score) - .push_bind(explain_json); - }); - - builder.push(" ON CONFLICT (item_id) DO NOTHING"); - builder.build().execute(executor).await?; - - Ok(()) -} - -async fn persist_trace_inline_candidates( - executor: &mut PgConnection, - trace_id: Uuid, - candidates: Vec, -) -> Result<()> { - if candidates.is_empty() { - return Ok(()); - } - - let mut builder = QueryBuilder::new( - "\ -INSERT INTO search_trace_candidates ( - candidate_id, - trace_id, - note_id, - chunk_id, - chunk_index, - snippet, - candidate_snapshot, - retrieval_rank, - rerank_score, - note_scope, - note_importance, - note_updated_at, - note_hit_count, - note_last_hit_at, - created_at, - expires_at -) ", - ); - - builder.push_values(candidates, |mut b, candidate| { - b.push_bind(candidate.candidate_id) - .push_bind(trace_id) - .push_bind(candidate.note_id) - .push_bind(candidate.chunk_id) - .push_bind(candidate.chunk_index) - .push_bind(candidate.snippet) - .push_bind(candidate.candidate_snapshot) - .push_bind(candidate.retrieval_rank as i32) - .push_bind(candidate.rerank_score) - .push_bind(candidate.note_scope) - .push_bind(candidate.note_importance) - .push_bind(candidate.note_updated_at) - .push_bind(candidate.note_hit_count) - .push_bind(candidate.note_last_hit_at) - .push_bind(candidate.created_at) - .push_bind(candidate.expires_at); - }); - builder.push(" ON CONFLICT (candidate_id) DO NOTHING"); - builder.build().execute(executor).await?; - - Ok(()) -} - -async fn record_hits<'e, E>( - executor: E, - query: &str, - scored: &[ScoredChunk], - now: OffsetDateTime, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - if scored.is_empty() { - return Ok(()); - } - - let query_hash = ranking::hash_query(query); - let mut hit_ids = Vec::with_capacity(scored.len()); - let mut note_ids = Vec::with_capacity(scored.len()); - let mut chunk_ids = Vec::with_capacity(scored.len()); - let mut ranks = Vec::with_capacity(scored.len()); - let mut final_scores = Vec::with_capacity(scored.len()); - - for (rank, scored_chunk) in scored.iter().enumerate() { - hit_ids.push(Uuid::new_v4()); - note_ids.push(scored_chunk.item.note.note_id); - chunk_ids.push(scored_chunk.item.chunk.chunk_id); - ranks.push(rank as i32); - final_scores.push(scored_chunk.final_score); - } - - sqlx::query( - "\ -WITH hits AS ( - SELECT * - FROM unnest( - $1::uuid[], - $2::uuid[], - $3::uuid[], - $4::int4[], - $5::real[] - ) AS t(hit_id, note_id, chunk_id, rank, final_score) -), -updated AS ( - UPDATE memory_notes - SET - hit_count = hit_count + 1, - last_hit_at = $6 - WHERE note_id = ANY($2) -) -INSERT INTO memory_hits ( - hit_id, - note_id, - chunk_id, - query_hash, - rank, - final_score, - ts -) -SELECT - hit_id, - note_id, - chunk_id, - $7, - rank, - final_score, - $6 - FROM hits", - ) - .bind(&hit_ids) - .bind(¬e_ids) - .bind(&chunk_ids) - .bind(&ranks) - .bind(&final_scores) - .bind(now) - .bind(query_hash.as_str()) - .execute(executor) - .await?; - - Ok(()) -} - -async fn fetch_cache_payload<'e, E>( - executor: E, - kind: CacheKind, - key: &str, - now: OffsetDateTime, -) -> Result> -where - E: PgExecutor<'e>, -{ - let payload: Option = sqlx::query_scalar( - "\ -WITH updated AS ( - UPDATE llm_cache - SET - last_accessed_at = $3, - hit_count = hit_count + 1 - WHERE - cache_kind = $1 - AND cache_key = $2 - AND expires_at > $3 - RETURNING payload -) - SELECT payload -FROM updated", - ) - .bind(kind.as_str()) - .bind(key) - .bind(now) - .fetch_optional(executor) - .await?; - let Some(payload) = payload else { - return Ok(None); - }; - let size_bytes = serde_json::to_vec(&payload) - .map_err(|err| crate::Error::Storage { - message: format!("Failed to encode cache payload: {err}"), - })? - .len(); - - Ok(Some(CachePayload { value: payload, size_bytes })) -} - -async fn store_cache_payload<'e, E>( - executor: E, - kind: CacheKind, - key: &str, - payload: Value, - now: OffsetDateTime, - expires_at: OffsetDateTime, - max_payload_bytes: Option, -) -> Result> -where - E: PgExecutor<'e>, -{ - let payload_bytes = serde_json::to_vec(&payload).map_err(|err| crate::Error::Storage { - message: format!("Failed to encode cache payload: {err}"), - })?; - let payload_size = payload_bytes.len(); - - if let Some(max) = max_payload_bytes - && payload_size as u64 > max - { - return Ok(None); - } - - sqlx::query( - "\ - INSERT INTO llm_cache ( - cache_id, - cache_kind, - cache_key, - payload, - created_at, - last_accessed_at, - expires_at, - hit_count -) -VALUES ($1, $2, $3, $4, $5, $5, $6, 0) -ON CONFLICT (cache_kind, cache_key) DO UPDATE SET -payload = EXCLUDED.payload, - last_accessed_at = EXCLUDED.last_accessed_at, - expires_at = EXCLUDED.expires_at, - hit_count = 0", - ) - .bind(Uuid::new_v4()) - .bind(kind.as_str()) - .bind(key) - .bind(payload) - .bind(now) - .bind(expires_at) - .execute(executor) - .await?; - - Ok(Some(payload_size)) + Ok(replay_helpers::build_replay_items( + cfg, + &blend_policy, + &diversity_policy, + policy_id.as_str(), + &replay_diversity_decisions, + results, + )) } #[cfg(test)] -mod tests { - use serde_json::Value; - - use crate::search::{ - self, BlendRankingOverride, ChunkCandidate, ChunkMeta, ChunkSnippet, HashMap, NoteMeta, - OffsetDateTime, RankingRequestOverride, RerankCacheCandidate, RerankCacheItem, - RerankCachePayload, RetrievalSourceCandidates, RetrievalSourceKind, - RetrievalSourcesRankingOverride, ScoredChunk, TraceReplayCandidate, TraceReplayContext, - Uuid, ranking, - }; - use elf_config::{Config, SearchDynamic}; - - #[test] - fn dense_embedding_input_includes_project_context_suffix() { - let input = ranking::build_dense_embedding_input( - "Find payments code.", - Some("This is a billing API."), - ); - - assert!(input.starts_with("Find payments code.\n\nProject context:\n")); - assert!(input.contains("This is a billing API.")); - } - - #[test] - fn dense_embedding_input_skips_empty_project_context() { - let input = ranking::build_dense_embedding_input("Find payments code.", Some(" ")); - - assert_eq!(input, "Find payments code."); - } - - #[test] - fn scope_description_boost_matches_whole_tokens_only() { - let tokens = vec!["go".to_string()]; - let boost = ranking::scope_description_boost(&tokens, "MongoDB operational notes.", 0.1); - - assert_eq!(boost, 0.0); - } - - #[test] - fn scope_description_boost_scales_by_fraction_of_matched_tokens() { - let tokens = vec!["security".to_string(), "policy".to_string(), "deployment".to_string()]; - let boost = ranking::scope_description_boost(&tokens, "Security policy notes.", 0.12); - - assert!((boost - 0.08).abs() < 1e-4, "Unexpected boost: {boost}"); - } - - #[test] - fn normalize_queries_includes_original_and_dedupes() { - let queries = vec!["alpha".to_string(), "beta".to_string(), "alpha".to_string()]; - let normalized = ranking::normalize_queries(queries, "alpha", true, 4); - - assert_eq!(normalized, vec!["alpha".to_string(), "beta".to_string()]); - } - - #[test] - fn normalize_queries_respects_max_queries() { - let queries = - vec!["one".to_string(), "two".to_string(), "three".to_string(), "four".to_string()]; - let normalized = ranking::normalize_queries(queries, "zero", true, 3); - - assert_eq!(normalized.len(), 3); - } - - #[test] - fn dynamic_trigger_checks_candidates_and_score() { - let cfg = SearchDynamic { min_candidates: 10, min_top_score: 0.2 }; - - assert!(ranking::should_expand_dynamic(5, 0.9, &cfg)); - assert!(ranking::should_expand_dynamic(20, 0.1, &cfg)); - assert!(!ranking::should_expand_dynamic(20, 0.9, &cfg)); - } - - #[test] - fn rank_normalize_maps_rank_to_unit_interval() { - assert!((ranking::rank_normalize(1, 1) - 1.0).abs() < 1e-6); - assert!((ranking::rank_normalize(1, 5) - 1.0).abs() < 1e-6); - assert!((ranking::rank_normalize(3, 5) - 0.5).abs() < 1e-6); - assert!((ranking::rank_normalize(5, 5) - 0.0).abs() < 1e-6); - assert!((ranking::rank_normalize(0, 5) - 0.0).abs() < 1e-6); - } - - #[test] - fn build_trace_audit_includes_token_id_when_present() { - let audit = search::build_trace_audit("agent-a", Some("tok-123")); - - assert_eq!(audit.get("actor_id"), Some(&Value::from("agent-a"))); - assert_eq!(audit.get("token_id"), Some(&Value::from("tok-123"))); - } - - #[test] - fn build_trace_audit_omits_token_id_when_empty() { - let audit = search::build_trace_audit("agent-a", Some(" ")); - - assert_eq!(audit.get("actor_id"), Some(&Value::from("agent-a"))); - assert!(audit.get("token_id").is_none()); - } - - #[test] - fn relation_context_rows_without_evidence_are_suppressed() { - let now = OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"); - let note_id = Uuid::from_u128(1); - let contexts = crate::ElfService::group_relation_context_rows(vec![ - search::SearchRelationContextRow { - note_id, - fact_id: Uuid::from_u128(2), - scope: "project_shared".to_string(), - subject_canonical: Some("Alice".to_string()), - subject_kind: Some("person".to_string()), - predicate: "prefers".to_string(), - object_entity_id: None, - object_canonical: None, - object_kind: None, - object_value: Some("source-bound recall".to_string()), - valid_from: now, - valid_to: None, - is_current: true, - evidence_note_ids: Vec::new(), - }, - ]); - - assert!(!contexts.contains_key(¬e_id)); - } - - #[test] - fn relation_context_sql_enforces_shared_grant_keys() { - assert!( - search::RELATION_CONTEXT_SQL - .contains("concat(gf.scope, ':', gf.agent_id) = ANY($10::text[])") - ); - assert!(search::RELATION_CONTEXT_SQL.contains( - "concat(evidence_note.scope, ':', evidence_note.agent_id) = ANY($10::text[])" - )); - } - - fn test_chunk_candidate(note_id: Uuid, retrieval_rank: u32) -> ChunkCandidate { - ChunkCandidate { - chunk_id: Uuid::new_v4(), - note_id, - chunk_index: 0, - retrieval_rank, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: Some("v1".to_string()), - } - } - - fn default_retrieval_sources_policy() -> ranking::ResolvedRetrievalSourcesPolicy { - ranking::ResolvedRetrievalSourcesPolicy { - fusion_weight: 1.0, - structured_field_weight: 1.0, - recursive_weight: 0.0, - fusion_priority: 1, - structured_field_priority: 0, - recursive_priority: 0, - } - } - - #[test] - fn merge_retrieval_candidates_keeps_structured_hits_under_full_fusion_capacity() { - let mut fusion = Vec::new(); - - for rank in 1..=10 { - fusion.push(test_chunk_candidate(Uuid::new_v4(), rank)); - } - - let structured = vec![test_chunk_candidate(Uuid::new_v4(), 1)]; - let structured_chunk_id = structured[0].chunk_id; - let merged = ranking::merge_retrieval_candidates( - vec![ - RetrievalSourceCandidates { - source: RetrievalSourceKind::Fusion, - candidates: fusion, - }, - RetrievalSourceCandidates { - source: RetrievalSourceKind::StructuredField, - candidates: structured, - }, - ], - &default_retrieval_sources_policy(), - 10, - ); - let merged_chunk_ids: Vec = - merged.iter().map(|candidate| candidate.chunk_id).collect(); - - assert!( - merged_chunk_ids.contains(&structured_chunk_id), - "Structured candidate was dropped by retrieval fusion." - ); - } - - #[test] - fn merge_retrieval_candidates_prefers_dual_source_signal_on_tie() { - let shared_note_id = Uuid::new_v4(); - let shared_chunk_id = Uuid::new_v4(); - let fusion_only_note_id = Uuid::new_v4(); - let fusion_only_chunk_id = Uuid::new_v4(); - let fusion = vec![ - ChunkCandidate { - chunk_id: shared_chunk_id, - note_id: shared_note_id, - chunk_index: 0, - retrieval_rank: 9, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: Some("v1".to_string()), - }, - ChunkCandidate { - chunk_id: fusion_only_chunk_id, - note_id: fusion_only_note_id, - chunk_index: 0, - retrieval_rank: 1, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: Some("v1".to_string()), - }, - ]; - let structured = vec![ChunkCandidate { - chunk_id: shared_chunk_id, - note_id: shared_note_id, - chunk_index: 0, - retrieval_rank: 1, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: Some("v1".to_string()), - }]; - let merged = ranking::merge_retrieval_candidates( - vec![ - RetrievalSourceCandidates { - source: RetrievalSourceKind::Fusion, - candidates: fusion, - }, - RetrievalSourceCandidates { - source: RetrievalSourceKind::StructuredField, - candidates: structured, - }, - ], - &default_retrieval_sources_policy(), - 1, - ); - let first = merged.first().expect("Expected merged candidate."); - - assert_eq!(first.chunk_id, shared_chunk_id); - } - - #[test] - fn retrieval_weight_for_rank_uses_first_matching_segment_or_last() { - let segments = vec![ - ranking::BlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.7 }, - ranking::BlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.2 }, - ]; - - assert!((ranking::retrieval_weight_for_rank(1, &segments) - 0.7).abs() < 1e-6); - assert!((ranking::retrieval_weight_for_rank(3, &segments) - 0.7).abs() < 1e-6); - assert!((ranking::retrieval_weight_for_rank(4, &segments) - 0.2).abs() < 1e-6); - assert!((ranking::retrieval_weight_for_rank(999, &segments) - 0.2).abs() < 1e-6); - } - - #[test] - fn blend_math_is_linear_and_additive() { - let segments = vec![ - ranking::BlendSegment { max_retrieval_rank: 2, retrieval_weight: 0.7 }, - ranking::BlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.2 }, - ]; - let retrieval_rank = 3; - let rerank_rank = 2; - let retrieval_norm = ranking::rank_normalize(retrieval_rank, 10); - let rerank_norm = ranking::rank_normalize(rerank_rank, 4); - let blend_retrieval_weight = ranking::retrieval_weight_for_rank(retrieval_rank, &segments); - - assert!((blend_retrieval_weight - 0.2).abs() < 1e-6); - assert!((retrieval_norm - (7.0 / 9.0)).abs() < 1e-6); - assert!((rerank_norm - (2.0 / 3.0)).abs() < 1e-6); - - let retrieval_term = blend_retrieval_weight * retrieval_norm; - let rerank_term = (1.0 - blend_retrieval_weight) * rerank_norm; - let tie_breaker_score = 0.1; - let scope_context_boost = 0.0; - let final_score = retrieval_term + rerank_term + tie_breaker_score + scope_context_boost; - let expected = (0.2 * (7.0 / 9.0)) + (0.8 * (2.0 / 3.0)) + 0.1; - - assert!((final_score - expected).abs() < 1e-6, "Unexpected final_score: {final_score}"); - } - - #[test] - fn expansion_cache_key_changes_with_max_queries() { - let key_a = ranking::build_expansion_cache_key("alpha", 4, true, "llm", "model", 0.1_f32) - .expect("Expected cache key."); - let key_b = ranking::build_expansion_cache_key("alpha", 5, true, "llm", "model", 0.1_f32) - .expect("Expected cache key."); - - assert_ne!(key_a, key_b); - } - - #[test] - fn rerank_cache_key_changes_with_updated_at() { - let ts_a = OffsetDateTime::from_unix_timestamp(1).expect("Valid timestamp."); - let ts_b = OffsetDateTime::from_unix_timestamp(2).expect("Valid timestamp."); - let chunk_id = Uuid::new_v4(); - let key_a = ranking::build_rerank_cache_key("q", "rerank", "model", &[(chunk_id, ts_a)]) - .expect("Expected cache key."); - let key_b = ranking::build_rerank_cache_key("q", "rerank", "model", &[(chunk_id, ts_b)]) - .expect("Expected cache key."); - - assert_ne!(key_a, key_b); - } - - #[test] - fn rerank_cache_payload_rejects_mismatched_counts() { - let payload = RerankCachePayload { - items: vec![RerankCacheItem { - chunk_id: Uuid::new_v4(), - updated_at: OffsetDateTime::from_unix_timestamp(1).expect("Valid timestamp."), - score: 0.5, - }], - }; - let candidates = vec![RerankCacheCandidate { - chunk_id: Uuid::new_v4(), - updated_at: OffsetDateTime::from_unix_timestamp(1).expect("Valid timestamp."), - }]; - - assert!(ranking::build_cached_scores(&payload, &candidates).is_none()); - } - - #[test] - fn cache_key_prefix_is_stable() { - let prefix = ranking::cache_key_prefix("abcd1234efgh5678"); - - assert_eq!(prefix, "abcd1234efgh"); - } - - #[test] - fn lexical_overlap_ratio_is_deterministic_and_bounded() { - let query_tokens = vec!["deploy".to_string(), "steps".to_string()]; - let ratio = ranking::lexical_overlap_ratio(&query_tokens, "Deploy steps for staging.", 128); - - assert!((ratio - 1.0).abs() < 1e-6, "Unexpected ratio: {ratio}"); - - let ratio = ranking::lexical_overlap_ratio(&query_tokens, "Deploy only.", 128); - - assert!((ratio - 0.5).abs() < 1e-6, "Unexpected ratio: {ratio}"); - assert!((0.0..=1.0).contains(&ratio), "Ratio must be in [0, 1]."); - } - - #[test] - fn deterministic_ranking_terms_do_not_apply_when_disabled() { - let mut cfg = parse_example_config(); - - cfg.ranking.deterministic.enabled = false; - cfg.ranking.deterministic.lexical.enabled = true; - cfg.ranking.deterministic.hits.enabled = true; - cfg.ranking.deterministic.decay.enabled = true; - - let now = OffsetDateTime::from_unix_timestamp(1_000_000).expect("Valid timestamp."); - let note = NoteMeta { - note_id: Uuid::new_v4(), - note_type: "fact".to_string(), - key: None, - scope: "project_shared".to_string(), - agent_id: "agent-a".to_string(), - importance: 0.1, - confidence: 0.9, - updated_at: now, - expires_at: None, - source_ref: serde_json::json!({}), - embedding_version: "v1".to_string(), - hit_count: 8, - last_hit_at: Some(now), - }; - let chunk = - ChunkMeta { chunk_id: Uuid::new_v4(), chunk_index: 0, start_offset: 0, end_offset: 10 }; - let item = ChunkSnippet { - note, - chunk, - snippet: "deploy steps".to_string(), - retrieval_rank: 1, - retrieval_score: None, - }; - let mut scored = ScoredChunk { - item, - final_score: 1.0, - rerank_score: 0.5, - rerank_rank: 1, - rerank_norm: 1.0, - retrieval_norm: 1.0, - blend_retrieval_weight: 0.5, - retrieval_term: 0.5, - rerank_term: 0.5, - tie_breaker_score: 0.0, - scope_context_boost: 0.0, - age_days: 30.0, - importance: 0.1, - deterministic_lexical_overlap_ratio: 0.0, - deterministic_lexical_bonus: 0.0, - deterministic_hit_count: 0, - deterministic_last_hit_age_days: None, - deterministic_hit_boost: 0.0, - deterministic_decay_penalty: 0.0, - }; - let terms = ranking::compute_deterministic_ranking_terms( - &cfg, - &ranking::tokenize_query( - "deploy steps", - cfg.ranking.deterministic.lexical.max_query_terms as usize, - ), - scored.item.snippet.as_str(), - scored.item.note.hit_count, - scored.item.note.last_hit_at, - scored.age_days, - now, - ); - - scored.final_score += terms.lexical_bonus + terms.hit_boost + terms.decay_penalty; - scored.deterministic_lexical_overlap_ratio = terms.lexical_overlap_ratio; - scored.deterministic_lexical_bonus = terms.lexical_bonus; - scored.deterministic_hit_count = terms.hit_count; - scored.deterministic_last_hit_age_days = terms.last_hit_age_days; - scored.deterministic_hit_boost = terms.hit_boost; - scored.deterministic_decay_penalty = terms.decay_penalty; - - assert!((scored.final_score - 1.0).abs() < 1e-6, "Score must not change."); - assert!((scored.deterministic_lexical_bonus - 0.0).abs() < 1e-6); - assert!((scored.deterministic_hit_boost - 0.0).abs() < 1e-6); - assert!((scored.deterministic_decay_penalty - 0.0).abs() < 1e-6); - } - - #[test] - fn deterministic_ranking_terms_apply_and_are_bounded() { - let mut cfg = parse_example_config(); - - cfg.ranking.deterministic.enabled = true; - cfg.ranking.deterministic.lexical.enabled = true; - cfg.ranking.deterministic.hits.enabled = true; - cfg.ranking.deterministic.decay.enabled = true; - - let now = OffsetDateTime::from_unix_timestamp(1_000_000).expect("Valid timestamp."); - let note = NoteMeta { - note_id: Uuid::new_v4(), - note_type: "fact".to_string(), - key: None, - scope: "project_shared".to_string(), - agent_id: "agent-a".to_string(), - importance: 0.1, - confidence: 0.9, - updated_at: now, - expires_at: None, - source_ref: serde_json::json!({}), - embedding_version: "v1".to_string(), - hit_count: 8, - last_hit_at: Some(now), - }; - let chunk = - ChunkMeta { chunk_id: Uuid::new_v4(), chunk_index: 0, start_offset: 0, end_offset: 10 }; - let item = ChunkSnippet { - note, - chunk, - snippet: "deploy steps".to_string(), - retrieval_rank: 1, - retrieval_score: None, - }; - let mut scored = ScoredChunk { - item, - final_score: 1.0, - rerank_score: 0.5, - rerank_rank: 1, - rerank_norm: 1.0, - retrieval_norm: 1.0, - blend_retrieval_weight: 0.5, - retrieval_term: 0.5, - rerank_term: 0.5, - tie_breaker_score: 0.0, - scope_context_boost: 0.0, - age_days: 30.0, - importance: 0.1, - deterministic_lexical_overlap_ratio: 0.0, - deterministic_lexical_bonus: 0.0, - deterministic_hit_count: 0, - deterministic_last_hit_age_days: None, - deterministic_hit_boost: 0.0, - deterministic_decay_penalty: 0.0, - }; - let terms = ranking::compute_deterministic_ranking_terms( - &cfg, - &ranking::tokenize_query( - "deploy steps", - cfg.ranking.deterministic.lexical.max_query_terms as usize, - ), - scored.item.snippet.as_str(), - scored.item.note.hit_count, - scored.item.note.last_hit_at, - scored.age_days, - now, - ); - - scored.final_score += terms.lexical_bonus + terms.hit_boost + terms.decay_penalty; - scored.deterministic_lexical_overlap_ratio = terms.lexical_overlap_ratio; - scored.deterministic_lexical_bonus = terms.lexical_bonus; - scored.deterministic_hit_count = terms.hit_count; - scored.deterministic_last_hit_age_days = terms.last_hit_age_days; - scored.deterministic_hit_boost = terms.hit_boost; - scored.deterministic_decay_penalty = terms.decay_penalty; - - assert!(scored.final_score.is_finite(), "Score must be finite."); - assert!((0.0..=1.0).contains(&scored.deterministic_lexical_overlap_ratio)); - assert!(scored.deterministic_lexical_bonus >= 0.0); - assert!(scored.deterministic_hit_boost >= 0.0); - assert!(scored.deterministic_decay_penalty <= 0.0); - - let expected_lex = cfg.ranking.deterministic.lexical.weight; - - assert!((scored.deterministic_lexical_bonus - expected_lex).abs() < 1e-6); - - let expected_hit = cfg.ranking.deterministic.hits.weight * 0.5; - - assert!((scored.deterministic_hit_boost - expected_hit).abs() < 1e-6); - } - - fn test_scored_chunk(note_id: Uuid, retrieval_rank: u32, now: OffsetDateTime) -> ScoredChunk { - let note = NoteMeta { - note_id, - note_type: "fact".to_string(), - key: None, - scope: "project_shared".to_string(), - agent_id: "agent-a".to_string(), - importance: 0.1, - confidence: 0.9, - updated_at: now, - expires_at: None, - source_ref: serde_json::json!({}), - embedding_version: "v1".to_string(), - hit_count: 0, - last_hit_at: None, - }; - let chunk = ChunkMeta { - chunk_id: Uuid::new_v4(), - chunk_index: i32::try_from(retrieval_rank.saturating_sub(1)).unwrap_or(0), - start_offset: 0, - end_offset: 16, - }; - let item = ChunkSnippet { - note, - chunk, - snippet: format!("snippet-{retrieval_rank}"), - retrieval_rank, - retrieval_score: None, - }; - - ScoredChunk { - item, - final_score: 0.0, - rerank_score: 0.0, - rerank_rank: retrieval_rank, - rerank_norm: 0.0, - retrieval_norm: 0.0, - blend_retrieval_weight: 0.5, - retrieval_term: 0.0, - rerank_term: 0.0, - tie_breaker_score: 0.0, - scope_context_boost: 0.0, - age_days: 0.0, - importance: 0.1, - deterministic_lexical_overlap_ratio: 0.0, - deterministic_lexical_bonus: 0.0, - deterministic_hit_count: 0, - deterministic_last_hit_age_days: None, - deterministic_hit_boost: 0.0, - deterministic_decay_penalty: 0.0, - } - } - - #[test] - fn diversity_selection_skips_high_similarity_when_alternative_exists() { - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let note_a = Uuid::new_v4(); - let note_b = Uuid::new_v4(); - let note_c = Uuid::new_v4(); - let candidates = vec![ - test_scored_chunk(note_a, 1, now), - test_scored_chunk(note_b, 2, now), - test_scored_chunk(note_c, 3, now), - ]; - let mut vectors = HashMap::new(); - - vectors.insert(note_a, vec![1.0, 0.0]); - vectors.insert(note_b, vec![0.99, 0.01]); - vectors.insert(note_c, vec![0.0, 1.0]); - - let policy = ranking::ResolvedDiversityPolicy { - enabled: true, - sim_threshold: 0.9, - mmr_lambda: 0.7, - max_skips: 64, - }; - let (selected, decisions) = - ranking::select_diverse_results(candidates, 2, &policy, &vectors); - let selected_ids: Vec = selected.iter().map(|item| item.item.note.note_id).collect(); - - assert_eq!(selected_ids, vec![note_a, note_c]); - assert_eq!( - decisions.get(¬e_b).and_then(|decision| decision.skipped_reason.as_deref()), - Some("similarity_threshold") - ); - } - - #[test] - fn diversity_selection_backfills_when_max_skips_is_reached() { - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let note_a = Uuid::new_v4(); - let note_b = Uuid::new_v4(); - let candidates = vec![test_scored_chunk(note_a, 1, now), test_scored_chunk(note_b, 2, now)]; - let mut vectors = HashMap::new(); - - vectors.insert(note_a, vec![1.0, 0.0]); - vectors.insert(note_b, vec![0.99, 0.01]); - - let policy = ranking::ResolvedDiversityPolicy { - enabled: true, - sim_threshold: 0.9, - mmr_lambda: 0.7, - max_skips: 0, - }; - let (selected, decisions) = - ranking::select_diverse_results(candidates, 2, &policy, &vectors); - let selected_ids: Vec = selected.iter().map(|item| item.item.note.note_id).collect(); - let selected_reason = - decisions.get(¬e_b).map(|decision| decision.selected_reason.as_str()); - - assert_eq!(selected_ids, vec![note_a, note_b]); - assert_eq!(selected_reason, Some("max_skips_backfill")); - } - - #[test] - fn replay_diversity_decisions_prefer_selected_entry_for_same_note() { - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let note_id = Uuid::new_v4(); - let first = TraceReplayCandidate { - note_id, - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "first".to_string(), - retrieval_rank: 2, - retrieval_score: None, - rerank_score: 0.2, - note_scope: "project_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: Some(false), - diversity_selected_rank: None, - diversity_selected_reason: Some("not_selected".to_string()), - diversity_skipped_reason: Some("lower_mmr".to_string()), - diversity_nearest_selected_note_id: None, - diversity_similarity: Some(0.95), - diversity_mmr_score: Some(0.12), - diversity_missing_embedding: Some(false), - }; - let second = TraceReplayCandidate { - note_id, - chunk_id: Uuid::new_v4(), - chunk_index: 1, - snippet: "second".to_string(), - retrieval_rank: 1, - retrieval_score: None, - rerank_score: 0.3, - note_scope: "project_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: Some(true), - diversity_selected_rank: Some(2), - diversity_selected_reason: Some("mmr".to_string()), - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: Some(0.35), - diversity_mmr_score: Some(0.44), - diversity_missing_embedding: Some(false), - }; - let decisions = ranking::extract_replay_diversity_decisions(&[first, second]); - let decision = decisions.get(¬e_id).expect("Expected merged decision."); - - assert!(decision.selected); - assert_eq!(decision.selected_rank, Some(2)); - assert_eq!(decision.selected_reason, "mmr"); - } - - fn parse_example_config() -> Config { - let root_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - let path = root_dir.join("elf.example.toml"); - - elf_config::load(&path).expect("elf.example.toml must remain parseable and valid.") - } - - #[test] - fn ranking_policy_id_is_stable_and_has_expected_format() { - let cfg = parse_example_config(); - let id_a = search::ranking_policy_id(&cfg, None).expect("Expected policy id."); - let id_b = search::ranking_policy_id(&cfg, None).expect("Expected policy id."); - - assert_eq!(id_a, id_b); - assert!(id_a.starts_with("ranking_v2:"), "Unexpected policy id: {id_a}"); - assert_eq!(id_a.len(), "ranking_v2:".len() + 12, "Unexpected policy id: {id_a}"); - } - - #[test] - fn ranking_policy_id_changes_with_override() { - let cfg = parse_example_config(); - let base = search::ranking_policy_id(&cfg, None).expect("Expected base policy id."); - let override_ = RankingRequestOverride { - blend: Some(BlendRankingOverride { - enabled: Some(false), - rerank_normalization: None, - retrieval_normalization: None, - segments: None, - }), - diversity: None, - retrieval_sources: None, - }; - let overridden = search::ranking_policy_id(&cfg, Some(&override_)) - .expect("Expected overridden policy id."); - - assert_ne!(base, overridden); - } - - #[test] - fn ranking_policy_id_changes_with_retrieval_source_override() { - let cfg = parse_example_config(); - let base = search::ranking_policy_id(&cfg, None).expect("Expected base policy id."); - let override_ = RankingRequestOverride { - blend: None, - diversity: None, - retrieval_sources: Some(RetrievalSourcesRankingOverride { - fusion_weight: Some(0.75), - structured_field_weight: Some(1.25), - recursive_weight: Some(0.0), - fusion_priority: Some(2), - structured_field_priority: Some(1), - recursive_priority: Some(0), - }), - }; - let overridden = search::ranking_policy_id(&cfg, Some(&override_)) - .expect("Expected overridden policy id."); - - assert_ne!(base, overridden); - } - - #[test] - fn replay_ranking_policy_id_matches_ranking_policy_id() { - let cfg = parse_example_config(); - let expected = search::ranking_policy_id(&cfg, None).expect("Expected policy id."); - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let trace = TraceReplayContext { - trace_id: Uuid::new_v4(), - query: "deployment steps".to_string(), - candidate_count: 3, - top_k: 2, - created_at: now, - }; - let candidates = vec![ - TraceReplayCandidate { - note_id: Uuid::new_v4(), - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "deployment steps".to_string(), - retrieval_rank: 1, - retrieval_score: None, - rerank_score: 0.1, - note_scope: "project_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - TraceReplayCandidate { - note_id: Uuid::new_v4(), - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "deployment steps".to_string(), - retrieval_rank: 2, - retrieval_score: None, - rerank_score: 0.9, - note_scope: "project_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - TraceReplayCandidate { - note_id: Uuid::new_v4(), - chunk_id: Uuid::new_v4(), - chunk_index: 0, - snippet: "deployment steps".to_string(), - retrieval_rank: 3, - retrieval_score: None, - rerank_score: 0.2, - note_scope: "org_shared".to_string(), - note_importance: 0.1, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: None, - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: None, - diversity_missing_embedding: None, - }, - ]; - let out = search::replay_ranking_from_candidates(&cfg, &trace, None, &candidates, 2) - .expect("Expected replay output."); - - for item in out { - assert_eq!(item.explain.ranking.policy_id, expected); - } - } -} +#[path = "search/tests.rs"] +mod tests; diff --git a/packages/elf-service/src/search/api.rs b/packages/elf-service/src/search/api.rs new file mode 100644 index 00000000..2b7e8a43 --- /dev/null +++ b/packages/elf-service/src/search/api.rs @@ -0,0 +1,39 @@ +mod explain; +mod payload; +mod query_plan; +mod request; +mod trace; + +pub use self::{ + explain::{ + SearchDiversityExplain, SearchExplain, SearchExplainRelationContext, + SearchExplainRelationContextObject, SearchExplainRelationEntityRef, SearchItem, + SearchMatchExplain, SearchResponse, + }, + payload::PayloadLevel, + query_plan::{ + QueryPlan, QueryPlanBlendSegment, QueryPlanBudget, QueryPlanDynamicGate, + QueryPlanFusionPolicy, QueryPlanIntent, QueryPlanRerankPolicy, QueryPlanRetrievalStage, + QueryPlanRewrite, QueryPlanStage, SearchRawPlannedResponse, + }, + request::{ + BlendRankingOverride, BlendSegmentOverride, DiversityRankingOverride, + RankingRequestOverride, RetrievalSourcesRankingOverride, SearchRequest, + }, + trace::{ + RecentTraceHeader, SearchExplainItem, SearchExplainRequest, SearchExplainResponse, + SearchExplainTrajectory, SearchExplainTrajectoryMatch, SearchExplainTrajectoryStage, + SearchTrace, SearchTrajectoryResponse, SearchTrajectoryStage, SearchTrajectoryStageItem, + SearchTrajectorySummary, SearchTrajectorySummaryStage, TraceBundleGetRequest, + TraceBundleMode, TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentCursor, + TraceRecentListRequest, TraceRecentListResponse, TraceReplayCandidate, TraceReplayContext, + TraceReplayItem, TraceTrajectoryGetRequest, + }, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{graph::RelationTemporalStatus, ranking_explain_v2::SearchRankingExplain}; diff --git a/packages/elf-service/src/search/api/explain.rs b/packages/elf-service/src/search/api/explain.rs new file mode 100644 index 00000000..fd71de6e --- /dev/null +++ b/packages/elf-service/src/search/api/explain.rs @@ -0,0 +1,153 @@ +use crate::search::api::{ + Deserialize, OffsetDateTime, RelationTemporalStatus, SearchRankingExplain, + SearchTrajectorySummary, Serialize, Uuid, Value, +}; + +/// Full explanation attached to one search item. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplain { + /// Match-specific explanation. + pub r#match: SearchMatchExplain, + /// Ranking-term explanation. + pub ranking: SearchRankingExplain, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional relation-context snippets supporting the match. + pub relation_context: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional diversity-selection explanation. + pub diversity: Option, +} + +/// Relation-context row attached to a search explanation. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainRelationContext { + /// Fact identifier. + pub fact_id: Uuid, + /// Scope key for the fact. + pub scope: String, + /// Subject entity reference. + pub subject: SearchExplainRelationEntityRef, + /// Predicate surface. + pub predicate: String, + /// Object payload. + pub object: SearchExplainRelationContextObject, + #[serde(with = "crate::time_serde")] + /// Start of the fact validity window. + pub valid_from: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// End of the fact validity window, if superseded. + pub valid_to: Option, + #[serde(default)] + /// Temporal state for the fact relative to the search read timestamp. + pub temporal_status: RelationTemporalStatus, + #[serde(default)] + /// Evidence note identifiers supporting the fact. + pub evidence_note_ids: Vec, +} + +/// Lightweight entity reference used in search explanations. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainRelationEntityRef { + #[serde(skip_serializing_if = "Option::is_none")] + /// Canonical entity surface. + pub canonical: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Object payload used in search explanation relation context. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainRelationContextObject { + #[serde(skip_serializing_if = "Option::is_none")] + /// Entity-shaped object value. + pub entity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Scalar object value. + pub value: Option, +} + +/// Match-level explanation for a search item. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchMatchExplain { + /// Query terms matched by the item. + pub matched_terms: Vec, + /// Fields that supplied the matches. + pub matched_fields: Vec, +} + +/// Diversity-selection explanation for a search item. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchDiversityExplain { + /// Whether diversity ranking was enabled. + pub enabled: bool, + /// Reason the item was selected. + pub selected_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Reason the item was skipped, when applicable. + pub skipped_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Nearest already selected note that influenced the decision. + pub nearest_selected_note_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Similarity to the nearest selected note. + pub similarity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// MMR score used by diversity selection. + pub mmr_score: Option, + #[serde(default)] + /// Whether the item lacked an embedding needed for diversity scoring. + pub missing_embedding: bool, +} + +/// One ranked search result item. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchItem { + /// Stable result-handle identifier for explain APIs. + pub result_handle: Uuid, + /// Note identifier. + pub note_id: Uuid, + /// Chunk identifier. + pub chunk_id: Uuid, + /// Zero-based chunk position. + pub chunk_index: i32, + /// Inclusive start byte offset of the snippet chunk. + pub start_offset: i32, + /// Exclusive end byte offset of the snippet chunk. + pub end_offset: i32, + /// Returned snippet text. + pub snippet: String, + /// Note type discriminator. + pub r#type: String, + /// Optional application-defined key. + pub key: Option, + /// Scope key for the note. + pub scope: String, + /// Importance score. + pub importance: f32, + /// Confidence score. + pub confidence: f32, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// Optional expiry timestamp. + pub expires_at: Option, + /// Final ranked score. + pub final_score: f32, + /// Structured source reference metadata. + pub source_ref: Value, + /// Item-level explanation payload. + pub explain: SearchExplain, +} + +/// Response payload for raw search results. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchResponse { + /// Search trace identifier. + pub trace_id: Uuid, + /// Ranked search items. + pub items: Vec, + /// Optional condensed explain output. + pub trajectory_summary: Option, +} diff --git a/packages/elf-service/src/search/api/payload.rs b/packages/elf-service/src/search/api/payload.rs new file mode 100644 index 00000000..1178ca27 --- /dev/null +++ b/packages/elf-service/src/search/api/payload.rs @@ -0,0 +1,51 @@ +use crate::search::api::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; + +/// Payload-detail level used by search and trace APIs. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum PayloadLevel { + #[default] + /// Level 0 payloads. + L0, + /// Level 1 payloads. + L1, + /// Level 2 payloads. + L2, +} +impl PayloadLevel { + fn as_str(self) -> &'static str { + match self { + Self::L0 => "l0", + Self::L1 => "l1", + Self::L2 => "l2", + } + } + + fn parse(raw: &str) -> Option { + match raw.to_ascii_lowercase().as_str() { + "l0" => Some(Self::L0), + "l1" => Some(Self::L1), + "l2" => Some(Self::L2), + _ => None, + } + } +} + +impl Serialize for PayloadLevel { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.as_str().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PayloadLevel { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + + Self::parse(&raw).ok_or_else(|| Error::custom("payload_level must be l0, l1, or l2")) + } +} diff --git a/packages/elf-service/src/search/api/query_plan.rs b/packages/elf-service/src/search/api/query_plan.rs new file mode 100644 index 00000000..e3729cb6 --- /dev/null +++ b/packages/elf-service/src/search/api/query_plan.rs @@ -0,0 +1,174 @@ +use crate::search::api::{ + Deserialize, SearchItem, SearchTrajectorySummary, Serialize, Uuid, Value, +}; + +/// Planned-search variant of the raw search response. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchRawPlannedResponse { + /// Search trace identifier. + pub trace_id: Uuid, + /// Ranked search items. + pub items: Vec, + /// Optional condensed explain output. + pub trajectory_summary: Option, + /// Query plan used for the search. + pub query_plan: QueryPlan, +} + +/// Query plan emitted by planned search. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlan { + /// Query-plan schema identifier. + pub schema: String, + /// Query-plan version string. + pub version: String, + /// Ordered planning stages. + pub stages: Vec, + /// Request intent snapshot. + pub intent: QueryPlanIntent, + /// Query rewrite output. + pub rewrite: QueryPlanRewrite, + /// Retrieval-stage plan. + pub retrieval_stages: Vec, + /// Fusion-policy snapshot. + pub fusion_policy: QueryPlanFusionPolicy, + /// Rerank-policy snapshot. + pub rerank_policy: QueryPlanRerankPolicy, + /// Budget snapshot. + pub budget: QueryPlanBudget, +} + +/// One stage in a query plan. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanStage { + /// Stage name. + pub name: String, + /// Free-form stage details. + pub details: Value, +} + +/// Request intent captured in a query plan. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanIntent { + /// Original search query text. + pub query: String, + /// Tenant to search within. + pub tenant_id: String, + /// Project to search within. + pub project_id: String, + /// Agent requesting the search. + pub agent_id: String, + /// Read profile used for the search. + pub read_profile: String, + /// Scopes allowed by the read profile. + pub allowed_scopes: Vec, +} + +/// Rewrite section of a query plan. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanRewrite { + /// Expansion mode label. + pub expansion_mode: String, + /// Expanded query strings. + pub expanded_queries: Vec, + /// Dynamic-gate summary. + pub dynamic_gate: QueryPlanDynamicGate, +} + +/// Dynamic-query-expansion gate summary. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanDynamicGate { + /// Whether the dynamic gate was considered. + pub considered: bool, + /// Whether the dynamic gate decided to expand. + pub should_expand: Option, + /// Candidate count observed by the gate. + pub observed_candidates: Option, + /// Top score observed by the gate. + pub observed_top_score: Option, + /// Minimum candidates threshold. + pub min_candidates: u32, + /// Minimum top-score threshold. + pub min_top_score: f32, +} + +/// Retrieval-stage entry in a query plan. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanRetrievalStage { + /// Stage name. + pub name: String, + /// Retrieval source label. + pub source: String, + /// Whether the stage is enabled. + pub enabled: bool, + /// Candidate limit for the stage. + pub candidate_limit: u32, +} + +/// Fusion-policy snapshot used during search. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanFusionPolicy { + /// Fusion strategy label. + pub strategy: String, + /// Weight for fusion retrieval. + pub fusion_weight: f32, + /// Weight for structured-field retrieval. + pub structured_field_weight: f32, + /// Weight for recursive retrieval. + pub recursive_weight: f32, + /// Priority for fusion retrieval. + pub fusion_priority: u32, + /// Priority for structured-field retrieval. + pub structured_field_priority: u32, + /// Priority for recursive retrieval. + pub recursive_priority: u32, +} + +/// One blend segment in the rerank policy. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanBlendSegment { + /// Highest retrieval rank covered by the segment. + pub max_retrieval_rank: u32, + /// Retrieval weight applied within the segment. + pub retrieval_weight: f32, +} + +/// Rerank-policy snapshot used during search. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanRerankPolicy { + /// Provider identifier. + pub provider_id: String, + /// Model identifier. + pub model: String, + /// Whether blend ranking was enabled. + pub blend_enabled: bool, + /// Rerank normalization label. + pub rerank_normalization: String, + /// Retrieval normalization label. + pub retrieval_normalization: String, + /// Blend segments used by the policy. + pub blend_segments: Vec, + /// Whether diversity ranking was enabled. + pub diversity_enabled: bool, + /// Diversity similarity threshold. + pub diversity_sim_threshold: f32, + /// Diversity MMR lambda. + pub diversity_mmr_lambda: f32, + /// Diversity max-skips limit. + pub diversity_max_skips: u32, +} + +/// Budget snapshot used during search. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct QueryPlanBudget { + /// Final top-k budget. + pub top_k: u32, + /// Candidate-k budget. + pub candidate_k: u32, + /// Prefilter candidate cap. + pub prefilter_max_candidates: u32, + /// Query-expansion cap. + pub expansion_max_queries: u32, + /// Whether ranking caches were enabled. + pub cache_enabled: bool, +} diff --git a/packages/elf-service/src/search/api/request.rs b/packages/elf-service/src/search/api/request.rs new file mode 100644 index 00000000..bdacfe23 --- /dev/null +++ b/packages/elf-service/src/search/api/request.rs @@ -0,0 +1,95 @@ +use crate::search::api::{Deserialize, PayloadLevel, Serialize, Value}; + +/// Request payload for search APIs. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchRequest { + /// Tenant to search within. + pub tenant_id: String, + /// Project to search within. + pub project_id: String, + /// Agent requesting the search. + pub agent_id: String, + /// Optional auth token identifier used for role checks. + pub token_id: Option, + #[serde(default)] + /// Requested payload-detail level. + pub payload_level: PayloadLevel, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Search query text. + pub query: String, + /// Requested number of returned items. + pub top_k: Option, + /// Retrieval breadth before ranking and projection. + pub candidate_k: Option, + + /// Optional structured filter expression. + pub filter: Option, + /// When true, records note-hit metrics for returned items. + pub record_hits: Option, + /// Optional ranking-policy overrides. + pub ranking: Option, +} + +/// Ranking override bundle supplied on a search request. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RankingRequestOverride { + /// Blend-ranking override. + pub blend: Option, + /// Diversity-ranking override. + pub diversity: Option, + /// Retrieval-source weighting override. + pub retrieval_sources: Option, +} + +/// Blend-ranking override supplied on a search request. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BlendRankingOverride { + /// Enables or disables blend ranking. + pub enabled: Option, + /// Override for rerank-score normalization. + pub rerank_normalization: Option, + /// Override for retrieval-score normalization. + pub retrieval_normalization: Option, + /// Override for blend segments. + pub segments: Option>, +} + +/// One blend segment override. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BlendSegmentOverride { + /// Highest retrieval rank covered by the segment. + pub max_retrieval_rank: u32, + /// Retrieval weight applied within the segment. + pub retrieval_weight: f32, +} + +/// Diversity-ranking override supplied on a search request. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DiversityRankingOverride { + /// Enables or disables diversity selection. + pub enabled: Option, + /// Similarity threshold for duplicate suppression. + pub sim_threshold: Option, + /// MMR lambda value. + pub mmr_lambda: Option, + /// Maximum number of candidates to skip while selecting diverse results. + pub max_skips: Option, +} + +/// Retrieval-source weighting override supplied on a search request. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RetrievalSourcesRankingOverride { + /// Weight for fusion retrieval. + pub fusion_weight: Option, + /// Weight for structured-field retrieval. + pub structured_field_weight: Option, + /// Priority for fusion retrieval. + pub fusion_priority: Option, + /// Priority for structured-field retrieval. + pub structured_field_priority: Option, + /// Weight for recursive retrieval. + pub recursive_weight: Option, + /// Priority for recursive retrieval. + pub recursive_priority: Option, +} diff --git a/packages/elf-service/src/search/api/trace.rs b/packages/elf-service/src/search/api/trace.rs new file mode 100644 index 00000000..10d292db --- /dev/null +++ b/packages/elf-service/src/search/api/trace.rs @@ -0,0 +1,32 @@ +mod bundle; +mod explain; +mod get; +mod metadata; +mod recent; +mod replay; +mod trajectory; + +pub use self::{ + bundle::{TraceBundleGetRequest, TraceBundleMode, TraceBundleResponse}, + explain::{ + SearchExplainItem, SearchExplainRequest, SearchExplainResponse, SearchExplainTrajectory, + SearchExplainTrajectoryMatch, SearchExplainTrajectoryStage, + }, + get::{TraceGetRequest, TraceGetResponse, TraceTrajectoryGetRequest}, + metadata::SearchTrace, + recent::{ + RecentTraceHeader, TraceRecentCursor, TraceRecentListRequest, TraceRecentListResponse, + }, + replay::{TraceReplayCandidate, TraceReplayContext, TraceReplayItem}, + trajectory::{ + SearchTrajectoryResponse, SearchTrajectoryStage, SearchTrajectoryStageItem, + SearchTrajectorySummary, SearchTrajectorySummaryStage, + }, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::search::api::SearchExplain; diff --git a/packages/elf-service/src/search/api/trace/bundle.rs b/packages/elf-service/src/search/api/trace/bundle.rs new file mode 100644 index 00000000..680300a1 --- /dev/null +++ b/packages/elf-service/src/search/api/trace/bundle.rs @@ -0,0 +1,63 @@ +use crate::search::api::trace::{ + Deserialize, OffsetDateTime, Serialize, Uuid, + explain::SearchExplainItem, + metadata::SearchTrace, + replay::TraceReplayCandidate, + trajectory::{SearchTrajectoryStage, SearchTrajectorySummary}, +}; + +/// Request payload for loading a trace bundle. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceBundleGetRequest { + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent requesting the bundle. + pub agent_id: String, + /// Trace identifier. + pub trace_id: Uuid, + #[serde(default)] + /// Bundle mode controlling output size. + pub mode: TraceBundleMode, + + /// Optional cap for per-stage items. + pub stage_items_limit: Option, + + /// Optional cap for replay candidates. + pub candidates_limit: Option, +} + +/// Response payload for trace bundles. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceBundleResponse { + /// Response schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Bundle generation timestamp. + pub generated_at: OffsetDateTime, + /// Trace metadata. + pub trace: SearchTrace, + /// Explained items from the trace. + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional condensed trajectory summary. + pub trajectory_summary: Option, + /// Full trajectory stages. + pub stages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional replay candidates. + pub candidates: Option>, +} + +/// Bundle-size mode for trace exports. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum TraceBundleMode { + #[default] + /// Return the bounded default export. + Bounded, + /// Return the full export. + Full, +} diff --git a/packages/elf-service/src/search/api/trace/explain.rs b/packages/elf-service/src/search/api/trace/explain.rs new file mode 100644 index 00000000..e83bdf1c --- /dev/null +++ b/packages/elf-service/src/search/api/trace/explain.rs @@ -0,0 +1,81 @@ +use crate::search::api::trace::{ + Deserialize, SearchExplain, Serialize, Uuid, Value, metadata::SearchTrace, +}; + +/// Request payload for loading one item-level explanation. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainRequest { + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent requesting the explain payload. + pub agent_id: String, + /// Result-handle identifier returned by search. + pub result_handle: Uuid, +} + +/// Item-level explain trajectory. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainTrajectory { + /// Trajectory schema identifier. + pub schema: String, + /// Ordered explain stages. + pub stages: Vec, +} + +/// One stage in an item-level explain trajectory. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainTrajectoryStage { + /// Zero-based stage order. + pub stage_order: u32, + /// Stable stage name. + pub stage_name: String, + /// Stage-level payload. + pub stage_payload: Value, + /// Per-item metrics. + pub metrics: Value, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional match information for the selected item. + pub match_info: Option, +} + +/// Match reference for one explain trajectory stage. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainTrajectoryMatch { + /// Match kind label. + pub kind: String, + /// Stage-item identifier, when persisted. + pub item_id: Option, + /// Note identifier, when applicable. + pub note_id: Option, + /// Chunk identifier, when applicable. + pub chunk_id: Option, +} + +/// Explain payload for one ranked search item. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainItem { + /// Stable result-handle identifier. + pub result_handle: Uuid, + /// Note identifier. + pub note_id: Uuid, + /// Chunk identifier, when applicable. + pub chunk_id: Option, + /// 1-based final rank. + pub rank: u32, + /// Item-level explanation payload. + pub explain: SearchExplain, +} + +/// Response payload for item-level explanations. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchExplainResponse { + /// Trace metadata. + pub trace: SearchTrace, + /// Explained item payload. + pub item: SearchExplainItem, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional explain trajectory. + pub trajectory: Option, +} diff --git a/packages/elf-service/src/search/api/trace/get.rs b/packages/elf-service/src/search/api/trace/get.rs new file mode 100644 index 00000000..8560fe89 --- /dev/null +++ b/packages/elf-service/src/search/api/trace/get.rs @@ -0,0 +1,42 @@ +use crate::search::api::trace::{ + Deserialize, Serialize, Uuid, explain::SearchExplainItem, metadata::SearchTrace, + trajectory::SearchTrajectorySummary, +}; + +/// Request payload for loading trace metadata and items. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceGetRequest { + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent requesting the trace. + pub agent_id: String, + /// Trace identifier. + pub trace_id: Uuid, +} + +/// Request payload for loading full trajectory stages. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceTrajectoryGetRequest { + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent requesting the trajectory. + pub agent_id: String, + /// Trace identifier. + pub trace_id: Uuid, +} + +/// Response payload for trace metadata and explained items. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceGetResponse { + /// Trace metadata. + pub trace: SearchTrace, + /// Explained items from the trace. + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional condensed trajectory summary. + pub trajectory_summary: Option, +} diff --git a/packages/elf-service/src/search/api/trace/metadata.rs b/packages/elf-service/src/search/api/trace/metadata.rs new file mode 100644 index 00000000..1b6b58cc --- /dev/null +++ b/packages/elf-service/src/search/api/trace/metadata.rs @@ -0,0 +1,35 @@ +use crate::search::api::trace::{Deserialize, OffsetDateTime, Serialize, Uuid, Value}; + +/// Search trace metadata persisted for one search run. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTrace { + /// Search trace identifier. + pub trace_id: Uuid, + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent that ran the search. + pub agent_id: String, + /// Read profile used for the search. + pub read_profile: String, + /// Search query text. + pub query: String, + /// Expansion mode label. + pub expansion_mode: String, + /// Expanded query strings. + pub expanded_queries: Vec, + /// Scopes allowed by the read profile. + pub allowed_scopes: Vec, + /// Candidate count observed by the search. + pub candidate_count: u32, + /// Top-k budget used by the search. + pub top_k: u32, + /// Config snapshot captured for the trace. + pub config_snapshot: Value, + #[serde(with = "crate::time_serde")] + /// Trace creation timestamp. + pub created_at: OffsetDateTime, + /// Trace schema version. + pub trace_version: i32, +} diff --git a/packages/elf-service/src/search/api/trace/recent.rs b/packages/elf-service/src/search/api/trace/recent.rs new file mode 100644 index 00000000..0ac642ca --- /dev/null +++ b/packages/elf-service/src/search/api/trace/recent.rs @@ -0,0 +1,75 @@ +use crate::search::api::trace::{Deserialize, OffsetDateTime, Serialize, Uuid}; + +/// Request payload for listing recent traces. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceRecentListRequest { + /// Tenant that owns the traces. + pub tenant_id: String, + /// Project that owns the traces. + pub project_id: String, + /// Agent requesting the list. + pub agent_id: String, + + /// Maximum number of traces to return. + pub limit: Option, + + /// Cursor creation timestamp for pagination. + pub cursor_created_at: Option, + + /// Cursor trace identifier for pagination. + pub cursor_trace_id: Option, + + /// Optional agent filter. + pub agent_id_filter: Option, + + /// Optional read-profile filter. + pub read_profile: Option, + #[serde(with = "crate::time_serde::option")] + /// Optional lower bound for trace creation time. + pub created_after: Option, + #[serde(with = "crate::time_serde::option")] + /// Optional upper bound for trace creation time. + pub created_before: Option, +} + +/// Header row returned by recent-trace listing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RecentTraceHeader { + /// Trace identifier. + pub trace_id: Uuid, + /// Tenant that owns the trace. + pub tenant_id: String, + /// Project that owns the trace. + pub project_id: String, + /// Agent that ran the trace. + pub agent_id: String, + /// Read profile used for the trace. + pub read_profile: String, + /// Search query text. + pub query: String, + #[serde(with = "crate::time_serde")] + /// Trace creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Pagination cursor returned by recent-trace listing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceRecentCursor { + #[serde(with = "crate::time_serde")] + /// Cursor creation timestamp. + pub created_at: OffsetDateTime, + /// Cursor trace identifier. + pub trace_id: Uuid, +} + +/// Response payload for recent-trace listing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceRecentListResponse { + /// Response schema identifier. + pub schema: String, + /// Returned trace headers. + pub traces: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Cursor for the next page, when more results remain. + pub next_cursor: Option, +} diff --git a/packages/elf-service/src/search/api/trace/replay.rs b/packages/elf-service/src/search/api/trace/replay.rs new file mode 100644 index 00000000..2e2162c5 --- /dev/null +++ b/packages/elf-service/src/search/api/trace/replay.rs @@ -0,0 +1,80 @@ +use crate::search::api::trace::{Deserialize, OffsetDateTime, SearchExplain, Serialize, Uuid}; + +/// Context needed to replay ranking against stored candidates. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceReplayContext { + /// Trace identifier. + pub trace_id: Uuid, + /// Search query text. + pub query: String, + /// Candidate count observed during the trace. + pub candidate_count: u32, + /// Top-k budget used during the trace. + pub top_k: u32, + #[serde(with = "crate::time_serde")] + /// Trace creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Candidate row used for replaying ranking offline. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceReplayCandidate { + /// Note identifier. + pub note_id: Uuid, + /// Chunk identifier. + pub chunk_id: Uuid, + /// Zero-based chunk position. + pub chunk_index: i32, + /// Candidate snippet text. + pub snippet: String, + /// 1-based retrieval rank. + pub retrieval_rank: u32, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional merged retrieval score captured before rerank. + pub retrieval_score: Option, + /// Raw rerank-model score. + pub rerank_score: f32, + /// Scope key for the note. + pub note_scope: String, + /// Note importance score. + pub note_importance: f32, + #[serde(with = "crate::time_serde")] + /// Note last-update timestamp. + pub note_updated_at: OffsetDateTime, + /// Note hit counter. + pub note_hit_count: i64, + #[serde(with = "crate::time_serde::option")] + /// Timestamp of the note's most recent hit. + pub note_last_hit_at: Option, + /// Whether the candidate was selected by diversity ranking. + pub diversity_selected: Option, + /// Final selected rank under diversity ranking. + pub diversity_selected_rank: Option, + /// Reason the candidate was selected by diversity ranking. + pub diversity_selected_reason: Option, + /// Reason the candidate was skipped by diversity ranking. + pub diversity_skipped_reason: Option, + /// Nearest selected note that influenced the diversity decision. + pub diversity_nearest_selected_note_id: Option, + /// Similarity to the nearest selected note. + pub diversity_similarity: Option, + /// MMR score used for diversity selection. + pub diversity_mmr_score: Option, + /// Whether the candidate lacked an embedding for diversity scoring. + pub diversity_missing_embedding: Option, +} + +/// Final replayed ranking item. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TraceReplayItem { + /// Note identifier. + pub note_id: Uuid, + /// Chunk identifier. + pub chunk_id: Uuid, + /// 1-based retrieval rank. + pub retrieval_rank: u32, + /// Final replayed score. + pub final_score: f32, + /// Recomputed explanation payload. + pub explain: SearchExplain, +} diff --git a/packages/elf-service/src/search/api/trace/trajectory.rs b/packages/elf-service/src/search/api/trace/trajectory.rs new file mode 100644 index 00000000..c1fd7fce --- /dev/null +++ b/packages/elf-service/src/search/api/trace/trajectory.rs @@ -0,0 +1,60 @@ +use crate::search::api::trace::{Deserialize, Serialize, Uuid, Value, metadata::SearchTrace}; + +/// Condensed search-trajectory explanation. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTrajectorySummary { + /// Summary schema identifier. + pub schema: String, + /// Ordered summary stages. + pub stages: Vec, +} + +/// One stage in a condensed search trajectory. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTrajectorySummaryStage { + /// Zero-based stage order. + pub stage_order: u32, + /// Stable stage name. + pub stage_name: String, + /// Number of items after the stage. + pub item_count: u32, + /// Free-form stage statistics. + pub stats: Value, +} + +/// One full search-trajectory stage. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTrajectoryStage { + /// Zero-based stage order. + pub stage_order: u32, + /// Stable stage name. + pub stage_name: String, + /// Stage-level payload. + pub stage_payload: Value, + /// Item rows for the stage. + pub items: Vec, +} + +/// One item row inside a search-trajectory stage. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTrajectoryStageItem { + /// Stage-item identifier, when persisted. + pub item_id: Option, + /// Note identifier, when applicable. + pub note_id: Option, + /// Chunk identifier, when applicable. + pub chunk_id: Option, + /// Free-form per-item metrics. + pub metrics: Value, +} + +/// Full search-trajectory response. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SearchTrajectoryResponse { + /// Trace metadata. + pub trace: SearchTrace, + /// Condensed trajectory summary. + pub trajectory: SearchTrajectorySummary, + /// Full trajectory stages. + pub stages: Vec, +} diff --git a/packages/elf-service/src/search/cache.rs b/packages/elf-service/src/search/cache.rs new file mode 100644 index 00000000..0c81c288 --- /dev/null +++ b/packages/elf-service/src/search/cache.rs @@ -0,0 +1,100 @@ +use crate::{ + Error, + search::{CacheKind, CachePayload, OffsetDateTime, PgExecutor, Result, Uuid, Value}, +}; + +pub(super) async fn fetch_cache_payload<'e, E>( + executor: E, + kind: CacheKind, + key: &str, + now: OffsetDateTime, +) -> Result> +where + E: PgExecutor<'e>, +{ + let payload: Option = sqlx::query_scalar( + "\ +WITH updated AS ( + UPDATE llm_cache + SET + last_accessed_at = $3, + hit_count = hit_count + 1 + WHERE + cache_kind = $1 + AND cache_key = $2 + AND expires_at > $3 + RETURNING payload +) + SELECT payload +FROM updated", + ) + .bind(kind.as_str()) + .bind(key) + .bind(now) + .fetch_optional(executor) + .await?; + let Some(payload) = payload else { + return Ok(None); + }; + let size_bytes = serde_json::to_vec(&payload) + .map_err(|err| Error::Storage { + message: format!("Failed to encode cache payload: {err}"), + })? + .len(); + + Ok(Some(CachePayload { value: payload, size_bytes })) +} + +pub(super) async fn store_cache_payload<'e, E>( + executor: E, + kind: CacheKind, + key: &str, + payload: Value, + now: OffsetDateTime, + expires_at: OffsetDateTime, + max_payload_bytes: Option, +) -> Result> +where + E: PgExecutor<'e>, +{ + let payload_bytes = serde_json::to_vec(&payload).map_err(|err| Error::Storage { + message: format!("Failed to encode cache payload: {err}"), + })?; + let payload_size = payload_bytes.len(); + + if let Some(max) = max_payload_bytes + && payload_size as u64 > max + { + return Ok(None); + } + + sqlx::query( + "\ + INSERT INTO llm_cache ( + cache_id, + cache_kind, + cache_key, + payload, + created_at, + last_accessed_at, + expires_at, + hit_count +) +VALUES ($1, $2, $3, $4, $5, $5, $6, 0) +ON CONFLICT (cache_kind, cache_key) DO UPDATE SET +payload = EXCLUDED.payload, + last_accessed_at = EXCLUDED.last_accessed_at, + expires_at = EXCLUDED.expires_at, + hit_count = 0", + ) + .bind(Uuid::new_v4()) + .bind(kind.as_str()) + .bind(key) + .bind(payload) + .bind(now) + .bind(expires_at) + .execute(executor) + .await?; + + Ok(Some(payload_size)) +} diff --git a/packages/elf-service/src/search/db_helpers.rs b/packages/elf-service/src/search/db_helpers.rs new file mode 100644 index 00000000..ff0d2581 --- /dev/null +++ b/packages/elf-service/src/search/db_helpers.rs @@ -0,0 +1,89 @@ +use crate::search::{ + ChunkRow, HashMap, HashSet, NoteVectorRow, PgExecutor, QueryBuilder, Result, ScoredChunk, Uuid, +}; + +pub(super) async fn fetch_chunks_by_pair<'e, E>( + executor: E, + pairs: &[(Uuid, i32)], +) -> Result> +where + E: PgExecutor<'e>, +{ + if pairs.is_empty() { + return Ok(Vec::new()); + } + + let mut builder = QueryBuilder::new( + "SELECT chunk_id, note_id, chunk_index, start_offset, end_offset, text \ + FROM memory_note_chunks WHERE ", + ); + let mut separated = builder.separated(" OR "); + + for (note_id, chunk_index) in pairs { + separated.push("("); + separated + .push_unseparated("note_id = ") + .push_bind_unseparated(note_id) + .push_unseparated(" AND chunk_index = ") + .push_bind_unseparated(chunk_index) + .push_unseparated(")"); + } + + let query = builder.build_query_as(); + let rows = query.fetch_all(executor).await?; + + Ok(rows) +} + +pub(super) async fn fetch_note_vectors_for_diversity<'e, E>( + executor: E, + scored: &[ScoredChunk], +) -> Result>> +where + E: PgExecutor<'e>, +{ + if scored.is_empty() { + return Ok(HashMap::new()); + } + + let mut note_ids = Vec::new(); + let mut embedding_versions = Vec::new(); + let mut seen = HashSet::new(); + + for scored_chunk in scored { + let note_id = scored_chunk.item.note.note_id; + + if seen.insert(note_id) { + note_ids.push(note_id); + embedding_versions.push(scored_chunk.item.note.embedding_version.clone()); + } + } + + let rows = sqlx::query_as::<_, NoteVectorRow>( + "\ +WITH expected AS ( + SELECT * + FROM unnest($1::uuid[], $2::text[]) AS t(note_id, embedding_version) +) +SELECT + e.note_id, + n.vec::text AS vec_text +FROM expected e +JOIN note_embeddings n + ON n.note_id = e.note_id + AND n.embedding_version = e.embedding_version", + ) + .bind(note_ids.as_slice()) + .bind(embedding_versions.as_slice()) + .fetch_all(executor) + .await?; + let mut out = HashMap::new(); + + for row in rows { + let vec = crate::parse_pg_vector(row.vec_text.as_str())?; + + out.insert(row.note_id, vec); + } + + Ok(out) +} diff --git a/packages/elf-service/src/search/filter.rs b/packages/elf-service/src/search/filter.rs index 7e94077e..e8604481 100644 --- a/packages/elf-service/src/search/filter.rs +++ b/packages/elf-service/src/search/filter.rs @@ -1,1133 +1,8 @@ -use std::{ - cmp::Ordering, - collections::HashMap, - fmt::{Display, Formatter}, -}; +mod expr; +mod impact; +mod parser; +mod value; -use serde::Serialize; -use serde_json::{Map, Value}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use uuid::Uuid; +pub(crate) use self::{impact::SearchFilterImpact, parser::SearchFilter}; -use crate::search::{ChunkCandidate, NoteMeta, SEARCH_FILTER_IMPACT_SCHEMA_V1}; - -const SEARCH_FILTER_EXPR_SCHEMA_V1: &str = "search_filter_expr/v1"; -const MAX_FILTER_DEPTH: usize = 8; -const MAX_FILTER_NODES: usize = 128; -const MAX_IN_LIST_ITEMS: usize = 128; -const MAX_STRING_BYTES: usize = 512; - -#[derive(Clone, Debug)] -pub(crate) struct FilterParseError { - path: String, - message: String, -} -impl Display for FilterParseError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.path, self.message) - } -} - -#[derive(Clone, Debug)] -pub(crate) struct SearchFilter { - expr: FilterExpr, - json: Value, -} -impl SearchFilter { - fn as_value(&self) -> Value { - self.json.clone() - } - - fn evaluate(&self, note: &NoteMeta) -> (bool, Option) { - self.expr.evaluate(note) - } - - pub(crate) fn parse(raw: &Value) -> Result { - let path = "$.filter"; - let obj = raw.as_object().ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "filter must be an object.".to_string(), - })?; - let schema = obj.get("schema").and_then(Value::as_str).ok_or_else(|| FilterParseError { - path: format!("{path}.schema"), - message: "filter.schema is required.".to_string(), - })?; - - if schema != SEARCH_FILTER_EXPR_SCHEMA_V1 { - return Err(FilterParseError { - path: format!("{path}.schema"), - message: format!( - "unsupported filter schema '{schema}', expected '{SEARCH_FILTER_EXPR_SCHEMA_V1}'." - ), - }); - } - - let expr = obj.get("expr").ok_or_else(|| FilterParseError { - path: format!("{path}.expr"), - message: "filter.expr is required.".to_string(), - })?; - let mut state = FilterParseState::default(); - let parsed = parse_expr(expr, "$.filter.expr", 1, &mut state)?; - - Ok(Self { - expr: parsed.clone(), - json: serde_json::json!({"schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": parsed.to_value()}), - }) - } - - pub(crate) fn eval( - &self, - candidates: Vec, - note_meta: &HashMap, - requested_candidate_k: u32, - effective_candidate_k: u32, - ) -> (Vec, SearchFilterImpact) { - let impact = SearchFilterImpact::from_eval( - self, - candidates.as_slice(), - note_meta, - requested_candidate_k, - effective_candidate_k, - ); - let pre = candidates.len(); - let mut kept = Vec::with_capacity(impact.candidate_count_post); - - for candidate in candidates { - let Some(note) = note_meta.get(&candidate.note_id) else { - continue; - }; - - if self.expr.evaluate(note).0 { - kept.push(candidate); - } - } - - let post = kept.len(); - - ( - kept, - SearchFilterImpact { - candidate_count_post: post, - dropped_total: pre.saturating_sub(post), - ..impact - }, - ) - } -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct SearchFilterImpact { - requested_candidate_k: u32, - effective_candidate_k: u32, - candidate_count_pre: usize, - candidate_count_post: usize, - dropped_total: usize, - top_drop_reasons: Vec, - filter: Value, -} -impl SearchFilterImpact { - pub(crate) fn from_eval( - filter: &SearchFilter, - note_candidates: &[ChunkCandidate], - note_meta: &HashMap, - requested_candidate_k: u32, - effective_candidate_k: u32, - ) -> Self { - let pre = note_candidates.len(); - let mut kept: Vec = Vec::new(); - let mut dropped_reason_counts: HashMap = HashMap::new(); - - for candidate in note_candidates { - let Some(note) = note_meta.get(&candidate.note_id) else { - dropped_reason_counts - .entry("note_meta_missing".to_string()) - .and_modify(|count| *count += 1) - .or_insert(1); - - continue; - }; - let (keep, reason) = filter.evaluate(note); - - if keep { - kept.push(candidate.clone()); - } else { - dropped_reason_counts - .entry(reason.unwrap_or_else(|| "filter.no_match".to_string())) - .and_modify(|count| *count += 1) - .or_insert(1); - } - } - - let mut top_drop_reasons: Vec<_> = dropped_reason_counts - .into_iter() - .map(|(reason, count)| SearchFilterDropReason { reason, count }) - .collect(); - - top_drop_reasons.sort_by(|a, b| match b.count.cmp(&a.count) { - Ordering::Equal => a.reason.cmp(&b.reason), - other => other, - }); - top_drop_reasons.truncate(5); - - let post = kept.len(); - - Self { - requested_candidate_k, - effective_candidate_k, - candidate_count_pre: pre, - candidate_count_post: post, - dropped_total: pre.saturating_sub(post), - top_drop_reasons, - filter: filter.as_value(), - } - } - - pub(crate) fn to_stage_payload(&self) -> Value { - serde_json::json!({ - "schema": SEARCH_FILTER_IMPACT_SCHEMA_V1, - "requested_candidate_k": self.requested_candidate_k, - "effective_candidate_k": self.effective_candidate_k, - "candidate_count_pre": self.candidate_count_pre, - "candidate_count_post": self.candidate_count_post, - "dropped_total": self.dropped_total, - "top_drop_reasons": self.top_drop_reasons, - "filter": self.filter, - }) - } -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct SearchFilterDropReason { - reason: String, - count: usize, -} - -#[derive(Default)] -struct FilterParseState { - nodes: usize, - max_depth: usize, -} - -#[derive(Clone, Debug)] -enum FilterField { - Type, - Key, - Scope, - AgentId, - Importance, - Confidence, - UpdatedAt, - ExpiresAt, - HitCount, - LastHitAt, -} -impl FilterField { - fn as_str(&self) -> &'static str { - match self { - Self::Type => "type", - Self::Key => "key", - Self::Scope => "scope", - Self::AgentId => "agent_id", - Self::Importance => "importance", - Self::Confidence => "confidence", - Self::UpdatedAt => "updated_at", - Self::ExpiresAt => "expires_at", - Self::HitCount => "hit_count", - Self::LastHitAt => "last_hit_at", - } - } - - fn parse(path: &str, raw: &Value) -> Result { - let field = raw - .as_str() - .ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "filter field must be a string.".to_string(), - })? - .to_ascii_lowercase(); - - match field.as_str() { - "type" => Ok(Self::Type), - "key" => Ok(Self::Key), - "scope" => Ok(Self::Scope), - "agent_id" => Ok(Self::AgentId), - "importance" => Ok(Self::Importance), - "confidence" => Ok(Self::Confidence), - "updated_at" => Ok(Self::UpdatedAt), - "expires_at" => Ok(Self::ExpiresAt), - "hit_count" => Ok(Self::HitCount), - "last_hit_at" => Ok(Self::LastHitAt), - _ => Err(FilterParseError { - path: path.to_string(), - message: format!( - "field '{}' is not in allowlist: type, key, scope, agent_id, importance, confidence, updated_at, expires_at, hit_count, last_hit_at", - field, - ), - }), - } - } - - fn lookup_note_value(&self, note: &NoteMeta) -> FilterNodeValue { - FilterExpr::lookup_note_value(self, note) - } -} - -#[derive(Clone, Debug)] -enum FilterExpr { - And(Vec), - Or(Vec), - Not(Box), - Eq { field: FilterField, value: FilterValue }, - Neq { field: FilterField, value: FilterValue }, - In { field: FilterField, values: Vec }, - Contains { field: FilterField, value: String }, - Gt { field: FilterField, value: FilterValue }, - Gte { field: FilterField, value: FilterValue }, - Lt { field: FilterField, value: FilterValue }, - Lte { field: FilterField, value: FilterValue }, -} -impl FilterExpr { - fn to_value(&self) -> Value { - match self { - Self::And(exprs) => { - serde_json::json!({ "op": "and", "args": Value::Array(exprs.iter().map(Self::to_value).collect()) }) - }, - Self::Or(exprs) => { - serde_json::json!({ "op": "or", "args": Value::Array(exprs.iter().map(Self::to_value).collect()) }) - }, - Self::Not(expr) => { - serde_json::json!({ "op": "not", "expr": expr.to_value() }) - }, - Self::Eq { field, value } => { - serde_json::json!({ "op": "eq", "field": field.as_str(), "value": value.to_value() }) - }, - Self::Neq { field, value } => { - serde_json::json!({ "op": "neq", "field": field.as_str(), "value": value.to_value() }) - }, - Self::In { field, values } => { - serde_json::json!({ - "op": "in", - "field": field.as_str(), - "value": Value::Array(values.iter().map(FilterValue::to_value).collect()) - }) - }, - Self::Contains { field, value } => { - serde_json::json!({ "op": "contains", "field": field.as_str(), "value": value }) - }, - Self::Gt { field, value } => { - serde_json::json!({ "op": "gt", "field": field.as_str(), "value": value.to_value() }) - }, - Self::Gte { field, value } => { - serde_json::json!({ "op": "gte", "field": field.as_str(), "value": value.to_value() }) - }, - Self::Lt { field, value } => { - serde_json::json!({ "op": "lt", "field": field.as_str(), "value": value.to_value() }) - }, - Self::Lte { field, value } => { - serde_json::json!({ "op": "lte", "field": field.as_str(), "value": value.to_value() }) - }, - } - } - - fn evaluate(&self, note: &NoteMeta) -> (bool, Option) { - match self { - Self::And(nodes) => Self::evaluate_and(nodes, note), - Self::Or(nodes) => Self::evaluate_or(nodes, note), - Self::Not(node) => Self::evaluate_not(node, note), - Self::Eq { field, value } => Self::evaluate_eq(field, value, note), - Self::Neq { field, value } => Self::evaluate_neq(field, value, note), - Self::In { field, values } => Self::evaluate_in(field, values, note), - Self::Contains { field, value } => Self::evaluate_contains(field, value, note), - Self::Gt { field, value } => Self::evaluate_gt(field, value, note), - Self::Gte { field, value } => Self::evaluate_gte(field, value, note), - Self::Lt { field, value } => Self::evaluate_lt(field, value, note), - Self::Lte { field, value } => Self::evaluate_lte(field, value, note), - } - } - - fn evaluate_and(nodes: &[Self], note: &NoteMeta) -> (bool, Option) { - for node in nodes { - let (passed, reason) = node.evaluate(note); - - if !passed { - return (false, reason); - } - } - - (true, None) - } - - fn evaluate_or(nodes: &[Self], note: &NoteMeta) -> (bool, Option) { - let mut first_reason = None; - - for node in nodes { - let (passed, reason) = node.evaluate(note); - - if passed { - return (true, None); - } - if first_reason.is_none() { - first_reason = reason; - } - } - - (false, first_reason.or_else(|| Some("or.no_match".to_string()))) - } - - fn evaluate_not(node: &Self, note: &NoteMeta) -> (bool, Option) { - let (passed, reason) = node.evaluate(note); - - if passed { (false, Some("not.true".to_string())) } else { (true, reason) } - } - - fn evaluate_eq( - field: &FilterField, - value: &FilterValue, - note: &NoteMeta, - ) -> (bool, Option) { - let note_value = field.lookup_note_value(note); - let filter_value = value.to_node_value(); - let matches = note_value == filter_value; - - (matches, Some(format!("eq:{}", field.as_str())).filter(|_| !matches)) - } - - fn evaluate_neq( - field: &FilterField, - value: &FilterValue, - note: &NoteMeta, - ) -> (bool, Option) { - let note_value = field.lookup_note_value(note); - let filter_value = value.to_node_value(); - let matches = note_value != filter_value; - - (matches, Some(format!("neq:{}", field.as_str())).filter(|_| !matches)) - } - - fn evaluate_in( - field: &FilterField, - values: &[FilterValue], - note: &NoteMeta, - ) -> (bool, Option) { - let note_value = field.lookup_note_value(note); - let matches = values.iter().any(|value| note_value == FilterNodeValue::from(value)); - - (matches, Some(format!("in:{}", field.as_str())).filter(|_| !matches)) - } - - fn evaluate_contains( - field: &FilterField, - value: &str, - note: &NoteMeta, - ) -> (bool, Option) { - let note_value = field.lookup_note_value(note); - let note_text = match note_value { - FilterNodeValue::String(s) => s, - _ => { - return (false, Some(format!("contains:{}", field.as_str()))); - }, - }; - let matches = note_text.contains(value); - - (matches, Some(format!("contains:{}", field.as_str())).filter(|_| !matches)) - } - - fn evaluate_gt( - field: &FilterField, - value: &FilterValue, - note: &NoteMeta, - ) -> (bool, Option) { - match field.lookup_note_value(note) { - FilterNodeValue::Number(note_value) => { - let matches = note_value > value.to_numeric(); - - (matches, Some(format!("gt:{}", field.as_str())).filter(|_| !matches)) - }, - FilterNodeValue::DateTime(note_value) => { - let matches = match value { - FilterValue::DateTime(filter_value) => note_value > *filter_value, - _ => false, - }; - - (matches, Some(format!("gt:{}", field.as_str())).filter(|_| !matches)) - }, - _ => (false, Some(format!("gt:{}", field.as_str()))), - } - } - - fn evaluate_gte( - field: &FilterField, - value: &FilterValue, - note: &NoteMeta, - ) -> (bool, Option) { - match field.lookup_note_value(note) { - FilterNodeValue::Number(note_value) => { - let matches = note_value >= value.to_numeric(); - - (matches, Some(format!("gte:{}", field.as_str())).filter(|_| !matches)) - }, - FilterNodeValue::DateTime(note_value) => { - let matches = match value { - FilterValue::DateTime(filter_value) => note_value >= *filter_value, - _ => false, - }; - - (matches, Some(format!("gte:{}", field.as_str())).filter(|_| !matches)) - }, - _ => (false, Some(format!("gte:{}", field.as_str()))), - } - } - - fn evaluate_lt( - field: &FilterField, - value: &FilterValue, - note: &NoteMeta, - ) -> (bool, Option) { - match field.lookup_note_value(note) { - FilterNodeValue::Number(note_value) => { - let matches = note_value < value.to_numeric(); - - (matches, Some(format!("lt:{}", field.as_str())).filter(|_| !matches)) - }, - FilterNodeValue::DateTime(note_value) => { - let matches = match value { - FilterValue::DateTime(filter_value) => note_value < *filter_value, - _ => false, - }; - - (matches, Some(format!("lt:{}", field.as_str())).filter(|_| !matches)) - }, - _ => (false, Some(format!("lt:{}", field.as_str()))), - } - } - - fn evaluate_lte( - field: &FilterField, - value: &FilterValue, - note: &NoteMeta, - ) -> (bool, Option) { - match field.lookup_note_value(note) { - FilterNodeValue::Number(note_value) => { - let matches = note_value <= value.to_numeric(); - - (matches, Some(format!("lte:{}", field.as_str())).filter(|_| !matches)) - }, - FilterNodeValue::DateTime(note_value) => { - let matches = match value { - FilterValue::DateTime(filter_value) => note_value <= *filter_value, - _ => false, - }; - - (matches, Some(format!("lte:{}", field.as_str())).filter(|_| !matches)) - }, - _ => (false, Some(format!("lte:{}", field.as_str()))), - } - } - - fn lookup_note_value(field: &FilterField, note: &NoteMeta) -> FilterNodeValue { - match field { - FilterField::Type => FilterNodeValue::String(note.note_type.clone()), - FilterField::Key => FilterNodeValue::String(note.key.clone().unwrap_or_default()), - FilterField::Scope => FilterNodeValue::String(note.scope.clone()), - FilterField::AgentId => FilterNodeValue::String(note.agent_id.clone()), - FilterField::Importance => FilterNodeValue::Number(note.importance as f64), - FilterField::Confidence => FilterNodeValue::Number(note.confidence as f64), - FilterField::HitCount => FilterNodeValue::Number(note.hit_count as f64), - FilterField::UpdatedAt => FilterNodeValue::DateTime(note.updated_at), - FilterField::ExpiresAt => - note.expires_at.map_or(FilterNodeValue::Null, FilterNodeValue::DateTime), - FilterField::LastHitAt => - note.last_hit_at.map_or(FilterNodeValue::Null, FilterNodeValue::DateTime), - } - } - - fn parse_args( - value: &Value, - path: &str, - depth: usize, - state: &mut FilterParseState, - ) -> Result, FilterParseError> { - let nodes = value.as_array().ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "op args must be an array.".to_string(), - })?; - - if nodes.is_empty() { - return Err(FilterParseError { - path: path.to_string(), - message: "op args must contain at least one node.".to_string(), - }); - } - - nodes - .iter() - .enumerate() - .map(|(index, node)| { - let child_path = format!("{path}[{index}]"); - - parse_expr(node, &child_path, depth.saturating_add(1), state) - }) - .collect() - } - - fn parse_in_values( - field: &FilterField, - value: &Value, - path: &str, - ) -> Result, FilterParseError> { - let values = value.as_array().ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "in value must be an array.".to_string(), - })?; - - if values.len() > MAX_IN_LIST_ITEMS { - return Err(FilterParseError { - path: path.to_string(), - message: format!( - "in list exceeds maximum size ({}/{})", - values.len(), - MAX_IN_LIST_ITEMS - ), - }); - } - - values - .iter() - .enumerate() - .map(|(index, raw)| { - let item_path = format!("{path}[{index}]"); - - parse_value(field, raw, &item_path) - }) - .collect() - } - - fn validate_metrics( - path: &str, - depth: usize, - state: &mut FilterParseState, - ) -> Result<(), FilterParseError> { - state.nodes = state.nodes.saturating_add(1); - state.max_depth = state.max_depth.max(depth); - - if state.nodes > MAX_FILTER_NODES { - return Err(FilterParseError { - path: path.to_string(), - message: format!( - "filter exceeds node limit ({}/{})", - state.nodes, MAX_FILTER_NODES - ), - }); - } - if state.max_depth > MAX_FILTER_DEPTH { - return Err(FilterParseError { - path: path.to_string(), - message: format!( - "filter exceeds depth limit ({}/{})", - state.max_depth, MAX_FILTER_DEPTH - ), - }); - } - - Ok(()) - } - - fn parse_leaf( - raw: &Map, - op: &str, - path: &str, - ) -> Result { - let field = FilterField::parse( - &format!("{path}.field"), - raw.get("field").ok_or_else(|| FilterParseError { - path: format!("{path}.field"), - message: "op node is missing required field 'field'.".to_string(), - })?, - )?; - let path_value = format!("{path}.value"); - let value_raw = raw.get("value").ok_or_else(|| FilterParseError { - path: format!("{path}.value"), - message: "op node is missing required field 'value'.".to_string(), - })?; - let value = parse_value(&field, value_raw, &path_value)?; - - match op { - "eq" => Ok(Self::Eq { field, value }), - "neq" => Ok(Self::Neq { field, value }), - "contains" => match value { - FilterValue::String(value) => Ok(Self::Contains { field, value }), - _ => Err(FilterParseError { - path: path_value, - message: "contains requires a string value.".to_string(), - }), - }, - "gt" => Ok(Self::Gt { field, value }), - "gte" => Ok(Self::Gte { field, value }), - "lt" => Ok(Self::Lt { field, value }), - "lte" => Ok(Self::Lte { field, value }), - "in" => { - let values = Self::parse_in_values(&field, value_raw, &path_value)?; - - Ok(Self::In { field, values }) - }, - _ => Err(FilterParseError { - path: path.to_string(), - message: format!("unsupported leaf op '{op}'."), - }), - } - } -} - -impl Default for FilterExpr { - fn default() -> Self { - Self::Eq { field: FilterField::Type, value: FilterValue::Null } - } -} - -#[derive(Clone, Debug)] -enum FilterValue { - String(String), - Number(f64), - DateTime(OffsetDateTime), - Null, -} -impl FilterValue { - fn to_node_value(&self) -> FilterNodeValue { - match self { - Self::String(value) => FilterNodeValue::String(value.clone()), - Self::Number(value) => FilterNodeValue::Number(*value), - Self::DateTime(value) => FilterNodeValue::DateTime(*value), - Self::Null => FilterNodeValue::Null, - } - } - - fn to_value(&self) -> Value { - match self { - Self::String(value) => Value::String(value.clone()), - Self::Number(value) => serde_json::json!(value), - Self::DateTime(value) => Value::String(value.format(&Rfc3339).unwrap_or_default()), - Self::Null => Value::Null, - } - } - - fn to_numeric(&self) -> f64 { - match self { - Self::Number(value) => *value, - _ => 0.0, - } - } -} - -impl PartialEq for FilterValue { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::String(lhs), Self::String(rhs)) => lhs == rhs, - (Self::Number(lhs), Self::Number(rhs)) => lhs == rhs, - (Self::DateTime(lhs), Self::DateTime(rhs)) => lhs == rhs, - (Self::Null, Self::Null) => true, - _ => false, - } - } -} - -#[derive(Clone, Debug)] -enum FilterNodeValue { - String(String), - Number(f64), - DateTime(OffsetDateTime), - Null, -} -impl From<&FilterValue> for FilterNodeValue { - fn from(value: &FilterValue) -> Self { - match value { - FilterValue::String(value) => Self::String(value.clone()), - FilterValue::Number(value) => Self::Number(*value), - FilterValue::DateTime(value) => Self::DateTime(*value), - FilterValue::Null => Self::Null, - } - } -} - -impl PartialEq for FilterNodeValue { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::String(lhs), Self::String(rhs)) => lhs == rhs, - (Self::Number(lhs), Self::Number(rhs)) => lhs == rhs, - (Self::DateTime(lhs), Self::DateTime(rhs)) => lhs == rhs, - (Self::Null, Self::Null) => true, - _ => false, - } - } -} - -fn parse_expr( - value: &Value, - path: &str, - depth: usize, - state: &mut FilterParseState, -) -> Result { - FilterExpr::validate_metrics(path, depth, state)?; - - let Some(map) = value.as_object() else { - return Err(FilterParseError { - path: path.to_string(), - message: "filter node must be an object.".to_string(), - }); - }; - let op = map.get("op").and_then(Value::as_str).ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "filter node is missing required string op.".to_string(), - })?; - - match op { - "and" => { - let args = map.get("args").ok_or_else(|| FilterParseError { - path: format!("{path}.args"), - message: "and node requires args.".to_string(), - })?; - let args = FilterExpr::parse_args(args, &format!("{path}.args"), depth, state)?; - - Ok(FilterExpr::And(args)) - }, - "or" => { - let args = map.get("args").ok_or_else(|| FilterParseError { - path: format!("{path}.args"), - message: "or node requires args.".to_string(), - })?; - let args = FilterExpr::parse_args(args, &format!("{path}.args"), depth, state)?; - - Ok(FilterExpr::Or(args)) - }, - "not" => { - let expr = map.get("expr").ok_or_else(|| FilterParseError { - path: format!("{path}.expr"), - message: "not node requires expr.".to_string(), - })?; - let child = parse_expr(expr, &format!("{path}.expr"), depth.saturating_add(1), state)?; - - Ok(FilterExpr::Not(Box::new(child))) - }, - "in" => FilterExpr::parse_leaf(map, op, path), - "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" => - FilterExpr::parse_leaf(map, op, path), - _ => Err(FilterParseError { - path: path.to_string(), - message: format!("unsupported filter op '{op}'."), - }), - } -} - -fn parse_string(path: &str, raw: &Value) -> Result { - let value = raw.as_str().ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "string value expected.".to_string(), - })?; - - if value.len() > MAX_STRING_BYTES { - return Err(FilterParseError { - path: path.to_string(), - message: format!("string value exceeds maximum bytes ({}).", MAX_STRING_BYTES), - }); - } - - Ok(value.to_string()) -} - -fn parse_value( - field: &FilterField, - raw: &Value, - path: &str, -) -> Result { - match field { - FilterField::Type | FilterField::Key | FilterField::Scope | FilterField::AgentId => - match raw { - Value::String(_) | Value::Null if matches!(field, FilterField::Key) => { - if raw.is_null() { - Ok(FilterValue::Null) - } else { - parse_string(path, raw).map(FilterValue::String) - } - }, - _ => parse_string(path, raw).map(FilterValue::String), - }, - FilterField::Importance | FilterField::Confidence | FilterField::HitCount => { - let value = raw.as_f64().ok_or_else(|| FilterParseError { - path: path.to_string(), - message: "numeric value expected.".to_string(), - })?; - - Ok(FilterValue::Number(value)) - }, - FilterField::UpdatedAt => - OffsetDateTime::parse(parse_string(path, raw)?.as_str(), &Rfc3339) - .map(FilterValue::DateTime) - .map_err(|_| FilterParseError { - path: path.to_string(), - message: "datetime value must be RFC3339.".to_string(), - }), - FilterField::ExpiresAt | FilterField::LastHitAt => - if raw.is_null() { - Ok(FilterValue::Null) - } else { - OffsetDateTime::parse(parse_string(path, raw)?.as_str(), &Rfc3339) - .map(FilterValue::DateTime) - .map_err(|_| FilterParseError { - path: path.to_string(), - message: "datetime value must be RFC3339.".to_string(), - }) - }, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use serde_json::{Map, Value}; - use time::OffsetDateTime; - use uuid::Uuid; - - use crate::search::filter::{ - ChunkCandidate, MAX_FILTER_NODES, MAX_IN_LIST_ITEMS, MAX_STRING_BYTES, NoteMeta, - SEARCH_FILTER_EXPR_SCHEMA_V1, SearchFilter, - }; - - fn note_meta() -> NoteMeta { - NoteMeta { - note_id: Uuid::new_v4(), - note_type: "fact".to_string(), - key: Some("foo".to_string()), - scope: "project_shared".to_string(), - agent_id: "agent-a".to_string(), - importance: 0.9, - confidence: 0.8, - updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"), - expires_at: None, - source_ref: Value::Object(Map::new()), - embedding_version: "provider:model:1".to_string(), - hit_count: 4, - last_hit_at: None, - } - } - - #[test] - fn parse_requires_known_schema() { - let raw = serde_json::json!({ "schema": "bad", "expr": { "op": "eq", "field": "scope", "value": "project_shared" } }); - - assert!(SearchFilter::parse(&raw).is_err()); - } - - #[test] - fn parse_and_validate_depth_limit() { - let mut expr = - serde_json::json!({ "op": "eq", "field": "scope", "value": "project_shared" }); - - for _ in 0..9 { - expr = serde_json::json!({ "op": "not", "expr": expr }); - } - - let raw = serde_json::json!({ "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": expr }); - - assert!(SearchFilter::parse(&raw).is_err()); - } - - #[test] - fn parse_and_validate_node_limit() { - let leaf = serde_json::json!({ "op": "eq", "field": "scope", "value": "project_shared" }); - let mut args = Vec::with_capacity(MAX_FILTER_NODES); - - for _ in 0..(MAX_FILTER_NODES - 1) { - args.push(leaf.clone()); - } - - let expr = serde_json::json!({ "op": "and", "args": args }); - let raw = serde_json::json!({ "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": expr }); - - assert!(SearchFilter::parse(&raw).is_ok()); - - let expr = serde_json::json!({ "op": "and", "args": [expr, leaf] }); - let raw = serde_json::json!({ "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": expr }); - - assert!( - SearchFilter::parse(&raw).is_err(), - "expected parse failure when node count is greater than limit" - ); - } - - #[test] - fn parse_in_list_limit() { - let values = (0_i32..=MAX_IN_LIST_ITEMS as i32) - .map(|value| serde_json::json!(value)) - .collect::>(); - let raw = serde_json::json!({ - "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, - "expr": { - "op": "in", - "field": "importance", - "value": values, - }, - }); - - assert!(SearchFilter::parse(&raw).is_err()); - } - - #[test] - fn parse_rejects_unknown_field_with_json_path() { - let raw = serde_json::json!({ - "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, - "expr": { "op": "eq", "field": "bad_field", "value": "project_shared" }, - }); - let err = SearchFilter::parse(&raw).expect_err("expected unknown field error"); - - assert!(err.to_string().contains("$.filter.expr")); - assert!(err.to_string().contains("not in allowlist")); - } - - #[test] - fn parse_rejects_invalid_value_type_with_json_path() { - let raw = serde_json::json!({ - "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, - "expr": { "op": "eq", "field": "importance", "value": "wrong" }, - }); - let err = SearchFilter::parse(&raw).expect_err("expected invalid value type error"); - - assert!(err.to_string().contains("$.filter.expr.value")); - } - - #[test] - fn parse_rejects_oversize_string_with_json_path() { - let value = "x".repeat(MAX_STRING_BYTES + 1); - let raw = serde_json::json!({ - "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, - "expr": { "op": "eq", "field": "scope", "value": value }, - }); - let err = SearchFilter::parse(&raw).expect_err("expected string too long error"); - - assert!(err.to_string().contains("$.filter.expr.value")); - } - - #[test] - fn eval_filters_note_metadata() { - let raw = serde_json::json!({ - "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, - "expr": { - "op": "and", - "args": [ - { "op": "eq", "field": "scope", "value": "project_shared" }, - { "op": "gte", "field": "importance", "value": 0.5 }, - ], - }, - }); - let filter = SearchFilter::parse(&raw).expect("valid filter"); - let meta = note_meta(); - let note_meta = HashMap::from([(meta.note_id, meta)]); - let candidate = ChunkCandidate { - note_id: Uuid::new_v4(), - chunk_id: Uuid::new_v4(), - chunk_index: 0, - retrieval_rank: 1, - retrieval_score: None, - scope: Some("project_shared".to_string()), - updated_at: None, - embedding_version: None, - }; - let (result, impact) = filter.eval(vec![candidate], ¬e_meta, 10, 12); - - assert_eq!(result.len(), 0); - assert_eq!(impact.requested_candidate_k, 10); - assert_eq!(impact.effective_candidate_k, 12); - } - - #[test] - fn filter_impact_lists_top_drop_reasons_deterministically() { - let filter = SearchFilter::parse(&serde_json::json!({ - "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, - "expr": { "op": "eq", "field": "scope", "value": "project_shared" }, - })) - .expect("valid filter"); - let first = Uuid::new_v4(); - let second = Uuid::new_v4(); - let third = Uuid::new_v4(); - let mut note_meta = HashMap::new(); - - note_meta.insert( - first, - NoteMeta { - note_id: first, - note_type: "fact".to_string(), - key: Some("k1".to_string()), - scope: "agent_private".to_string(), - agent_id: "a".to_string(), - importance: 0.9, - confidence: 0.9, - updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"), - expires_at: None, - source_ref: Value::Object(Map::new()), - embedding_version: "provider:model:1".to_string(), - hit_count: 0, - last_hit_at: None, - }, - ); - note_meta.insert( - second, - NoteMeta { - note_id: second, - note_type: "fact".to_string(), - key: Some("k2".to_string()), - scope: "agent_private".to_string(), - agent_id: "a".to_string(), - importance: 0.9, - confidence: 0.9, - updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_001).expect("timestamp"), - expires_at: None, - source_ref: Value::Object(Map::new()), - embedding_version: "provider:model:1".to_string(), - hit_count: 0, - last_hit_at: None, - }, - ); - - let candidates = vec![ - ChunkCandidate { - note_id: first, - chunk_id: Uuid::new_v4(), - chunk_index: 0, - retrieval_rank: 1, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: None, - }, - ChunkCandidate { - note_id: second, - chunk_id: Uuid::new_v4(), - chunk_index: 1, - retrieval_rank: 2, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: None, - }, - ChunkCandidate { - note_id: third, - chunk_id: Uuid::new_v4(), - chunk_index: 2, - retrieval_rank: 3, - retrieval_score: None, - scope: None, - updated_at: None, - embedding_version: None, - }, - ]; - let (_, impact) = filter.eval(candidates, ¬e_meta, 10, 20); - - assert_eq!(impact.candidate_count_pre, 3); - assert_eq!(impact.candidate_count_post, 0); - assert_eq!(impact.dropped_total, 3); - assert_eq!(impact.top_drop_reasons.len(), 2); - assert_eq!(impact.top_drop_reasons[0].reason, "eq:scope"); - assert_eq!(impact.top_drop_reasons[0].count, 2); - assert_eq!(impact.top_drop_reasons[1].reason, "note_meta_missing"); - assert_eq!(impact.top_drop_reasons[1].count, 1); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/search/filter/expr.rs b/packages/elf-service/src/search/filter/expr.rs new file mode 100644 index 00000000..39661d0b --- /dev/null +++ b/packages/elf-service/src/search/filter/expr.rs @@ -0,0 +1,28 @@ +mod evaluate; +mod field; +mod parse; +mod serialize; + +pub(super) use field::FilterField; + +use crate::search::filter::value::FilterValue; + +#[derive(Clone, Debug)] +pub(super) enum FilterExpr { + And(Vec), + Or(Vec), + Not(Box), + Eq { field: FilterField, value: FilterValue }, + Neq { field: FilterField, value: FilterValue }, + In { field: FilterField, values: Vec }, + Contains { field: FilterField, value: String }, + Gt { field: FilterField, value: FilterValue }, + Gte { field: FilterField, value: FilterValue }, + Lt { field: FilterField, value: FilterValue }, + Lte { field: FilterField, value: FilterValue }, +} +impl Default for FilterExpr { + fn default() -> Self { + Self::Eq { field: FilterField::Type, value: FilterValue::Null } + } +} diff --git a/packages/elf-service/src/search/filter/expr/evaluate.rs b/packages/elf-service/src/search/filter/expr/evaluate.rs new file mode 100644 index 00000000..c15d2013 --- /dev/null +++ b/packages/elf-service/src/search/filter/expr/evaluate.rs @@ -0,0 +1,204 @@ +use crate::search::{ + NoteMeta, + filter::{ + expr::{FilterExpr, FilterField}, + value::{FilterNodeValue, FilterValue}, + }, +}; + +impl FilterExpr { + pub(in crate::search::filter) fn evaluate(&self, note: &NoteMeta) -> (bool, Option) { + match self { + Self::And(nodes) => Self::evaluate_and(nodes, note), + Self::Or(nodes) => Self::evaluate_or(nodes, note), + Self::Not(node) => Self::evaluate_not(node, note), + Self::Eq { field, value } => Self::evaluate_eq(field, value, note), + Self::Neq { field, value } => Self::evaluate_neq(field, value, note), + Self::In { field, values } => Self::evaluate_in(field, values, note), + Self::Contains { field, value } => Self::evaluate_contains(field, value, note), + Self::Gt { field, value } => Self::evaluate_gt(field, value, note), + Self::Gte { field, value } => Self::evaluate_gte(field, value, note), + Self::Lt { field, value } => Self::evaluate_lt(field, value, note), + Self::Lte { field, value } => Self::evaluate_lte(field, value, note), + } + } + + fn evaluate_and(nodes: &[Self], note: &NoteMeta) -> (bool, Option) { + for node in nodes { + let (passed, reason) = node.evaluate(note); + + if !passed { + return (false, reason); + } + } + + (true, None) + } + + fn evaluate_or(nodes: &[Self], note: &NoteMeta) -> (bool, Option) { + let mut first_reason = None; + + for node in nodes { + let (passed, reason) = node.evaluate(note); + + if passed { + return (true, None); + } + if first_reason.is_none() { + first_reason = reason; + } + } + + (false, first_reason.or_else(|| Some("or.no_match".to_string()))) + } + + fn evaluate_not(node: &Self, note: &NoteMeta) -> (bool, Option) { + let (passed, reason) = node.evaluate(note); + + if passed { (false, Some("not.true".to_string())) } else { (true, reason) } + } + + fn evaluate_eq( + field: &FilterField, + value: &FilterValue, + note: &NoteMeta, + ) -> (bool, Option) { + let note_value = field.lookup_note_value(note); + let filter_value = value.to_node_value(); + let matches = note_value == filter_value; + + (matches, Some(format!("eq:{}", field.as_str())).filter(|_| !matches)) + } + + fn evaluate_neq( + field: &FilterField, + value: &FilterValue, + note: &NoteMeta, + ) -> (bool, Option) { + let note_value = field.lookup_note_value(note); + let filter_value = value.to_node_value(); + let matches = note_value != filter_value; + + (matches, Some(format!("neq:{}", field.as_str())).filter(|_| !matches)) + } + + fn evaluate_in( + field: &FilterField, + values: &[FilterValue], + note: &NoteMeta, + ) -> (bool, Option) { + let note_value = field.lookup_note_value(note); + let matches = values.iter().any(|value| note_value == FilterNodeValue::from(value)); + + (matches, Some(format!("in:{}", field.as_str())).filter(|_| !matches)) + } + + fn evaluate_contains( + field: &FilterField, + value: &str, + note: &NoteMeta, + ) -> (bool, Option) { + let note_value = field.lookup_note_value(note); + let note_text = match note_value { + FilterNodeValue::String(s) => s, + _ => { + return (false, Some(format!("contains:{}", field.as_str()))); + }, + }; + let matches = note_text.contains(value); + + (matches, Some(format!("contains:{}", field.as_str())).filter(|_| !matches)) + } + + fn evaluate_gt( + field: &FilterField, + value: &FilterValue, + note: &NoteMeta, + ) -> (bool, Option) { + match field.lookup_note_value(note) { + FilterNodeValue::Number(note_value) => { + let matches = note_value > value.to_numeric(); + + (matches, Some(format!("gt:{}", field.as_str())).filter(|_| !matches)) + }, + FilterNodeValue::DateTime(note_value) => { + let matches = match value { + FilterValue::DateTime(filter_value) => note_value > *filter_value, + _ => false, + }; + + (matches, Some(format!("gt:{}", field.as_str())).filter(|_| !matches)) + }, + _ => (false, Some(format!("gt:{}", field.as_str()))), + } + } + + fn evaluate_gte( + field: &FilterField, + value: &FilterValue, + note: &NoteMeta, + ) -> (bool, Option) { + match field.lookup_note_value(note) { + FilterNodeValue::Number(note_value) => { + let matches = note_value >= value.to_numeric(); + + (matches, Some(format!("gte:{}", field.as_str())).filter(|_| !matches)) + }, + FilterNodeValue::DateTime(note_value) => { + let matches = match value { + FilterValue::DateTime(filter_value) => note_value >= *filter_value, + _ => false, + }; + + (matches, Some(format!("gte:{}", field.as_str())).filter(|_| !matches)) + }, + _ => (false, Some(format!("gte:{}", field.as_str()))), + } + } + + fn evaluate_lt( + field: &FilterField, + value: &FilterValue, + note: &NoteMeta, + ) -> (bool, Option) { + match field.lookup_note_value(note) { + FilterNodeValue::Number(note_value) => { + let matches = note_value < value.to_numeric(); + + (matches, Some(format!("lt:{}", field.as_str())).filter(|_| !matches)) + }, + FilterNodeValue::DateTime(note_value) => { + let matches = match value { + FilterValue::DateTime(filter_value) => note_value < *filter_value, + _ => false, + }; + + (matches, Some(format!("lt:{}", field.as_str())).filter(|_| !matches)) + }, + _ => (false, Some(format!("lt:{}", field.as_str()))), + } + } + + fn evaluate_lte( + field: &FilterField, + value: &FilterValue, + note: &NoteMeta, + ) -> (bool, Option) { + match field.lookup_note_value(note) { + FilterNodeValue::Number(note_value) => { + let matches = note_value <= value.to_numeric(); + + (matches, Some(format!("lte:{}", field.as_str())).filter(|_| !matches)) + }, + FilterNodeValue::DateTime(note_value) => { + let matches = match value { + FilterValue::DateTime(filter_value) => note_value <= *filter_value, + _ => false, + }; + + (matches, Some(format!("lte:{}", field.as_str())).filter(|_| !matches)) + }, + _ => (false, Some(format!("lte:{}", field.as_str()))), + } + } +} diff --git a/packages/elf-service/src/search/filter/expr/field.rs b/packages/elf-service/src/search/filter/expr/field.rs new file mode 100644 index 00000000..912c27b4 --- /dev/null +++ b/packages/elf-service/src/search/filter/expr/field.rs @@ -0,0 +1,86 @@ +use serde_json::Value; + +use crate::search::{ + NoteMeta, + filter::{parser::FilterParseError, value::FilterNodeValue}, +}; + +#[derive(Clone, Debug)] +pub(in crate::search::filter) enum FilterField { + Type, + Key, + Scope, + AgentId, + Importance, + Confidence, + UpdatedAt, + ExpiresAt, + HitCount, + LastHitAt, +} +impl FilterField { + pub(in crate::search::filter) fn as_str(&self) -> &'static str { + match self { + Self::Type => "type", + Self::Key => "key", + Self::Scope => "scope", + Self::AgentId => "agent_id", + Self::Importance => "importance", + Self::Confidence => "confidence", + Self::UpdatedAt => "updated_at", + Self::ExpiresAt => "expires_at", + Self::HitCount => "hit_count", + Self::LastHitAt => "last_hit_at", + } + } + + pub(in crate::search::filter) fn parse( + path: &str, + raw: &Value, + ) -> Result { + let field = raw + .as_str() + .ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "filter field must be a string.".to_string(), + })? + .to_ascii_lowercase(); + + match field.as_str() { + "type" => Ok(Self::Type), + "key" => Ok(Self::Key), + "scope" => Ok(Self::Scope), + "agent_id" => Ok(Self::AgentId), + "importance" => Ok(Self::Importance), + "confidence" => Ok(Self::Confidence), + "updated_at" => Ok(Self::UpdatedAt), + "expires_at" => Ok(Self::ExpiresAt), + "hit_count" => Ok(Self::HitCount), + "last_hit_at" => Ok(Self::LastHitAt), + _ => Err(FilterParseError { + path: path.to_string(), + message: format!( + "field '{}' is not in allowlist: type, key, scope, agent_id, importance, confidence, updated_at, expires_at, hit_count, last_hit_at", + field, + ), + }), + } + } + + pub(in crate::search::filter) fn lookup_note_value(&self, note: &NoteMeta) -> FilterNodeValue { + match self { + Self::Type => FilterNodeValue::String(note.note_type.clone()), + Self::Key => FilterNodeValue::String(note.key.clone().unwrap_or_default()), + Self::Scope => FilterNodeValue::String(note.scope.clone()), + Self::AgentId => FilterNodeValue::String(note.agent_id.clone()), + Self::Importance => FilterNodeValue::Number(note.importance as f64), + Self::Confidence => FilterNodeValue::Number(note.confidence as f64), + Self::HitCount => FilterNodeValue::Number(note.hit_count as f64), + Self::UpdatedAt => FilterNodeValue::DateTime(note.updated_at), + Self::ExpiresAt => + note.expires_at.map_or(FilterNodeValue::Null, FilterNodeValue::DateTime), + Self::LastHitAt => + note.last_hit_at.map_or(FilterNodeValue::Null, FilterNodeValue::DateTime), + } + } +} diff --git a/packages/elf-service/src/search/filter/expr/parse.rs b/packages/elf-service/src/search/filter/expr/parse.rs new file mode 100644 index 00000000..7b9f3741 --- /dev/null +++ b/packages/elf-service/src/search/filter/expr/parse.rs @@ -0,0 +1,148 @@ +use serde_json::{Map, Value}; + +use crate::search::filter::{ + expr::{FilterExpr, FilterField}, + parser::{ + self, FilterParseError, FilterParseState, MAX_FILTER_DEPTH, MAX_FILTER_NODES, + MAX_IN_LIST_ITEMS, + }, + value::FilterValue, +}; + +impl FilterExpr { + pub(in crate::search::filter) fn parse_args( + value: &Value, + path: &str, + depth: usize, + state: &mut FilterParseState, + ) -> Result, FilterParseError> { + let nodes = value.as_array().ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "op args must be an array.".to_string(), + })?; + + if nodes.is_empty() { + return Err(FilterParseError { + path: path.to_string(), + message: "op args must contain at least one node.".to_string(), + }); + } + + nodes + .iter() + .enumerate() + .map(|(index, node)| { + let child_path = format!("{path}[{index}]"); + + parser::parse_expr(node, &child_path, depth.saturating_add(1), state) + }) + .collect() + } + + fn parse_in_values( + field: &FilterField, + value: &Value, + path: &str, + ) -> Result, FilterParseError> { + let values = value.as_array().ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "in value must be an array.".to_string(), + })?; + + if values.len() > MAX_IN_LIST_ITEMS { + return Err(FilterParseError { + path: path.to_string(), + message: format!( + "in list exceeds maximum size ({}/{})", + values.len(), + MAX_IN_LIST_ITEMS + ), + }); + } + + values + .iter() + .enumerate() + .map(|(index, raw)| { + let item_path = format!("{path}[{index}]"); + + parser::parse_value(field, raw, &item_path) + }) + .collect() + } + + pub(in crate::search::filter) fn validate_metrics( + path: &str, + depth: usize, + state: &mut FilterParseState, + ) -> Result<(), FilterParseError> { + state.nodes = state.nodes.saturating_add(1); + state.max_depth = state.max_depth.max(depth); + + if state.nodes > MAX_FILTER_NODES { + return Err(FilterParseError { + path: path.to_string(), + message: format!( + "filter exceeds node limit ({}/{})", + state.nodes, MAX_FILTER_NODES + ), + }); + } + if state.max_depth > MAX_FILTER_DEPTH { + return Err(FilterParseError { + path: path.to_string(), + message: format!( + "filter exceeds depth limit ({}/{})", + state.max_depth, MAX_FILTER_DEPTH + ), + }); + } + + Ok(()) + } + + pub(in crate::search::filter) fn parse_leaf( + raw: &Map, + op: &str, + path: &str, + ) -> Result { + let field = FilterField::parse( + &format!("{path}.field"), + raw.get("field").ok_or_else(|| FilterParseError { + path: format!("{path}.field"), + message: "op node is missing required field 'field'.".to_string(), + })?, + )?; + let path_value = format!("{path}.value"); + let value_raw = raw.get("value").ok_or_else(|| FilterParseError { + path: format!("{path}.value"), + message: "op node is missing required field 'value'.".to_string(), + })?; + let value = parser::parse_value(&field, value_raw, &path_value)?; + + match op { + "eq" => Ok(Self::Eq { field, value }), + "neq" => Ok(Self::Neq { field, value }), + "contains" => match value { + FilterValue::String(value) => Ok(Self::Contains { field, value }), + _ => Err(FilterParseError { + path: path_value, + message: "contains requires a string value.".to_string(), + }), + }, + "gt" => Ok(Self::Gt { field, value }), + "gte" => Ok(Self::Gte { field, value }), + "lt" => Ok(Self::Lt { field, value }), + "lte" => Ok(Self::Lte { field, value }), + "in" => { + let values = Self::parse_in_values(&field, value_raw, &path_value)?; + + Ok(Self::In { field, values }) + }, + _ => Err(FilterParseError { + path: path.to_string(), + message: format!("unsupported leaf op '{op}'."), + }), + } + } +} diff --git a/packages/elf-service/src/search/filter/expr/serialize.rs b/packages/elf-service/src/search/filter/expr/serialize.rs new file mode 100644 index 00000000..d6107d00 --- /dev/null +++ b/packages/elf-service/src/search/filter/expr/serialize.rs @@ -0,0 +1,47 @@ +use serde_json::Value; + +use crate::search::filter::{expr::FilterExpr, value::FilterValue}; + +impl FilterExpr { + pub(in crate::search::filter) fn to_value(&self) -> Value { + match self { + Self::And(exprs) => { + serde_json::json!({ "op": "and", "args": Value::Array(exprs.iter().map(Self::to_value).collect()) }) + }, + Self::Or(exprs) => { + serde_json::json!({ "op": "or", "args": Value::Array(exprs.iter().map(Self::to_value).collect()) }) + }, + Self::Not(expr) => { + serde_json::json!({ "op": "not", "expr": expr.to_value() }) + }, + Self::Eq { field, value } => { + serde_json::json!({ "op": "eq", "field": field.as_str(), "value": value.to_value() }) + }, + Self::Neq { field, value } => { + serde_json::json!({ "op": "neq", "field": field.as_str(), "value": value.to_value() }) + }, + Self::In { field, values } => { + serde_json::json!({ + "op": "in", + "field": field.as_str(), + "value": Value::Array(values.iter().map(FilterValue::to_value).collect()) + }) + }, + Self::Contains { field, value } => { + serde_json::json!({ "op": "contains", "field": field.as_str(), "value": value }) + }, + Self::Gt { field, value } => { + serde_json::json!({ "op": "gt", "field": field.as_str(), "value": value.to_value() }) + }, + Self::Gte { field, value } => { + serde_json::json!({ "op": "gte", "field": field.as_str(), "value": value.to_value() }) + }, + Self::Lt { field, value } => { + serde_json::json!({ "op": "lt", "field": field.as_str(), "value": value.to_value() }) + }, + Self::Lte { field, value } => { + serde_json::json!({ "op": "lte", "field": field.as_str(), "value": value.to_value() }) + }, + } + } +} diff --git a/packages/elf-service/src/search/filter/impact.rs b/packages/elf-service/src/search/filter/impact.rs new file mode 100644 index 00000000..481e566a --- /dev/null +++ b/packages/elf-service/src/search/filter/impact.rs @@ -0,0 +1,96 @@ +use std::{cmp::Ordering, collections::HashMap}; + +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::search::{ + ChunkCandidate, NoteMeta, SEARCH_FILTER_IMPACT_SCHEMA_V1, filter::parser::SearchFilter, +}; + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct SearchFilterImpact { + pub(crate) requested_candidate_k: u32, + pub(crate) effective_candidate_k: u32, + pub(crate) candidate_count_pre: usize, + pub(crate) candidate_count_post: usize, + pub(crate) dropped_total: usize, + pub(crate) top_drop_reasons: Vec, + pub(crate) filter: Value, +} +impl SearchFilterImpact { + pub(crate) fn from_eval( + filter: &SearchFilter, + note_candidates: &[ChunkCandidate], + note_meta: &HashMap, + requested_candidate_k: u32, + effective_candidate_k: u32, + ) -> Self { + let pre = note_candidates.len(); + let mut kept: Vec = Vec::new(); + let mut dropped_reason_counts: HashMap = HashMap::new(); + + for candidate in note_candidates { + let Some(note) = note_meta.get(&candidate.note_id) else { + dropped_reason_counts + .entry("note_meta_missing".to_string()) + .and_modify(|count| *count += 1) + .or_insert(1); + + continue; + }; + let (keep, reason) = filter.evaluate(note); + + if keep { + kept.push(candidate.clone()); + } else { + dropped_reason_counts + .entry(reason.unwrap_or_else(|| "filter.no_match".to_string())) + .and_modify(|count| *count += 1) + .or_insert(1); + } + } + + let mut top_drop_reasons: Vec<_> = dropped_reason_counts + .into_iter() + .map(|(reason, count)| SearchFilterDropReason { reason, count }) + .collect(); + + top_drop_reasons.sort_by(|a, b| match b.count.cmp(&a.count) { + Ordering::Equal => a.reason.cmp(&b.reason), + other => other, + }); + top_drop_reasons.truncate(5); + + let post = kept.len(); + + Self { + requested_candidate_k, + effective_candidate_k, + candidate_count_pre: pre, + candidate_count_post: post, + dropped_total: pre.saturating_sub(post), + top_drop_reasons, + filter: filter.as_value(), + } + } + + pub(crate) fn to_stage_payload(&self) -> Value { + serde_json::json!({ + "schema": SEARCH_FILTER_IMPACT_SCHEMA_V1, + "requested_candidate_k": self.requested_candidate_k, + "effective_candidate_k": self.effective_candidate_k, + "candidate_count_pre": self.candidate_count_pre, + "candidate_count_post": self.candidate_count_post, + "dropped_total": self.dropped_total, + "top_drop_reasons": self.top_drop_reasons, + "filter": self.filter, + }) + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct SearchFilterDropReason { + pub(crate) reason: String, + pub(crate) count: usize, +} diff --git a/packages/elf-service/src/search/filter/parser.rs b/packages/elf-service/src/search/filter/parser.rs new file mode 100644 index 00000000..da46afd4 --- /dev/null +++ b/packages/elf-service/src/search/filter/parser.rs @@ -0,0 +1,246 @@ +use std::{ + collections::HashMap, + fmt::{Display, Formatter}, +}; + +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +use crate::search::{ + ChunkCandidate, NoteMeta, + filter::{ + expr::{FilterExpr, FilterField}, + impact::SearchFilterImpact, + value::FilterValue, + }, +}; + +pub(super) const SEARCH_FILTER_EXPR_SCHEMA_V1: &str = "search_filter_expr/v1"; +pub(super) const MAX_FILTER_DEPTH: usize = 8; +pub(super) const MAX_FILTER_NODES: usize = 128; +pub(super) const MAX_IN_LIST_ITEMS: usize = 128; +pub(super) const MAX_STRING_BYTES: usize = 512; + +#[derive(Clone, Debug)] +pub(crate) struct FilterParseError { + pub(super) path: String, + pub(super) message: String, +} +impl Display for FilterParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.path, self.message) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SearchFilter { + expr: FilterExpr, + json: Value, +} +impl SearchFilter { + pub(super) fn as_value(&self) -> Value { + self.json.clone() + } + + pub(super) fn evaluate(&self, note: &NoteMeta) -> (bool, Option) { + self.expr.evaluate(note) + } + + pub(crate) fn parse(raw: &Value) -> Result { + let path = "$.filter"; + let obj = raw.as_object().ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "filter must be an object.".to_string(), + })?; + let schema = obj.get("schema").and_then(Value::as_str).ok_or_else(|| FilterParseError { + path: format!("{path}.schema"), + message: "filter.schema is required.".to_string(), + })?; + + if schema != SEARCH_FILTER_EXPR_SCHEMA_V1 { + return Err(FilterParseError { + path: format!("{path}.schema"), + message: format!( + "unsupported filter schema '{schema}', expected '{SEARCH_FILTER_EXPR_SCHEMA_V1}'." + ), + }); + } + + let expr = obj.get("expr").ok_or_else(|| FilterParseError { + path: format!("{path}.expr"), + message: "filter.expr is required.".to_string(), + })?; + let mut state = FilterParseState::default(); + let parsed = parse_expr(expr, "$.filter.expr", 1, &mut state)?; + + Ok(Self { + expr: parsed.clone(), + json: serde_json::json!({"schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": parsed.to_value()}), + }) + } + + pub(crate) fn eval( + &self, + candidates: Vec, + note_meta: &HashMap, + requested_candidate_k: u32, + effective_candidate_k: u32, + ) -> (Vec, SearchFilterImpact) { + let impact = SearchFilterImpact::from_eval( + self, + candidates.as_slice(), + note_meta, + requested_candidate_k, + effective_candidate_k, + ); + let pre = candidates.len(); + let mut kept = Vec::with_capacity(impact.candidate_count_post); + + for candidate in candidates { + let Some(note) = note_meta.get(&candidate.note_id) else { + continue; + }; + + if self.expr.evaluate(note).0 { + kept.push(candidate); + } + } + + let post = kept.len(); + + ( + kept, + SearchFilterImpact { + candidate_count_post: post, + dropped_total: pre.saturating_sub(post), + ..impact + }, + ) + } +} + +#[derive(Default)] +pub(super) struct FilterParseState { + pub(super) nodes: usize, + pub(super) max_depth: usize, +} + +pub(super) fn parse_expr( + value: &Value, + path: &str, + depth: usize, + state: &mut FilterParseState, +) -> Result { + FilterExpr::validate_metrics(path, depth, state)?; + + let Some(map) = value.as_object() else { + return Err(FilterParseError { + path: path.to_string(), + message: "filter node must be an object.".to_string(), + }); + }; + let op = map.get("op").and_then(Value::as_str).ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "filter node is missing required string op.".to_string(), + })?; + + match op { + "and" => { + let args = map.get("args").ok_or_else(|| FilterParseError { + path: format!("{path}.args"), + message: "and node requires args.".to_string(), + })?; + let args = FilterExpr::parse_args(args, &format!("{path}.args"), depth, state)?; + + Ok(FilterExpr::And(args)) + }, + "or" => { + let args = map.get("args").ok_or_else(|| FilterParseError { + path: format!("{path}.args"), + message: "or node requires args.".to_string(), + })?; + let args = FilterExpr::parse_args(args, &format!("{path}.args"), depth, state)?; + + Ok(FilterExpr::Or(args)) + }, + "not" => { + let expr = map.get("expr").ok_or_else(|| FilterParseError { + path: format!("{path}.expr"), + message: "not node requires expr.".to_string(), + })?; + let child = parse_expr(expr, &format!("{path}.expr"), depth.saturating_add(1), state)?; + + Ok(FilterExpr::Not(Box::new(child))) + }, + "in" => FilterExpr::parse_leaf(map, op, path), + "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" => + FilterExpr::parse_leaf(map, op, path), + _ => Err(FilterParseError { + path: path.to_string(), + message: format!("unsupported filter op '{op}'."), + }), + } +} + +pub(super) fn parse_value( + field: &FilterField, + raw: &Value, + path: &str, +) -> Result { + match field { + FilterField::Type | FilterField::Key | FilterField::Scope | FilterField::AgentId => + match raw { + Value::String(_) | Value::Null if matches!(field, FilterField::Key) => { + if raw.is_null() { + Ok(FilterValue::Null) + } else { + parse_string(path, raw).map(FilterValue::String) + } + }, + _ => parse_string(path, raw).map(FilterValue::String), + }, + FilterField::Importance | FilterField::Confidence | FilterField::HitCount => { + let value = raw.as_f64().ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "numeric value expected.".to_string(), + })?; + + Ok(FilterValue::Number(value)) + }, + FilterField::UpdatedAt => + OffsetDateTime::parse(parse_string(path, raw)?.as_str(), &Rfc3339) + .map(FilterValue::DateTime) + .map_err(|_| FilterParseError { + path: path.to_string(), + message: "datetime value must be RFC3339.".to_string(), + }), + FilterField::ExpiresAt | FilterField::LastHitAt => + if raw.is_null() { + Ok(FilterValue::Null) + } else { + OffsetDateTime::parse(parse_string(path, raw)?.as_str(), &Rfc3339) + .map(FilterValue::DateTime) + .map_err(|_| FilterParseError { + path: path.to_string(), + message: "datetime value must be RFC3339.".to_string(), + }) + }, + } +} + +fn parse_string(path: &str, raw: &Value) -> Result { + let value = raw.as_str().ok_or_else(|| FilterParseError { + path: path.to_string(), + message: "string value expected.".to_string(), + })?; + + if value.len() > MAX_STRING_BYTES { + return Err(FilterParseError { + path: path.to_string(), + message: format!("string value exceeds maximum bytes ({}).", MAX_STRING_BYTES), + }); + } + + Ok(value.to_string()) +} diff --git a/packages/elf-service/src/search/filter/tests.rs b/packages/elf-service/src/search/filter/tests.rs new file mode 100644 index 00000000..ee02ff2f --- /dev/null +++ b/packages/elf-service/src/search/filter/tests.rs @@ -0,0 +1,253 @@ +use std::collections::HashMap; + +use serde_json::{Map, Value}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::search::{ + ChunkCandidate, NoteMeta, + filter::{ + SearchFilter, + parser::{ + MAX_FILTER_NODES, MAX_IN_LIST_ITEMS, MAX_STRING_BYTES, SEARCH_FILTER_EXPR_SCHEMA_V1, + }, + }, +}; + +fn note_meta() -> NoteMeta { + NoteMeta { + note_id: Uuid::new_v4(), + note_type: "fact".to_string(), + key: Some("foo".to_string()), + scope: "project_shared".to_string(), + agent_id: "agent-a".to_string(), + importance: 0.9, + confidence: 0.8, + updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"), + expires_at: None, + source_ref: Value::Object(Map::new()), + embedding_version: "provider:model:1".to_string(), + hit_count: 4, + last_hit_at: None, + } +} + +#[test] +fn parse_requires_known_schema() { + let raw = serde_json::json!({ "schema": "bad", "expr": { "op": "eq", "field": "scope", "value": "project_shared" } }); + + assert!(SearchFilter::parse(&raw).is_err()); +} + +#[test] +fn parse_and_validate_depth_limit() { + let mut expr = serde_json::json!({ "op": "eq", "field": "scope", "value": "project_shared" }); + + for _ in 0..9 { + expr = serde_json::json!({ "op": "not", "expr": expr }); + } + + let raw = serde_json::json!({ "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": expr }); + + assert!(SearchFilter::parse(&raw).is_err()); +} + +#[test] +fn parse_and_validate_node_limit() { + let leaf = serde_json::json!({ "op": "eq", "field": "scope", "value": "project_shared" }); + let mut args = Vec::with_capacity(MAX_FILTER_NODES); + + for _ in 0..(MAX_FILTER_NODES - 1) { + args.push(leaf.clone()); + } + + let expr = serde_json::json!({ "op": "and", "args": args }); + let raw = serde_json::json!({ "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": expr }); + + assert!(SearchFilter::parse(&raw).is_ok()); + + let expr = serde_json::json!({ "op": "and", "args": [expr, leaf] }); + let raw = serde_json::json!({ "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, "expr": expr }); + + assert!( + SearchFilter::parse(&raw).is_err(), + "expected parse failure when node count is greater than limit" + ); +} + +#[test] +fn parse_in_list_limit() { + let values = (0_i32..=MAX_IN_LIST_ITEMS as i32) + .map(|value| serde_json::json!(value)) + .collect::>(); + let raw = serde_json::json!({ + "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, + "expr": { + "op": "in", + "field": "importance", + "value": values, + }, + }); + + assert!(SearchFilter::parse(&raw).is_err()); +} + +#[test] +fn parse_rejects_unknown_field_with_json_path() { + let raw = serde_json::json!({ + "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, + "expr": { "op": "eq", "field": "bad_field", "value": "project_shared" }, + }); + let err = SearchFilter::parse(&raw).expect_err("expected unknown field error"); + + assert!(err.to_string().contains("$.filter.expr")); + assert!(err.to_string().contains("not in allowlist")); +} + +#[test] +fn parse_rejects_invalid_value_type_with_json_path() { + let raw = serde_json::json!({ + "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, + "expr": { "op": "eq", "field": "importance", "value": "wrong" }, + }); + let err = SearchFilter::parse(&raw).expect_err("expected invalid value type error"); + + assert!(err.to_string().contains("$.filter.expr.value")); +} + +#[test] +fn parse_rejects_oversize_string_with_json_path() { + let value = "x".repeat(MAX_STRING_BYTES + 1); + let raw = serde_json::json!({ + "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, + "expr": { "op": "eq", "field": "scope", "value": value }, + }); + let err = SearchFilter::parse(&raw).expect_err("expected string too long error"); + + assert!(err.to_string().contains("$.filter.expr.value")); +} + +#[test] +fn eval_filters_note_metadata() { + let raw = serde_json::json!({ + "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, + "expr": { + "op": "and", + "args": [ + { "op": "eq", "field": "scope", "value": "project_shared" }, + { "op": "gte", "field": "importance", "value": 0.5 }, + ], + }, + }); + let filter = SearchFilter::parse(&raw).expect("valid filter"); + let meta = note_meta(); + let note_meta = HashMap::from([(meta.note_id, meta)]); + let candidate = ChunkCandidate { + note_id: Uuid::new_v4(), + chunk_id: Uuid::new_v4(), + chunk_index: 0, + retrieval_rank: 1, + retrieval_score: None, + scope: Some("project_shared".to_string()), + updated_at: None, + embedding_version: None, + }; + let (result, impact) = filter.eval(vec![candidate], ¬e_meta, 10, 12); + + assert_eq!(result.len(), 0); + assert_eq!(impact.requested_candidate_k, 10); + assert_eq!(impact.effective_candidate_k, 12); +} + +#[test] +fn filter_impact_lists_top_drop_reasons_deterministically() { + let filter = SearchFilter::parse(&serde_json::json!({ + "schema": SEARCH_FILTER_EXPR_SCHEMA_V1, + "expr": { "op": "eq", "field": "scope", "value": "project_shared" }, + })) + .expect("valid filter"); + let first = Uuid::new_v4(); + let second = Uuid::new_v4(); + let third = Uuid::new_v4(); + let mut note_meta = HashMap::new(); + + note_meta.insert( + first, + NoteMeta { + note_id: first, + note_type: "fact".to_string(), + key: Some("k1".to_string()), + scope: "agent_private".to_string(), + agent_id: "a".to_string(), + importance: 0.9, + confidence: 0.9, + updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"), + expires_at: None, + source_ref: Value::Object(Map::new()), + embedding_version: "provider:model:1".to_string(), + hit_count: 0, + last_hit_at: None, + }, + ); + note_meta.insert( + second, + NoteMeta { + note_id: second, + note_type: "fact".to_string(), + key: Some("k2".to_string()), + scope: "agent_private".to_string(), + agent_id: "a".to_string(), + importance: 0.9, + confidence: 0.9, + updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_001).expect("timestamp"), + expires_at: None, + source_ref: Value::Object(Map::new()), + embedding_version: "provider:model:1".to_string(), + hit_count: 0, + last_hit_at: None, + }, + ); + + let candidates = vec![ + ChunkCandidate { + note_id: first, + chunk_id: Uuid::new_v4(), + chunk_index: 0, + retrieval_rank: 1, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: None, + }, + ChunkCandidate { + note_id: second, + chunk_id: Uuid::new_v4(), + chunk_index: 1, + retrieval_rank: 2, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: None, + }, + ChunkCandidate { + note_id: third, + chunk_id: Uuid::new_v4(), + chunk_index: 2, + retrieval_rank: 3, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: None, + }, + ]; + let (_, impact) = filter.eval(candidates, ¬e_meta, 10, 20); + + assert_eq!(impact.candidate_count_pre, 3); + assert_eq!(impact.candidate_count_post, 0); + assert_eq!(impact.dropped_total, 3); + assert_eq!(impact.top_drop_reasons.len(), 2); + assert_eq!(impact.top_drop_reasons[0].reason, "eq:scope"); + assert_eq!(impact.top_drop_reasons[0].count, 2); + assert_eq!(impact.top_drop_reasons[1].reason, "note_meta_missing"); + assert_eq!(impact.top_drop_reasons[1].count, 1); +} diff --git a/packages/elf-service/src/search/filter/value.rs b/packages/elf-service/src/search/filter/value.rs new file mode 100644 index 00000000..cafc87ce --- /dev/null +++ b/packages/elf-service/src/search/filter/value.rs @@ -0,0 +1,78 @@ +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +#[derive(Clone, Debug)] +pub(super) enum FilterValue { + String(String), + Number(f64), + DateTime(OffsetDateTime), + Null, +} +impl FilterValue { + pub(super) fn to_node_value(&self) -> FilterNodeValue { + match self { + Self::String(value) => FilterNodeValue::String(value.clone()), + Self::Number(value) => FilterNodeValue::Number(*value), + Self::DateTime(value) => FilterNodeValue::DateTime(*value), + Self::Null => FilterNodeValue::Null, + } + } + + pub(super) fn to_value(&self) -> Value { + match self { + Self::String(value) => Value::String(value.clone()), + Self::Number(value) => serde_json::json!(value), + Self::DateTime(value) => Value::String(value.format(&Rfc3339).unwrap_or_default()), + Self::Null => Value::Null, + } + } + + pub(super) fn to_numeric(&self) -> f64 { + match self { + Self::Number(value) => *value, + _ => 0.0, + } + } +} + +impl PartialEq for FilterValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::String(lhs), Self::String(rhs)) => lhs == rhs, + (Self::Number(lhs), Self::Number(rhs)) => lhs == rhs, + (Self::DateTime(lhs), Self::DateTime(rhs)) => lhs == rhs, + (Self::Null, Self::Null) => true, + _ => false, + } + } +} + +#[derive(Clone, Debug)] +pub(super) enum FilterNodeValue { + String(String), + Number(f64), + DateTime(OffsetDateTime), + Null, +} +impl From<&FilterValue> for FilterNodeValue { + fn from(value: &FilterValue) -> Self { + match value { + FilterValue::String(value) => Self::String(value.clone()), + FilterValue::Number(value) => Self::Number(*value), + FilterValue::DateTime(value) => Self::DateTime(*value), + FilterValue::Null => Self::Null, + } + } +} + +impl PartialEq for FilterNodeValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::String(lhs), Self::String(rhs)) => lhs == rhs, + (Self::Number(lhs), Self::Number(rhs)) => lhs == rhs, + (Self::DateTime(lhs), Self::DateTime(rhs)) => lhs == rhs, + (Self::Null, Self::Null) => true, + _ => false, + } + } +} diff --git a/packages/elf-service/src/search/finish.rs b/packages/elf-service/src/search/finish.rs new file mode 100644 index 00000000..d30d0922 --- /dev/null +++ b/packages/elf-service/src/search/finish.rs @@ -0,0 +1,5 @@ +mod metadata; +mod relations; +mod rerank; +mod scoring; +mod trace; diff --git a/packages/elf-service/src/search/finish/metadata.rs b/packages/elf-service/src/search/finish/metadata.rs new file mode 100644 index 00000000..021269d5 --- /dev/null +++ b/packages/elf-service/src/search/finish/metadata.rs @@ -0,0 +1,77 @@ +use crate::{ + access, + search::{ + ElfService, HashMap, MemoryNote, NoteMeta, ORG_PROJECT_ID, OffsetDateTime, Result, Uuid, + }, +}; + +impl ElfService { + pub(in crate::search) async fn fetch_note_meta_for_candidates( + &self, + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], + candidate_note_ids: &[Uuid], + now: OffsetDateTime, + ) -> Result> { + if candidate_note_ids.is_empty() { + return Ok(HashMap::new()); + } + + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await?; + let notes: Vec = sqlx::query_as( + "\ +SELECT * +FROM memory_notes +WHERE note_id = ANY($1::uuid[]) + AND tenant_id = $2 + AND ( + project_id = $3 + OR (project_id = $4 AND scope = 'org_shared') + )", + ) + .bind(candidate_note_ids) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_all(&self.db.pool) + .await?; + let mut note_meta = HashMap::new(); + + for note in notes { + if !access::note_read_allowed(¬e, agent_id, allowed_scopes, &shared_grants, now) { + continue; + } + + note_meta.insert( + note.note_id, + NoteMeta { + note_id: note.note_id, + note_type: note.r#type, + key: note.key, + scope: note.scope, + agent_id: note.agent_id, + importance: note.importance, + confidence: note.confidence, + updated_at: note.updated_at, + expires_at: note.expires_at, + source_ref: note.source_ref, + embedding_version: note.embedding_version, + hit_count: note.hit_count, + last_hit_at: note.last_hit_at, + }, + ); + } + + Ok(note_meta) + } +} diff --git a/packages/elf-service/src/search/finish/relations.rs b/packages/elf-service/src/search/finish/relations.rs new file mode 100644 index 00000000..967f6bc7 --- /dev/null +++ b/packages/elf-service/src/search/finish/relations.rs @@ -0,0 +1,173 @@ +use crate::{ + access, + search::{ + ElfService, HashMap, OffsetDateTime, RELATION_CONTEXT_SQL, RelationTemporalStatus, Result, + ScoredChunk, SearchExplainRelationContext, SearchExplainRelationContextObject, + SearchExplainRelationEntityRef, SearchRelationContextRow, Uuid, + }, +}; + +impl ElfService { + pub(in crate::search) async fn build_relation_context_for_selected_results( + &self, + selected_results: &[ScoredChunk], + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], + now: OffsetDateTime, + ) -> Result>> { + if !self.cfg.search.graph_context.enabled { + return Ok(HashMap::new()); + } + + let selected_note_ids: Vec = + selected_results.iter().map(|chunk| chunk.item.note.note_id).collect(); + + if selected_note_ids.is_empty() { + return Ok(HashMap::new()); + } + + self.fetch_relation_contexts_for_notes( + selected_note_ids.as_slice(), + tenant_id, + project_id, + agent_id, + allowed_scopes, + now, + ) + .await + } + + pub(in crate::search) async fn fetch_relation_contexts_for_notes( + &self, + note_ids: &[Uuid], + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], + now: OffsetDateTime, + ) -> Result>> { + if note_ids.is_empty() { + return Ok(HashMap::new()); + } + + let private_allowed = allowed_scopes.iter().any(|scope| scope == "agent_private"); + let non_private_scopes: Vec = + allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &self.db.pool, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await?; + let shared_scope_keys = access::shared_scope_key_strings(&shared_grants); + let (max_evidence_notes_per_fact, max_facts_per_item) = self.relation_context_bounds(); + let rows = self + .fetch_relation_context_rows( + note_ids, + tenant_id, + project_id, + agent_id, + &non_private_scopes, + shared_scope_keys.as_slice(), + private_allowed, + now, + max_evidence_notes_per_fact, + max_facts_per_item, + ) + .await?; + + Ok(Self::group_relation_context_rows(rows)) + } + + pub(in crate::search) fn relation_context_bounds(&self) -> (i32, i32) { + let max_evidence_notes_per_fact = + i32::try_from(self.cfg.search.graph_context.max_evidence_notes_per_fact) + .unwrap_or(i32::MAX); + let max_facts_per_item = + i32::try_from(self.cfg.search.graph_context.max_facts_per_item).unwrap_or(i32::MAX); + + (max_evidence_notes_per_fact, max_facts_per_item) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::search) async fn fetch_relation_context_rows( + &self, + note_ids: &[Uuid], + tenant_id: &str, + project_id: &str, + agent_id: &str, + non_private_scopes: &[String], + shared_scope_keys: &[String], + private_allowed: bool, + now: OffsetDateTime, + max_evidence_notes_per_fact: i32, + max_facts_per_item: i32, + ) -> Result> { + Ok(sqlx::query_as::<_, SearchRelationContextRow>(RELATION_CONTEXT_SQL) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(now) + .bind(private_allowed) + .bind(non_private_scopes) + .bind(note_ids) + .bind(max_evidence_notes_per_fact) + .bind(max_facts_per_item) + .bind(shared_scope_keys) + .fetch_all(&self.db.pool) + .await?) + } + + pub(in crate::search) fn group_relation_context_rows( + rows: Vec, + ) -> HashMap> { + let mut relation_context_by_note: HashMap> = + HashMap::new(); + + for row in rows { + if row.evidence_note_ids.is_empty() { + continue; + } + + let object = if row.object_entity_id.is_some() { + SearchExplainRelationContextObject { + entity: Some(SearchExplainRelationEntityRef { + canonical: row.object_canonical, + kind: row.object_kind, + }), + value: None, + } + } else { + SearchExplainRelationContextObject { entity: None, value: row.object_value } + }; + + relation_context_by_note.entry(row.note_id).or_default().push( + SearchExplainRelationContext { + fact_id: row.fact_id, + scope: row.scope, + subject: SearchExplainRelationEntityRef { + canonical: row.subject_canonical, + kind: row.subject_kind, + }, + predicate: row.predicate, + object, + valid_from: row.valid_from, + valid_to: row.valid_to, + temporal_status: if row.is_current { + RelationTemporalStatus::Current + } else { + RelationTemporalStatus::Historical + }, + evidence_note_ids: row.evidence_note_ids, + }, + ); + } + + relation_context_by_note + } +} diff --git a/packages/elf-service/src/search/finish/rerank.rs b/packages/elf-service/src/search/finish/rerank.rs new file mode 100644 index 00000000..b6e74834 --- /dev/null +++ b/packages/elf-service/src/search/finish/rerank.rs @@ -0,0 +1,300 @@ +use crate::{ + Error, + search::{ + self, CacheKind, ChunkCandidate, ChunkMeta, ChunkSnippet, Duration, ElfService, HashMap, + NoteMeta, OffsetDateTime, RerankCacheCandidate, RerankCacheItem, RerankCachePayload, + Result, SearchCache, Uuid, ranking, + }, +}; + +impl ElfService { + pub(in crate::search) async fn build_snippet_items( + &self, + filtered_candidates: &[ChunkCandidate], + note_meta: &HashMap, + ) -> Result> { + if filtered_candidates.is_empty() { + return Ok(Vec::new()); + } + + let pairs = ranking::collect_neighbor_pairs(filtered_candidates); + let chunk_rows = search::fetch_chunks_by_pair(&self.db.pool, &pairs).await?; + let mut chunk_by_id = HashMap::new(); + let mut chunk_by_note_index = HashMap::new(); + + for row in chunk_rows { + chunk_by_note_index.insert((row.note_id, row.chunk_index), row.clone()); + chunk_by_id.insert(row.chunk_id, row); + } + + let mut items = Vec::new(); + + for candidate in filtered_candidates { + let Some(chunk_row) = chunk_by_id.get(&candidate.chunk_id) else { + tracing::warn!( + chunk_id = %candidate.chunk_id, + "Chunk metadata missing for candidate." + ); + + continue; + }; + let snippet = ranking::stitch_snippet( + candidate.note_id, + chunk_row.chunk_index, + &chunk_by_note_index, + ); + + if snippet.is_empty() { + continue; + } + + let Some(note) = note_meta.get(&candidate.note_id) else { continue }; + let chunk = ChunkMeta { + chunk_id: chunk_row.chunk_id, + chunk_index: chunk_row.chunk_index, + start_offset: chunk_row.start_offset, + end_offset: chunk_row.end_offset, + }; + + items.push(ChunkSnippet { + note: note.clone(), + chunk, + snippet, + retrieval_rank: candidate.retrieval_rank, + retrieval_score: candidate.retrieval_score, + }); + } + + Ok(items) + } + + pub(in crate::search) async fn rerank_snippet_items( + &self, + query: &str, + snippet_items: &[ChunkSnippet], + cache_cfg: &SearchCache, + now: OffsetDateTime, + ) -> Result> { + if snippet_items.is_empty() { + return Ok(Vec::new()); + } + + let (cache_candidates, signature) = Self::build_rerank_cache_signature(snippet_items); + let mut cache_key: Option = None; + let mut cached_scores: Option> = None; + + if cache_cfg.enabled { + match ranking::build_rerank_cache_key( + query, + self.cfg.providers.rerank.provider_id.as_str(), + self.cfg.providers.rerank.model.as_str(), + &signature, + ) { + Ok(key) => { + cache_key = Some(key.clone()); + cached_scores = self + .read_rerank_cache_scores(&key, cache_candidates.as_slice(), cache_cfg, now) + .await; + }, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Rerank.as_str(), + "Cache key build failed." + ); + }, + } + } + + if let Some(scores) = cached_scores { + return Ok(scores); + } + + let docs: Vec = snippet_items.iter().map(|item| item.snippet.clone()).collect(); + let scores = self.providers.rerank.rerank(&self.cfg.providers.rerank, query, &docs).await?; + + if scores.len() != snippet_items.len() { + return Err(Error::Provider { + message: "Rerank provider returned mismatched score count.".to_string(), + }); + } + if cache_cfg.enabled + && let Some(key) = cache_key.as_ref() + && !cache_candidates.is_empty() + { + self.store_rerank_cache_scores( + key, + cache_candidates.as_slice(), + scores.as_slice(), + cache_cfg, + ) + .await; + } + + Ok(scores) + } + + pub(in crate::search) fn build_rerank_cache_signature( + snippet_items: &[ChunkSnippet], + ) -> (Vec, Vec<(Uuid, OffsetDateTime)>) { + let candidates: Vec = snippet_items + .iter() + .map(|item| RerankCacheCandidate { + chunk_id: item.chunk.chunk_id, + updated_at: item.note.updated_at, + }) + .collect(); + let signature: Vec<(Uuid, OffsetDateTime)> = + candidates.iter().map(|candidate| (candidate.chunk_id, candidate.updated_at)).collect(); + + (candidates, signature) + } + + pub(in crate::search) async fn read_rerank_cache_scores( + &self, + key: &str, + cache_candidates: &[RerankCacheCandidate], + cache_cfg: &SearchCache, + now: OffsetDateTime, + ) -> Option> { + match search::fetch_cache_payload(&self.db.pool, CacheKind::Rerank, key, now).await { + Ok(Some(payload)) => { + let decoded: RerankCachePayload = match serde_json::from_value(payload.value) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache payload decode failed." + ); + + RerankCachePayload { items: Vec::new() } + }, + }; + + if let Some(scores) = ranking::build_cached_scores(&decoded, cache_candidates) { + tracing::info!( + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = true, + payload_size = payload.size_bytes, + ttl_days = cache_cfg.rerank_ttl_days, + "Cache hit." + ); + + Some(scores) + } else { + tracing::warn!( + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size = payload.size_bytes, + ttl_days = cache_cfg.rerank_ttl_days, + "Cache payload did not match candidates." + ); + + None + } + }, + Ok(None) => { + tracing::info!( + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size = 0_u64, + ttl_days = cache_cfg.rerank_ttl_days, + "Cache miss." + ); + + None + }, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache read failed." + ); + + None + }, + } + } + + pub(in crate::search) async fn store_rerank_cache_scores( + &self, + key: &str, + cache_candidates: &[RerankCacheCandidate], + scores: &[f32], + cache_cfg: &SearchCache, + ) { + let payload = RerankCachePayload { + items: cache_candidates + .iter() + .zip(scores.iter()) + .map(|(candidate, score)| RerankCacheItem { + chunk_id: candidate.chunk_id, + updated_at: candidate.updated_at, + score: *score, + }) + .collect(), + }; + + match serde_json::to_value(&payload) { + Ok(payload_json) => { + let stored_at = OffsetDateTime::now_utc(); + let expires_at = stored_at + Duration::days(cache_cfg.rerank_ttl_days); + + match search::store_cache_payload( + &self.db.pool, + CacheKind::Rerank, + key, + payload_json, + stored_at, + expires_at, + cache_cfg.max_payload_bytes, + ) + .await + { + Ok(Some(payload_size)) => { + tracing::info!( + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size, + ttl_days = cache_cfg.rerank_ttl_days, + "Cache stored." + ); + }, + Ok(None) => { + tracing::warn!( + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size = 0_u64, + ttl_days = cache_cfg.rerank_ttl_days, + "Cache payload skipped due to size." + ); + }, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache write failed." + ); + }, + } + }, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Rerank.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache payload encode failed." + ); + }, + } + } +} diff --git a/packages/elf-service/src/search/finish/scoring.rs b/packages/elf-service/src/search/finish/scoring.rs new file mode 100644 index 00000000..bafd16ff --- /dev/null +++ b/packages/elf-service/src/search/finish/scoring.rs @@ -0,0 +1,257 @@ +use crate::search::{ + self, ChunkCandidate, ChunkSnippet, DiversityDecision, ElfService, FinishSearchPolicies, + FinishSearchScoringResult, HashMap, MAX_MATCHED_TERMS, NoteMeta, OffsetDateTime, Ordering, + RankingRequestOverride, ResolvedDiversityPolicy, Result, ScoreCandidateCtx, ScoreSnippetArgs, + ScoredChunk, SearchFilter, SearchFilterImpact, Uuid, ranking, structured, +}; + +impl ElfService { + #[allow(clippy::too_many_arguments)] + pub(in crate::search) async fn build_finish_search_scoring( + &self, + query: &str, + candidates: Vec, + note_meta: &HashMap, + policies: &FinishSearchPolicies, + top_k: u32, + candidate_count: usize, + filter: Option<&SearchFilter>, + requested_candidate_k: u32, + effective_candidate_k: u32, + now: OffsetDateTime, + skip_rerank: bool, + ) -> Result { + let (filtered_candidates, filter_impact) = self.apply_filter_to_candidates( + candidates, + note_meta, + filter, + requested_candidate_k, + effective_candidate_k, + ); + let filtered_candidate_count = filtered_candidates.len(); + let snippet_items = self.build_snippet_items(&filtered_candidates, note_meta).await?; + let snippet_count = snippet_items.len(); + let query_tokens = ranking::tokenize_query(query, MAX_MATCHED_TERMS); + let scope_context_boost_by_scope = + ranking::build_scope_context_boost_by_scope(&query_tokens, self.cfg.context.as_ref()); + let det_query_tokens = structured::build_deterministic_query_tokens(&self.cfg, query); + let scored = self + .score_snippet_items(ScoreSnippetArgs { + query, + snippet_items, + scope_context_boost_by_scope: &scope_context_boost_by_scope, + det_query_tokens: det_query_tokens.as_slice(), + blend_policy: &policies.blend_policy, + cache_cfg: &self.cfg.search.cache, + now, + candidate_count, + skip_rerank, + }) + .await?; + let scored_count = scored.len(); + let trace_candidates = self.build_trace_candidates(&scored, now); + let results = search::select_best_scored_chunks(scored); + let fused_results = results.clone(); + let (selected_results, diversity_decisions) = + self.apply_diversity_policy(results, top_k, &policies.diversity_policy).await?; + let selected_count = selected_results.len(); + + Ok(FinishSearchScoringResult { + query_tokens, + filtered_candidates, + scored_count, + snippet_count, + filtered_candidate_count, + filter_impact, + trace_candidates, + fused_results, + selected_results, + diversity_decisions, + selected_count, + }) + } + + pub(in crate::search) fn apply_filter_to_candidates( + &self, + candidates: Vec, + note_meta: &HashMap, + filter: Option<&SearchFilter>, + requested_candidate_k: u32, + effective_candidate_k: u32, + ) -> (Vec, Option) { + let filtered_candidates: Vec = candidates + .into_iter() + .filter(|candidate| ranking::candidate_matches_note(note_meta, candidate)) + .collect(); + + match filter { + Some(filter) => { + let (candidates, filter_impact) = filter.eval( + filtered_candidates, + note_meta, + requested_candidate_k, + effective_candidate_k, + ); + + (candidates, Some(filter_impact)) + }, + None => (filtered_candidates, None), + } + } + + pub(in crate::search) fn resolve_finish_search_policies( + &self, + ranking_override: Option<&RankingRequestOverride>, + ) -> Result { + let blend_policy = ranking::resolve_blend_policy( + &self.cfg.ranking.blend, + ranking_override.and_then(|override_| override_.blend.as_ref()), + )?; + let diversity_policy = ranking::resolve_diversity_policy( + &self.cfg.ranking.diversity, + ranking_override.and_then(|override_| override_.diversity.as_ref()), + )?; + let retrieval_sources_policy = ranking::resolve_retrieval_sources_policy( + &self.cfg.ranking.retrieval_sources, + ranking_override.and_then(|override_| override_.retrieval_sources.as_ref()), + )?; + let policy_snapshot = ranking::build_policy_snapshot( + &self.cfg, + &blend_policy, + &diversity_policy, + &retrieval_sources_policy, + ranking_override, + ); + let policy_hash = ranking::hash_policy_snapshot(&policy_snapshot)?; + let policy_id = format!("ranking_v2:{}", &policy_hash[..12.min(policy_hash.len())]); + + Ok(FinishSearchPolicies { + blend_policy, + diversity_policy, + retrieval_sources_policy, + policy_snapshot, + policy_id, + }) + } + + pub(in crate::search) async fn score_snippet_items( + &self, + args: ScoreSnippetArgs<'_, '_>, + ) -> Result> { + let ScoreSnippetArgs { + query, + snippet_items, + scope_context_boost_by_scope, + det_query_tokens, + blend_policy, + cache_cfg, + now, + candidate_count, + skip_rerank, + } = args; + + if snippet_items.is_empty() { + return Ok(Vec::new()); + } + + let scores = if skip_rerank { + Self::build_quick_find_rerank_scores(&snippet_items) + } else { + self.rerank_snippet_items(query, snippet_items.as_slice(), cache_cfg, now).await? + }; + let rerank_ranks = ranking::build_rerank_ranks(&snippet_items, &scores); + let total_rerank = u32::try_from(scores.len()).unwrap_or(1).max(1); + let total_retrieval = u32::try_from(candidate_count).unwrap_or(1).max(1); + let score_ctx = ScoreCandidateCtx { + cfg: &self.cfg, + blend_policy, + scope_context_boost_by_scope, + det_query_tokens, + now, + total_rerank, + total_retrieval, + }; + let mut scored = Vec::with_capacity(snippet_items.len()); + + for ((item, rerank_score), rerank_rank) in + snippet_items.into_iter().zip(scores).zip(rerank_ranks) + { + scored.push(search::score_chunk_candidate(&score_ctx, item, rerank_score, rerank_rank)); + } + + Ok(scored) + } + + pub(in crate::search) fn build_quick_find_rerank_scores( + snippet_items: &[ChunkSnippet], + ) -> Vec { + let mut idxs: Vec = (0..snippet_items.len()).collect(); + + idxs.sort_by(|&a, &b| { + let ord = snippet_items[a].retrieval_rank.cmp(&snippet_items[b].retrieval_rank); + + if ord != Ordering::Equal { + return ord; + } + + let ord = snippet_items[a].chunk.chunk_index.cmp(&snippet_items[b].chunk.chunk_index); + + if ord != Ordering::Equal { + return ord; + } + + snippet_items[a].chunk.chunk_id.cmp(&snippet_items[b].chunk.chunk_id) + }); + + let total = idxs.len(); + + if total == 0 { + return Vec::new(); + } + + let mut scores = vec![0_f32; total]; + + for (rank, idx) in idxs.into_iter().enumerate() { + scores[idx] = 1.0 / (rank as f32 + 1.0); + } + + scores + } + + pub(in crate::search) async fn apply_diversity_policy( + &self, + results: Vec, + top_k: u32, + diversity_policy: &ResolvedDiversityPolicy, + ) -> Result<(Vec, HashMap)> { + let note_vectors = if diversity_policy.enabled { + search::fetch_note_vectors_for_diversity(&self.db.pool, results.as_slice()).await? + } else { + HashMap::new() + }; + let (selected_results, diversity_decisions) = + ranking::select_diverse_results(results, top_k, diversity_policy, ¬e_vectors); + + Ok((selected_results, diversity_decisions)) + } + + pub(in crate::search) async fn record_hits_if_enabled( + &self, + enabled: bool, + query: &str, + selected_results: &[ScoredChunk], + now: OffsetDateTime, + ) -> Result<()> { + if !enabled || selected_results.is_empty() { + return Ok(()); + } + + let mut tx = self.db.pool.begin().await?; + + search::record_hits(&mut *tx, query, selected_results, now).await?; + + tx.commit().await?; + + Ok(()) + } +} diff --git a/packages/elf-service/src/search/finish/trace.rs b/packages/elf-service/src/search/finish/trace.rs new file mode 100644 index 00000000..5bb52622 --- /dev/null +++ b/packages/elf-service/src/search/finish/trace.rs @@ -0,0 +1,178 @@ +use crate::search::{ + self, BuildSearchItemArgs, BuildTraceArgs, Duration, ElfService, OffsetDateTime, Result, + ScoredChunk, SearchItem, SearchTraceBuilder, SearchTrajectoryStage, SearchTrajectoryStageItem, + SearchTrajectorySummary, TraceCandidateRecord, TraceContext, TracePayload, + TraceTrajectoryStageItemRecord, Uuid, ranking, +}; + +impl ElfService { + pub(in crate::search) async fn build_items_and_write_trace( + &self, + args: BuildTraceArgs<'_>, + ) -> Result<(Vec, SearchTrajectorySummary)> { + let trace_id = args.trace_id; + let (items, trajectory_summary, trace_payload) = self.build_items_and_trace_payload(args); + + self.write_trace_payload(trace_id, trace_payload).await?; + + Ok((items, trajectory_summary)) + } + + pub(in crate::search) fn build_trace_candidates( + &self, + scored: &[ScoredChunk], + now: OffsetDateTime, + ) -> Vec { + if !self.cfg.search.explain.capture_candidates || scored.is_empty() { + return Vec::new(); + } + + let candidate_expires_at = + now + Duration::days(self.cfg.search.explain.candidate_retention_days); + + scored + .iter() + .map(|scored_chunk| { + search::build_trace_candidate_record(scored_chunk, now, candidate_expires_at) + }) + .collect() + } + + pub(in crate::search) fn build_items_and_trace_payload( + &self, + args: BuildTraceArgs<'_>, + ) -> (Vec, SearchTrajectorySummary, TracePayload) { + let mut trajectory_stages = search::build_trace_trajectory_stages(&args); + let trace_context = TraceContext { + trace_id: args.trace_id, + tenant_id: args.tenant_id, + project_id: args.project_id, + agent_id: args.agent_id, + read_profile: args.read_profile, + query: args.query, + expansion_mode: args.expansion_mode, + expanded_queries: args.expanded_queries.clone(), + allowed_scopes: args.allowed_scopes, + candidate_count: args.candidate_count, + top_k: args.top_k, + }; + let mut config_snapshot = ranking::build_config_snapshot( + &self.cfg, + &args.policies.blend_policy, + &args.policies.diversity_policy, + &args.policies.retrieval_sources_policy, + args.ranking_override.as_ref(), + args.policies.policy_id.as_str(), + &args.policies.policy_snapshot, + ); + + if let Some(object) = config_snapshot.as_object_mut() { + object.insert( + "audit".to_string(), + search::build_trace_audit(args.agent_id, args.token_id), + ); + } + + let mut items = Vec::with_capacity(args.selected_results.len()); + let mut trace_builder = SearchTraceBuilder::new( + trace_context, + config_snapshot, + self.cfg.search.explain.retention_days, + args.now, + ); + let mut final_stage_items = Vec::new(); + + for candidate in args.trace_candidates { + trace_builder.push_candidate(candidate); + } + for (idx, scored_chunk) in args.selected_results.into_iter().enumerate() { + let rank = idx as u32 + 1; + let (item, trace_item) = + search::build_search_item_and_trace_item(BuildSearchItemArgs { + cfg: &self.cfg, + policy_id: args.policies.policy_id.as_str(), + blend_policy: &args.policies.blend_policy, + diversity_policy: &args.policies.diversity_policy, + diversity_decisions: args.diversity_decisions, + query_tokens: args.query_tokens, + structured_matches: args.structured_matches, + relation_contexts: &args.relation_contexts, + scored_chunk, + rank, + }); + let item = search::apply_payload_level_to_search_item(item, args.payload_level); + + final_stage_items.push(TraceTrajectoryStageItemRecord { + id: Uuid::new_v4(), + item_id: Some(item.result_handle), + note_id: Some(item.note_id), + chunk_id: Some(item.chunk_id), + metrics: serde_json::json!({ + "rank": rank, + "final_score": item.final_score, + }), + }); + items.push(item); + trace_builder.push_item(trace_item); + } + + if let Some(stage) = + trajectory_stages.iter_mut().find(|stage| stage.stage_name == "selection.final") + { + stage.items = final_stage_items; + } + + let trajectory_summary = search::build_trajectory_summary_from_stages( + &trajectory_stages + .iter() + .map(|stage| SearchTrajectoryStage { + stage_order: stage.stage_order, + stage_name: stage.stage_name.clone(), + stage_payload: stage.stage_payload.clone(), + items: stage + .items + .iter() + .map(|item| SearchTrajectoryStageItem { + item_id: item.item_id, + note_id: item.note_id, + chunk_id: item.chunk_id, + metrics: item.metrics.clone(), + }) + .collect(), + }) + .collect::>(), + ); + + for stage in trajectory_stages { + trace_builder.push_stage(stage); + } + + (items, trajectory_summary, trace_builder.build()) + } + + pub(in crate::search) async fn write_trace_payload( + &self, + trace_id: Uuid, + trace_payload: TracePayload, + ) -> Result<()> { + match self.cfg.search.explain.write_mode.trim().to_ascii_lowercase().as_str() { + "inline" => { + let mut tx = self.db.pool.begin().await?; + + search::persist_trace_inline(&mut tx, trace_payload).await?; + + tx.commit().await?; + }, + _ => + if let Err(err) = search::enqueue_trace(&self.db.pool, trace_payload).await { + tracing::error!( + error = %err, + trace_id = %trace_id, + "Failed to enqueue search trace." + ); + }, + } + + Ok(()) + } +} diff --git a/packages/elf-service/src/search/helpers.rs b/packages/elf-service/src/search/helpers.rs new file mode 100644 index 00000000..4b277123 --- /dev/null +++ b/packages/elf-service/src/search/helpers.rs @@ -0,0 +1,132 @@ +use crate::{ + Error, + search::{ + Condition, Filter, MinShould, ORG_PROJECT_ID, PayloadLevel, RawSearchPath, Result, + SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, SearchItem, SearchTrajectoryStage, + SearchTrajectorySummary, SearchTrajectorySummaryStage, english_gate, + }, +}; + +pub(super) fn apply_payload_level_to_search_item( + mut item: SearchItem, + payload_level: PayloadLevel, +) -> SearchItem { + if payload_level == PayloadLevel::L2 { + return item; + } + + item.source_ref = serde_json::json!({}); + + item +} + +pub(super) fn validate_search_request_inputs( + tenant_id: &str, + project_id: &str, + agent_id: &str, + query: &str, +) -> Result<()> { + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + if !english_gate::is_english_natural_language(query) { + return Err(Error::NonEnglishInput { field: "$.query".to_string() }); + } + + Ok(()) +} + +pub(super) fn raw_search_path_label(path: RawSearchPath) -> &'static str { + match path { + RawSearchPath::Quick => "quick", + RawSearchPath::Planned => "planned", + } +} + +pub(super) fn sorted_unique_strings(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + + values +} + +pub(super) fn build_trajectory_summary_from_stages( + stages: &[SearchTrajectoryStage], +) -> SearchTrajectorySummary { + let summary_stages = stages + .iter() + .map(|stage| { + let stats = + stage.stage_payload.get("stats").cloned().unwrap_or_else(|| serde_json::json!({})); + + SearchTrajectorySummaryStage { + stage_order: stage.stage_order, + stage_name: stage.stage_name.clone(), + item_count: stage.items.len() as u32, + stats, + } + }) + .collect(); + + SearchTrajectorySummary { + schema: SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1.to_string(), + stages: summary_stages, + } +} + +pub(super) fn build_search_filter( + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], +) -> Filter { + let private_scope = "agent_private".to_string(); + let non_private_scopes: Vec = + allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); + let mut scope_should_conditions = Vec::new(); + + if allowed_scopes.iter().any(|scope| scope == "agent_private") { + let private_filter = Filter::all([ + Condition::matches("scope", private_scope), + Condition::matches("agent_id", agent_id.to_string()), + ]); + + scope_should_conditions.push(Condition::from(private_filter)); + } + if !non_private_scopes.is_empty() { + scope_should_conditions.push(Condition::matches("scope", non_private_scopes)); + } + + let scope_min_should = if scope_should_conditions.is_empty() { + None + } else { + Some(MinShould { min_count: 1, conditions: scope_should_conditions }) + }; + let mut project_or_org_branches = vec![Condition::from(Filter { + must: vec![Condition::matches("project_id", project_id.to_string())], + should: Vec::new(), + must_not: Vec::new(), + min_should: scope_min_should, + })]; + + if allowed_scopes.iter().any(|scope| scope == "org_shared") { + let org_filter = Filter::all([ + Condition::matches("project_id", ORG_PROJECT_ID.to_string()), + Condition::matches("scope", "org_shared".to_string()), + ]); + + project_or_org_branches.push(Condition::from(org_filter)); + } + + Filter { + must: vec![ + Condition::matches("tenant_id", tenant_id.to_string()), + Condition::matches("status", "active".to_string()), + ], + should: Vec::new(), + must_not: Vec::new(), + min_should: Some(MinShould { min_count: 1, conditions: project_or_org_branches }), + } +} diff --git a/packages/elf-service/src/search/hits.rs b/packages/elf-service/src/search/hits.rs new file mode 100644 index 00000000..014ffaf6 --- /dev/null +++ b/packages/elf-service/src/search/hits.rs @@ -0,0 +1,80 @@ +use crate::search::{OffsetDateTime, PgExecutor, Result, ScoredChunk, Uuid, ranking}; + +pub(super) async fn record_hits<'e, E>( + executor: E, + query: &str, + scored: &[ScoredChunk], + now: OffsetDateTime, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + if scored.is_empty() { + return Ok(()); + } + + let query_hash = ranking::hash_query(query); + let mut hit_ids = Vec::with_capacity(scored.len()); + let mut note_ids = Vec::with_capacity(scored.len()); + let mut chunk_ids = Vec::with_capacity(scored.len()); + let mut ranks = Vec::with_capacity(scored.len()); + let mut final_scores = Vec::with_capacity(scored.len()); + + for (rank, scored_chunk) in scored.iter().enumerate() { + hit_ids.push(Uuid::new_v4()); + note_ids.push(scored_chunk.item.note.note_id); + chunk_ids.push(scored_chunk.item.chunk.chunk_id); + ranks.push(rank as i32); + final_scores.push(scored_chunk.final_score); + } + + sqlx::query( + "\ +WITH hits AS ( + SELECT * + FROM unnest( + $1::uuid[], + $2::uuid[], + $3::uuid[], + $4::int4[], + $5::real[] + ) AS t(hit_id, note_id, chunk_id, rank, final_score) +), +updated AS ( + UPDATE memory_notes + SET + hit_count = hit_count + 1, + last_hit_at = $6 + WHERE note_id = ANY($2) +) +INSERT INTO memory_hits ( + hit_id, + note_id, + chunk_id, + query_hash, + rank, + final_score, + ts +) +SELECT + hit_id, + note_id, + chunk_id, + $7, + rank, + final_score, + $6 + FROM hits", + ) + .bind(&hit_ids) + .bind(¬e_ids) + .bind(&chunk_ids) + .bind(&ranks) + .bind(&final_scores) + .bind(now) + .bind(query_hash.as_str()) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/packages/elf-service/src/search/item_builders.rs b/packages/elf-service/src/search/item_builders.rs new file mode 100644 index 00000000..4bd5542b --- /dev/null +++ b/packages/elf-service/src/search/item_builders.rs @@ -0,0 +1,163 @@ +use crate::{ + ranking_explain_v2, + search::{ + BuildSearchItemArgs, MAX_MATCHED_TERMS, OffsetDateTime, SEARCH_RANKING_EXPLAIN_SCHEMA_V2, + ScoredChunk, SearchExplain, SearchItem, SearchMatchExplain, SearchRankingExplain, + TraceCandidateRecord, TraceItemRecord, TraceReplayCandidate, TraceTermsArgs, Uuid, ranking, + }, +}; + +pub(super) fn build_trace_candidate_record( + scored_chunk: &ScoredChunk, + now: OffsetDateTime, + expires_at: OffsetDateTime, +) -> TraceCandidateRecord { + let note = &scored_chunk.item.note; + + TraceCandidateRecord { + candidate_id: Uuid::new_v4(), + note_id: note.note_id, + chunk_id: scored_chunk.item.chunk.chunk_id, + chunk_index: scored_chunk.item.chunk.chunk_index, + snippet: scored_chunk.item.snippet.clone(), + candidate_snapshot: serde_json::to_value(TraceReplayCandidate { + note_id: note.note_id, + chunk_id: scored_chunk.item.chunk.chunk_id, + chunk_index: scored_chunk.item.chunk.chunk_index, + snippet: scored_chunk.item.snippet.clone(), + retrieval_rank: scored_chunk.item.retrieval_rank, + retrieval_score: scored_chunk.item.retrieval_score, + rerank_score: scored_chunk.rerank_score, + note_scope: note.scope.clone(), + note_importance: note.importance, + note_updated_at: note.updated_at, + note_hit_count: note.hit_count, + note_last_hit_at: note.last_hit_at, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }) + .unwrap_or_else(|_| serde_json::json!({})), + retrieval_rank: scored_chunk.item.retrieval_rank, + rerank_score: scored_chunk.rerank_score, + note_scope: note.scope.clone(), + note_importance: note.importance, + note_updated_at: note.updated_at, + note_hit_count: note.hit_count, + note_last_hit_at: note.last_hit_at, + created_at: now, + expires_at, + } +} + +pub(super) fn build_search_item_and_trace_item( + args: BuildSearchItemArgs<'_>, +) -> (SearchItem, TraceItemRecord) { + let (matched_terms, matched_fields) = ranking::match_terms_in_text( + args.query_tokens, + args.scored_chunk.item.snippet.as_str(), + args.scored_chunk.item.note.key.as_deref(), + MAX_MATCHED_TERMS, + ); + let matched_fields = ranking::merge_matched_fields( + matched_fields, + args.structured_matches.get(&args.scored_chunk.item.note.note_id), + ); + let trace_terms = ranking_explain_v2::build_trace_terms_v2(TraceTermsArgs { + cfg: args.cfg, + blend_enabled: args.blend_policy.enabled, + retrieval_normalization: args.blend_policy.retrieval_normalization.as_str(), + rerank_normalization: args.blend_policy.rerank_normalization.as_str(), + blend_retrieval_weight: args.scored_chunk.blend_retrieval_weight, + retrieval_rank: args.scored_chunk.item.retrieval_rank, + retrieval_norm: args.scored_chunk.retrieval_norm, + retrieval_term: args.scored_chunk.retrieval_term, + rerank_score: args.scored_chunk.rerank_score, + rerank_rank: args.scored_chunk.rerank_rank, + rerank_norm: args.scored_chunk.rerank_norm, + rerank_term: args.scored_chunk.rerank_term, + tie_breaker_score: args.scored_chunk.tie_breaker_score, + importance: args.scored_chunk.importance, + age_days: args.scored_chunk.age_days, + scope: args.scored_chunk.item.note.scope.as_str(), + scope_context_boost: args.scored_chunk.scope_context_boost, + deterministic_lexical_overlap_ratio: args.scored_chunk.deterministic_lexical_overlap_ratio, + deterministic_lexical_bonus: args.scored_chunk.deterministic_lexical_bonus, + deterministic_hit_count: args.scored_chunk.deterministic_hit_count, + deterministic_last_hit_age_days: args.scored_chunk.deterministic_last_hit_age_days, + deterministic_hit_boost: args.scored_chunk.deterministic_hit_boost, + deterministic_decay_penalty: args.scored_chunk.deterministic_decay_penalty, + }); + let response_terms = ranking_explain_v2::strip_term_inputs(&trace_terms); + let relation_context = + args.relation_contexts.get(&args.scored_chunk.item.note.note_id).cloned(); + let diversity = if args.diversity_policy.enabled { + args.diversity_decisions + .get(&args.scored_chunk.item.note.note_id) + .map(ranking::build_diversity_explain) + } else { + None + }; + let response_explain = SearchExplain { + r#match: SearchMatchExplain { + matched_terms: matched_terms.clone(), + matched_fields: matched_fields.clone(), + }, + ranking: SearchRankingExplain { + schema: SEARCH_RANKING_EXPLAIN_SCHEMA_V2.to_string(), + policy_id: args.policy_id.to_string(), + final_score: args.scored_chunk.final_score, + terms: response_terms, + }, + relation_context: relation_context.clone(), + diversity: diversity.clone(), + }; + let trace_explain = SearchExplain { + r#match: SearchMatchExplain { matched_terms, matched_fields }, + ranking: SearchRankingExplain { + schema: SEARCH_RANKING_EXPLAIN_SCHEMA_V2.to_string(), + policy_id: args.policy_id.to_string(), + final_score: args.scored_chunk.final_score, + terms: trace_terms, + }, + relation_context, + diversity, + }; + let result_handle = Uuid::new_v4(); + let note = &args.scored_chunk.item.note; + let chunk = &args.scored_chunk.item.chunk; + let item = SearchItem { + result_handle, + note_id: note.note_id, + chunk_id: chunk.chunk_id, + chunk_index: chunk.chunk_index, + start_offset: chunk.start_offset, + end_offset: chunk.end_offset, + snippet: args.scored_chunk.item.snippet.clone(), + r#type: note.note_type.clone(), + key: note.key.clone(), + scope: note.scope.clone(), + importance: note.importance, + confidence: note.confidence, + updated_at: note.updated_at, + expires_at: note.expires_at, + final_score: args.scored_chunk.final_score, + source_ref: note.source_ref.clone(), + explain: response_explain, + }; + let trace_item = TraceItemRecord { + item_id: result_handle, + note_id: note.note_id, + chunk_id: Some(chunk.chunk_id), + rank: args.rank, + final_score: args.scored_chunk.final_score, + explain: trace_explain, + }; + + (item, trace_item) +} diff --git a/packages/elf-service/src/search/query_plan.rs b/packages/elf-service/src/search/query_plan.rs new file mode 100644 index 00000000..a4e7b335 --- /dev/null +++ b/packages/elf-service/src/search/query_plan.rs @@ -0,0 +1,221 @@ +use crate::search::{ + self, BuildQueryPlanArgs, DynamicGateSummary, ElfService, ExpansionMode, FinishSearchPolicies, + QueryPlan, QueryPlanBlendSegment, QueryPlanBudget, QueryPlanDynamicGate, QueryPlanFusionPolicy, + QueryPlanIntent, QueryPlanRerankPolicy, QueryPlanRetrievalStage, QueryPlanRewrite, + QueryPlanStage, QueryPlanStagesArgs, ResolvedRetrievalSourcesPolicy, ranking, + raw_search_path_label, +}; + +const QUERY_PLAN_SCHEMA: &str = "elf.search.query_plan"; +const QUERY_PLAN_VERSION: &str = "v1"; + +impl ElfService { + pub(super) fn build_query_plan(&self, args: BuildQueryPlanArgs<'_>) -> QueryPlan { + let allowed_scopes = search::sorted_unique_strings(args.allowed_scopes.to_vec()); + let expanded_queries = search::sorted_unique_strings(args.expanded_queries); + let retrieval_stages = self.build_query_plan_retrieval_stages( + args.candidate_k, + args.retrieval_sources_policy, + args.recursive_enabled, + ); + let rewrite = + self.build_query_plan_rewrite(args.expansion_mode, expanded_queries, args.dynamic_gate); + let fusion_policy = self.build_query_plan_fusion_policy(args.retrieval_sources_policy); + let rerank_policy = self.build_query_plan_rerank_policy(args.policies); + let budget = self.build_query_plan_budget(args.top_k, args.candidate_k); + let stages = Self::build_query_plan_stages(QueryPlanStagesArgs { + path: args.path, + query: args.query, + read_profile: args.read_profile, + allowed_scope_count: allowed_scopes.len(), + rewrite: &rewrite, + retrieval_stages: &retrieval_stages, + fusion_policy: &fusion_policy, + rerank_policy: &rerank_policy, + budget: &budget, + }); + + QueryPlan { + schema: QUERY_PLAN_SCHEMA.to_string(), + version: QUERY_PLAN_VERSION.to_string(), + stages, + intent: QueryPlanIntent { + query: args.query.to_string(), + tenant_id: args.tenant_id.to_string(), + project_id: args.project_id.to_string(), + agent_id: args.agent_id.to_string(), + read_profile: args.read_profile.to_string(), + allowed_scopes, + }, + rewrite, + retrieval_stages, + fusion_policy, + rerank_policy, + budget, + } + } + + fn build_query_plan_retrieval_stages( + &self, + candidate_k: u32, + retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, + recursive_enabled: bool, + ) -> Vec { + let mut stages = vec![ + QueryPlanRetrievalStage { + name: "fusion_dense_bm25".to_string(), + source: "qdrant_fusion".to_string(), + enabled: true, + candidate_limit: candidate_k, + }, + QueryPlanRetrievalStage { + name: "structured_field_vector".to_string(), + source: "postgres_vector".to_string(), + enabled: retrieval_sources_policy.structured_field_weight > 0.0, + candidate_limit: candidate_k, + }, + ]; + + if recursive_enabled { + stages.push(QueryPlanRetrievalStage { + name: "recursive_scope".to_string(), + source: "scope_graph".to_string(), + enabled: retrieval_sources_policy.recursive_weight > 0.0, + candidate_limit: candidate_k, + }); + } + + stages + } + + fn build_query_plan_rewrite( + &self, + expansion_mode: ExpansionMode, + expanded_queries: Vec, + dynamic_gate: DynamicGateSummary, + ) -> QueryPlanRewrite { + QueryPlanRewrite { + expansion_mode: ranking::expansion_mode_label(expansion_mode).to_string(), + expanded_queries, + dynamic_gate: QueryPlanDynamicGate { + considered: dynamic_gate.considered, + should_expand: dynamic_gate.should_expand, + observed_candidates: dynamic_gate.observed_candidates, + observed_top_score: dynamic_gate.observed_top_score, + min_candidates: self.cfg.search.dynamic.min_candidates, + min_top_score: self.cfg.search.dynamic.min_top_score, + }, + } + } + + fn build_query_plan_fusion_policy( + &self, + retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, + ) -> QueryPlanFusionPolicy { + QueryPlanFusionPolicy { + strategy: "weighted_merge".to_string(), + fusion_weight: retrieval_sources_policy.fusion_weight, + structured_field_weight: retrieval_sources_policy.structured_field_weight, + recursive_weight: retrieval_sources_policy.recursive_weight, + fusion_priority: retrieval_sources_policy.fusion_priority, + structured_field_priority: retrieval_sources_policy.structured_field_priority, + recursive_priority: retrieval_sources_policy.recursive_priority, + } + } + + fn build_query_plan_rerank_policy( + &self, + policies: &FinishSearchPolicies, + ) -> QueryPlanRerankPolicy { + QueryPlanRerankPolicy { + provider_id: self.cfg.providers.rerank.provider_id.clone(), + model: self.cfg.providers.rerank.model.clone(), + blend_enabled: policies.blend_policy.enabled, + rerank_normalization: policies.blend_policy.rerank_normalization.as_str().to_string(), + retrieval_normalization: policies + .blend_policy + .retrieval_normalization + .as_str() + .to_string(), + blend_segments: policies + .blend_policy + .segments + .iter() + .map(|segment| QueryPlanBlendSegment { + max_retrieval_rank: segment.max_retrieval_rank, + retrieval_weight: segment.retrieval_weight, + }) + .collect(), + diversity_enabled: policies.diversity_policy.enabled, + diversity_sim_threshold: policies.diversity_policy.sim_threshold, + diversity_mmr_lambda: policies.diversity_policy.mmr_lambda, + diversity_max_skips: policies.diversity_policy.max_skips, + } + } + + fn build_query_plan_budget(&self, top_k: u32, candidate_k: u32) -> QueryPlanBudget { + QueryPlanBudget { + top_k, + candidate_k, + prefilter_max_candidates: self.cfg.search.prefilter.max_candidates, + expansion_max_queries: self.cfg.search.expansion.max_queries, + cache_enabled: self.cfg.search.cache.enabled, + } + } + + fn build_query_plan_stages(args: QueryPlanStagesArgs<'_>) -> Vec { + vec![ + QueryPlanStage { + name: "intent".to_string(), + details: serde_json::json!({ + "path": raw_search_path_label(args.path), + "query": args.query, + "read_profile": args.read_profile, + "allowed_scope_count": args.allowed_scope_count, + }), + }, + QueryPlanStage { + name: "rewrite".to_string(), + details: serde_json::json!({ + "expansion_mode": args.rewrite.expansion_mode.as_str(), + "expanded_query_count": args.rewrite.expanded_queries.len(), + "dynamic_gate_considered": args.rewrite.dynamic_gate.considered, + "dynamic_gate_should_expand": args.rewrite.dynamic_gate.should_expand, + }), + }, + QueryPlanStage { + name: "retrieval".to_string(), + details: serde_json::json!({ + "stages": args.retrieval_stages, + }), + }, + QueryPlanStage { + name: "fusion".to_string(), + details: serde_json::json!({ + "strategy": args.fusion_policy.strategy.as_str(), + "fusion_weight": args.fusion_policy.fusion_weight, + "structured_field_weight": args.fusion_policy.structured_field_weight, + }), + }, + QueryPlanStage { + name: "rerank".to_string(), + details: serde_json::json!({ + "provider_id": args.rerank_policy.provider_id.as_str(), + "model": args.rerank_policy.model.as_str(), + "blend_enabled": args.rerank_policy.blend_enabled, + "diversity_enabled": args.rerank_policy.diversity_enabled, + }), + }, + QueryPlanStage { + name: "budget".to_string(), + details: serde_json::json!({ + "top_k": args.budget.top_k, + "candidate_k": args.budget.candidate_k, + "prefilter_max_candidates": args.budget.prefilter_max_candidates, + "expansion_max_queries": args.budget.expansion_max_queries, + "cache_enabled": args.budget.cache_enabled, + }), + }, + ] + } +} diff --git a/packages/elf-service/src/search/ranking.rs b/packages/elf-service/src/search/ranking.rs index e5397982..037cf8fb 100644 --- a/packages/elf-service/src/search/ranking.rs +++ b/packages/elf-service/src/search/ranking.rs @@ -37,6 +37,6 @@ pub(super) use self::{ }; #[cfg(test)] pub(super) use self::{ - policy::BlendSegment, + policy::types::BlendSegment, text::{lexical_overlap_ratio, scope_description_boost}, }; diff --git a/packages/elf-service/src/search/ranking/diversity.rs b/packages/elf-service/src/search/ranking/diversity.rs index ea09085f..a52c33a2 100644 --- a/packages/elf-service/src/search/ranking/diversity.rs +++ b/packages/elf-service/src/search/ranking/diversity.rs @@ -1,498 +1,13 @@ -use std::{cmp::Ordering, collections::HashMap}; - -use uuid::Uuid; - -use crate::search::{ - ChunkSnippet, DiversityDecision, ScoredChunk, SearchDiversityExplain, TraceCandidateRecord, - TraceReplayCandidate, - ranking::{policy::ResolvedDiversityPolicy, retrieval}, +mod rank; +mod selection; +mod similarity; +mod trace; + +pub use self::{ + rank::{build_rerank_ranks, build_rerank_ranks_for_replay}, + selection::select_diverse_results, + trace::{ + attach_diversity_decisions_to_trace_candidates, build_diversity_explain, + extract_replay_diversity_decisions, + }, }; - -#[derive(Clone, Copy)] -struct DiversityPick { - remaining_pos: usize, - mmr_score: f32, - nearest_note_id: Option, - similarity: Option, - missing_embedding: bool, - retrieval_rank: u32, -} -impl DiversityPick { - fn better_than(self, other: &Self) -> bool { - self.mmr_score > other.mmr_score - || (self.mmr_score == other.mmr_score && self.retrieval_rank < other.retrieval_rank) - } -} - -pub fn build_diversity_explain(decision: &DiversityDecision) -> SearchDiversityExplain { - SearchDiversityExplain { - enabled: true, - selected_reason: decision.selected_reason.clone(), - skipped_reason: decision.skipped_reason.clone(), - nearest_selected_note_id: decision.nearest_selected_note_id, - similarity: decision.similarity, - mmr_score: decision.mmr_score, - missing_embedding: decision.missing_embedding, - } -} - -pub fn cosine_similarity(lhs: &[f32], rhs: &[f32]) -> Option { - if lhs.is_empty() || lhs.len() != rhs.len() { - return None; - } - - let mut dot = 0.0_f32; - let mut lhs_norm = 0.0_f32; - let mut rhs_norm = 0.0_f32; - - for (l, r) in lhs.iter().zip(rhs.iter()) { - dot += l * r; - lhs_norm += l * l; - rhs_norm += r * r; - } - - if lhs_norm <= f32::EPSILON || rhs_norm <= f32::EPSILON { - return None; - } - - Some((dot / (lhs_norm.sqrt() * rhs_norm.sqrt())).clamp(-1.0, 1.0)) -} - -pub fn nearest_selected_similarity( - note_id: Uuid, - candidates: &[ScoredChunk], - selected_indices: &[usize], - note_vectors: &HashMap>, -) -> (Option, Option, bool) { - let Some(candidate_vec) = note_vectors.get(¬e_id) else { - return (None, None, true); - }; - let mut best_similarity: Option = None; - let mut nearest_note_id: Option = None; - - for selected_idx in selected_indices { - let selected_note_id = candidates[*selected_idx].item.note.note_id; - let Some(selected_vec) = note_vectors.get(&selected_note_id) else { - continue; - }; - let Some(similarity) = cosine_similarity(candidate_vec, selected_vec) else { - continue; - }; - - if best_similarity.map(|value| similarity > value).unwrap_or(true) { - best_similarity = Some(similarity); - nearest_note_id = Some(selected_note_id); - } - } - - (best_similarity, nearest_note_id, false) -} - -pub fn select_diverse_results( - candidates: Vec, - top_k: u32, - policy: &ResolvedDiversityPolicy, - note_vectors: &HashMap>, -) -> (Vec, HashMap) { - if candidates.is_empty() || top_k == 0 { - return (Vec::new(), HashMap::new()); - } - if !policy.enabled { - return select_diverse_results_disabled(candidates, top_k, note_vectors); - } - - select_diverse_results_enabled(candidates, top_k, policy, note_vectors) -} - -pub fn attach_diversity_decisions_to_trace_candidates( - candidates: &mut [TraceCandidateRecord], - decisions: &HashMap, -) { - for candidate in candidates { - let Some(decision) = decisions.get(&candidate.note_id) else { continue }; - let mut snapshot = candidate.candidate_snapshot.clone(); - let Some(object) = snapshot.as_object_mut() else { continue }; - - object.insert("diversity_selected".to_string(), serde_json::json!(decision.selected)); - object.insert( - "diversity_selected_rank".to_string(), - serde_json::json!(decision.selected_rank), - ); - object.insert( - "diversity_selected_reason".to_string(), - serde_json::json!(decision.selected_reason), - ); - object.insert( - "diversity_skipped_reason".to_string(), - serde_json::json!(decision.skipped_reason), - ); - object.insert( - "diversity_nearest_selected_note_id".to_string(), - serde_json::json!(decision.nearest_selected_note_id), - ); - object.insert("diversity_similarity".to_string(), serde_json::json!(decision.similarity)); - object.insert("diversity_mmr_score".to_string(), serde_json::json!(decision.mmr_score)); - object.insert( - "diversity_missing_embedding".to_string(), - serde_json::json!(decision.missing_embedding), - ); - - candidate.candidate_snapshot = snapshot; - } -} - -pub fn extract_replay_diversity_decisions( - candidates: &[TraceReplayCandidate], -) -> HashMap { - let mut out: HashMap = HashMap::new(); - - for candidate in candidates { - let has_diversity = candidate.diversity_selected.is_some() - || candidate.diversity_selected_rank.is_some() - || candidate.diversity_selected_reason.is_some() - || candidate.diversity_skipped_reason.is_some() - || candidate.diversity_nearest_selected_note_id.is_some() - || candidate.diversity_similarity.is_some() - || candidate.diversity_mmr_score.is_some() - || candidate.diversity_missing_embedding.is_some(); - - if !has_diversity { - continue; - } - - let selected = candidate.diversity_selected.unwrap_or(false); - let decision = DiversityDecision { - selected, - selected_rank: candidate.diversity_selected_rank, - selected_reason: candidate - .diversity_selected_reason - .clone() - .unwrap_or_else(|| "replay_selected".to_string()), - skipped_reason: candidate.diversity_skipped_reason.clone(), - nearest_selected_note_id: candidate.diversity_nearest_selected_note_id, - similarity: candidate.diversity_similarity, - mmr_score: candidate.diversity_mmr_score, - missing_embedding: candidate.diversity_missing_embedding.unwrap_or(false), - }; - let replace = match out.get(&candidate.note_id) { - None => true, - Some(existing) => - if decision.selected != existing.selected { - decision.selected - } else { - let lhs = decision.selected_rank.unwrap_or(u32::MAX); - let rhs = existing.selected_rank.unwrap_or(u32::MAX); - - lhs < rhs - }, - }; - - if replace { - out.insert(candidate.note_id, decision); - } - } - - out -} - -pub fn build_rerank_ranks(items: &[ChunkSnippet], scores: &[f32]) -> Vec { - let n = items.len(); - - if n == 0 { - return Vec::new(); - } - - let mut idxs: Vec = (0..n).collect(); - - idxs.sort_by(|&a, &b| { - let score_a = scores.get(a).copied().unwrap_or(f32::NAN); - let score_b = scores.get(b).copied().unwrap_or(f32::NAN); - let ord = retrieval::cmp_f32_desc(score_a, score_b); - - if ord != Ordering::Equal { - return ord; - } - if items[a].note.note_id == items[b].note.note_id { - let ord = items[a].chunk.chunk_index.cmp(&items[b].chunk.chunk_index); - - if ord != Ordering::Equal { - return ord; - } - } - - let ord = items[a].retrieval_rank.cmp(&items[b].retrieval_rank); - - if ord != Ordering::Equal { - return ord; - } - - items[a].chunk.chunk_id.cmp(&items[b].chunk.chunk_id) - }); - - let mut ranks = vec![0_u32; n]; - - for (pos, idx) in idxs.into_iter().enumerate() { - ranks[idx] = pos as u32 + 1; - } - - ranks -} - -pub fn build_rerank_ranks_for_replay(candidates: &[TraceReplayCandidate]) -> Vec { - let n = candidates.len(); - - if n == 0 { - return Vec::new(); - } - - let mut idxs: Vec = (0..n).collect(); - - idxs.sort_by(|&a, &b| { - let score_a = candidates.get(a).map(|candidate| candidate.rerank_score).unwrap_or(f32::NAN); - let score_b = candidates.get(b).map(|candidate| candidate.rerank_score).unwrap_or(f32::NAN); - let ord = retrieval::cmp_f32_desc(score_a, score_b); - - if ord != Ordering::Equal { - return ord; - } - - let ra = candidates.get(a).map(|candidate| candidate.retrieval_rank).unwrap_or(0); - let rb = candidates.get(b).map(|candidate| candidate.retrieval_rank).unwrap_or(0); - let ord = ra.cmp(&rb); - - if ord != Ordering::Equal { - return ord; - } - - let na = candidates.get(a).map(|candidate| candidate.note_id).unwrap_or(Uuid::nil()); - let nb = candidates.get(b).map(|candidate| candidate.note_id).unwrap_or(Uuid::nil()); - let ord = na.cmp(&nb); - - if ord != Ordering::Equal { - return ord; - } - - let ca = candidates.get(a).map(|candidate| candidate.chunk_id).unwrap_or(Uuid::nil()); - let cb = candidates.get(b).map(|candidate| candidate.chunk_id).unwrap_or(Uuid::nil()); - - ca.cmp(&cb) - }); - - let mut ranks = vec![0_u32; n]; - - for (pos, idx) in idxs.into_iter().enumerate() { - ranks[idx] = pos as u32 + 1; - } - - ranks -} - -fn select_diverse_results_disabled( - candidates: Vec, - top_k: u32, - note_vectors: &HashMap>, -) -> (Vec, HashMap) { - let mut decisions = HashMap::new(); - let mut selected = Vec::new(); - - for (idx, candidate) in candidates.into_iter().enumerate() { - let selected_rank = (idx < top_k as usize).then_some(idx as u32 + 1); - let is_selected = selected_rank.is_some(); - let note_id = candidate.item.note.note_id; - let missing_embedding = !note_vectors.contains_key(¬e_id); - - decisions.insert( - note_id, - DiversityDecision { - selected: is_selected, - selected_rank, - selected_reason: if is_selected { - "disabled_passthrough".to_string() - } else { - "disabled_truncate".to_string() - }, - skipped_reason: if is_selected { - None - } else { - Some("disabled_truncate".to_string()) - }, - nearest_selected_note_id: None, - similarity: None, - mmr_score: None, - missing_embedding, - }, - ); - - if is_selected { - selected.push(candidate); - } - } - - (selected, decisions) -} - -fn select_diverse_results_enabled( - candidates: Vec, - top_k: u32, - policy: &ResolvedDiversityPolicy, - note_vectors: &HashMap>, -) -> (Vec, HashMap) { - let total = u32::try_from(candidates.len()).unwrap_or(1).max(1); - let relevance_by_idx: Vec = - (0..candidates.len()).map(|idx| retrieval::rank_normalize(idx as u32 + 1, total)).collect(); - let mut remaining_indices: Vec = (0..candidates.len()).collect(); - let mut selected_indices: Vec = Vec::new(); - let mut decisions: HashMap = HashMap::new(); - let first_idx = remaining_indices.remove(0); - let first_note_id = candidates[first_idx].item.note.note_id; - let first_missing_embedding = !note_vectors.contains_key(&first_note_id); - - selected_indices.push(first_idx); - decisions.insert( - first_note_id, - DiversityDecision { - selected: true, - selected_rank: Some(1), - selected_reason: "top_relevance".to_string(), - skipped_reason: None, - nearest_selected_note_id: None, - similarity: None, - mmr_score: Some(relevance_by_idx[first_idx]), - missing_embedding: first_missing_embedding, - }, - ); - - while selected_indices.len() < top_k as usize && !remaining_indices.is_empty() { - let Some((selected_pick, selected_reason)) = pick_next_candidate( - &remaining_indices, - &candidates, - &selected_indices, - note_vectors, - &relevance_by_idx, - policy, - ) else { - break; - }; - let picked_idx = remaining_indices.remove(selected_pick.remaining_pos); - - selected_indices.push(picked_idx); - - let selected_note_id = candidates[picked_idx].item.note.note_id; - - decisions.insert( - selected_note_id, - DiversityDecision { - selected: true, - selected_rank: Some(selected_indices.len() as u32), - selected_reason: selected_reason.to_string(), - skipped_reason: None, - nearest_selected_note_id: selected_pick.nearest_note_id, - similarity: selected_pick.similarity, - mmr_score: Some(selected_pick.mmr_score), - missing_embedding: selected_pick.missing_embedding, - }, - ); - } - - for candidate_idx in remaining_indices { - let note_id = candidates[candidate_idx].item.note.note_id; - let (similarity, nearest_note_id, missing_embedding) = - nearest_selected_similarity(note_id, &candidates, &selected_indices, note_vectors); - let skipped_reason = - if similarity.map(|value| value > policy.sim_threshold).unwrap_or(false) { - "similarity_threshold" - } else { - "lower_mmr" - }; - let redundancy = similarity.unwrap_or(0.0); - let mmr_score = policy.mmr_lambda * relevance_by_idx[candidate_idx] - - (1.0 - policy.mmr_lambda) * redundancy; - - decisions.insert( - note_id, - DiversityDecision { - selected: false, - selected_rank: None, - selected_reason: "not_selected".to_string(), - skipped_reason: Some(skipped_reason.to_string()), - nearest_selected_note_id: nearest_note_id, - similarity, - mmr_score: Some(mmr_score), - missing_embedding, - }, - ); - } - - let selected = selected_indices.into_iter().map(|idx| candidates[idx].clone()).collect(); - - (selected, decisions) -} - -fn pick_next_candidate( - remaining_indices: &[usize], - candidates: &[ScoredChunk], - selected_indices: &[usize], - note_vectors: &HashMap>, - relevance_by_idx: &[f32], - policy: &ResolvedDiversityPolicy, -) -> Option<(DiversityPick, &'static str)> { - let mut best_non_filtered: Option = None; - let mut best_filtered: Option = None; - let mut best_any: Option = None; - let mut filtered_count = 0_u32; - - for (remaining_pos, candidate_idx) in remaining_indices.iter().copied().enumerate() { - let note_id = candidates[candidate_idx].item.note.note_id; - let (similarity, nearest_note_id, missing_embedding) = - nearest_selected_similarity(note_id, candidates, selected_indices, note_vectors); - let redundancy = similarity.unwrap_or(0.0); - let mmr_score = policy.mmr_lambda * relevance_by_idx[candidate_idx] - - (1.0 - policy.mmr_lambda) * redundancy; - let high_similarity = similarity.map(|value| value > policy.sim_threshold).unwrap_or(false); - - if high_similarity { - filtered_count += 1; - } - - let candidate_pick = DiversityPick { - remaining_pos, - mmr_score, - nearest_note_id, - similarity, - missing_embedding, - retrieval_rank: candidates[candidate_idx].item.retrieval_rank, - }; - - if best_any.as_ref().map(|current| candidate_pick.better_than(current)).unwrap_or(true) { - best_any = Some(candidate_pick); - } - if high_similarity { - if best_filtered - .as_ref() - .map(|current| candidate_pick.better_than(current)) - .unwrap_or(true) - { - best_filtered = Some(candidate_pick); - } - - continue; - } - if best_non_filtered - .as_ref() - .map(|current| candidate_pick.better_than(current)) - .unwrap_or(true) - { - best_non_filtered = Some(candidate_pick); - } - } - - if let Some(best) = best_non_filtered { - return Some((best, "mmr")); - } - - if filtered_count >= policy.max_skips { - return best_any.map(|best| (best, "max_skips_backfill")); - } - - best_filtered.map(|best| (best, "threshold_backfill")) -} diff --git a/packages/elf-service/src/search/ranking/diversity/rank.rs b/packages/elf-service/src/search/ranking/diversity/rank.rs new file mode 100644 index 00000000..d74b078f --- /dev/null +++ b/packages/elf-service/src/search/ranking/diversity/rank.rs @@ -0,0 +1,97 @@ +use std::cmp::Ordering; + +use uuid::Uuid; + +use crate::search::{ChunkSnippet, TraceReplayCandidate, ranking::retrieval}; + +pub fn build_rerank_ranks(items: &[ChunkSnippet], scores: &[f32]) -> Vec { + let n = items.len(); + + if n == 0 { + return Vec::new(); + } + + let mut idxs: Vec = (0..n).collect(); + + idxs.sort_by(|&a, &b| { + let score_a = scores.get(a).copied().unwrap_or(f32::NAN); + let score_b = scores.get(b).copied().unwrap_or(f32::NAN); + let ord = retrieval::cmp_f32_desc(score_a, score_b); + + if ord != Ordering::Equal { + return ord; + } + if items[a].note.note_id == items[b].note.note_id { + let ord = items[a].chunk.chunk_index.cmp(&items[b].chunk.chunk_index); + + if ord != Ordering::Equal { + return ord; + } + } + + let ord = items[a].retrieval_rank.cmp(&items[b].retrieval_rank); + + if ord != Ordering::Equal { + return ord; + } + + items[a].chunk.chunk_id.cmp(&items[b].chunk.chunk_id) + }); + + let mut ranks = vec![0_u32; n]; + + for (pos, idx) in idxs.into_iter().enumerate() { + ranks[idx] = pos as u32 + 1; + } + + ranks +} + +pub fn build_rerank_ranks_for_replay(candidates: &[TraceReplayCandidate]) -> Vec { + let n = candidates.len(); + + if n == 0 { + return Vec::new(); + } + + let mut idxs: Vec = (0..n).collect(); + + idxs.sort_by(|&a, &b| { + let score_a = candidates.get(a).map(|candidate| candidate.rerank_score).unwrap_or(f32::NAN); + let score_b = candidates.get(b).map(|candidate| candidate.rerank_score).unwrap_or(f32::NAN); + let ord = retrieval::cmp_f32_desc(score_a, score_b); + + if ord != Ordering::Equal { + return ord; + } + + let ra = candidates.get(a).map(|candidate| candidate.retrieval_rank).unwrap_or(0); + let rb = candidates.get(b).map(|candidate| candidate.retrieval_rank).unwrap_or(0); + let ord = ra.cmp(&rb); + + if ord != Ordering::Equal { + return ord; + } + + let na = candidates.get(a).map(|candidate| candidate.note_id).unwrap_or(Uuid::nil()); + let nb = candidates.get(b).map(|candidate| candidate.note_id).unwrap_or(Uuid::nil()); + let ord = na.cmp(&nb); + + if ord != Ordering::Equal { + return ord; + } + + let ca = candidates.get(a).map(|candidate| candidate.chunk_id).unwrap_or(Uuid::nil()); + let cb = candidates.get(b).map(|candidate| candidate.chunk_id).unwrap_or(Uuid::nil()); + + ca.cmp(&cb) + }); + + let mut ranks = vec![0_u32; n]; + + for (pos, idx) in idxs.into_iter().enumerate() { + ranks[idx] = pos as u32 + 1; + } + + ranks +} diff --git a/packages/elf-service/src/search/ranking/diversity/selection.rs b/packages/elf-service/src/search/ranking/diversity/selection.rs new file mode 100644 index 00000000..acfe28ea --- /dev/null +++ b/packages/elf-service/src/search/ranking/diversity/selection.rs @@ -0,0 +1,260 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::search::{ + DiversityDecision, ScoredChunk, + ranking::{diversity::similarity, policy::ResolvedDiversityPolicy, retrieval}, +}; + +#[derive(Clone, Copy)] +struct DiversityPick { + remaining_pos: usize, + mmr_score: f32, + nearest_note_id: Option, + similarity: Option, + missing_embedding: bool, + retrieval_rank: u32, +} +impl DiversityPick { + fn better_than(self, other: &Self) -> bool { + self.mmr_score > other.mmr_score + || (self.mmr_score == other.mmr_score && self.retrieval_rank < other.retrieval_rank) + } +} + +pub fn select_diverse_results( + candidates: Vec, + top_k: u32, + policy: &ResolvedDiversityPolicy, + note_vectors: &HashMap>, +) -> (Vec, HashMap) { + if candidates.is_empty() || top_k == 0 { + return (Vec::new(), HashMap::new()); + } + if !policy.enabled { + return select_diverse_results_disabled(candidates, top_k, note_vectors); + } + + select_diverse_results_enabled(candidates, top_k, policy, note_vectors) +} + +fn select_diverse_results_disabled( + candidates: Vec, + top_k: u32, + note_vectors: &HashMap>, +) -> (Vec, HashMap) { + let mut decisions = HashMap::new(); + let mut selected = Vec::new(); + + for (idx, candidate) in candidates.into_iter().enumerate() { + let selected_rank = (idx < top_k as usize).then_some(idx as u32 + 1); + let is_selected = selected_rank.is_some(); + let note_id = candidate.item.note.note_id; + let missing_embedding = !note_vectors.contains_key(¬e_id); + + decisions.insert( + note_id, + DiversityDecision { + selected: is_selected, + selected_rank, + selected_reason: if is_selected { + "disabled_passthrough".to_string() + } else { + "disabled_truncate".to_string() + }, + skipped_reason: if is_selected { + None + } else { + Some("disabled_truncate".to_string()) + }, + nearest_selected_note_id: None, + similarity: None, + mmr_score: None, + missing_embedding, + }, + ); + + if is_selected { + selected.push(candidate); + } + } + + (selected, decisions) +} + +fn select_diverse_results_enabled( + candidates: Vec, + top_k: u32, + policy: &ResolvedDiversityPolicy, + note_vectors: &HashMap>, +) -> (Vec, HashMap) { + let total = u32::try_from(candidates.len()).unwrap_or(1).max(1); + let relevance_by_idx: Vec = + (0..candidates.len()).map(|idx| retrieval::rank_normalize(idx as u32 + 1, total)).collect(); + let mut remaining_indices: Vec = (0..candidates.len()).collect(); + let mut selected_indices: Vec = Vec::new(); + let mut decisions: HashMap = HashMap::new(); + let first_idx = remaining_indices.remove(0); + let first_note_id = candidates[first_idx].item.note.note_id; + let first_missing_embedding = !note_vectors.contains_key(&first_note_id); + + selected_indices.push(first_idx); + decisions.insert( + first_note_id, + DiversityDecision { + selected: true, + selected_rank: Some(1), + selected_reason: "top_relevance".to_string(), + skipped_reason: None, + nearest_selected_note_id: None, + similarity: None, + mmr_score: Some(relevance_by_idx[first_idx]), + missing_embedding: first_missing_embedding, + }, + ); + + while selected_indices.len() < top_k as usize && !remaining_indices.is_empty() { + let Some((selected_pick, selected_reason)) = pick_next_candidate( + &remaining_indices, + &candidates, + &selected_indices, + note_vectors, + &relevance_by_idx, + policy, + ) else { + break; + }; + let picked_idx = remaining_indices.remove(selected_pick.remaining_pos); + + selected_indices.push(picked_idx); + + let selected_note_id = candidates[picked_idx].item.note.note_id; + + decisions.insert( + selected_note_id, + DiversityDecision { + selected: true, + selected_rank: Some(selected_indices.len() as u32), + selected_reason: selected_reason.to_string(), + skipped_reason: None, + nearest_selected_note_id: selected_pick.nearest_note_id, + similarity: selected_pick.similarity, + mmr_score: Some(selected_pick.mmr_score), + missing_embedding: selected_pick.missing_embedding, + }, + ); + } + + for candidate_idx in remaining_indices { + let note_id = candidates[candidate_idx].item.note.note_id; + let (similarity, nearest_note_id, missing_embedding) = + similarity::nearest_selected_similarity( + note_id, + &candidates, + &selected_indices, + note_vectors, + ); + let skipped_reason = + if similarity.map(|value| value > policy.sim_threshold).unwrap_or(false) { + "similarity_threshold" + } else { + "lower_mmr" + }; + let redundancy = similarity.unwrap_or(0.0); + let mmr_score = policy.mmr_lambda * relevance_by_idx[candidate_idx] + - (1.0 - policy.mmr_lambda) * redundancy; + + decisions.insert( + note_id, + DiversityDecision { + selected: false, + selected_rank: None, + selected_reason: "not_selected".to_string(), + skipped_reason: Some(skipped_reason.to_string()), + nearest_selected_note_id: nearest_note_id, + similarity, + mmr_score: Some(mmr_score), + missing_embedding, + }, + ); + } + + let selected = selected_indices.into_iter().map(|idx| candidates[idx].clone()).collect(); + + (selected, decisions) +} + +fn pick_next_candidate( + remaining_indices: &[usize], + candidates: &[ScoredChunk], + selected_indices: &[usize], + note_vectors: &HashMap>, + relevance_by_idx: &[f32], + policy: &ResolvedDiversityPolicy, +) -> Option<(DiversityPick, &'static str)> { + let mut best_non_filtered: Option = None; + let mut best_filtered: Option = None; + let mut best_any: Option = None; + let mut filtered_count = 0_u32; + + for (remaining_pos, candidate_idx) in remaining_indices.iter().copied().enumerate() { + let note_id = candidates[candidate_idx].item.note.note_id; + let (similarity, nearest_note_id, missing_embedding) = + similarity::nearest_selected_similarity( + note_id, + candidates, + selected_indices, + note_vectors, + ); + let redundancy = similarity.unwrap_or(0.0); + let mmr_score = policy.mmr_lambda * relevance_by_idx[candidate_idx] + - (1.0 - policy.mmr_lambda) * redundancy; + let high_similarity = similarity.map(|value| value > policy.sim_threshold).unwrap_or(false); + + if high_similarity { + filtered_count += 1; + } + + let candidate_pick = DiversityPick { + remaining_pos, + mmr_score, + nearest_note_id, + similarity, + missing_embedding, + retrieval_rank: candidates[candidate_idx].item.retrieval_rank, + }; + + if best_any.as_ref().map(|current| candidate_pick.better_than(current)).unwrap_or(true) { + best_any = Some(candidate_pick); + } + if high_similarity { + if best_filtered + .as_ref() + .map(|current| candidate_pick.better_than(current)) + .unwrap_or(true) + { + best_filtered = Some(candidate_pick); + } + + continue; + } + if best_non_filtered + .as_ref() + .map(|current| candidate_pick.better_than(current)) + .unwrap_or(true) + { + best_non_filtered = Some(candidate_pick); + } + } + + if let Some(best) = best_non_filtered { + return Some((best, "mmr")); + } + + if filtered_count >= policy.max_skips { + return best_any.map(|best| (best, "max_skips_backfill")); + } + + best_filtered.map(|best| (best, "threshold_backfill")) +} diff --git a/packages/elf-service/src/search/ranking/diversity/similarity.rs b/packages/elf-service/src/search/ranking/diversity/similarity.rs new file mode 100644 index 00000000..0c82329e --- /dev/null +++ b/packages/elf-service/src/search/ranking/diversity/similarity.rs @@ -0,0 +1,57 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::search::ScoredChunk; + +pub fn cosine_similarity(lhs: &[f32], rhs: &[f32]) -> Option { + if lhs.is_empty() || lhs.len() != rhs.len() { + return None; + } + + let mut dot = 0.0_f32; + let mut lhs_norm = 0.0_f32; + let mut rhs_norm = 0.0_f32; + + for (l, r) in lhs.iter().zip(rhs.iter()) { + dot += l * r; + lhs_norm += l * l; + rhs_norm += r * r; + } + + if lhs_norm <= f32::EPSILON || rhs_norm <= f32::EPSILON { + return None; + } + + Some((dot / (lhs_norm.sqrt() * rhs_norm.sqrt())).clamp(-1.0, 1.0)) +} + +pub fn nearest_selected_similarity( + note_id: Uuid, + candidates: &[ScoredChunk], + selected_indices: &[usize], + note_vectors: &HashMap>, +) -> (Option, Option, bool) { + let Some(candidate_vec) = note_vectors.get(¬e_id) else { + return (None, None, true); + }; + let mut best_similarity: Option = None; + let mut nearest_note_id: Option = None; + + for selected_idx in selected_indices { + let selected_note_id = candidates[*selected_idx].item.note.note_id; + let Some(selected_vec) = note_vectors.get(&selected_note_id) else { + continue; + }; + let Some(similarity) = cosine_similarity(candidate_vec, selected_vec) else { + continue; + }; + + if best_similarity.map(|value| similarity > value).unwrap_or(true) { + best_similarity = Some(similarity); + nearest_note_id = Some(selected_note_id); + } + } + + (best_similarity, nearest_note_id, false) +} diff --git a/packages/elf-service/src/search/ranking/diversity/trace.rs b/packages/elf-service/src/search/ranking/diversity/trace.rs new file mode 100644 index 00000000..a715fe69 --- /dev/null +++ b/packages/elf-service/src/search/ranking/diversity/trace.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::search::{ + DiversityDecision, SearchDiversityExplain, TraceCandidateRecord, TraceReplayCandidate, +}; + +pub fn build_diversity_explain(decision: &DiversityDecision) -> SearchDiversityExplain { + SearchDiversityExplain { + enabled: true, + selected_reason: decision.selected_reason.clone(), + skipped_reason: decision.skipped_reason.clone(), + nearest_selected_note_id: decision.nearest_selected_note_id, + similarity: decision.similarity, + mmr_score: decision.mmr_score, + missing_embedding: decision.missing_embedding, + } +} + +pub fn attach_diversity_decisions_to_trace_candidates( + candidates: &mut [TraceCandidateRecord], + decisions: &HashMap, +) { + for candidate in candidates { + let Some(decision) = decisions.get(&candidate.note_id) else { continue }; + let mut snapshot = candidate.candidate_snapshot.clone(); + let Some(object) = snapshot.as_object_mut() else { continue }; + + object.insert("diversity_selected".to_string(), serde_json::json!(decision.selected)); + object.insert( + "diversity_selected_rank".to_string(), + serde_json::json!(decision.selected_rank), + ); + object.insert( + "diversity_selected_reason".to_string(), + serde_json::json!(decision.selected_reason), + ); + object.insert( + "diversity_skipped_reason".to_string(), + serde_json::json!(decision.skipped_reason), + ); + object.insert( + "diversity_nearest_selected_note_id".to_string(), + serde_json::json!(decision.nearest_selected_note_id), + ); + object.insert("diversity_similarity".to_string(), serde_json::json!(decision.similarity)); + object.insert("diversity_mmr_score".to_string(), serde_json::json!(decision.mmr_score)); + object.insert( + "diversity_missing_embedding".to_string(), + serde_json::json!(decision.missing_embedding), + ); + + candidate.candidate_snapshot = snapshot; + } +} + +pub fn extract_replay_diversity_decisions( + candidates: &[TraceReplayCandidate], +) -> HashMap { + let mut out: HashMap = HashMap::new(); + + for candidate in candidates { + let has_diversity = candidate.diversity_selected.is_some() + || candidate.diversity_selected_rank.is_some() + || candidate.diversity_selected_reason.is_some() + || candidate.diversity_skipped_reason.is_some() + || candidate.diversity_nearest_selected_note_id.is_some() + || candidate.diversity_similarity.is_some() + || candidate.diversity_mmr_score.is_some() + || candidate.diversity_missing_embedding.is_some(); + + if !has_diversity { + continue; + } + + let selected = candidate.diversity_selected.unwrap_or(false); + let decision = DiversityDecision { + selected, + selected_rank: candidate.diversity_selected_rank, + selected_reason: candidate + .diversity_selected_reason + .clone() + .unwrap_or_else(|| "replay_selected".to_string()), + skipped_reason: candidate.diversity_skipped_reason.clone(), + nearest_selected_note_id: candidate.diversity_nearest_selected_note_id, + similarity: candidate.diversity_similarity, + mmr_score: candidate.diversity_mmr_score, + missing_embedding: candidate.diversity_missing_embedding.unwrap_or(false), + }; + let replace = match out.get(&candidate.note_id) { + None => true, + Some(existing) => + if decision.selected != existing.selected { + decision.selected + } else { + let lhs = decision.selected_rank.unwrap_or(u32::MAX); + let rhs = existing.selected_rank.unwrap_or(u32::MAX); + + lhs < rhs + }, + }; + + if replace { + out.insert(candidate.note_id, decision); + } + } + + out +} diff --git a/packages/elf-service/src/search/ranking/policy.rs b/packages/elf-service/src/search/ranking/policy.rs index 86f51d93..09cde956 100644 --- a/packages/elf-service/src/search/ranking/policy.rs +++ b/packages/elf-service/src/search/ranking/policy.rs @@ -1,461 +1,16 @@ -use serde_json::Value; +pub(super) mod types; -use crate::{ - Error, Result, - search::{ - BlendRankingOverride, DiversityRankingOverride, RankingRequestOverride, - RetrievalSourcesRankingOverride, +mod resolve; +mod snapshot; + +pub use self::{ + resolve::{ + resolve_blend_policy, resolve_diversity_policy, resolve_retrieval_sources_policy, + resolve_scopes, retrieval_weight_for_rank, + }, + snapshot::{build_config_snapshot, build_policy_snapshot, hash_policy_snapshot}, + types::{ + NormalizationKind, ResolvedBlendPolicy, ResolvedDiversityPolicy, + ResolvedRetrievalSourcesPolicy, }, }; -use elf_config::{Config, RankingBlend, RankingDiversity, RankingRetrievalSources}; - -#[derive(Clone, Copy, Debug)] -pub enum NormalizationKind { - Rank, -} -impl NormalizationKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Rank => "rank", - } - } -} - -#[derive(Clone, Debug)] -pub struct BlendSegment { - pub max_retrieval_rank: u32, - pub retrieval_weight: f32, -} - -#[derive(Clone, Debug)] -pub struct ResolvedBlendPolicy { - pub enabled: bool, - pub rerank_normalization: NormalizationKind, - pub retrieval_normalization: NormalizationKind, - pub segments: Vec, -} - -#[derive(Clone, Debug)] -pub struct ResolvedDiversityPolicy { - pub enabled: bool, - pub sim_threshold: f32, - pub mmr_lambda: f32, - pub max_skips: u32, -} - -#[derive(Clone, Debug)] -pub struct ResolvedRetrievalSourcesPolicy { - pub fusion_weight: f32, - pub structured_field_weight: f32, - pub recursive_weight: f32, - pub fusion_priority: u32, - pub structured_field_priority: u32, - pub recursive_priority: u32, -} - -pub fn build_config_snapshot( - cfg: &Config, - blend_policy: &ResolvedBlendPolicy, - diversity_policy: &ResolvedDiversityPolicy, - retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, - ranking_override: Option<&RankingRequestOverride>, - policy_id: &str, - policy_snapshot: &Value, -) -> Value { - let override_json = ranking_override.and_then(|value| serde_json::to_value(value).ok()); - - serde_json::json!({ - "search": { - "expansion": { - "mode": cfg.search.expansion.mode.as_str(), - "max_queries": cfg.search.expansion.max_queries, - "include_original": cfg.search.expansion.include_original, - }, - "dynamic": { - "min_candidates": cfg.search.dynamic.min_candidates, - "min_top_score": cfg.search.dynamic.min_top_score, - }, - "prefilter": { - "max_candidates": cfg.search.prefilter.max_candidates, - }, - "explain": { - "retention_days": cfg.search.explain.retention_days, - }, - }, - "ranking": { - "policy_id": policy_id, - "policy_snapshot": policy_snapshot.clone(), - "recency_tau_days": cfg.ranking.recency_tau_days, - "tie_breaker_weight": cfg.ranking.tie_breaker_weight, - "deterministic": { - "enabled": cfg.ranking.deterministic.enabled, - "lexical": { - "enabled": cfg.ranking.deterministic.lexical.enabled, - "weight": cfg.ranking.deterministic.lexical.weight, - "min_ratio": cfg.ranking.deterministic.lexical.min_ratio, - "max_query_terms": cfg.ranking.deterministic.lexical.max_query_terms, - "max_text_terms": cfg.ranking.deterministic.lexical.max_text_terms, - }, - "hits": { - "enabled": cfg.ranking.deterministic.hits.enabled, - "weight": cfg.ranking.deterministic.hits.weight, - "half_saturation": cfg.ranking.deterministic.hits.half_saturation, - "last_hit_tau_days": cfg.ranking.deterministic.hits.last_hit_tau_days, - }, - "decay": { - "enabled": cfg.ranking.deterministic.decay.enabled, - "weight": cfg.ranking.deterministic.decay.weight, - "tau_days": cfg.ranking.deterministic.decay.tau_days, - }, - }, - "blend": { - "enabled": blend_policy.enabled, - "rerank_normalization": blend_policy.rerank_normalization.as_str(), - "retrieval_normalization": blend_policy.retrieval_normalization.as_str(), - "segments": blend_policy - .segments - .iter() - .map(|segment| { - serde_json::json!({ - "max_retrieval_rank": segment.max_retrieval_rank, - "retrieval_weight": segment.retrieval_weight, - }) - }) - .collect::>(), - }, - "diversity": { - "enabled": diversity_policy.enabled, - "sim_threshold": diversity_policy.sim_threshold, - "mmr_lambda": diversity_policy.mmr_lambda, - "max_skips": diversity_policy.max_skips, - }, - "retrieval_sources": { - "fusion_weight": retrieval_sources_policy.fusion_weight, - "structured_field_weight": retrieval_sources_policy.structured_field_weight, - "recursive_weight": retrieval_sources_policy.recursive_weight, - "fusion_priority": retrieval_sources_policy.fusion_priority, - "structured_field_priority": retrieval_sources_policy.structured_field_priority, - "recursive_priority": retrieval_sources_policy.recursive_priority, - }, - "override": override_json, - }, - "providers": { - "embedding": { - "provider_id": cfg.providers.embedding.provider_id.as_str(), - "model": cfg.providers.embedding.model.as_str(), - "dimensions": cfg.providers.embedding.dimensions, - }, - "rerank": { - "provider_id": cfg.providers.rerank.provider_id.as_str(), - "model": cfg.providers.rerank.model.as_str(), - }, - }, - "storage": { - "qdrant": { - "vector_dim": cfg.storage.qdrant.vector_dim, - "collection": cfg.storage.qdrant.collection.as_str(), - }, - }, - "context": { - "scope_boost_weight": cfg.context.as_ref().and_then(|ctx| ctx.scope_boost_weight), - "project_description_count": cfg - .context - .as_ref() - .and_then(|ctx| ctx.project_descriptions.as_ref()) - .map(|descriptions| descriptions.len()) - .unwrap_or(0), - "scope_description_count": cfg - .context - .as_ref() - .and_then(|ctx| ctx.scope_descriptions.as_ref()) - .map(|descriptions| descriptions.len()) - .unwrap_or(0), - }, - }) -} - -pub fn build_policy_snapshot( - cfg: &Config, - blend_policy: &ResolvedBlendPolicy, - diversity_policy: &ResolvedDiversityPolicy, - retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, - ranking_override: Option<&RankingRequestOverride>, -) -> Value { - let override_json = ranking_override.and_then(|value| serde_json::to_value(value).ok()); - - serde_json::json!({ - "ranking": { - "recency_tau_days": cfg.ranking.recency_tau_days, - "tie_breaker_weight": cfg.ranking.tie_breaker_weight, - "deterministic": { - "enabled": cfg.ranking.deterministic.enabled, - "lexical": { - "enabled": cfg.ranking.deterministic.lexical.enabled, - "weight": cfg.ranking.deterministic.lexical.weight, - "min_ratio": cfg.ranking.deterministic.lexical.min_ratio, - "max_query_terms": cfg.ranking.deterministic.lexical.max_query_terms, - "max_text_terms": cfg.ranking.deterministic.lexical.max_text_terms, - }, - "hits": { - "enabled": cfg.ranking.deterministic.hits.enabled, - "weight": cfg.ranking.deterministic.hits.weight, - "half_saturation": cfg.ranking.deterministic.hits.half_saturation, - "last_hit_tau_days": cfg.ranking.deterministic.hits.last_hit_tau_days, - }, - "decay": { - "enabled": cfg.ranking.deterministic.decay.enabled, - "weight": cfg.ranking.deterministic.decay.weight, - "tau_days": cfg.ranking.deterministic.decay.tau_days, - }, - }, - "blend": { - "enabled": blend_policy.enabled, - "rerank_normalization": blend_policy.rerank_normalization.as_str(), - "retrieval_normalization": blend_policy.retrieval_normalization.as_str(), - "segments": blend_policy - .segments - .iter() - .map(|segment| { - serde_json::json!({ - "max_retrieval_rank": segment.max_retrieval_rank, - "retrieval_weight": segment.retrieval_weight, - }) - }) - .collect::>(), - }, - "diversity": { - "enabled": diversity_policy.enabled, - "sim_threshold": diversity_policy.sim_threshold, - "mmr_lambda": diversity_policy.mmr_lambda, - "max_skips": diversity_policy.max_skips, - }, - "retrieval_sources": { - "fusion_weight": retrieval_sources_policy.fusion_weight, - "structured_field_weight": retrieval_sources_policy.structured_field_weight, - "recursive_weight": retrieval_sources_policy.recursive_weight, - "fusion_priority": retrieval_sources_policy.fusion_priority, - "structured_field_priority": retrieval_sources_policy.structured_field_priority, - "recursive_priority": retrieval_sources_policy.recursive_priority, - }, - "override": override_json, - }, - "context": { - "scope_boost_weight": cfg.context.as_ref().and_then(|ctx| ctx.scope_boost_weight), - "project_description_count": cfg - .context - .as_ref() - .and_then(|ctx| ctx.project_descriptions.as_ref()) - .map(|descriptions| descriptions.len()) - .unwrap_or(0), - "scope_description_count": cfg - .context - .as_ref() - .and_then(|ctx| ctx.scope_descriptions.as_ref()) - .map(|descriptions| descriptions.len()) - .unwrap_or(0), - }, - }) -} - -pub fn hash_policy_snapshot(payload: &Value) -> Result { - let raw = serde_json::to_vec(payload).map_err(|err| Error::Storage { - message: format!("Failed to encode policy snapshot: {err}"), - })?; - - Ok(blake3::hash(&raw).to_hex().to_string()) -} - -pub fn resolve_blend_policy( - cfg: &RankingBlend, - override_: Option<&BlendRankingOverride>, -) -> Result { - let enabled = override_.and_then(|value| value.enabled).unwrap_or(cfg.enabled); - let rerank_norm = override_ - .and_then(|value| value.rerank_normalization.as_deref()) - .unwrap_or(cfg.rerank_normalization.as_str()); - let retrieval_norm = override_ - .and_then(|value| value.retrieval_normalization.as_deref()) - .unwrap_or(cfg.retrieval_normalization.as_str()); - let rerank_normalization = - parse_normalization_kind(rerank_norm, "ranking.blend.rerank_normalization")?; - let retrieval_normalization = - parse_normalization_kind(retrieval_norm, "ranking.blend.retrieval_normalization")?; - let segments: Vec = - if let Some(override_segments) = override_.and_then(|value| value.segments.as_ref()) { - override_segments - .iter() - .map(|segment| BlendSegment { - max_retrieval_rank: segment.max_retrieval_rank, - retrieval_weight: segment.retrieval_weight, - }) - .collect::>() - } else { - cfg.segments - .iter() - .map(|segment| BlendSegment { - max_retrieval_rank: segment.max_retrieval_rank, - retrieval_weight: segment.retrieval_weight, - }) - .collect::>() - }; - - validate_blend_segments(&segments)?; - - Ok(ResolvedBlendPolicy { enabled, rerank_normalization, retrieval_normalization, segments }) -} - -pub fn resolve_diversity_policy( - cfg: &RankingDiversity, - override_: Option<&DiversityRankingOverride>, -) -> Result { - let enabled = override_.and_then(|value| value.enabled).unwrap_or(cfg.enabled); - let sim_threshold = - override_.and_then(|value| value.sim_threshold).unwrap_or(cfg.sim_threshold); - let mmr_lambda = override_.and_then(|value| value.mmr_lambda).unwrap_or(cfg.mmr_lambda); - let max_skips = override_.and_then(|value| value.max_skips).unwrap_or(cfg.max_skips); - - if !sim_threshold.is_finite() { - return Err(Error::InvalidRequest { - message: "ranking.diversity.sim_threshold must be a finite number.".to_string(), - }); - } - if !(0.0..=1.0).contains(&sim_threshold) { - return Err(Error::InvalidRequest { - message: "ranking.diversity.sim_threshold must be in the range 0.0-1.0.".to_string(), - }); - } - if !mmr_lambda.is_finite() { - return Err(Error::InvalidRequest { - message: "ranking.diversity.mmr_lambda must be a finite number.".to_string(), - }); - } - if !(0.0..=1.0).contains(&mmr_lambda) { - return Err(Error::InvalidRequest { - message: "ranking.diversity.mmr_lambda must be in the range 0.0-1.0.".to_string(), - }); - } - - Ok(ResolvedDiversityPolicy { enabled, sim_threshold, mmr_lambda, max_skips }) -} - -pub fn resolve_retrieval_sources_policy( - cfg: &RankingRetrievalSources, - override_: Option<&RetrievalSourcesRankingOverride>, -) -> Result { - let fusion_weight = - override_.and_then(|value| value.fusion_weight).unwrap_or(cfg.fusion_weight); - let structured_field_weight = override_ - .and_then(|value| value.structured_field_weight) - .unwrap_or(cfg.structured_field_weight); - let recursive_weight = - override_.and_then(|value| value.recursive_weight).unwrap_or(structured_field_weight); - let fusion_priority = - override_.and_then(|value| value.fusion_priority).unwrap_or(cfg.fusion_priority); - let structured_field_priority = override_ - .and_then(|value| value.structured_field_priority) - .unwrap_or(cfg.structured_field_priority); - let recursive_priority = override_ - .and_then(|value| value.recursive_priority) - .unwrap_or(structured_field_priority.saturating_add(1)); - - for (path, value) in [ - ("ranking.retrieval_sources.fusion_weight", fusion_weight), - ("ranking.retrieval_sources.structured_field_weight", structured_field_weight), - ("ranking.retrieval_sources.recursive_weight", recursive_weight), - ] { - if !value.is_finite() { - return Err(Error::InvalidRequest { - message: format!("{path} must be a finite number."), - }); - } - if value < 0.0 { - return Err(Error::InvalidRequest { - message: format!("{path} must be zero or greater."), - }); - } - } - - if fusion_weight <= 0.0 && structured_field_weight <= 0.0 && recursive_weight <= 0.0 { - return Err(Error::InvalidRequest { - message: "At least one retrieval source weight must be greater than zero.".to_string(), - }); - } - - Ok(ResolvedRetrievalSourcesPolicy { - fusion_weight, - structured_field_weight, - recursive_weight, - fusion_priority, - structured_field_priority, - recursive_priority, - }) -} - -pub fn parse_normalization_kind(value: &str, label: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "rank" => Ok(NormalizationKind::Rank), - other => Err(Error::InvalidRequest { - message: format!("{label} must be one of: rank. Got {other}."), - }), - } -} - -pub fn validate_blend_segments(segments: &[BlendSegment]) -> Result<()> { - if segments.is_empty() { - return Err(Error::InvalidRequest { - message: "ranking.blend.segments must be non-empty.".to_string(), - }); - } - - let mut last_max = 0_u32; - - for (idx, segment) in segments.iter().enumerate() { - if segment.max_retrieval_rank == 0 { - return Err(Error::InvalidRequest { - message: "ranking.blend.segments.max_retrieval_rank must be greater than zero." - .to_string(), - }); - } - if idx > 0 && segment.max_retrieval_rank <= last_max { - return Err(Error::InvalidRequest { - message: "ranking.blend.segments.max_retrieval_rank must be strictly increasing." - .to_string(), - }); - } - if !segment.retrieval_weight.is_finite() { - return Err(Error::InvalidRequest { - message: "ranking.blend.segments.retrieval_weight must be a finite number." - .to_string(), - }); - } - if !(0.0..=1.0).contains(&segment.retrieval_weight) { - return Err(Error::InvalidRequest { - message: "ranking.blend.segments.retrieval_weight must be in the range 0.0-1.0." - .to_string(), - }); - } - - last_max = segment.max_retrieval_rank; - } - - Ok(()) -} - -pub fn retrieval_weight_for_rank(rank: u32, segments: &[BlendSegment]) -> f32 { - for segment in segments { - if rank <= segment.max_retrieval_rank { - return segment.retrieval_weight; - } - } - - segments.last().map(|segment| segment.retrieval_weight).unwrap_or(0.5) -} - -pub fn resolve_scopes(cfg: &Config, profile: &str) -> Result> { - match profile { - "private_only" => Ok(cfg.scopes.read_profiles.private_only.clone()), - "private_plus_project" => Ok(cfg.scopes.read_profiles.private_plus_project.clone()), - "all_scopes" => Ok(cfg.scopes.read_profiles.all_scopes.clone()), - _ => Err(Error::InvalidRequest { message: "Unknown read_profile.".to_string() }), - } -} diff --git a/packages/elf-service/src/search/ranking/policy/resolve.rs b/packages/elf-service/src/search/ranking/policy/resolve.rs new file mode 100644 index 00000000..a6d3e5e1 --- /dev/null +++ b/packages/elf-service/src/search/ranking/policy/resolve.rs @@ -0,0 +1,206 @@ +use crate::{ + Error, Result, + search::{ + BlendRankingOverride, DiversityRankingOverride, RetrievalSourcesRankingOverride, + ranking::policy::types::{ + BlendSegment, NormalizationKind, ResolvedBlendPolicy, ResolvedDiversityPolicy, + ResolvedRetrievalSourcesPolicy, + }, + }, +}; +use elf_config::{Config, RankingBlend, RankingDiversity, RankingRetrievalSources}; + +pub fn resolve_blend_policy( + cfg: &RankingBlend, + override_: Option<&BlendRankingOverride>, +) -> Result { + let enabled = override_.and_then(|value| value.enabled).unwrap_or(cfg.enabled); + let rerank_norm = override_ + .and_then(|value| value.rerank_normalization.as_deref()) + .unwrap_or(cfg.rerank_normalization.as_str()); + let retrieval_norm = override_ + .and_then(|value| value.retrieval_normalization.as_deref()) + .unwrap_or(cfg.retrieval_normalization.as_str()); + let rerank_normalization = + parse_normalization_kind(rerank_norm, "ranking.blend.rerank_normalization")?; + let retrieval_normalization = + parse_normalization_kind(retrieval_norm, "ranking.blend.retrieval_normalization")?; + let segments: Vec = + if let Some(override_segments) = override_.and_then(|value| value.segments.as_ref()) { + override_segments + .iter() + .map(|segment| BlendSegment { + max_retrieval_rank: segment.max_retrieval_rank, + retrieval_weight: segment.retrieval_weight, + }) + .collect::>() + } else { + cfg.segments + .iter() + .map(|segment| BlendSegment { + max_retrieval_rank: segment.max_retrieval_rank, + retrieval_weight: segment.retrieval_weight, + }) + .collect::>() + }; + + validate_blend_segments(&segments)?; + + Ok(ResolvedBlendPolicy { enabled, rerank_normalization, retrieval_normalization, segments }) +} + +pub fn resolve_diversity_policy( + cfg: &RankingDiversity, + override_: Option<&DiversityRankingOverride>, +) -> Result { + let enabled = override_.and_then(|value| value.enabled).unwrap_or(cfg.enabled); + let sim_threshold = + override_.and_then(|value| value.sim_threshold).unwrap_or(cfg.sim_threshold); + let mmr_lambda = override_.and_then(|value| value.mmr_lambda).unwrap_or(cfg.mmr_lambda); + let max_skips = override_.and_then(|value| value.max_skips).unwrap_or(cfg.max_skips); + + if !sim_threshold.is_finite() { + return Err(Error::InvalidRequest { + message: "ranking.diversity.sim_threshold must be a finite number.".to_string(), + }); + } + if !(0.0..=1.0).contains(&sim_threshold) { + return Err(Error::InvalidRequest { + message: "ranking.diversity.sim_threshold must be in the range 0.0-1.0.".to_string(), + }); + } + if !mmr_lambda.is_finite() { + return Err(Error::InvalidRequest { + message: "ranking.diversity.mmr_lambda must be a finite number.".to_string(), + }); + } + if !(0.0..=1.0).contains(&mmr_lambda) { + return Err(Error::InvalidRequest { + message: "ranking.diversity.mmr_lambda must be in the range 0.0-1.0.".to_string(), + }); + } + + Ok(ResolvedDiversityPolicy { enabled, sim_threshold, mmr_lambda, max_skips }) +} + +pub fn resolve_retrieval_sources_policy( + cfg: &RankingRetrievalSources, + override_: Option<&RetrievalSourcesRankingOverride>, +) -> Result { + let fusion_weight = + override_.and_then(|value| value.fusion_weight).unwrap_or(cfg.fusion_weight); + let structured_field_weight = override_ + .and_then(|value| value.structured_field_weight) + .unwrap_or(cfg.structured_field_weight); + let recursive_weight = + override_.and_then(|value| value.recursive_weight).unwrap_or(structured_field_weight); + let fusion_priority = + override_.and_then(|value| value.fusion_priority).unwrap_or(cfg.fusion_priority); + let structured_field_priority = override_ + .and_then(|value| value.structured_field_priority) + .unwrap_or(cfg.structured_field_priority); + let recursive_priority = override_ + .and_then(|value| value.recursive_priority) + .unwrap_or(structured_field_priority.saturating_add(1)); + + for (path, value) in [ + ("ranking.retrieval_sources.fusion_weight", fusion_weight), + ("ranking.retrieval_sources.structured_field_weight", structured_field_weight), + ("ranking.retrieval_sources.recursive_weight", recursive_weight), + ] { + if !value.is_finite() { + return Err(Error::InvalidRequest { + message: format!("{path} must be a finite number."), + }); + } + if value < 0.0 { + return Err(Error::InvalidRequest { + message: format!("{path} must be zero or greater."), + }); + } + } + + if fusion_weight <= 0.0 && structured_field_weight <= 0.0 && recursive_weight <= 0.0 { + return Err(Error::InvalidRequest { + message: "At least one retrieval source weight must be greater than zero.".to_string(), + }); + } + + Ok(ResolvedRetrievalSourcesPolicy { + fusion_weight, + structured_field_weight, + recursive_weight, + fusion_priority, + structured_field_priority, + recursive_priority, + }) +} + +pub fn parse_normalization_kind(value: &str, label: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "rank" => Ok(NormalizationKind::Rank), + other => Err(Error::InvalidRequest { + message: format!("{label} must be one of: rank. Got {other}."), + }), + } +} + +pub fn validate_blend_segments(segments: &[BlendSegment]) -> Result<()> { + if segments.is_empty() { + return Err(Error::InvalidRequest { + message: "ranking.blend.segments must be non-empty.".to_string(), + }); + } + + let mut last_max = 0_u32; + + for (idx, segment) in segments.iter().enumerate() { + if segment.max_retrieval_rank == 0 { + return Err(Error::InvalidRequest { + message: "ranking.blend.segments.max_retrieval_rank must be greater than zero." + .to_string(), + }); + } + if idx > 0 && segment.max_retrieval_rank <= last_max { + return Err(Error::InvalidRequest { + message: "ranking.blend.segments.max_retrieval_rank must be strictly increasing." + .to_string(), + }); + } + if !segment.retrieval_weight.is_finite() { + return Err(Error::InvalidRequest { + message: "ranking.blend.segments.retrieval_weight must be a finite number." + .to_string(), + }); + } + if !(0.0..=1.0).contains(&segment.retrieval_weight) { + return Err(Error::InvalidRequest { + message: "ranking.blend.segments.retrieval_weight must be in the range 0.0-1.0." + .to_string(), + }); + } + + last_max = segment.max_retrieval_rank; + } + + Ok(()) +} + +pub fn retrieval_weight_for_rank(rank: u32, segments: &[BlendSegment]) -> f32 { + for segment in segments { + if rank <= segment.max_retrieval_rank { + return segment.retrieval_weight; + } + } + + segments.last().map(|segment| segment.retrieval_weight).unwrap_or(0.5) +} + +pub fn resolve_scopes(cfg: &Config, profile: &str) -> Result> { + match profile { + "private_only" => Ok(cfg.scopes.read_profiles.private_only.clone()), + "private_plus_project" => Ok(cfg.scopes.read_profiles.private_plus_project.clone()), + "all_scopes" => Ok(cfg.scopes.read_profiles.all_scopes.clone()), + _ => Err(Error::InvalidRequest { message: "Unknown read_profile.".to_string() }), + } +} diff --git a/packages/elf-service/src/search/ranking/policy/snapshot.rs b/packages/elf-service/src/search/ranking/policy/snapshot.rs new file mode 100644 index 00000000..0f10a282 --- /dev/null +++ b/packages/elf-service/src/search/ranking/policy/snapshot.rs @@ -0,0 +1,224 @@ +use serde_json::Value; + +use crate::{ + Error, Result, + search::{ + RankingRequestOverride, + ranking::policy::types::{ + ResolvedBlendPolicy, ResolvedDiversityPolicy, ResolvedRetrievalSourcesPolicy, + }, + }, +}; +use elf_config::Config; + +pub fn build_config_snapshot( + cfg: &Config, + blend_policy: &ResolvedBlendPolicy, + diversity_policy: &ResolvedDiversityPolicy, + retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, + ranking_override: Option<&RankingRequestOverride>, + policy_id: &str, + policy_snapshot: &Value, +) -> Value { + let override_json = ranking_override.and_then(|value| serde_json::to_value(value).ok()); + + serde_json::json!({ + "search": { + "expansion": { + "mode": cfg.search.expansion.mode.as_str(), + "max_queries": cfg.search.expansion.max_queries, + "include_original": cfg.search.expansion.include_original, + }, + "dynamic": { + "min_candidates": cfg.search.dynamic.min_candidates, + "min_top_score": cfg.search.dynamic.min_top_score, + }, + "prefilter": { + "max_candidates": cfg.search.prefilter.max_candidates, + }, + "explain": { + "retention_days": cfg.search.explain.retention_days, + }, + }, + "ranking": { + "policy_id": policy_id, + "policy_snapshot": policy_snapshot.clone(), + "recency_tau_days": cfg.ranking.recency_tau_days, + "tie_breaker_weight": cfg.ranking.tie_breaker_weight, + "deterministic": { + "enabled": cfg.ranking.deterministic.enabled, + "lexical": { + "enabled": cfg.ranking.deterministic.lexical.enabled, + "weight": cfg.ranking.deterministic.lexical.weight, + "min_ratio": cfg.ranking.deterministic.lexical.min_ratio, + "max_query_terms": cfg.ranking.deterministic.lexical.max_query_terms, + "max_text_terms": cfg.ranking.deterministic.lexical.max_text_terms, + }, + "hits": { + "enabled": cfg.ranking.deterministic.hits.enabled, + "weight": cfg.ranking.deterministic.hits.weight, + "half_saturation": cfg.ranking.deterministic.hits.half_saturation, + "last_hit_tau_days": cfg.ranking.deterministic.hits.last_hit_tau_days, + }, + "decay": { + "enabled": cfg.ranking.deterministic.decay.enabled, + "weight": cfg.ranking.deterministic.decay.weight, + "tau_days": cfg.ranking.deterministic.decay.tau_days, + }, + }, + "blend": { + "enabled": blend_policy.enabled, + "rerank_normalization": blend_policy.rerank_normalization.as_str(), + "retrieval_normalization": blend_policy.retrieval_normalization.as_str(), + "segments": blend_policy + .segments + .iter() + .map(|segment| { + serde_json::json!({ + "max_retrieval_rank": segment.max_retrieval_rank, + "retrieval_weight": segment.retrieval_weight, + }) + }) + .collect::>(), + }, + "diversity": { + "enabled": diversity_policy.enabled, + "sim_threshold": diversity_policy.sim_threshold, + "mmr_lambda": diversity_policy.mmr_lambda, + "max_skips": diversity_policy.max_skips, + }, + "retrieval_sources": { + "fusion_weight": retrieval_sources_policy.fusion_weight, + "structured_field_weight": retrieval_sources_policy.structured_field_weight, + "recursive_weight": retrieval_sources_policy.recursive_weight, + "fusion_priority": retrieval_sources_policy.fusion_priority, + "structured_field_priority": retrieval_sources_policy.structured_field_priority, + "recursive_priority": retrieval_sources_policy.recursive_priority, + }, + "override": override_json, + }, + "providers": { + "embedding": { + "provider_id": cfg.providers.embedding.provider_id.as_str(), + "model": cfg.providers.embedding.model.as_str(), + "dimensions": cfg.providers.embedding.dimensions, + }, + "rerank": { + "provider_id": cfg.providers.rerank.provider_id.as_str(), + "model": cfg.providers.rerank.model.as_str(), + }, + }, + "storage": { + "qdrant": { + "vector_dim": cfg.storage.qdrant.vector_dim, + "collection": cfg.storage.qdrant.collection.as_str(), + }, + }, + "context": { + "scope_boost_weight": cfg.context.as_ref().and_then(|ctx| ctx.scope_boost_weight), + "project_description_count": cfg + .context + .as_ref() + .and_then(|ctx| ctx.project_descriptions.as_ref()) + .map(|descriptions| descriptions.len()) + .unwrap_or(0), + "scope_description_count": cfg + .context + .as_ref() + .and_then(|ctx| ctx.scope_descriptions.as_ref()) + .map(|descriptions| descriptions.len()) + .unwrap_or(0), + }, + }) +} + +pub fn build_policy_snapshot( + cfg: &Config, + blend_policy: &ResolvedBlendPolicy, + diversity_policy: &ResolvedDiversityPolicy, + retrieval_sources_policy: &ResolvedRetrievalSourcesPolicy, + ranking_override: Option<&RankingRequestOverride>, +) -> Value { + let override_json = ranking_override.and_then(|value| serde_json::to_value(value).ok()); + + serde_json::json!({ + "ranking": { + "recency_tau_days": cfg.ranking.recency_tau_days, + "tie_breaker_weight": cfg.ranking.tie_breaker_weight, + "deterministic": { + "enabled": cfg.ranking.deterministic.enabled, + "lexical": { + "enabled": cfg.ranking.deterministic.lexical.enabled, + "weight": cfg.ranking.deterministic.lexical.weight, + "min_ratio": cfg.ranking.deterministic.lexical.min_ratio, + "max_query_terms": cfg.ranking.deterministic.lexical.max_query_terms, + "max_text_terms": cfg.ranking.deterministic.lexical.max_text_terms, + }, + "hits": { + "enabled": cfg.ranking.deterministic.hits.enabled, + "weight": cfg.ranking.deterministic.hits.weight, + "half_saturation": cfg.ranking.deterministic.hits.half_saturation, + "last_hit_tau_days": cfg.ranking.deterministic.hits.last_hit_tau_days, + }, + "decay": { + "enabled": cfg.ranking.deterministic.decay.enabled, + "weight": cfg.ranking.deterministic.decay.weight, + "tau_days": cfg.ranking.deterministic.decay.tau_days, + }, + }, + "blend": { + "enabled": blend_policy.enabled, + "rerank_normalization": blend_policy.rerank_normalization.as_str(), + "retrieval_normalization": blend_policy.retrieval_normalization.as_str(), + "segments": blend_policy + .segments + .iter() + .map(|segment| { + serde_json::json!({ + "max_retrieval_rank": segment.max_retrieval_rank, + "retrieval_weight": segment.retrieval_weight, + }) + }) + .collect::>(), + }, + "diversity": { + "enabled": diversity_policy.enabled, + "sim_threshold": diversity_policy.sim_threshold, + "mmr_lambda": diversity_policy.mmr_lambda, + "max_skips": diversity_policy.max_skips, + }, + "retrieval_sources": { + "fusion_weight": retrieval_sources_policy.fusion_weight, + "structured_field_weight": retrieval_sources_policy.structured_field_weight, + "recursive_weight": retrieval_sources_policy.recursive_weight, + "fusion_priority": retrieval_sources_policy.fusion_priority, + "structured_field_priority": retrieval_sources_policy.structured_field_priority, + "recursive_priority": retrieval_sources_policy.recursive_priority, + }, + "override": override_json, + }, + "context": { + "scope_boost_weight": cfg.context.as_ref().and_then(|ctx| ctx.scope_boost_weight), + "project_description_count": cfg + .context + .as_ref() + .and_then(|ctx| ctx.project_descriptions.as_ref()) + .map(|descriptions| descriptions.len()) + .unwrap_or(0), + "scope_description_count": cfg + .context + .as_ref() + .and_then(|ctx| ctx.scope_descriptions.as_ref()) + .map(|descriptions| descriptions.len()) + .unwrap_or(0), + }, + }) +} + +pub fn hash_policy_snapshot(payload: &Value) -> Result { + let raw = serde_json::to_vec(payload).map_err(|err| Error::Storage { + message: format!("Failed to encode policy snapshot: {err}"), + })?; + + Ok(blake3::hash(&raw).to_hex().to_string()) +} diff --git a/packages/elf-service/src/search/ranking/policy/types.rs b/packages/elf-service/src/search/ranking/policy/types.rs new file mode 100644 index 00000000..0a28eb26 --- /dev/null +++ b/packages/elf-service/src/search/ranking/policy/types.rs @@ -0,0 +1,43 @@ +#[derive(Clone, Copy, Debug)] +pub enum NormalizationKind { + Rank, +} +impl NormalizationKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Rank => "rank", + } + } +} + +#[derive(Clone, Debug)] +pub struct BlendSegment { + pub max_retrieval_rank: u32, + pub retrieval_weight: f32, +} + +#[derive(Clone, Debug)] +pub struct ResolvedBlendPolicy { + pub enabled: bool, + pub rerank_normalization: NormalizationKind, + pub retrieval_normalization: NormalizationKind, + pub segments: Vec, +} + +#[derive(Clone, Debug)] +pub struct ResolvedDiversityPolicy { + pub enabled: bool, + pub sim_threshold: f32, + pub mmr_lambda: f32, + pub max_skips: u32, +} + +#[derive(Clone, Debug)] +pub struct ResolvedRetrievalSourcesPolicy { + pub fusion_weight: f32, + pub structured_field_weight: f32, + pub recursive_weight: f32, + pub fusion_priority: u32, + pub structured_field_priority: u32, + pub recursive_priority: u32, +} diff --git a/packages/elf-service/src/search/replay_helpers.rs b/packages/elf-service/src/search/replay_helpers.rs new file mode 100644 index 00000000..ea7702e2 --- /dev/null +++ b/packages/elf-service/src/search/replay_helpers.rs @@ -0,0 +1,226 @@ +use crate::{ + ranking_explain_v2, + search::{ + Config, DiversityDecision, HashMap, NormalizationKind, Ordering, ResolvedBlendPolicy, + ResolvedDiversityPolicy, SEARCH_RANKING_EXPLAIN_SCHEMA_V2, ScoreCandidateCtx, ScoredReplay, + SearchExplain, SearchMatchExplain, SearchRankingExplain, TraceReplayCandidate, + TraceReplayItem, TraceTermsArgs, Uuid, ranking, + }, +}; + +pub(super) fn score_replay_candidate( + ctx: &ScoreCandidateCtx<'_, '_>, + candidate: &TraceReplayCandidate, + rerank_rank: u32, +) -> ScoredReplay { + let importance = candidate.note_importance; + let retrieval_rank = candidate.retrieval_rank; + let age_days = (ctx.now - candidate.note_updated_at).as_seconds_f32() / 86_400.0; + let decay = if ctx.cfg.ranking.recency_tau_days > 0.0 { + (-age_days / ctx.cfg.ranking.recency_tau_days).exp() + } else { + 1.0 + }; + let base = (1.0 + 0.6 * importance) * decay; + let tie_breaker_score = ctx.cfg.ranking.tie_breaker_weight * base; + let scope_context_boost = + ctx.scope_context_boost_by_scope.get(candidate.note_scope.as_str()).copied().unwrap_or(0.0); + let rerank_norm = match ctx.blend_policy.rerank_normalization { + NormalizationKind::Rank => ranking::rank_normalize(rerank_rank, ctx.total_rerank), + }; + let retrieval_norm = match ctx.blend_policy.retrieval_normalization { + NormalizationKind::Rank => ranking::rank_normalize(retrieval_rank, ctx.total_retrieval), + }; + let blend_retrieval_weight = if ctx.blend_policy.enabled { + ranking::retrieval_weight_for_rank(retrieval_rank, &ctx.blend_policy.segments) + } else { + 0.0 + }; + let retrieval_term = blend_retrieval_weight * retrieval_norm; + let rerank_term = (1.0 - blend_retrieval_weight) * rerank_norm; + let det_terms = ranking::compute_deterministic_ranking_terms( + ctx.cfg, + ctx.det_query_tokens, + candidate.snippet.as_str(), + candidate.note_hit_count, + candidate.note_last_hit_at, + age_days, + ctx.now, + ); + let final_score = retrieval_term + + rerank_term + + tie_breaker_score + + scope_context_boost + + det_terms.lexical_bonus + + det_terms.hit_boost + + det_terms.decay_penalty; + + ScoredReplay { + note_id: candidate.note_id, + chunk_id: candidate.chunk_id, + retrieval_rank, + final_score, + rerank_score: candidate.rerank_score, + rerank_rank, + rerank_norm, + retrieval_norm, + blend_retrieval_weight, + retrieval_term, + rerank_term, + tie_breaker_score, + scope_context_boost, + age_days, + importance, + note_scope: candidate.note_scope.clone(), + deterministic_lexical_overlap_ratio: det_terms.lexical_overlap_ratio, + deterministic_lexical_bonus: det_terms.lexical_bonus, + deterministic_hit_count: det_terms.hit_count, + deterministic_last_hit_age_days: det_terms.last_hit_age_days, + deterministic_hit_boost: det_terms.hit_boost, + deterministic_decay_penalty: det_terms.decay_penalty, + } +} + +pub(super) fn should_replace_replay_best(existing: &ScoredReplay, scored: &ScoredReplay) -> bool { + let ord = ranking::cmp_f32_desc(scored.final_score, existing.final_score); + + if ord != Ordering::Equal { + ord == Ordering::Less + } else { + scored.retrieval_rank < existing.retrieval_rank + } +} + +pub(super) fn cmp_scored_replay(a: &ScoredReplay, b: &ScoredReplay) -> Ordering { + let ord = ranking::cmp_f32_desc(a.final_score, b.final_score); + + if ord != Ordering::Equal { + return ord; + } + + let ord = a.retrieval_rank.cmp(&b.retrieval_rank); + + if ord != Ordering::Equal { + return ord; + } + + let ord = a.note_id.cmp(&b.note_id); + + if ord != Ordering::Equal { + return ord; + } + + a.chunk_id.cmp(&b.chunk_id) +} + +pub(super) fn apply_replay_diversity_selection( + mut results: Vec, + top_k: u32, + diversity_enabled: bool, + replay_diversity_decisions: &HashMap, +) -> Vec { + if diversity_enabled && !replay_diversity_decisions.is_empty() { + let mut selected: Vec = results + .iter() + .filter(|scored| { + replay_diversity_decisions + .get(&scored.note_id) + .map(|decision| decision.selected) + .unwrap_or(false) + }) + .cloned() + .collect(); + + selected.sort_by(|a, b| { + let rank_a = replay_diversity_decisions + .get(&a.note_id) + .and_then(|decision| decision.selected_rank) + .unwrap_or(u32::MAX); + let rank_b = replay_diversity_decisions + .get(&b.note_id) + .and_then(|decision| decision.selected_rank) + .unwrap_or(u32::MAX); + let ord = rank_a.cmp(&rank_b); + + if ord != Ordering::Equal { + return ord; + } + + a.note_id.cmp(&b.note_id) + }); + + if !selected.is_empty() { + results = selected; + } + } + + results.truncate(top_k.max(1) as usize); + + results +} + +pub(super) fn build_replay_items( + cfg: &Config, + blend_policy: &ResolvedBlendPolicy, + diversity_policy: &ResolvedDiversityPolicy, + policy_id: &str, + replay_diversity_decisions: &HashMap, + results: Vec, +) -> Vec { + let mut out = Vec::with_capacity(results.len()); + + for scored in results { + let terms = ranking_explain_v2::build_trace_terms_v2(TraceTermsArgs { + cfg, + blend_enabled: blend_policy.enabled, + retrieval_normalization: blend_policy.retrieval_normalization.as_str(), + rerank_normalization: blend_policy.rerank_normalization.as_str(), + blend_retrieval_weight: scored.blend_retrieval_weight, + retrieval_rank: scored.retrieval_rank, + retrieval_norm: scored.retrieval_norm, + retrieval_term: scored.retrieval_term, + rerank_score: scored.rerank_score, + rerank_rank: scored.rerank_rank, + rerank_norm: scored.rerank_norm, + rerank_term: scored.rerank_term, + tie_breaker_score: scored.tie_breaker_score, + importance: scored.importance, + age_days: scored.age_days, + scope: scored.note_scope.as_str(), + scope_context_boost: scored.scope_context_boost, + deterministic_lexical_overlap_ratio: scored.deterministic_lexical_overlap_ratio, + deterministic_lexical_bonus: scored.deterministic_lexical_bonus, + deterministic_hit_count: scored.deterministic_hit_count, + deterministic_last_hit_age_days: scored.deterministic_last_hit_age_days, + deterministic_hit_boost: scored.deterministic_hit_boost, + deterministic_decay_penalty: scored.deterministic_decay_penalty, + }); + let explain = SearchExplain { + r#match: SearchMatchExplain { matched_terms: Vec::new(), matched_fields: Vec::new() }, + ranking: SearchRankingExplain { + schema: SEARCH_RANKING_EXPLAIN_SCHEMA_V2.to_string(), + policy_id: policy_id.to_string(), + final_score: scored.final_score, + terms, + }, + relation_context: None, + diversity: if diversity_policy.enabled { + replay_diversity_decisions + .get(&scored.note_id) + .map(ranking::build_diversity_explain) + } else { + None + }, + }; + + out.push(TraceReplayItem { + note_id: scored.note_id, + chunk_id: scored.chunk_id, + retrieval_rank: scored.retrieval_rank, + final_score: scored.final_score, + explain, + }); + } + + out +} diff --git a/packages/elf-service/src/search/retrieval.rs b/packages/elf-service/src/search/retrieval.rs new file mode 100644 index 00000000..dbab86c0 --- /dev/null +++ b/packages/elf-service/src/search/retrieval.rs @@ -0,0 +1,5 @@ +mod embedding; +mod expansion; +mod flow; +mod recursive; +mod structured; diff --git a/packages/elf-service/src/search/retrieval/embedding.rs b/packages/elf-service/src/search/retrieval/embedding.rs new file mode 100644 index 00000000..60bd9938 --- /dev/null +++ b/packages/elf-service/src/search/retrieval/embedding.rs @@ -0,0 +1,176 @@ +use crate::{ + Error, + search::{ + BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME, Document, ElfService, Filter, Fusion, + PrefetchQueryBuilder, Query, QueryEmbedding, QueryPointsBuilder, Result, ScoredPoint, + english_gate, ranking, slice, + }, +}; + +impl ElfService { + pub(in crate::search) fn resolve_project_context_description<'a>( + &'a self, + tenant_id: &str, + project_id: &str, + ) -> Option<&'a str> { + let context = self.cfg.context.as_ref()?; + let descriptions = context.project_descriptions.as_ref()?; + let key = format!("{tenant_id}:{project_id}"); + let mut saw_non_english = false; + + if let Some(value) = descriptions.get(&key) { + let trimmed = value.trim(); + + if !trimmed.is_empty() { + if !english_gate::is_english_natural_language(trimmed) { + saw_non_english = true; + } else { + return Some(trimmed); + } + } + } + if let Some(value) = descriptions.get(project_id) { + let trimmed = value.trim(); + + if !trimmed.is_empty() { + if !english_gate::is_english_natural_language(trimmed) { + saw_non_english = true; + } else { + return Some(trimmed); + } + } + } + + if saw_non_english { + tracing::warn!( + tenant_id = %tenant_id, + project_id = %project_id, + "Project context description is non-English. Skipping context." + ); + } + + None + } + + pub(in crate::search::retrieval) async fn embed_single_query( + &self, + query: &str, + project_context_description: Option<&str>, + ) -> Result> { + let input = ranking::build_dense_embedding_input(query, project_context_description); + let embeddings = self + .providers + .embedding + .embed(&self.cfg.providers.embedding, slice::from_ref(&input)) + .await?; + let query_vec = embeddings.into_iter().next().ok_or_else(|| Error::Provider { + message: "Embedding provider returned no vectors.".to_string(), + })?; + + if query_vec.len() != self.cfg.storage.qdrant.vector_dim as usize { + return Err(Error::Provider { + message: "Embedding vector dimension mismatch.".to_string(), + }); + } + + Ok(query_vec) + } + + pub(in crate::search::retrieval) async fn embed_queries( + &self, + queries: &[String], + original_query: &str, + baseline_vector: Option<&Vec>, + project_context_description: Option<&str>, + ) -> Result> { + let mut extra_queries = Vec::new(); + let mut extra_inputs = Vec::new(); + + for query in queries { + if baseline_vector.is_some() && query == original_query { + continue; + } + + extra_queries.push(query.clone()); + extra_inputs + .push(ranking::build_dense_embedding_input(query, project_context_description)); + } + + let mut embedded_iter = if extra_queries.is_empty() { + Vec::new().into_iter() + } else { + let embedded = self + .providers + .embedding + .embed(&self.cfg.providers.embedding, &extra_inputs) + .await?; + + if embedded.len() != extra_queries.len() { + return Err(Error::Provider { + message: "Embedding provider returned mismatched vector count.".to_string(), + }); + } + + embedded.into_iter() + }; + let mut out = Vec::with_capacity(queries.len()); + + for query in queries { + let vector = if baseline_vector.is_some() && query == original_query { + baseline_vector + .ok_or_else(|| Error::Provider { + message: "Embedding baseline vector is missing.".to_string(), + })? + .clone() + } else { + embedded_iter.next().ok_or_else(|| Error::Provider { + message: "Embedding provider returned no vectors.".to_string(), + })? + }; + + if vector.len() != self.cfg.storage.qdrant.vector_dim as usize { + return Err(Error::Provider { + message: "Embedding vector dimension mismatch.".to_string(), + }); + } + + out.push(QueryEmbedding { text: query.clone(), vector }); + } + + Ok(out) + } + + pub(in crate::search::retrieval) async fn run_fusion_query( + &self, + queries: &[QueryEmbedding], + filter: &Filter, + candidate_k: u32, + ) -> Result> { + let mut search = QueryPointsBuilder::new(self.qdrant.collection.clone()); + + for query in queries { + let dense_prefetch = PrefetchQueryBuilder::default() + .query(Query::new_nearest(query.vector.clone())) + .using(DENSE_VECTOR_NAME) + .filter(filter.clone()) + .limit(candidate_k as u64); + let bm25_prefetch = PrefetchQueryBuilder::default() + .query(Query::new_nearest(Document::new(query.text.clone(), BM25_MODEL))) + .using(BM25_VECTOR_NAME) + .filter(filter.clone()) + .limit(candidate_k as u64); + + search = search.add_prefetch(dense_prefetch).add_prefetch(bm25_prefetch); + } + + let search = search.with_payload(true).query(Fusion::Rrf).limit(candidate_k as u64); + let response = self + .qdrant + .client + .query(search) + .await + .map_err(|err| Error::Qdrant { message: err.to_string() })?; + + Ok(response.result) + } +} diff --git a/packages/elf-service/src/search/retrieval/expansion.rs b/packages/elf-service/src/search/retrieval/expansion.rs new file mode 100644 index 00000000..b086bfa8 --- /dev/null +++ b/packages/elf-service/src/search/retrieval/expansion.rs @@ -0,0 +1,201 @@ +use crate::search::{ + self, CacheKind, Duration, ElfService, ExpansionCachePayload, ExpansionOutput, OffsetDateTime, + SearchCache, ranking, +}; + +impl ElfService { + pub(in crate::search::retrieval) async fn expand_queries(&self, query: &str) -> Vec { + let cfg = &self.cfg.search.expansion; + let cache_cfg = &self.cfg.search.cache; + let now = OffsetDateTime::now_utc(); + let cache_key = if cache_cfg.enabled { + match ranking::build_expansion_cache_key( + query, + cfg.max_queries, + cfg.include_original, + self.cfg.providers.llm_extractor.provider_id.as_str(), + self.cfg.providers.llm_extractor.model.as_str(), + self.cfg.providers.llm_extractor.temperature, + ) { + Ok(key) => Some(key), + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Expansion.as_str(), + "Cache key build failed." + ); + + None + }, + } + } else { + None + }; + + if let Some(key) = cache_key.as_ref() + && let Some(queries) = self.read_expansion_cache_queries(key, cache_cfg, now).await + { + return queries; + } + + let messages = + ranking::build_expansion_messages(query, cfg.max_queries, cfg.include_original); + let raw = match self + .providers + .extractor + .extract(&self.cfg.providers.llm_extractor, &messages) + .await + { + Ok(value) => value, + Err(err) => { + tracing::warn!(error = %err, "Query expansion failed; falling back to original query."); + + return vec![query.to_string()]; + }, + }; + let parsed: ExpansionOutput = match serde_json::from_value(raw) { + Ok(value) => value, + Err(err) => { + tracing::warn!(error = %err, "Query expansion returned invalid JSON; falling back to original query."); + + return vec![query.to_string()]; + }, + }; + let normalized = ranking::normalize_queries( + parsed.queries, + query, + cfg.include_original, + cfg.max_queries, + ); + let result = if normalized.is_empty() { vec![query.to_string()] } else { normalized }; + + if let Some(key) = cache_key { + self.store_expansion_cache_queries(&key, &result, cache_cfg).await; + } + + result + } + + async fn read_expansion_cache_queries( + &self, + key: &str, + cache_cfg: &SearchCache, + now: OffsetDateTime, + ) -> Option> { + match search::fetch_cache_payload(&self.db.pool, CacheKind::Expansion, key, now).await { + Ok(Some(payload)) => { + tracing::info!( + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = true, + payload_size = payload.size_bytes, + ttl_days = cache_cfg.expansion_ttl_days, + "Cache hit." + ); + + let cached: ExpansionCachePayload = match serde_json::from_value(payload.value) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache payload decode failed." + ); + + ExpansionCachePayload { queries: Vec::new() } + }, + }; + + (!cached.queries.is_empty()).then_some(cached.queries) + }, + Ok(None) => { + tracing::info!( + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size = 0_u64, + ttl_days = cache_cfg.expansion_ttl_days, + "Cache miss." + ); + + None + }, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache read failed." + ); + + None + }, + } + } + + async fn store_expansion_cache_queries( + &self, + key: &str, + queries: &[String], + cache_cfg: &SearchCache, + ) { + let payload = ExpansionCachePayload { queries: queries.to_vec() }; + let payload_json = match serde_json::to_value(&payload) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache payload encode failed." + ); + + return; + }, + }; + let stored_at = OffsetDateTime::now_utc(); + let expires_at = stored_at + Duration::days(cache_cfg.expansion_ttl_days); + + match search::store_cache_payload( + &self.db.pool, + CacheKind::Expansion, + key, + payload_json, + stored_at, + expires_at, + cache_cfg.max_payload_bytes, + ) + .await + { + Ok(Some(payload_size)) => { + tracing::info!( + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size, + ttl_days = cache_cfg.expansion_ttl_days, + "Cache stored." + ); + }, + Ok(None) => { + tracing::warn!( + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + hit = false, + payload_size = 0_u64, + ttl_days = cache_cfg.expansion_ttl_days, + "Cache payload skipped due to size." + ); + }, + Err(err) => { + tracing::warn!( + error = %err, + cache_kind = CacheKind::Expansion.as_str(), + cache_key_prefix = ranking::cache_key_prefix(key), + "Cache write failed." + ); + }, + } + } +} diff --git a/packages/elf-service/src/search/retrieval/flow.rs b/packages/elf-service/src/search/retrieval/flow.rs new file mode 100644 index 00000000..0acfa9ce --- /dev/null +++ b/packages/elf-service/src/search/retrieval/flow.rs @@ -0,0 +1,225 @@ +use crate::search::{ + DynamicGateSummary, ElfService, ExpansionMode, FinishSearchArgs, MaybeDynamicSearchArgs, + OffsetDateTime, QueryEmbedding, RecursiveRetrievalArgs, Result, RetrievalSourceCandidates, + RetrievalSourceKind, SearchResponse, SearchRetrievalArgs, SearchRetrievalResult, + StructuredFieldRetrievalArgs, StructuredFieldRetrievalResult, ranking, +}; + +impl ElfService { + pub(in crate::search) async fn maybe_finish_dynamic_search( + &self, + args: MaybeDynamicSearchArgs<'_>, + ) -> Result<(Option>, Option, DynamicGateSummary)> { + if !args.enabled { + return Ok((None, None, DynamicGateSummary::default())); + } + + let query_vec = + self.embed_single_query(args.query, args.project_context_description).await?; + let baseline_points = self + .run_fusion_query( + &[QueryEmbedding { text: args.query.to_string(), vector: query_vec.clone() }], + args.filter, + args.candidate_k, + ) + .await?; + let top_score = baseline_points.first().map(|point| point.score).unwrap_or(0.0); + let fusion_candidates = ranking::collect_chunk_candidates( + &baseline_points, + self.cfg.search.prefilter.max_candidates, + args.candidate_k, + ); + let should_expand = ranking::should_expand_dynamic( + baseline_points.len(), + top_score, + &self.cfg.search.dynamic, + ); + let dynamic_gate = DynamicGateSummary { + considered: true, + should_expand: Some(should_expand), + observed_candidates: Some(baseline_points.len() as u32), + observed_top_score: Some(top_score), + }; + + if should_expand { + return Ok((Some(query_vec), None, dynamic_gate)); + } + + let StructuredFieldRetrievalResult { + candidates: structured_candidates, + structured_matches, + } = self + .retrieve_structured_field_candidates(StructuredFieldRetrievalArgs { + tenant_id: args.tenant_id, + project_id: args.project_id, + agent_id: args.agent_id, + allowed_scopes: args.allowed_scopes, + query_vec: query_vec.as_slice(), + candidate_k: args.candidate_k, + now: OffsetDateTime::now_utc(), + }) + .await?; + let mut seed_candidates = + Vec::with_capacity(fusion_candidates.len() + structured_candidates.len()); + + seed_candidates.extend_from_slice(fusion_candidates.as_slice()); + seed_candidates.extend_from_slice(structured_candidates.as_slice()); + + let recursive = self + .run_recursive_retrieval(RecursiveRetrievalArgs { + query: args.query, + query_vec: query_vec.as_slice(), + filter: args.filter, + candidate_k: args.candidate_k, + retrieval_sources_policy: args.retrieval_sources_policy, + seed_candidates: seed_candidates.as_slice(), + }) + .await?; + let mut retrieval_sources = vec![ + RetrievalSourceCandidates { + source: RetrievalSourceKind::Fusion, + candidates: fusion_candidates, + }, + RetrievalSourceCandidates { + source: RetrievalSourceKind::StructuredField, + candidates: structured_candidates, + }, + ]; + + if recursive.enabled { + retrieval_sources.push(RetrievalSourceCandidates { + source: RetrievalSourceKind::Recursive, + candidates: recursive.candidates.clone(), + }); + } + + let merged_candidates = ranking::merge_retrieval_candidates( + retrieval_sources, + args.retrieval_sources_policy, + args.candidate_k, + ); + let response = self + .finish_search(FinishSearchArgs { + path: args.path, + trace_id: args.trace_id, + query: args.query, + tenant_id: args.tenant_id, + project_id: args.project_id, + agent_id: args.agent_id, + token_id: args.token_id, + read_profile: args.read_profile, + allowed_scopes: args.allowed_scopes, + expanded_queries: vec![args.query.to_string()], + expansion_mode: ExpansionMode::Dynamic, + candidates: merged_candidates, + structured_matches, + recursive_retrieval: Some(recursive), + top_k: args.top_k, + record_hits_enabled: args.record_hits_enabled, + ranking_override: args.ranking_override.cloned(), + payload_level: args.payload_level, + filter: args.service_filter, + requested_candidate_k: args.requested_candidate_k, + effective_candidate_k: args.effective_candidate_k, + }) + .await?; + + Ok((Some(query_vec), Some(response), dynamic_gate)) + } + + pub(in crate::search) async fn retrieve_search_candidates( + &self, + args: SearchRetrievalArgs<'_>, + ) -> Result { + let queries = match args.expansion_mode { + ExpansionMode::Off => vec![args.query.to_string()], + ExpansionMode::Always | ExpansionMode::Dynamic => self.expand_queries(args.query).await, + }; + let expanded_queries = queries.clone(); + let query_embeddings = self + .embed_queries( + queries.as_slice(), + args.query, + args.baseline_vector, + args.project_context_description, + ) + .await?; + let fusion_points = + self.run_fusion_query(&query_embeddings, args.filter, args.candidate_k).await?; + let fusion_candidates = ranking::collect_chunk_candidates( + &fusion_points, + self.cfg.search.prefilter.max_candidates, + args.candidate_k, + ); + let original_query_vec = query_embeddings + .iter() + .find(|embedded| embedded.text == args.query) + .map(|embedded| embedded.vector.clone()) + .unwrap_or_else(Vec::new); + let original_query_vec = if original_query_vec.is_empty() { + self.embed_single_query(args.query, args.project_context_description).await? + } else { + original_query_vec + }; + let StructuredFieldRetrievalResult { + candidates: structured_candidates, + structured_matches, + } = self + .retrieve_structured_field_candidates(StructuredFieldRetrievalArgs { + tenant_id: args.tenant_id, + project_id: args.project_id, + agent_id: args.agent_id, + allowed_scopes: args.allowed_scopes, + query_vec: original_query_vec.as_slice(), + candidate_k: args.candidate_k, + now: OffsetDateTime::now_utc(), + }) + .await?; + let mut seed_candidates = + Vec::with_capacity(fusion_candidates.len() + structured_candidates.len()); + + seed_candidates.extend_from_slice(fusion_candidates.as_slice()); + seed_candidates.extend_from_slice(structured_candidates.as_slice()); + + let recursive = self + .run_recursive_retrieval(RecursiveRetrievalArgs { + query: args.query, + query_vec: original_query_vec.as_slice(), + filter: args.filter, + candidate_k: args.candidate_k, + retrieval_sources_policy: args.retrieval_sources_policy, + seed_candidates: seed_candidates.as_slice(), + }) + .await?; + let mut retrieval_sources = vec![ + RetrievalSourceCandidates { + source: RetrievalSourceKind::Fusion, + candidates: fusion_candidates, + }, + RetrievalSourceCandidates { + source: RetrievalSourceKind::StructuredField, + candidates: structured_candidates, + }, + ]; + + if recursive.enabled { + retrieval_sources.push(RetrievalSourceCandidates { + source: RetrievalSourceKind::Recursive, + candidates: recursive.candidates.clone(), + }); + } + + let merged_candidates = ranking::merge_retrieval_candidates( + retrieval_sources, + args.retrieval_sources_policy, + args.candidate_k, + ); + + Ok(SearchRetrievalResult { + expanded_queries, + candidates: merged_candidates, + structured_matches, + recursive: Some(recursive), + }) + } +} diff --git a/packages/elf-service/src/search/retrieval/recursive.rs b/packages/elf-service/src/search/retrieval/recursive.rs new file mode 100644 index 00000000..29343ef6 --- /dev/null +++ b/packages/elf-service/src/search/retrieval/recursive.rs @@ -0,0 +1,179 @@ +use crate::search::{ + ChunkCandidate, Condition, ElfService, HashMap, HashSet, QueryEmbedding, + RecursiveRetrievalArgs, RecursiveRetrievalResult, Result, VecDeque, ranking, slice, +}; + +impl ElfService { + pub(in crate::search::retrieval) async fn run_recursive_retrieval( + &self, + args: RecursiveRetrievalArgs<'_>, + ) -> Result { + let recursive_config = &self.cfg.search.recursive; + let mut result = RecursiveRetrievalResult { + enabled: recursive_config.enabled + && args.retrieval_sources_policy.recursive_weight > 0.0, + ..Default::default() + }; + + if !result.enabled { + result.stop_reason = Some("disabled".to_string()); + + return Ok(result); + } + if args.query_vec.is_empty() { + result.stop_reason = Some("missing_query_vector".to_string()); + + return Ok(result); + } + + let mut seed_scopes = HashSet::::new(); + + for candidate in args.seed_candidates { + if let Some(scope) = candidate.scope.as_deref() + && !scope.trim().is_empty() + { + seed_scopes.insert(scope.to_string()); + } + } + + result.scopes_seeded = seed_scopes.len(); + result.candidates_before = args.seed_candidates.len(); + + if seed_scopes.is_empty() { + result.stop_reason = Some("no_scope_seed".to_string()); + + return Ok(result); + } + + let max_depth = recursive_config.max_depth; + let max_children_per_node = + usize::try_from(recursive_config.max_children_per_node).unwrap_or(usize::MAX); + let max_nodes_per_scope = + usize::try_from(recursive_config.max_nodes_per_scope).unwrap_or(usize::MAX); + let max_total_nodes = + usize::try_from(recursive_config.max_total_nodes).unwrap_or(usize::MAX); + let child_query_embedding = + QueryEmbedding { text: args.query.to_string(), vector: args.query_vec.to_vec() }; + let per_query_candidate_k = + args.candidate_k.min(recursive_config.max_nodes_per_scope).max(1); + let (candidates, queried_scopes, rounds_executed, stop_reason) = self + .collect_recursive_candidates( + &args, + seed_scopes, + child_query_embedding, + max_depth, + max_children_per_node, + max_nodes_per_scope, + max_total_nodes, + per_query_candidate_k, + self.cfg.search.prefilter.max_candidates, + ) + .await?; + + result.scopes_queried = queried_scopes; + result.rounds_executed = rounds_executed; + result.total_queries = rounds_executed; + result.candidates = candidates; + result.candidates_added = result.candidates.len(); + result.candidates_after = result.candidates_before + result.candidates_added; + result.stop_reason = stop_reason.or(Some("converged".to_string())); + + Ok(result) + } + + #[allow(clippy::too_many_arguments)] + async fn collect_recursive_candidates( + &self, + args: &RecursiveRetrievalArgs<'_>, + seed_scopes: HashSet, + child_query_embedding: QueryEmbedding, + max_depth: u32, + max_children_per_node: usize, + max_nodes_per_scope: usize, + max_total_nodes: usize, + per_query_candidate_k: u32, + prefilter_max_candidates: u32, + ) -> Result<(Vec, usize, u32, Option)> { + let mut queued_scopes: VecDeque<(String, u32)> = VecDeque::new(); + let mut discovered_scopes = seed_scopes.clone(); + let mut recursion_candidates = Vec::::new(); + let mut seen_chunks = + args.seed_candidates.iter().map(|candidate| candidate.chunk_id).collect::>(); + let mut scope_counts: HashMap = HashMap::new(); + let mut queried_scopes = 0_usize; + let mut rounds_executed = 0_u32; + let mut stop_reason: Option = None; + + for scope in seed_scopes { + queued_scopes.push_back((scope, 1)); + } + + while let Some((scope, depth)) = queued_scopes.pop_front() { + if depth > max_depth { + stop_reason = Some("max_depth".to_string()); + + break; + } + + queried_scopes = queried_scopes.saturating_add(1); + rounds_executed = rounds_executed.saturating_add(1); + + let mut scoped_filter = args.filter.clone(); + + scoped_filter.must.push(Condition::matches("scope", scope.clone())); + + let recursive_points = self + .run_fusion_query( + slice::from_ref(&child_query_embedding), + &scoped_filter, + per_query_candidate_k, + ) + .await?; + let scope_query_limit = per_query_candidate_k.min(max_nodes_per_scope as u32); + let recursive_candidates_for_scope = ranking::collect_chunk_candidates( + &recursive_points, + prefilter_max_candidates.min(scope_query_limit), + scope_query_limit, + ); + let mut child_scopes = HashSet::::new(); + + for mut candidate in recursive_candidates_for_scope { + if recursion_candidates.len() >= max_total_nodes { + stop_reason = Some("max_total_nodes".to_string()); + + break; + } + + let scope_key = candidate.scope.clone().unwrap_or_else(|| scope.clone()); + let scope_count = scope_counts.entry(scope_key.clone()).or_default(); + + if (*scope_count as usize) >= max_nodes_per_scope { + continue; + } + if !seen_chunks.insert(candidate.chunk_id) { + continue; + } + + *scope_count = scope_count.saturating_add(1); + candidate.scope = Some(scope_key.clone()); + + recursion_candidates.push(candidate); + + if depth < max_depth + && child_scopes.len() < max_children_per_node + && !scope_key.is_empty() + && discovered_scopes.insert(scope_key.clone()) + { + child_scopes.insert(scope_key.clone()); + queued_scopes.push_back((scope_key.clone(), depth.saturating_add(1))); + } + } + + if stop_reason.is_some() { + break; + } + } + + Ok((recursion_candidates, queried_scopes, rounds_executed, stop_reason)) + } +} diff --git a/packages/elf-service/src/search/retrieval/structured.rs b/packages/elf-service/src/search/retrieval/structured.rs new file mode 100644 index 00000000..a7763e79 --- /dev/null +++ b/packages/elf-service/src/search/retrieval/structured.rs @@ -0,0 +1,247 @@ +use crate::search::{ + self, BestChunkForNoteRow, ElfService, FieldHit, HashMap, ORG_PROJECT_ID, Result, + StructuredFieldHitArgs, StructuredFieldHitRow, StructuredFieldRetrievalArgs, + StructuredFieldRetrievalResult, Uuid, +}; + +impl ElfService { + pub(in crate::search::retrieval) async fn retrieve_structured_field_candidates( + &self, + args: StructuredFieldRetrievalArgs<'_>, + ) -> Result { + let StructuredFieldRetrievalArgs { + tenant_id, + project_id, + agent_id, + allowed_scopes, + query_vec, + candidate_k, + now, + } = args; + + if query_vec.is_empty() { + return Ok(StructuredFieldRetrievalResult { + candidates: Vec::new(), + structured_matches: HashMap::new(), + }); + } + + let embed_version = crate::embedding_version(&self.cfg); + let vec_text = crate::vector_to_pg(query_vec); + let private_allowed = allowed_scopes.iter().any(|scope| scope == "agent_private"); + let non_private_scopes: Vec = + allowed_scopes.iter().filter(|scope| *scope != "agent_private").cloned().collect(); + let retrieval_limit = i64::from(candidate_k.saturating_mul(4).clamp(16, 400)); + let rows = self + .fetch_structured_field_hits(StructuredFieldHitArgs { + embed_version: embed_version.as_str(), + tenant_id, + project_id, + agent_id, + now, + vec_text: vec_text.as_str(), + retrieval_limit, + private_allowed, + non_private_scopes: non_private_scopes.as_slice(), + }) + .await?; + let (ordered_note_ids, structured_matches_out) = + search::build_structured_field_matches(rows); + + if ordered_note_ids.is_empty() { + return Ok(StructuredFieldRetrievalResult { + candidates: Vec::new(), + structured_matches: structured_matches_out, + }); + } + + let best_by_note = self + .fetch_best_chunks_for_notes( + embed_version.as_str(), + ordered_note_ids.as_slice(), + vec_text.as_str(), + ) + .await?; + let structured_candidates = search::build_structured_field_candidates( + candidate_k, + ordered_note_ids, + best_by_note, + embed_version.as_str(), + ); + + Ok(StructuredFieldRetrievalResult { + candidates: structured_candidates, + structured_matches: structured_matches_out, + }) + } + + async fn fetch_structured_field_hits( + &self, + args: StructuredFieldHitArgs<'_>, + ) -> Result> { + if args.private_allowed && args.non_private_scopes.is_empty() { + self.fetch_structured_field_hits_private_only(args).await + } else if !args.private_allowed { + self.fetch_structured_field_hits_non_private_only(args).await + } else { + self.fetch_structured_field_hits_mixed(args).await + } + } + + async fn fetch_structured_field_hits_private_only( + &self, + args: StructuredFieldHitArgs<'_>, + ) -> Result> { + let rows = sqlx::query_as::<_, StructuredFieldHitRow>( + "\ +SELECT + f.note_id, + f.field_kind +FROM memory_note_fields f +JOIN note_field_embeddings e + ON e.field_id = f.field_id + AND e.embedding_version = $1 +JOIN memory_notes n + ON n.note_id = f.note_id +WHERE n.tenant_id = $2 + AND n.project_id = $3 + AND n.status = 'active' + AND (n.expires_at IS NULL OR n.expires_at > $4) + AND n.scope = 'agent_private' + AND n.agent_id = $5 +ORDER BY e.vec <=> $6::text::vector ASC +LIMIT $7", + ) + .bind(args.embed_version) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.now) + .bind(args.agent_id) + .bind(args.vec_text) + .bind(args.retrieval_limit) + .fetch_all(&self.db.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| FieldHit { note_id: row.note_id, field_kind: row.field_kind }) + .collect()) + } + + async fn fetch_structured_field_hits_non_private_only( + &self, + args: StructuredFieldHitArgs<'_>, + ) -> Result> { + let rows = sqlx::query_as::<_, StructuredFieldHitRow>( + "\ +SELECT + f.note_id, + f.field_kind +FROM memory_note_fields f +JOIN note_field_embeddings e + ON e.field_id = f.field_id + AND e.embedding_version = $1 +JOIN memory_notes n + ON n.note_id = f.note_id +WHERE n.tenant_id = $2 + AND (n.project_id = $3 OR (n.project_id = $8 AND n.scope = 'org_shared')) + AND n.status = 'active' + AND (n.expires_at IS NULL OR n.expires_at > $4) + AND n.scope = ANY($5::text[]) +ORDER BY e.vec <=> $6::text::vector ASC +LIMIT $7", + ) + .bind(args.embed_version) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.now) + .bind(args.non_private_scopes) + .bind(args.vec_text) + .bind(args.retrieval_limit) + .bind(ORG_PROJECT_ID) + .fetch_all(&self.db.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| FieldHit { note_id: row.note_id, field_kind: row.field_kind }) + .collect()) + } + + async fn fetch_structured_field_hits_mixed( + &self, + args: StructuredFieldHitArgs<'_>, + ) -> Result> { + let rows = sqlx::query_as::<_, StructuredFieldHitRow>( + "\ +SELECT + f.note_id, + f.field_kind +FROM memory_note_fields f +JOIN note_field_embeddings e + ON e.field_id = f.field_id + AND e.embedding_version = $1 +JOIN memory_notes n + ON n.note_id = f.note_id +WHERE n.tenant_id = $2 + AND (n.project_id = $3 OR (n.project_id = $9 AND n.scope = 'org_shared')) + AND n.status = 'active' + AND (n.expires_at IS NULL OR n.expires_at > $4) + AND ( + (n.scope = 'agent_private' AND n.agent_id = $5) + OR n.scope = ANY($6::text[]) + ) +ORDER BY e.vec <=> $7::text::vector ASC +LIMIT $8", + ) + .bind(args.embed_version) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.now) + .bind(args.agent_id) + .bind(args.non_private_scopes) + .bind(args.vec_text) + .bind(args.retrieval_limit) + .bind(ORG_PROJECT_ID) + .fetch_all(&self.db.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| FieldHit { note_id: row.note_id, field_kind: row.field_kind }) + .collect()) + } + + async fn fetch_best_chunks_for_notes( + &self, + embed_version: &str, + ordered_note_ids: &[Uuid], + vec_text: &str, + ) -> Result> { + let best_chunks = sqlx::query_as::<_, BestChunkForNoteRow>( + "\ +SELECT DISTINCT ON (c.note_id) + c.note_id, + c.chunk_id, + c.chunk_index +FROM memory_note_chunks c +JOIN note_chunk_embeddings e + ON e.chunk_id = c.chunk_id + AND e.embedding_version = $1 +WHERE c.note_id = ANY($2::uuid[]) +ORDER BY c.note_id ASC, e.vec <=> $3::text::vector ASC", + ) + .bind(embed_version) + .bind(ordered_note_ids) + .bind(vec_text) + .fetch_all(&self.db.pool) + .await?; + let mut best_by_note = HashMap::new(); + + for row in best_chunks { + best_by_note.insert(row.note_id, (row.chunk_id, row.chunk_index)); + } + + Ok(best_by_note) + } +} diff --git a/packages/elf-service/src/search/scoring_helpers.rs b/packages/elf-service/src/search/scoring_helpers.rs new file mode 100644 index 00000000..a6af3092 --- /dev/null +++ b/packages/elf-service/src/search/scoring_helpers.rs @@ -0,0 +1,119 @@ +use crate::search::{ + ChunkSnippet, HashMap, NormalizationKind, Ordering, ScoreCandidateCtx, ScoredChunk, Uuid, + ranking, +}; + +pub(super) fn select_best_scored_chunks(scored: Vec) -> Vec { + let mut best_by_note: HashMap = HashMap::new(); + + for scored_item in scored { + let note_id = scored_item.item.note.note_id; + let replace = match best_by_note.get(¬e_id) { + Some(existing) => scored_item.final_score > existing.final_score, + None => true, + }; + + if replace { + best_by_note.insert(note_id, scored_item); + } + } + + let mut results: Vec = best_by_note.into_values().collect(); + + results.sort_by(cmp_scored_chunk); + + results +} + +pub(super) fn cmp_scored_chunk(a: &ScoredChunk, b: &ScoredChunk) -> Ordering { + let ord = ranking::cmp_f32_desc(a.final_score, b.final_score); + + if ord != Ordering::Equal { + return ord; + } + + let ord = a.item.retrieval_rank.cmp(&b.item.retrieval_rank); + + if ord != Ordering::Equal { + return ord; + } + + let ord = a.item.note.note_id.cmp(&b.item.note.note_id); + + if ord != Ordering::Equal { + return ord; + } + + a.item.chunk.chunk_id.cmp(&b.item.chunk.chunk_id) +} + +pub(super) fn score_chunk_candidate( + ctx: &ScoreCandidateCtx<'_, '_>, + item: ChunkSnippet, + rerank_score: f32, + rerank_rank: u32, +) -> ScoredChunk { + let importance = item.note.importance; + let retrieval_rank = item.retrieval_rank; + let age_days = (ctx.now - item.note.updated_at).as_seconds_f32() / 86_400.0; + let decay = if ctx.cfg.ranking.recency_tau_days > 0.0 { + (-age_days / ctx.cfg.ranking.recency_tau_days).exp() + } else { + 1.0 + }; + let base = (1.0 + 0.6 * importance) * decay; + let tie_breaker_score = ctx.cfg.ranking.tie_breaker_weight * base; + let scope_context_boost = + ctx.scope_context_boost_by_scope.get(item.note.scope.as_str()).copied().unwrap_or(0.0); + let rerank_norm = match ctx.blend_policy.rerank_normalization { + NormalizationKind::Rank => ranking::rank_normalize(rerank_rank, ctx.total_rerank), + }; + let retrieval_norm = match ctx.blend_policy.retrieval_normalization { + NormalizationKind::Rank => ranking::rank_normalize(retrieval_rank, ctx.total_retrieval), + }; + let blend_retrieval_weight = if ctx.blend_policy.enabled { + ranking::retrieval_weight_for_rank(retrieval_rank, &ctx.blend_policy.segments) + } else { + 0.0 + }; + let retrieval_term = blend_retrieval_weight * retrieval_norm; + let rerank_term = (1.0 - blend_retrieval_weight) * rerank_norm; + let det_terms = ranking::compute_deterministic_ranking_terms( + ctx.cfg, + ctx.det_query_tokens, + item.snippet.as_str(), + item.note.hit_count, + item.note.last_hit_at, + age_days, + ctx.now, + ); + let final_score = retrieval_term + + rerank_term + + tie_breaker_score + + scope_context_boost + + det_terms.lexical_bonus + + det_terms.hit_boost + + det_terms.decay_penalty; + + ScoredChunk { + item, + final_score, + rerank_score, + rerank_rank, + rerank_norm, + retrieval_norm, + blend_retrieval_weight, + retrieval_term, + rerank_term, + tie_breaker_score, + scope_context_boost, + age_days, + importance, + deterministic_lexical_overlap_ratio: det_terms.lexical_overlap_ratio, + deterministic_lexical_bonus: det_terms.lexical_bonus, + deterministic_hit_count: det_terms.hit_count, + deterministic_last_hit_age_days: det_terms.last_hit_age_days, + deterministic_hit_boost: det_terms.hit_boost, + deterministic_decay_penalty: det_terms.decay_penalty, + } +} diff --git a/packages/elf-service/src/search/service.rs b/packages/elf-service/src/search/service.rs new file mode 100644 index 00000000..b69f6daf --- /dev/null +++ b/packages/elf-service/src/search/service.rs @@ -0,0 +1,413 @@ +use crate::{ + Error, + search::{ + self, BuildQueryPlanArgs, BuildTraceArgs, DynamicGateSummary, ElfService, ExpansionMode, + FinishSearchArgs, FinishSearchScoringResult, HashMap, MAX_CANDIDATE_K, + MaybeDynamicSearchArgs, OffsetDateTime, RawSearchExecutionContext, RawSearchPath, Result, + SearchFilter, SearchRawPlannedResponse, SearchRequest, SearchResponse, SearchRetrievalArgs, + Uuid, ranking, + }, +}; + +impl ElfService { + /// Runs the quick raw-search path and returns ranked items without a query plan. + pub async fn search_raw_quick(&self, req: SearchRequest) -> Result { + self.execute_search_raw_path(req, RawSearchPath::Quick).await.map(|response| { + SearchResponse { + trace_id: response.trace_id, + items: response.items, + trajectory_summary: response.trajectory_summary, + } + }) + } + + /// Runs the planned raw-search path and returns ranked items plus a query plan. + pub async fn search_raw_planned(&self, req: SearchRequest) -> Result { + self.execute_search_raw_path(req, RawSearchPath::Planned).await + } + + /// Runs the default raw-search path and returns ranked items. + pub async fn search_raw(&self, req: SearchRequest) -> Result { + self.search_raw_planned(req).await.map(|response| SearchResponse { + trace_id: response.trace_id, + items: response.items, + trajectory_summary: response.trajectory_summary, + }) + } + + async fn execute_search_raw_path( + &self, + req: SearchRequest, + path: RawSearchPath, + ) -> Result { + let context = self.prepare_raw_search_execution(req, path)?; + + if context.allowed_scopes.is_empty() { + return self.execute_search_raw_no_allowed_scopes(&context, path).await; + } + + let dynamic_gate_enabled = + path == RawSearchPath::Planned && context.expansion_mode == ExpansionMode::Dynamic; + + self.execute_search_raw_with_allowed_scopes(&context, path, dynamic_gate_enabled).await + } + + async fn execute_search_raw_no_allowed_scopes( + &self, + context: &RawSearchExecutionContext, + path: RawSearchPath, + ) -> Result { + let expanded_queries = vec![context.query.clone()]; + let response = self + .finish_search(FinishSearchArgs { + path, + trace_id: context.trace_id, + query: context.query.as_str(), + tenant_id: context.tenant_id.as_str(), + project_id: context.project_id.as_str(), + agent_id: context.agent_id.as_str(), + token_id: context.token_id.as_deref(), + read_profile: context.read_profile.as_str(), + allowed_scopes: &context.allowed_scopes, + expanded_queries: expanded_queries.clone(), + expansion_mode: context.expansion_mode, + candidates: Vec::new(), + structured_matches: HashMap::new(), + recursive_retrieval: None, + top_k: context.top_k, + record_hits_enabled: context.record_hits_enabled, + ranking_override: context.ranking_override.clone(), + payload_level: context.payload_level, + filter: context.filter.as_ref(), + requested_candidate_k: context.requested_candidate_k, + effective_candidate_k: context.effective_candidate_k, + }) + .await?; + + Ok(self.build_raw_planned_response( + context, + path, + response, + expanded_queries, + DynamicGateSummary::default(), + )) + } + + async fn execute_search_raw_with_allowed_scopes( + &self, + context: &RawSearchExecutionContext, + path: RawSearchPath, + dynamic_gate_enabled: bool, + ) -> Result { + let filter = search::build_search_filter( + context.tenant_id.as_str(), + context.project_id.as_str(), + context.agent_id.as_str(), + &context.allowed_scopes, + ); + let retrieval_candidate_k = if context.filter.is_some() { + context.effective_candidate_k + } else { + context.candidate_k + }; + let (baseline_vector, early_response, dynamic_gate) = self + .maybe_finish_dynamic_search(MaybeDynamicSearchArgs { + path, + enabled: dynamic_gate_enabled, + trace_id: context.trace_id, + query: context.query.as_str(), + tenant_id: context.tenant_id.as_str(), + project_id: context.project_id.as_str(), + agent_id: context.agent_id.as_str(), + token_id: context.token_id.as_deref(), + read_profile: context.read_profile.as_str(), + allowed_scopes: &context.allowed_scopes, + project_context_description: context.project_context_description.as_deref(), + filter: &filter, + service_filter: context.filter.as_ref(), + candidate_k: retrieval_candidate_k, + requested_candidate_k: context.requested_candidate_k, + effective_candidate_k: context.effective_candidate_k, + top_k: context.top_k, + record_hits_enabled: context.record_hits_enabled, + ranking_override: context.ranking_override.as_ref(), + retrieval_sources_policy: &context.retrieval_sources_policy, + payload_level: context.payload_level, + }) + .await?; + + if let Some(response) = early_response { + return Ok(self.build_raw_planned_response( + context, + path, + response, + vec![context.query.clone()], + dynamic_gate, + )); + } + + let retrieval = self + .retrieve_search_candidates(SearchRetrievalArgs { + query: context.query.as_str(), + expansion_mode: context.expansion_mode, + project_context_description: context.project_context_description.as_deref(), + filter: &filter, + candidate_k: retrieval_candidate_k, + baseline_vector: baseline_vector.as_ref(), + tenant_id: context.tenant_id.as_str(), + project_id: context.project_id.as_str(), + agent_id: context.agent_id.as_str(), + allowed_scopes: &context.allowed_scopes, + retrieval_sources_policy: &context.retrieval_sources_policy, + }) + .await?; + let expanded_queries = retrieval.expanded_queries.clone(); + let response = self + .finish_search(FinishSearchArgs { + path, + trace_id: context.trace_id, + query: context.query.as_str(), + tenant_id: context.tenant_id.as_str(), + project_id: context.project_id.as_str(), + agent_id: context.agent_id.as_str(), + token_id: context.token_id.as_deref(), + read_profile: context.read_profile.as_str(), + allowed_scopes: &context.allowed_scopes, + expanded_queries: retrieval.expanded_queries, + expansion_mode: context.expansion_mode, + candidates: retrieval.candidates, + structured_matches: retrieval.structured_matches, + recursive_retrieval: retrieval.recursive, + top_k: context.top_k, + record_hits_enabled: context.record_hits_enabled, + ranking_override: context.ranking_override.clone(), + payload_level: context.payload_level, + filter: context.filter.as_ref(), + requested_candidate_k: context.requested_candidate_k, + effective_candidate_k: context.effective_candidate_k, + }) + .await?; + + Ok(self.build_raw_planned_response(context, path, response, expanded_queries, dynamic_gate)) + } + + fn prepare_raw_search_execution( + &self, + req: SearchRequest, + path: RawSearchPath, + ) -> Result { + let tenant_id = req.tenant_id.trim().to_string(); + let project_id = req.project_id.trim().to_string(); + let agent_id = req.agent_id.trim().to_string(); + let token_id = req + .token_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()); + + search::validate_search_request_inputs( + tenant_id.as_str(), + project_id.as_str(), + agent_id.as_str(), + req.query.as_str(), + )?; + + let top_k = req.top_k.unwrap_or(self.cfg.memory.top_k).max(1); + let candidate_k = req.candidate_k.unwrap_or(self.cfg.memory.candidate_k).max(top_k); + let requested_candidate_k = candidate_k; + let filter = req + .filter + .as_ref() + .map(SearchFilter::parse) + .transpose() + .map_err(|err| Error::InvalidRequest { message: err.to_string() })?; + let effective_candidate_k = if filter.is_some() { + requested_candidate_k.saturating_mul(3).min(MAX_CANDIDATE_K).max(top_k) + } else { + requested_candidate_k + }; + let query = req.query; + let read_profile = req.read_profile; + let record_hits_enabled = req.record_hits.unwrap_or(false); + let ranking_override = req.ranking; + let retrieval_sources_policy = ranking::resolve_retrieval_sources_policy( + &self.cfg.ranking.retrieval_sources, + ranking_override.as_ref().and_then(|override_| override_.retrieval_sources.as_ref()), + )?; + let expansion_mode = match path { + RawSearchPath::Quick => ExpansionMode::Off, + RawSearchPath::Planned => ranking::resolve_expansion_mode(&self.cfg), + }; + let trace_id = Uuid::new_v4(); + let project_context_description = self + .resolve_project_context_description(tenant_id.as_str(), project_id.as_str()) + .map(|value| value.to_string()); + let allowed_scopes = ranking::resolve_scopes(&self.cfg, read_profile.as_str())?; + let policies = self.resolve_finish_search_policies(ranking_override.as_ref())?; + + Ok(RawSearchExecutionContext { + tenant_id, + project_id, + agent_id, + token_id, + top_k, + candidate_k, + requested_candidate_k, + effective_candidate_k, + filter, + query, + read_profile, + payload_level: req.payload_level, + record_hits_enabled, + ranking_override, + retrieval_sources_policy, + expansion_mode, + trace_id, + project_context_description, + allowed_scopes, + policies, + }) + } + + fn build_raw_planned_response( + &self, + context: &RawSearchExecutionContext, + path: RawSearchPath, + response: SearchResponse, + expanded_queries: Vec, + dynamic_gate: DynamicGateSummary, + ) -> SearchRawPlannedResponse { + let query_plan = self.build_query_plan(BuildQueryPlanArgs { + path, + query: context.query.as_str(), + tenant_id: context.tenant_id.as_str(), + project_id: context.project_id.as_str(), + agent_id: context.agent_id.as_str(), + read_profile: context.read_profile.as_str(), + allowed_scopes: &context.allowed_scopes, + expansion_mode: context.expansion_mode, + expanded_queries, + top_k: context.top_k, + candidate_k: context.candidate_k, + retrieval_sources_policy: &context.retrieval_sources_policy, + recursive_enabled: self.cfg.search.recursive.enabled, + policies: &context.policies, + dynamic_gate, + }); + + SearchRawPlannedResponse { + trace_id: response.trace_id, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan, + } + } + + pub(super) async fn finish_search(&self, args: FinishSearchArgs<'_>) -> Result { + let now = OffsetDateTime::now_utc(); + let candidate_count = args.candidates.len(); + let candidate_note_ids: Vec = + args.candidates.iter().map(|candidate| candidate.note_id).collect(); + let policies = self.resolve_finish_search_policies(args.ranking_override.as_ref())?; + let note_meta = self + .fetch_note_meta_for_candidates( + args.tenant_id, + args.project_id, + args.agent_id, + args.allowed_scopes, + candidate_note_ids.as_slice(), + now, + ) + .await?; + let scoring = self + .build_finish_search_scoring( + args.query, + args.candidates, + ¬e_meta, + &policies, + args.top_k, + candidate_count, + args.filter, + args.requested_candidate_k, + args.effective_candidate_k, + now, + args.path == RawSearchPath::Quick, + ) + .await?; + let FinishSearchScoringResult { + query_tokens, + filtered_candidates, + scored_count, + snippet_count, + filtered_candidate_count, + filter_impact, + mut trace_candidates, + fused_results, + selected_results, + diversity_decisions, + selected_count, + } = scoring; + let relation_contexts = self + .build_relation_context_for_selected_results( + &selected_results, + args.tenant_id, + args.project_id, + args.agent_id, + args.allowed_scopes, + now, + ) + .await?; + + ranking::attach_diversity_decisions_to_trace_candidates( + &mut trace_candidates, + &diversity_decisions, + ); + + self.record_hits_if_enabled(args.record_hits_enabled, args.query, &selected_results, now) + .await?; + + let (items, trajectory_summary) = self + .build_items_and_write_trace(BuildTraceArgs { + path: args.path, + trace_id: args.trace_id, + query: args.query, + tenant_id: args.tenant_id, + project_id: args.project_id, + agent_id: args.agent_id, + token_id: args.token_id, + read_profile: args.read_profile, + expansion_mode: args.expansion_mode, + expanded_queries: args.expanded_queries, + allowed_scopes: args.allowed_scopes, + candidate_count, + filtered_candidate_count, + snippet_count, + scored_count, + fused_count: fused_results.len(), + selected_count, + top_k: args.top_k, + query_tokens: query_tokens.as_slice(), + structured_matches: &args.structured_matches, + policies: &policies, + diversity_decisions: &diversity_decisions, + recall_candidates: filtered_candidates, + fused_results, + selected_results, + relation_contexts, + trace_candidates, + recursive_retrieval: args.recursive_retrieval.as_ref(), + now, + ranking_override: &args.ranking_override, + filter_impact, + payload_level: args.payload_level, + }) + .await?; + + Ok(SearchResponse { + trace_id: args.trace_id, + items, + trajectory_summary: Some(trajectory_summary), + }) + } +} diff --git a/packages/elf-service/src/search/sql.rs b/packages/elf-service/src/search/sql.rs new file mode 100644 index 00000000..84d025ad --- /dev/null +++ b/packages/elf-service/src/search/sql.rs @@ -0,0 +1,218 @@ +pub(super) const SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1: &str = "search_retrieval_trajectory/v1"; +pub(super) const SEARCH_FILTER_IMPACT_SCHEMA_V1: &str = "search_filter_impact/v1"; +pub(super) const RECENT_TRACES_SCHEMA_V1: &str = "elf.recent_traces/v1"; +pub(super) const TRACE_BUNDLE_SCHEMA_V1: &str = "elf.trace_bundle/v1"; +pub(super) const MAX_RECENT_TRACES_LIMIT: u32 = 200; +pub(super) const DEFAULT_RECENT_TRACES_LIMIT: u32 = 50; +pub(super) const DEFAULT_BOUNDED_STAGE_ITEMS_LIMIT: u32 = 64; +pub(super) const DEFAULT_FULL_STAGE_ITEMS_LIMIT: u32 = 256; +pub(super) const DEFAULT_BOUNDED_CANDIDATES_LIMIT: u32 = 0; +pub(super) const DEFAULT_FULL_CANDIDATES_LIMIT: u32 = 200; +pub(super) const MAX_TRACE_BUNDLE_ITEMS_LIMIT: u32 = 256; +pub(super) const MAX_TRACE_BUNDLE_CANDIDATES_LIMIT: u32 = 1_000; +pub(super) const RELATION_CONTEXT_SQL: &str = r#" +WITH selected_facts AS ( + SELECT DISTINCT ON (snc.selected_note_id, gf.fact_id) + snc.selected_note_id, + gf.fact_id, + gf.scope, + subject_entity.canonical AS subject_canonical, + subject_entity.kind AS subject_kind, + gf.predicate, + gf.object_entity_id, + object_entity.canonical AS object_canonical, + object_entity.kind AS object_kind, + gf.object_value, + gf.valid_from, + gf.valid_to, + (gf.valid_from <= $4 AND (gf.valid_to IS NULL OR gf.valid_to > $4)) AS is_current + FROM unnest($7::uuid[]) AS snc(selected_note_id) + JOIN memory_notes selected_note + ON selected_note.note_id = snc.selected_note_id + JOIN graph_fact_evidence gfe + ON gfe.note_id = snc.selected_note_id + JOIN graph_facts gf + ON gf.fact_id = gfe.fact_id + JOIN graph_entities subject_entity + ON subject_entity.entity_id = gf.subject_entity_id + AND subject_entity.tenant_id = $1 + AND subject_entity.project_id = $2 + LEFT JOIN graph_entities object_entity + ON object_entity.entity_id = gf.object_entity_id + AND object_entity.tenant_id = $1 + AND object_entity.project_id = $2 + WHERE gf.tenant_id = $1 + AND gf.project_id = $2 + AND selected_note.tenant_id = $1 + AND selected_note.project_id = $2 + AND selected_note.status = 'active' + AND ( + selected_note.expires_at IS NULL + OR selected_note.expires_at > $4 + ) + AND ( + ($5 AND selected_note.scope = 'agent_private' AND selected_note.agent_id = $3) + OR ( + selected_note.scope = ANY($6::text[]) + AND ( + selected_note.agent_id = $3 + OR concat(selected_note.scope, ':', selected_note.agent_id) = ANY($10::text[]) + ) + ) + ) + AND ( + ($5 AND gf.scope = 'agent_private' AND gf.agent_id = $3) + OR ( + gf.scope = ANY($6::text[]) + AND ( + gf.agent_id = $3 + OR concat(gf.scope, ':', gf.agent_id) = ANY($10::text[]) + ) + ) + ) + AND gf.valid_from <= $4 + ORDER BY + snc.selected_note_id, + gf.fact_id, + (gf.valid_from <= $4 AND (gf.valid_to IS NULL OR gf.valid_to > $4)) DESC, + gf.valid_from DESC, + gf.fact_id ASC +), +ranked_facts AS ( + SELECT + selected_note_id, + fact_id, + scope, + subject_canonical, + subject_kind, + predicate, + object_entity_id, + object_canonical, + object_kind, + object_value, + valid_from, + valid_to, + is_current, + ROW_NUMBER() OVER ( + PARTITION BY selected_note_id + ORDER BY is_current DESC, valid_from DESC, fact_id ASC + ) AS fact_rank + FROM selected_facts +), +bounded_facts AS ( + SELECT + selected_note_id, + fact_id, + scope, + subject_canonical, + subject_kind, + predicate, + object_entity_id, + object_canonical, + object_kind, + object_value, + valid_from, + valid_to, + is_current, + fact_rank + FROM ranked_facts + WHERE fact_rank <= $9 +), +evidence_ranked AS ( + SELECT + bf.selected_note_id, + bf.fact_id, + bf.scope, + bf.subject_canonical, + bf.subject_kind, + bf.predicate, + bf.object_entity_id, + bf.object_canonical, + bf.object_kind, + bf.object_value, + bf.valid_from, + bf.valid_to, + bf.is_current, + bf.fact_rank, + e.note_id AS evidence_note_id, + e.created_at AS evidence_created_at, + ROW_NUMBER() OVER ( + PARTITION BY bf.selected_note_id, bf.fact_id + ORDER BY e.created_at ASC, e.note_id ASC + ) AS evidence_rank + FROM bounded_facts bf + JOIN graph_fact_evidence e + ON e.fact_id = bf.fact_id + JOIN memory_notes evidence_note + ON evidence_note.note_id = e.note_id + AND evidence_note.tenant_id = $1 + AND evidence_note.project_id = $2 + AND evidence_note.status = 'active' + AND ( + evidence_note.expires_at IS NULL + OR evidence_note.expires_at > $4 + ) + AND ( + ($5 AND evidence_note.scope = 'agent_private' AND evidence_note.agent_id = $3) + OR ( + evidence_note.scope = ANY($6::text[]) + AND ( + evidence_note.agent_id = $3 + OR concat(evidence_note.scope, ':', evidence_note.agent_id) = ANY($10::text[]) + ) + ) + ) +), +fact_contexts AS ( + SELECT + selected_note_id, + fact_id, + scope, + subject_canonical, + subject_kind, + predicate, + object_entity_id, + object_canonical, + object_kind, + object_value, + valid_from, + valid_to, + is_current, + fact_rank, + ARRAY_AGG(evidence_note_id ORDER BY evidence_created_at ASC, evidence_note_id ASC) AS evidence_note_ids + FROM evidence_ranked + WHERE evidence_rank <= $8 + GROUP BY + selected_note_id, + fact_id, + scope, + subject_canonical, + subject_kind, + predicate, + object_entity_id, + object_canonical, + object_kind, + object_value, + valid_from, + valid_to, + is_current, + fact_rank +) +SELECT + selected_note_id AS note_id, + fact_id, + scope, + subject_canonical, + subject_kind, + predicate, + object_entity_id, + object_canonical, + object_kind, + object_value, + valid_from, + valid_to, + is_current, + evidence_note_ids +FROM fact_contexts +ORDER BY note_id, fact_rank +"#; diff --git a/packages/elf-service/src/search/state.rs b/packages/elf-service/src/search/state.rs new file mode 100644 index 00000000..90b06236 --- /dev/null +++ b/packages/elf-service/src/search/state.rs @@ -0,0 +1,39 @@ +mod cache; +mod finish; +mod modes; +mod records; +mod retrieval; +mod scoring; +mod trace; + +pub(super) use self::{ + cache::{ + CacheKind, CachePayload, ExpansionCachePayload, ExpansionOutput, RerankCacheItem, + RerankCachePayload, + }, + finish::{ + BuildQueryPlanArgs, BuildSearchItemArgs, BuildTraceArgs, FinishSearchArgs, + FinishSearchPolicies, FinishSearchScoringResult, QueryPlanStagesArgs, + RawSearchExecutionContext, + }, + modes::{ExpansionMode, RawSearchPath, RetrievalSourceKind}, + records::{ + BestChunkForNoteRow, ChunkMeta, ChunkRow, ChunkSnippet, NoteMeta, NoteVectorRow, + SearchExplainTraceRow, SearchRecentTraceRow, SearchRelationContextRow, SearchTraceItemRow, + SearchTraceRow, StructuredFieldHitRow, TraceCandidateSnapshotRow, + }, + retrieval::{ + ChunkCandidate, DynamicGateSummary, FieldHit, MaybeDynamicSearchArgs, QueryEmbedding, + RecursiveRetrievalArgs, RecursiveRetrievalResult, RerankCacheCandidate, + RetrievalSourceCandidates, SearchRetrievalArgs, SearchRetrievalResult, + StructuredFieldHitArgs, StructuredFieldRetrievalArgs, StructuredFieldRetrievalResult, + }, + scoring::{ + DeterministicRankingTerms, DiversityDecision, ScoreCandidateCtx, ScoreSnippetArgs, + ScoredChunk, ScoredReplay, + }, + trace::{ + SearchTraceBuilder, TraceCandidateRecord, TraceContext, TraceItemRecord, TracePayload, + TraceRecord, TraceTrajectoryStageItemRecord, TraceTrajectoryStageRecord, + }, +}; diff --git a/packages/elf-service/src/search/state/cache.rs b/packages/elf-service/src/search/state/cache.rs new file mode 100644 index 00000000..b101a7d9 --- /dev/null +++ b/packages/elf-service/src/search/state/cache.rs @@ -0,0 +1,43 @@ +use crate::search::{Deserialize, OffsetDateTime, Serialize, Uuid, Value}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct ExpansionCachePayload { + pub(in crate::search) queries: Vec, +} + +#[derive(Debug, Deserialize)] +pub(in crate::search) struct ExpansionOutput { + pub(in crate::search) queries: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct RerankCacheItem { + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) updated_at: OffsetDateTime, + pub(in crate::search) score: f32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct RerankCachePayload { + pub(in crate::search) items: Vec, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct CachePayload { + pub(in crate::search) value: Value, + pub(in crate::search) size_bytes: usize, +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::search) enum CacheKind { + Expansion, + Rerank, +} +impl CacheKind { + pub(in crate::search) fn as_str(self) -> &'static str { + match self { + Self::Expansion => "expansion", + Self::Rerank => "rerank", + } + } +} diff --git a/packages/elf-service/src/search/state/finish.rs b/packages/elf-service/src/search/state/finish.rs new file mode 100644 index 00000000..a276f662 --- /dev/null +++ b/packages/elf-service/src/search/state/finish.rs @@ -0,0 +1,155 @@ +use crate::search::{ + ChunkCandidate, Config, DiversityDecision, DynamicGateSummary, ExpansionMode, HashMap, + OffsetDateTime, PayloadLevel, QueryPlanBudget, QueryPlanFusionPolicy, QueryPlanRerankPolicy, + QueryPlanRetrievalStage, QueryPlanRewrite, RankingRequestOverride, RawSearchPath, + RecursiveRetrievalResult, ResolvedBlendPolicy, ResolvedDiversityPolicy, + ResolvedRetrievalSourcesPolicy, ScoredChunk, SearchExplainRelationContext, SearchFilter, + SearchFilterImpact, TraceCandidateRecord, Uuid, Value, +}; + +pub(in crate::search) struct FinishSearchArgs<'a> { + pub(in crate::search) path: RawSearchPath, + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) query: &'a str, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) token_id: Option<&'a str>, + pub(in crate::search) read_profile: &'a str, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) expanded_queries: Vec, + pub(in crate::search) expansion_mode: ExpansionMode, + pub(in crate::search) candidates: Vec, + pub(in crate::search) structured_matches: HashMap>, + pub(in crate::search) recursive_retrieval: Option, + pub(in crate::search) top_k: u32, + pub(in crate::search) record_hits_enabled: bool, + pub(in crate::search) ranking_override: Option, + pub(in crate::search) filter: Option<&'a SearchFilter>, + pub(in crate::search) requested_candidate_k: u32, + pub(in crate::search) effective_candidate_k: u32, + pub(in crate::search) payload_level: PayloadLevel, +} + +pub(in crate::search) struct FinishSearchPolicies { + pub(in crate::search) blend_policy: ResolvedBlendPolicy, + pub(in crate::search) diversity_policy: ResolvedDiversityPolicy, + pub(in crate::search) retrieval_sources_policy: ResolvedRetrievalSourcesPolicy, + pub(in crate::search) policy_snapshot: Value, + pub(in crate::search) policy_id: String, +} + +pub(in crate::search) struct FinishSearchScoringResult { + pub(in crate::search) query_tokens: Vec, + pub(in crate::search) filtered_candidates: Vec, + pub(in crate::search) scored_count: usize, + pub(in crate::search) snippet_count: usize, + pub(in crate::search) filtered_candidate_count: usize, + pub(in crate::search) filter_impact: Option, + pub(in crate::search) trace_candidates: Vec, + pub(in crate::search) fused_results: Vec, + pub(in crate::search) selected_results: Vec, + pub(in crate::search) diversity_decisions: HashMap, + pub(in crate::search) selected_count: usize, +} + +pub(in crate::search) struct BuildTraceArgs<'a> { + pub(in crate::search) path: RawSearchPath, + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) query: &'a str, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) token_id: Option<&'a str>, + pub(in crate::search) read_profile: &'a str, + pub(in crate::search) expansion_mode: ExpansionMode, + pub(in crate::search) expanded_queries: Vec, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) candidate_count: usize, + pub(in crate::search) filtered_candidate_count: usize, + pub(in crate::search) snippet_count: usize, + pub(in crate::search) scored_count: usize, + pub(in crate::search) fused_count: usize, + pub(in crate::search) selected_count: usize, + pub(in crate::search) top_k: u32, + pub(in crate::search) query_tokens: &'a [String], + pub(in crate::search) structured_matches: &'a HashMap>, + pub(in crate::search) recursive_retrieval: Option<&'a RecursiveRetrievalResult>, + pub(in crate::search) policies: &'a FinishSearchPolicies, + pub(in crate::search) diversity_decisions: &'a HashMap, + pub(in crate::search) recall_candidates: Vec, + pub(in crate::search) fused_results: Vec, + pub(in crate::search) selected_results: Vec, + pub(in crate::search) relation_contexts: HashMap>, + pub(in crate::search) trace_candidates: Vec, + pub(in crate::search) now: OffsetDateTime, + pub(in crate::search) ranking_override: &'a Option, + pub(in crate::search) filter_impact: Option, + pub(in crate::search) payload_level: PayloadLevel, +} + +pub(in crate::search) struct BuildQueryPlanArgs<'a> { + pub(in crate::search) path: RawSearchPath, + pub(in crate::search) query: &'a str, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) read_profile: &'a str, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) expansion_mode: ExpansionMode, + pub(in crate::search) expanded_queries: Vec, + pub(in crate::search) top_k: u32, + pub(in crate::search) candidate_k: u32, + pub(in crate::search) retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, + pub(in crate::search) recursive_enabled: bool, + pub(in crate::search) policies: &'a FinishSearchPolicies, + pub(in crate::search) dynamic_gate: DynamicGateSummary, +} + +pub(in crate::search) struct RawSearchExecutionContext { + pub(in crate::search) tenant_id: String, + pub(in crate::search) project_id: String, + pub(in crate::search) agent_id: String, + pub(in crate::search) token_id: Option, + pub(in crate::search) top_k: u32, + pub(in crate::search) candidate_k: u32, + pub(in crate::search) requested_candidate_k: u32, + pub(in crate::search) effective_candidate_k: u32, + pub(in crate::search) query: String, + pub(in crate::search) read_profile: String, + pub(in crate::search) payload_level: PayloadLevel, + pub(in crate::search) filter: Option, + pub(in crate::search) record_hits_enabled: bool, + pub(in crate::search) ranking_override: Option, + pub(in crate::search) retrieval_sources_policy: ResolvedRetrievalSourcesPolicy, + pub(in crate::search) expansion_mode: ExpansionMode, + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) project_context_description: Option, + pub(in crate::search) allowed_scopes: Vec, + pub(in crate::search) policies: FinishSearchPolicies, +} + +pub(in crate::search) struct QueryPlanStagesArgs<'a> { + pub(in crate::search) path: RawSearchPath, + pub(in crate::search) query: &'a str, + pub(in crate::search) read_profile: &'a str, + pub(in crate::search) allowed_scope_count: usize, + pub(in crate::search) rewrite: &'a QueryPlanRewrite, + pub(in crate::search) retrieval_stages: &'a [QueryPlanRetrievalStage], + pub(in crate::search) fusion_policy: &'a QueryPlanFusionPolicy, + pub(in crate::search) rerank_policy: &'a QueryPlanRerankPolicy, + pub(in crate::search) budget: &'a QueryPlanBudget, +} + +pub(in crate::search) struct BuildSearchItemArgs<'a> { + pub(in crate::search) cfg: &'a Config, + pub(in crate::search) policy_id: &'a str, + pub(in crate::search) blend_policy: &'a ResolvedBlendPolicy, + pub(in crate::search) diversity_policy: &'a ResolvedDiversityPolicy, + pub(in crate::search) diversity_decisions: &'a HashMap, + pub(in crate::search) query_tokens: &'a [String], + pub(in crate::search) structured_matches: &'a HashMap>, + pub(in crate::search) relation_contexts: &'a HashMap>, + pub(in crate::search) scored_chunk: ScoredChunk, + pub(in crate::search) rank: u32, +} diff --git a/packages/elf-service/src/search/state/modes.rs b/packages/elf-service/src/search/state/modes.rs new file mode 100644 index 00000000..074850ec --- /dev/null +++ b/packages/elf-service/src/search/state/modes.rs @@ -0,0 +1,19 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::search) enum ExpansionMode { + Off, + Always, + Dynamic, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::search) enum RawSearchPath { + Quick, + Planned, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(in crate::search) enum RetrievalSourceKind { + Fusion, + StructuredField, + Recursive, +} diff --git a/packages/elf-service/src/search/state/records.rs b/packages/elf-service/src/search/state/records.rs new file mode 100644 index 00000000..2c9f897f --- /dev/null +++ b/packages/elf-service/src/search/state/records.rs @@ -0,0 +1,148 @@ +use crate::search::{FromRow, OffsetDateTime, Uuid, Value}; + +#[derive(Clone, Debug)] +pub(in crate::search) struct NoteMeta { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) note_type: String, + pub(in crate::search) key: Option, + pub(in crate::search) scope: String, + pub(in crate::search) agent_id: String, + pub(in crate::search) importance: f32, + pub(in crate::search) confidence: f32, + pub(in crate::search) updated_at: OffsetDateTime, + pub(in crate::search) expires_at: Option, + pub(in crate::search) source_ref: Value, + pub(in crate::search) embedding_version: String, + pub(in crate::search) hit_count: i64, + pub(in crate::search) last_hit_at: Option, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct ChunkRow { + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_index: i32, + pub(in crate::search) start_offset: i32, + pub(in crate::search) end_offset: i32, + pub(in crate::search) text: String, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct NoteVectorRow { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) vec_text: String, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct SearchExplainTraceRow { + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) tenant_id: String, + pub(in crate::search) project_id: String, + pub(in crate::search) agent_id: String, + pub(in crate::search) read_profile: String, + pub(in crate::search) query: String, + pub(in crate::search) expansion_mode: String, + pub(in crate::search) expanded_queries: Value, + pub(in crate::search) allowed_scopes: Value, + pub(in crate::search) candidate_count: i32, + pub(in crate::search) top_k: i32, + pub(in crate::search) config_snapshot: Value, + pub(in crate::search) trace_version: i32, + pub(in crate::search) created_at: OffsetDateTime, + pub(in crate::search) item_id: Uuid, + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_id: Option, + pub(in crate::search) rank: i32, + pub(in crate::search) explain: Value, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct SearchRelationContextRow { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) fact_id: Uuid, + pub(in crate::search) scope: String, + pub(in crate::search) subject_canonical: Option, + pub(in crate::search) subject_kind: Option, + pub(in crate::search) predicate: String, + pub(in crate::search) object_entity_id: Option, + pub(in crate::search) object_canonical: Option, + pub(in crate::search) object_kind: Option, + pub(in crate::search) object_value: Option, + pub(in crate::search) valid_from: OffsetDateTime, + pub(in crate::search) valid_to: Option, + pub(in crate::search) is_current: bool, + pub(in crate::search) evidence_note_ids: Vec, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct SearchTraceRow { + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) tenant_id: String, + pub(in crate::search) project_id: String, + pub(in crate::search) agent_id: String, + pub(in crate::search) read_profile: String, + pub(in crate::search) query: String, + pub(in crate::search) expansion_mode: String, + pub(in crate::search) expanded_queries: Value, + pub(in crate::search) allowed_scopes: Value, + pub(in crate::search) candidate_count: i32, + pub(in crate::search) top_k: i32, + pub(in crate::search) config_snapshot: Value, + pub(in crate::search) trace_version: i32, + pub(in crate::search) created_at: OffsetDateTime, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct SearchTraceItemRow { + pub(in crate::search) item_id: Uuid, + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_id: Option, + pub(in crate::search) rank: i32, + pub(in crate::search) explain: Value, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct SearchRecentTraceRow { + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) tenant_id: String, + pub(in crate::search) project_id: String, + pub(in crate::search) agent_id: String, + pub(in crate::search) read_profile: String, + pub(in crate::search) query: String, + pub(in crate::search) created_at: OffsetDateTime, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct TraceCandidateSnapshotRow { + pub(in crate::search) candidate_snapshot: Value, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct StructuredFieldHitRow { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) field_kind: String, +} + +#[derive(Clone, Debug, FromRow)] +pub(in crate::search) struct BestChunkForNoteRow { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) chunk_index: i32, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct ChunkMeta { + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) chunk_index: i32, + pub(in crate::search) start_offset: i32, + pub(in crate::search) end_offset: i32, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct ChunkSnippet { + pub(in crate::search) note: NoteMeta, + pub(in crate::search) chunk: ChunkMeta, + pub(in crate::search) snippet: String, + pub(in crate::search) retrieval_rank: u32, + pub(in crate::search) retrieval_score: Option, +} diff --git a/packages/elf-service/src/search/state/retrieval.rs b/packages/elf-service/src/search/state/retrieval.rs new file mode 100644 index 00000000..989f9118 --- /dev/null +++ b/packages/elf-service/src/search/state/retrieval.rs @@ -0,0 +1,144 @@ +use crate::search::{ + ExpansionMode, Filter, HashMap, OffsetDateTime, PayloadLevel, RankingRequestOverride, + RawSearchPath, ResolvedRetrievalSourcesPolicy, RetrievalSourceKind, SearchFilter, Uuid, +}; + +pub(in crate::search) struct MaybeDynamicSearchArgs<'a> { + pub(in crate::search) path: RawSearchPath, + pub(in crate::search) enabled: bool, + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) query: &'a str, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) token_id: Option<&'a str>, + pub(in crate::search) read_profile: &'a str, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) project_context_description: Option<&'a str>, + pub(in crate::search) filter: &'a Filter, + pub(in crate::search) service_filter: Option<&'a SearchFilter>, + pub(in crate::search) candidate_k: u32, + pub(in crate::search) requested_candidate_k: u32, + pub(in crate::search) effective_candidate_k: u32, + pub(in crate::search) top_k: u32, + pub(in crate::search) record_hits_enabled: bool, + pub(in crate::search) ranking_override: Option<&'a RankingRequestOverride>, + pub(in crate::search) retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, + pub(in crate::search) payload_level: PayloadLevel, +} + +pub(in crate::search) struct SearchRetrievalArgs<'a> { + pub(in crate::search) query: &'a str, + pub(in crate::search) expansion_mode: ExpansionMode, + pub(in crate::search) project_context_description: Option<&'a str>, + pub(in crate::search) filter: &'a Filter, + pub(in crate::search) candidate_k: u32, + pub(in crate::search) baseline_vector: Option<&'a Vec>, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, +} + +pub(in crate::search) struct RecursiveRetrievalArgs<'a> { + pub(in crate::search) query: &'a str, + pub(in crate::search) query_vec: &'a [f32], + pub(in crate::search) filter: &'a Filter, + pub(in crate::search) candidate_k: u32, + pub(in crate::search) retrieval_sources_policy: &'a ResolvedRetrievalSourcesPolicy, + pub(in crate::search) seed_candidates: &'a [ChunkCandidate], +} + +pub(in crate::search) struct SearchRetrievalResult { + pub(in crate::search) expanded_queries: Vec, + pub(in crate::search) candidates: Vec, + pub(in crate::search) structured_matches: HashMap>, + pub(in crate::search) recursive: Option, +} + +#[derive(Clone, Debug, Default)] +pub(in crate::search) struct RecursiveRetrievalResult { + pub(in crate::search) enabled: bool, + pub(in crate::search) rounds_executed: u32, + pub(in crate::search) scopes_seeded: usize, + pub(in crate::search) scopes_queried: usize, + pub(in crate::search) candidates_before: usize, + pub(in crate::search) candidates_after: usize, + pub(in crate::search) candidates_added: usize, + pub(in crate::search) total_queries: u32, + pub(in crate::search) stop_reason: Option, + pub(in crate::search) candidates: Vec, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct QueryEmbedding { + pub(in crate::search) text: String, + pub(in crate::search) vector: Vec, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct ChunkCandidate { + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_index: i32, + pub(in crate::search) retrieval_rank: u32, + pub(in crate::search) retrieval_score: Option, + pub(in crate::search) scope: Option, + pub(in crate::search) updated_at: Option, + pub(in crate::search) embedding_version: Option, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct RerankCacheCandidate { + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) updated_at: OffsetDateTime, +} + +pub(in crate::search) struct StructuredFieldRetrievalArgs<'a> { + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) query_vec: &'a [f32], + pub(in crate::search) candidate_k: u32, + pub(in crate::search) now: OffsetDateTime, +} + +#[derive(Debug)] +pub(in crate::search) struct FieldHit { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) field_kind: String, +} + +pub(in crate::search) struct StructuredFieldHitArgs<'a> { + pub(in crate::search) embed_version: &'a str, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) now: OffsetDateTime, + pub(in crate::search) vec_text: &'a str, + pub(in crate::search) retrieval_limit: i64, + pub(in crate::search) private_allowed: bool, + pub(in crate::search) non_private_scopes: &'a [String], +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct StructuredFieldRetrievalResult { + pub(in crate::search) candidates: Vec, + pub(in crate::search) structured_matches: HashMap>, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct RetrievalSourceCandidates { + pub(in crate::search) source: RetrievalSourceKind, + pub(in crate::search) candidates: Vec, +} + +#[derive(Clone, Debug, Default)] +pub(in crate::search) struct DynamicGateSummary { + pub(in crate::search) considered: bool, + pub(in crate::search) should_expand: Option, + pub(in crate::search) observed_candidates: Option, + pub(in crate::search) observed_top_score: Option, +} diff --git a/packages/elf-service/src/search/state/scoring.rs b/packages/elf-service/src/search/state/scoring.rs new file mode 100644 index 00000000..d842297f --- /dev/null +++ b/packages/elf-service/src/search/state/scoring.rs @@ -0,0 +1,108 @@ +use crate::search::{ + ChunkSnippet, Config, HashMap, OffsetDateTime, ResolvedBlendPolicy, SearchCache, Uuid, +}; + +pub(in crate::search) struct ScoreSnippetArgs<'a, 'k> { + pub(in crate::search) query: &'a str, + pub(in crate::search) snippet_items: Vec, + pub(in crate::search) scope_context_boost_by_scope: &'a HashMap<&'k str, f32>, + pub(in crate::search) det_query_tokens: &'a [String], + pub(in crate::search) blend_policy: &'a ResolvedBlendPolicy, + pub(in crate::search) cache_cfg: &'a SearchCache, + pub(in crate::search) now: OffsetDateTime, + pub(in crate::search) candidate_count: usize, + pub(in crate::search) skip_rerank: bool, +} + +pub(in crate::search) struct ScoreCandidateCtx<'a, 'k> { + pub(in crate::search) cfg: &'a Config, + pub(in crate::search) blend_policy: &'a ResolvedBlendPolicy, + pub(in crate::search) scope_context_boost_by_scope: &'a HashMap<&'k str, f32>, + pub(in crate::search) det_query_tokens: &'a [String], + pub(in crate::search) now: OffsetDateTime, + pub(in crate::search) total_rerank: u32, + pub(in crate::search) total_retrieval: u32, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct ScoredChunk { + pub(in crate::search) item: ChunkSnippet, + pub(in crate::search) final_score: f32, + pub(in crate::search) rerank_score: f32, + pub(in crate::search) rerank_rank: u32, + pub(in crate::search) rerank_norm: f32, + pub(in crate::search) retrieval_norm: f32, + pub(in crate::search) blend_retrieval_weight: f32, + pub(in crate::search) retrieval_term: f32, + pub(in crate::search) rerank_term: f32, + pub(in crate::search) tie_breaker_score: f32, + pub(in crate::search) scope_context_boost: f32, + pub(in crate::search) age_days: f32, + pub(in crate::search) importance: f32, + pub(in crate::search) deterministic_lexical_overlap_ratio: f32, + pub(in crate::search) deterministic_lexical_bonus: f32, + pub(in crate::search) deterministic_hit_count: i64, + pub(in crate::search) deterministic_last_hit_age_days: Option, + pub(in crate::search) deterministic_hit_boost: f32, + pub(in crate::search) deterministic_decay_penalty: f32, +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct DiversityDecision { + pub(in crate::search) selected: bool, + pub(in crate::search) selected_rank: Option, + pub(in crate::search) selected_reason: String, + pub(in crate::search) skipped_reason: Option, + pub(in crate::search) nearest_selected_note_id: Option, + pub(in crate::search) similarity: Option, + pub(in crate::search) mmr_score: Option, + pub(in crate::search) missing_embedding: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::search) struct DeterministicRankingTerms { + pub(in crate::search) lexical_overlap_ratio: f32, + pub(in crate::search) lexical_bonus: f32, + pub(in crate::search) hit_count: i64, + pub(in crate::search) last_hit_age_days: Option, + pub(in crate::search) hit_boost: f32, + pub(in crate::search) decay_penalty: f32, +} +impl Default for DeterministicRankingTerms { + fn default() -> Self { + Self { + lexical_overlap_ratio: 0.0, + lexical_bonus: 0.0, + hit_count: 0, + last_hit_age_days: None, + hit_boost: 0.0, + decay_penalty: 0.0, + } + } +} + +#[derive(Clone, Debug)] +pub(in crate::search) struct ScoredReplay { + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) retrieval_rank: u32, + pub(in crate::search) final_score: f32, + pub(in crate::search) rerank_score: f32, + pub(in crate::search) rerank_rank: u32, + pub(in crate::search) rerank_norm: f32, + pub(in crate::search) retrieval_norm: f32, + pub(in crate::search) blend_retrieval_weight: f32, + pub(in crate::search) retrieval_term: f32, + pub(in crate::search) rerank_term: f32, + pub(in crate::search) tie_breaker_score: f32, + pub(in crate::search) scope_context_boost: f32, + pub(in crate::search) age_days: f32, + pub(in crate::search) importance: f32, + pub(in crate::search) note_scope: String, + pub(in crate::search) deterministic_lexical_overlap_ratio: f32, + pub(in crate::search) deterministic_lexical_bonus: f32, + pub(in crate::search) deterministic_hit_count: i64, + pub(in crate::search) deterministic_last_hit_age_days: Option, + pub(in crate::search) deterministic_hit_boost: f32, + pub(in crate::search) deterministic_decay_penalty: f32, +} diff --git a/packages/elf-service/src/search/state/trace.rs b/packages/elf-service/src/search/state/trace.rs new file mode 100644 index 00000000..83a2cac5 --- /dev/null +++ b/packages/elf-service/src/search/state/trace.rs @@ -0,0 +1,153 @@ +use crate::search::{ + Deserialize, Duration, ExpansionMode, OffsetDateTime, SearchExplain, Serialize, TRACE_VERSION, + Uuid, Value, ranking, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct TracePayload { + pub(in crate::search) trace: TraceRecord, + pub(in crate::search) items: Vec, + #[serde(default)] + pub(in crate::search) candidates: Vec, + #[serde(default)] + pub(in crate::search) stages: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct TraceRecord { + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) tenant_id: String, + pub(in crate::search) project_id: String, + pub(in crate::search) agent_id: String, + pub(in crate::search) read_profile: String, + pub(in crate::search) query: String, + pub(in crate::search) expansion_mode: String, + pub(in crate::search) expanded_queries: Vec, + pub(in crate::search) allowed_scopes: Vec, + pub(in crate::search) candidate_count: u32, + pub(in crate::search) top_k: u32, + pub(in crate::search) config_snapshot: Value, + pub(in crate::search) trace_version: i32, + pub(in crate::search) created_at: OffsetDateTime, + pub(in crate::search) expires_at: OffsetDateTime, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct TraceItemRecord { + pub(in crate::search) item_id: Uuid, + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_id: Option, + pub(in crate::search) rank: u32, + pub(in crate::search) final_score: f32, + pub(in crate::search) explain: SearchExplain, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct TraceCandidateRecord { + pub(in crate::search) candidate_id: Uuid, + pub(in crate::search) note_id: Uuid, + pub(in crate::search) chunk_id: Uuid, + pub(in crate::search) chunk_index: i32, + pub(in crate::search) snippet: String, + #[serde(default)] + pub(in crate::search) candidate_snapshot: Value, + pub(in crate::search) retrieval_rank: u32, + pub(in crate::search) rerank_score: f32, + pub(in crate::search) note_scope: String, + pub(in crate::search) note_importance: f32, + pub(in crate::search) note_updated_at: OffsetDateTime, + pub(in crate::search) note_hit_count: i64, + pub(in crate::search) note_last_hit_at: Option, + pub(in crate::search) created_at: OffsetDateTime, + pub(in crate::search) expires_at: OffsetDateTime, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct TraceTrajectoryStageRecord { + pub(in crate::search) stage_id: Uuid, + pub(in crate::search) stage_order: u32, + pub(in crate::search) stage_name: String, + pub(in crate::search) stage_payload: Value, + pub(in crate::search) created_at: OffsetDateTime, + #[serde(default)] + pub(in crate::search) items: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(in crate::search) struct TraceTrajectoryStageItemRecord { + pub(in crate::search) id: Uuid, + pub(in crate::search) item_id: Option, + pub(in crate::search) note_id: Option, + pub(in crate::search) chunk_id: Option, + pub(in crate::search) metrics: Value, +} + +pub(in crate::search) struct TraceContext<'a> { + pub(in crate::search) trace_id: Uuid, + pub(in crate::search) tenant_id: &'a str, + pub(in crate::search) project_id: &'a str, + pub(in crate::search) agent_id: &'a str, + pub(in crate::search) read_profile: &'a str, + pub(in crate::search) query: &'a str, + pub(in crate::search) expansion_mode: ExpansionMode, + pub(in crate::search) expanded_queries: Vec, + pub(in crate::search) allowed_scopes: &'a [String], + pub(in crate::search) candidate_count: usize, + pub(in crate::search) top_k: u32, +} + +pub(in crate::search) struct SearchTraceBuilder { + pub(in crate::search) trace: TraceRecord, + pub(in crate::search) items: Vec, + pub(in crate::search) candidates: Vec, + pub(in crate::search) stages: Vec, +} +impl SearchTraceBuilder { + pub(in crate::search) fn new( + context: TraceContext<'_>, + config_snapshot: Value, + retention_days: i64, + now: OffsetDateTime, + ) -> Self { + let trace = TraceRecord { + trace_id: context.trace_id, + tenant_id: context.tenant_id.to_string(), + project_id: context.project_id.to_string(), + agent_id: context.agent_id.to_string(), + read_profile: context.read_profile.to_string(), + query: context.query.to_string(), + expansion_mode: ranking::expansion_mode_label(context.expansion_mode).to_string(), + expanded_queries: context.expanded_queries, + allowed_scopes: context.allowed_scopes.to_vec(), + candidate_count: context.candidate_count as u32, + top_k: context.top_k, + config_snapshot, + trace_version: TRACE_VERSION, + created_at: now, + expires_at: now + Duration::days(retention_days), + }; + + Self { trace, items: Vec::new(), candidates: Vec::new(), stages: Vec::new() } + } + + pub(in crate::search) fn push_item(&mut self, item: TraceItemRecord) { + self.items.push(item); + } + + pub(in crate::search) fn push_candidate(&mut self, candidate: TraceCandidateRecord) { + self.candidates.push(candidate); + } + + pub(in crate::search) fn push_stage(&mut self, stage: TraceTrajectoryStageRecord) { + self.stages.push(stage); + } + + pub(in crate::search) fn build(self) -> TracePayload { + TracePayload { + trace: self.trace, + items: self.items, + candidates: self.candidates, + stages: self.stages, + } + } +} diff --git a/packages/elf-service/src/search/structured.rs b/packages/elf-service/src/search/structured.rs new file mode 100644 index 00000000..ea7327f2 --- /dev/null +++ b/packages/elf-service/src/search/structured.rs @@ -0,0 +1,79 @@ +use crate::search::{ChunkCandidate, Config, FieldHit, HashMap, HashSet, Uuid, ranking}; + +pub(super) fn build_structured_field_matches( + rows: Vec, +) -> (Vec, HashMap>) { + let mut structured_matches: HashMap> = HashMap::new(); + let mut ordered_note_ids = Vec::new(); + let mut seen_notes = HashSet::new(); + + for row in rows { + let label = match row.field_kind.as_str() { + "summary" => "summary", + "fact" => "facts", + "concept" => "concepts", + _ => continue, + }; + + structured_matches.entry(row.note_id).or_default().insert(label.to_string()); + + if seen_notes.insert(row.note_id) { + ordered_note_ids.push(row.note_id); + } + } + + let mut structured_matches_out: HashMap> = HashMap::new(); + + for (note_id, fields) in structured_matches { + let mut fields: Vec = fields.into_iter().collect(); + + fields.sort(); + structured_matches_out.insert(note_id, fields); + } + + (ordered_note_ids, structured_matches_out) +} + +pub(super) fn build_structured_field_candidates( + candidate_k: u32, + ordered_note_ids: Vec, + best_by_note: HashMap, + embed_version: &str, +) -> Vec { + let mut structured_candidates = Vec::new(); + let mut next_rank = 1_u32; + + for note_id in ordered_note_ids { + if structured_candidates.len() >= candidate_k as usize { + break; + } + + let Some((chunk_id, chunk_index)) = best_by_note.get(¬e_id) else { continue }; + + structured_candidates.push(ChunkCandidate { + chunk_id: *chunk_id, + note_id, + chunk_index: *chunk_index, + retrieval_rank: next_rank, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: Some(embed_version.to_string()), + }); + + next_rank = next_rank.saturating_add(1); + } + + structured_candidates +} + +pub(super) fn build_deterministic_query_tokens(cfg: &Config, query: &str) -> Vec { + if cfg.ranking.deterministic.enabled + && cfg.ranking.deterministic.lexical.enabled + && cfg.ranking.deterministic.lexical.max_query_terms > 0 + { + ranking::tokenize_query(query, cfg.ranking.deterministic.lexical.max_query_terms as usize) + } else { + Vec::new() + } +} diff --git a/packages/elf-service/src/search/tests.rs b/packages/elf-service/src/search/tests.rs new file mode 100644 index 00000000..f6b01376 --- /dev/null +++ b/packages/elf-service/src/search/tests.rs @@ -0,0 +1,831 @@ +use std::path::PathBuf; + +use serde_json::Value; + +use crate::{ + ElfService, + search::{ + self, BlendRankingOverride, ChunkCandidate, ChunkMeta, ChunkSnippet, HashMap, NoteMeta, + OffsetDateTime, RankingRequestOverride, RerankCacheCandidate, RerankCacheItem, + RerankCachePayload, RetrievalSourceCandidates, RetrievalSourceKind, + RetrievalSourcesRankingOverride, ScoredChunk, TraceReplayCandidate, TraceReplayContext, + Uuid, + ranking::{self, ResolvedDiversityPolicy}, + }, +}; +use elf_config::{Config, SearchDynamic}; + +#[test] +fn dense_embedding_input_includes_project_context_suffix() { + let input = + ranking::build_dense_embedding_input("Find payments code.", Some("This is a billing API.")); + + assert!(input.starts_with("Find payments code.\n\nProject context:\n")); + assert!(input.contains("This is a billing API.")); +} + +#[test] +fn dense_embedding_input_skips_empty_project_context() { + let input = ranking::build_dense_embedding_input("Find payments code.", Some(" ")); + + assert_eq!(input, "Find payments code."); +} + +#[test] +fn scope_description_boost_matches_whole_tokens_only() { + let tokens = vec!["go".to_string()]; + let boost = ranking::scope_description_boost(&tokens, "MongoDB operational notes.", 0.1); + + assert_eq!(boost, 0.0); +} + +#[test] +fn scope_description_boost_scales_by_fraction_of_matched_tokens() { + let tokens = vec!["security".to_string(), "policy".to_string(), "deployment".to_string()]; + let boost = ranking::scope_description_boost(&tokens, "Security policy notes.", 0.12); + + assert!((boost - 0.08).abs() < 1e-4, "Unexpected boost: {boost}"); +} + +#[test] +fn normalize_queries_includes_original_and_dedupes() { + let queries = vec!["alpha".to_string(), "beta".to_string(), "alpha".to_string()]; + let normalized = ranking::normalize_queries(queries, "alpha", true, 4); + + assert_eq!(normalized, vec!["alpha".to_string(), "beta".to_string()]); +} + +#[test] +fn normalize_queries_respects_max_queries() { + let queries = + vec!["one".to_string(), "two".to_string(), "three".to_string(), "four".to_string()]; + let normalized = ranking::normalize_queries(queries, "zero", true, 3); + + assert_eq!(normalized.len(), 3); +} + +#[test] +fn dynamic_trigger_checks_candidates_and_score() { + let cfg = SearchDynamic { min_candidates: 10, min_top_score: 0.2 }; + + assert!(ranking::should_expand_dynamic(5, 0.9, &cfg)); + assert!(ranking::should_expand_dynamic(20, 0.1, &cfg)); + assert!(!ranking::should_expand_dynamic(20, 0.9, &cfg)); +} + +#[test] +fn rank_normalize_maps_rank_to_unit_interval() { + assert!((ranking::rank_normalize(1, 1) - 1.0).abs() < 1e-6); + assert!((ranking::rank_normalize(1, 5) - 1.0).abs() < 1e-6); + assert!((ranking::rank_normalize(3, 5) - 0.5).abs() < 1e-6); + assert!((ranking::rank_normalize(5, 5) - 0.0).abs() < 1e-6); + assert!((ranking::rank_normalize(0, 5) - 0.0).abs() < 1e-6); +} + +#[test] +fn build_trace_audit_includes_token_id_when_present() { + let audit = search::build_trace_audit("agent-a", Some("tok-123")); + + assert_eq!(audit.get("actor_id"), Some(&Value::from("agent-a"))); + assert_eq!(audit.get("token_id"), Some(&Value::from("tok-123"))); +} + +#[test] +fn build_trace_audit_omits_token_id_when_empty() { + let audit = search::build_trace_audit("agent-a", Some(" ")); + + assert_eq!(audit.get("actor_id"), Some(&Value::from("agent-a"))); + assert!(audit.get("token_id").is_none()); +} + +#[test] +fn relation_context_rows_without_evidence_are_suppressed() { + let now = OffsetDateTime::from_unix_timestamp(100).expect("valid timestamp"); + let note_id = Uuid::from_u128(1); + let contexts = + ElfService::group_relation_context_rows(vec![search::SearchRelationContextRow { + note_id, + fact_id: Uuid::from_u128(2), + scope: "project_shared".to_string(), + subject_canonical: Some("Alice".to_string()), + subject_kind: Some("person".to_string()), + predicate: "prefers".to_string(), + object_entity_id: None, + object_canonical: None, + object_kind: None, + object_value: Some("source-bound recall".to_string()), + valid_from: now, + valid_to: None, + is_current: true, + evidence_note_ids: Vec::new(), + }]); + + assert!(!contexts.contains_key(¬e_id)); +} + +#[test] +fn relation_context_sql_enforces_shared_grant_keys() { + assert!( + search::RELATION_CONTEXT_SQL + .contains("concat(gf.scope, ':', gf.agent_id) = ANY($10::text[])") + ); + assert!( + search::RELATION_CONTEXT_SQL.contains( + "concat(evidence_note.scope, ':', evidence_note.agent_id) = ANY($10::text[])" + ) + ); +} + +fn test_chunk_candidate(note_id: Uuid, retrieval_rank: u32) -> ChunkCandidate { + ChunkCandidate { + chunk_id: Uuid::new_v4(), + note_id, + chunk_index: 0, + retrieval_rank, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: Some("v1".to_string()), + } +} + +fn default_retrieval_sources_policy() -> ranking::ResolvedRetrievalSourcesPolicy { + ranking::ResolvedRetrievalSourcesPolicy { + fusion_weight: 1.0, + structured_field_weight: 1.0, + recursive_weight: 0.0, + fusion_priority: 1, + structured_field_priority: 0, + recursive_priority: 0, + } +} + +#[test] +fn merge_retrieval_candidates_keeps_structured_hits_under_full_fusion_capacity() { + let mut fusion = Vec::new(); + + for rank in 1..=10 { + fusion.push(test_chunk_candidate(Uuid::new_v4(), rank)); + } + + let structured = vec![test_chunk_candidate(Uuid::new_v4(), 1)]; + let structured_chunk_id = structured[0].chunk_id; + let merged = ranking::merge_retrieval_candidates( + vec![ + RetrievalSourceCandidates { source: RetrievalSourceKind::Fusion, candidates: fusion }, + RetrievalSourceCandidates { + source: RetrievalSourceKind::StructuredField, + candidates: structured, + }, + ], + &default_retrieval_sources_policy(), + 10, + ); + let merged_chunk_ids: Vec = merged.iter().map(|candidate| candidate.chunk_id).collect(); + + assert!( + merged_chunk_ids.contains(&structured_chunk_id), + "Structured candidate was dropped by retrieval fusion." + ); +} + +#[test] +fn merge_retrieval_candidates_prefers_dual_source_signal_on_tie() { + let shared_note_id = Uuid::new_v4(); + let shared_chunk_id = Uuid::new_v4(); + let fusion_only_note_id = Uuid::new_v4(); + let fusion_only_chunk_id = Uuid::new_v4(); + let fusion = vec![ + ChunkCandidate { + chunk_id: shared_chunk_id, + note_id: shared_note_id, + chunk_index: 0, + retrieval_rank: 9, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: Some("v1".to_string()), + }, + ChunkCandidate { + chunk_id: fusion_only_chunk_id, + note_id: fusion_only_note_id, + chunk_index: 0, + retrieval_rank: 1, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: Some("v1".to_string()), + }, + ]; + let structured = vec![ChunkCandidate { + chunk_id: shared_chunk_id, + note_id: shared_note_id, + chunk_index: 0, + retrieval_rank: 1, + retrieval_score: None, + scope: None, + updated_at: None, + embedding_version: Some("v1".to_string()), + }]; + let merged = ranking::merge_retrieval_candidates( + vec![ + RetrievalSourceCandidates { source: RetrievalSourceKind::Fusion, candidates: fusion }, + RetrievalSourceCandidates { + source: RetrievalSourceKind::StructuredField, + candidates: structured, + }, + ], + &default_retrieval_sources_policy(), + 1, + ); + let first = merged.first().expect("Expected merged candidate."); + + assert_eq!(first.chunk_id, shared_chunk_id); +} + +#[test] +fn retrieval_weight_for_rank_uses_first_matching_segment_or_last() { + let segments = vec![ + ranking::BlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.7 }, + ranking::BlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.2 }, + ]; + + assert!((ranking::retrieval_weight_for_rank(1, &segments) - 0.7).abs() < 1e-6); + assert!((ranking::retrieval_weight_for_rank(3, &segments) - 0.7).abs() < 1e-6); + assert!((ranking::retrieval_weight_for_rank(4, &segments) - 0.2).abs() < 1e-6); + assert!((ranking::retrieval_weight_for_rank(999, &segments) - 0.2).abs() < 1e-6); +} + +#[test] +fn blend_math_is_linear_and_additive() { + let segments = vec![ + ranking::BlendSegment { max_retrieval_rank: 2, retrieval_weight: 0.7 }, + ranking::BlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.2 }, + ]; + let retrieval_rank = 3; + let rerank_rank = 2; + let retrieval_norm = ranking::rank_normalize(retrieval_rank, 10); + let rerank_norm = ranking::rank_normalize(rerank_rank, 4); + let blend_retrieval_weight = ranking::retrieval_weight_for_rank(retrieval_rank, &segments); + + assert!((blend_retrieval_weight - 0.2).abs() < 1e-6); + assert!((retrieval_norm - (7.0 / 9.0)).abs() < 1e-6); + assert!((rerank_norm - (2.0 / 3.0)).abs() < 1e-6); + + let retrieval_term = blend_retrieval_weight * retrieval_norm; + let rerank_term = (1.0 - blend_retrieval_weight) * rerank_norm; + let tie_breaker_score = 0.1; + let scope_context_boost = 0.0; + let final_score = retrieval_term + rerank_term + tie_breaker_score + scope_context_boost; + let expected = (0.2 * (7.0 / 9.0)) + (0.8 * (2.0 / 3.0)) + 0.1; + + assert!((final_score - expected).abs() < 1e-6, "Unexpected final_score: {final_score}"); +} + +#[test] +fn expansion_cache_key_changes_with_max_queries() { + let key_a = ranking::build_expansion_cache_key("alpha", 4, true, "llm", "model", 0.1_f32) + .expect("Expected cache key."); + let key_b = ranking::build_expansion_cache_key("alpha", 5, true, "llm", "model", 0.1_f32) + .expect("Expected cache key."); + + assert_ne!(key_a, key_b); +} + +#[test] +fn rerank_cache_key_changes_with_updated_at() { + let ts_a = OffsetDateTime::from_unix_timestamp(1).expect("Valid timestamp."); + let ts_b = OffsetDateTime::from_unix_timestamp(2).expect("Valid timestamp."); + let chunk_id = Uuid::new_v4(); + let key_a = ranking::build_rerank_cache_key("q", "rerank", "model", &[(chunk_id, ts_a)]) + .expect("Expected cache key."); + let key_b = ranking::build_rerank_cache_key("q", "rerank", "model", &[(chunk_id, ts_b)]) + .expect("Expected cache key."); + + assert_ne!(key_a, key_b); +} + +#[test] +fn rerank_cache_payload_rejects_mismatched_counts() { + let payload = RerankCachePayload { + items: vec![RerankCacheItem { + chunk_id: Uuid::new_v4(), + updated_at: OffsetDateTime::from_unix_timestamp(1).expect("Valid timestamp."), + score: 0.5, + }], + }; + let candidates = vec![RerankCacheCandidate { + chunk_id: Uuid::new_v4(), + updated_at: OffsetDateTime::from_unix_timestamp(1).expect("Valid timestamp."), + }]; + + assert!(ranking::build_cached_scores(&payload, &candidates).is_none()); +} + +#[test] +fn cache_key_prefix_is_stable() { + let prefix = ranking::cache_key_prefix("abcd1234efgh5678"); + + assert_eq!(prefix, "abcd1234efgh"); +} + +#[test] +fn lexical_overlap_ratio_is_deterministic_and_bounded() { + let query_tokens = vec!["deploy".to_string(), "steps".to_string()]; + let ratio = ranking::lexical_overlap_ratio(&query_tokens, "Deploy steps for staging.", 128); + + assert!((ratio - 1.0).abs() < 1e-6, "Unexpected ratio: {ratio}"); + + let ratio = ranking::lexical_overlap_ratio(&query_tokens, "Deploy only.", 128); + + assert!((ratio - 0.5).abs() < 1e-6, "Unexpected ratio: {ratio}"); + assert!((0.0..=1.0).contains(&ratio), "Ratio must be in [0, 1]."); +} + +#[test] +fn deterministic_ranking_terms_do_not_apply_when_disabled() { + let mut cfg = parse_example_config(); + + cfg.ranking.deterministic.enabled = false; + cfg.ranking.deterministic.lexical.enabled = true; + cfg.ranking.deterministic.hits.enabled = true; + cfg.ranking.deterministic.decay.enabled = true; + + let now = OffsetDateTime::from_unix_timestamp(1_000_000).expect("Valid timestamp."); + let note = NoteMeta { + note_id: Uuid::new_v4(), + note_type: "fact".to_string(), + key: None, + scope: "project_shared".to_string(), + agent_id: "agent-a".to_string(), + importance: 0.1, + confidence: 0.9, + updated_at: now, + expires_at: None, + source_ref: serde_json::json!({}), + embedding_version: "v1".to_string(), + hit_count: 8, + last_hit_at: Some(now), + }; + let chunk = + ChunkMeta { chunk_id: Uuid::new_v4(), chunk_index: 0, start_offset: 0, end_offset: 10 }; + let item = ChunkSnippet { + note, + chunk, + snippet: "deploy steps".to_string(), + retrieval_rank: 1, + retrieval_score: None, + }; + let mut scored = ScoredChunk { + item, + final_score: 1.0, + rerank_score: 0.5, + rerank_rank: 1, + rerank_norm: 1.0, + retrieval_norm: 1.0, + blend_retrieval_weight: 0.5, + retrieval_term: 0.5, + rerank_term: 0.5, + tie_breaker_score: 0.0, + scope_context_boost: 0.0, + age_days: 30.0, + importance: 0.1, + deterministic_lexical_overlap_ratio: 0.0, + deterministic_lexical_bonus: 0.0, + deterministic_hit_count: 0, + deterministic_last_hit_age_days: None, + deterministic_hit_boost: 0.0, + deterministic_decay_penalty: 0.0, + }; + let terms = ranking::compute_deterministic_ranking_terms( + &cfg, + &ranking::tokenize_query( + "deploy steps", + cfg.ranking.deterministic.lexical.max_query_terms as usize, + ), + scored.item.snippet.as_str(), + scored.item.note.hit_count, + scored.item.note.last_hit_at, + scored.age_days, + now, + ); + + scored.final_score += terms.lexical_bonus + terms.hit_boost + terms.decay_penalty; + scored.deterministic_lexical_overlap_ratio = terms.lexical_overlap_ratio; + scored.deterministic_lexical_bonus = terms.lexical_bonus; + scored.deterministic_hit_count = terms.hit_count; + scored.deterministic_last_hit_age_days = terms.last_hit_age_days; + scored.deterministic_hit_boost = terms.hit_boost; + scored.deterministic_decay_penalty = terms.decay_penalty; + + assert!((scored.final_score - 1.0).abs() < 1e-6, "Score must not change."); + assert!((scored.deterministic_lexical_bonus - 0.0).abs() < 1e-6); + assert!((scored.deterministic_hit_boost - 0.0).abs() < 1e-6); + assert!((scored.deterministic_decay_penalty - 0.0).abs() < 1e-6); +} + +#[test] +fn deterministic_ranking_terms_apply_and_are_bounded() { + let mut cfg = parse_example_config(); + + cfg.ranking.deterministic.enabled = true; + cfg.ranking.deterministic.lexical.enabled = true; + cfg.ranking.deterministic.hits.enabled = true; + cfg.ranking.deterministic.decay.enabled = true; + + let now = OffsetDateTime::from_unix_timestamp(1_000_000).expect("Valid timestamp."); + let note = NoteMeta { + note_id: Uuid::new_v4(), + note_type: "fact".to_string(), + key: None, + scope: "project_shared".to_string(), + agent_id: "agent-a".to_string(), + importance: 0.1, + confidence: 0.9, + updated_at: now, + expires_at: None, + source_ref: serde_json::json!({}), + embedding_version: "v1".to_string(), + hit_count: 8, + last_hit_at: Some(now), + }; + let chunk = + ChunkMeta { chunk_id: Uuid::new_v4(), chunk_index: 0, start_offset: 0, end_offset: 10 }; + let item = ChunkSnippet { + note, + chunk, + snippet: "deploy steps".to_string(), + retrieval_rank: 1, + retrieval_score: None, + }; + let mut scored = ScoredChunk { + item, + final_score: 1.0, + rerank_score: 0.5, + rerank_rank: 1, + rerank_norm: 1.0, + retrieval_norm: 1.0, + blend_retrieval_weight: 0.5, + retrieval_term: 0.5, + rerank_term: 0.5, + tie_breaker_score: 0.0, + scope_context_boost: 0.0, + age_days: 30.0, + importance: 0.1, + deterministic_lexical_overlap_ratio: 0.0, + deterministic_lexical_bonus: 0.0, + deterministic_hit_count: 0, + deterministic_last_hit_age_days: None, + deterministic_hit_boost: 0.0, + deterministic_decay_penalty: 0.0, + }; + let terms = ranking::compute_deterministic_ranking_terms( + &cfg, + &ranking::tokenize_query( + "deploy steps", + cfg.ranking.deterministic.lexical.max_query_terms as usize, + ), + scored.item.snippet.as_str(), + scored.item.note.hit_count, + scored.item.note.last_hit_at, + scored.age_days, + now, + ); + + scored.final_score += terms.lexical_bonus + terms.hit_boost + terms.decay_penalty; + scored.deterministic_lexical_overlap_ratio = terms.lexical_overlap_ratio; + scored.deterministic_lexical_bonus = terms.lexical_bonus; + scored.deterministic_hit_count = terms.hit_count; + scored.deterministic_last_hit_age_days = terms.last_hit_age_days; + scored.deterministic_hit_boost = terms.hit_boost; + scored.deterministic_decay_penalty = terms.decay_penalty; + + assert!(scored.final_score.is_finite(), "Score must be finite."); + assert!((0.0..=1.0).contains(&scored.deterministic_lexical_overlap_ratio)); + assert!(scored.deterministic_lexical_bonus >= 0.0); + assert!(scored.deterministic_hit_boost >= 0.0); + assert!(scored.deterministic_decay_penalty <= 0.0); + + let expected_lex = cfg.ranking.deterministic.lexical.weight; + + assert!((scored.deterministic_lexical_bonus - expected_lex).abs() < 1e-6); + + let expected_hit = cfg.ranking.deterministic.hits.weight * 0.5; + + assert!((scored.deterministic_hit_boost - expected_hit).abs() < 1e-6); +} + +fn test_scored_chunk(note_id: Uuid, retrieval_rank: u32, now: OffsetDateTime) -> ScoredChunk { + let note = NoteMeta { + note_id, + note_type: "fact".to_string(), + key: None, + scope: "project_shared".to_string(), + agent_id: "agent-a".to_string(), + importance: 0.1, + confidence: 0.9, + updated_at: now, + expires_at: None, + source_ref: serde_json::json!({}), + embedding_version: "v1".to_string(), + hit_count: 0, + last_hit_at: None, + }; + let chunk = ChunkMeta { + chunk_id: Uuid::new_v4(), + chunk_index: i32::try_from(retrieval_rank.saturating_sub(1)).unwrap_or(0), + start_offset: 0, + end_offset: 16, + }; + let item = ChunkSnippet { + note, + chunk, + snippet: format!("snippet-{retrieval_rank}"), + retrieval_rank, + retrieval_score: None, + }; + + ScoredChunk { + item, + final_score: 0.0, + rerank_score: 0.0, + rerank_rank: retrieval_rank, + rerank_norm: 0.0, + retrieval_norm: 0.0, + blend_retrieval_weight: 0.5, + retrieval_term: 0.0, + rerank_term: 0.0, + tie_breaker_score: 0.0, + scope_context_boost: 0.0, + age_days: 0.0, + importance: 0.1, + deterministic_lexical_overlap_ratio: 0.0, + deterministic_lexical_bonus: 0.0, + deterministic_hit_count: 0, + deterministic_last_hit_age_days: None, + deterministic_hit_boost: 0.0, + deterministic_decay_penalty: 0.0, + } +} + +#[test] +fn diversity_selection_skips_high_similarity_when_alternative_exists() { + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let note_a = Uuid::new_v4(); + let note_b = Uuid::new_v4(); + let note_c = Uuid::new_v4(); + let candidates = vec![ + test_scored_chunk(note_a, 1, now), + test_scored_chunk(note_b, 2, now), + test_scored_chunk(note_c, 3, now), + ]; + let mut vectors = HashMap::new(); + + vectors.insert(note_a, vec![1.0, 0.0]); + vectors.insert(note_b, vec![0.99, 0.01]); + vectors.insert(note_c, vec![0.0, 1.0]); + + let policy = ResolvedDiversityPolicy { + enabled: true, + sim_threshold: 0.9, + mmr_lambda: 0.7, + max_skips: 64, + }; + let (selected, decisions) = ranking::select_diverse_results(candidates, 2, &policy, &vectors); + let selected_ids: Vec = selected.iter().map(|item| item.item.note.note_id).collect(); + + assert_eq!(selected_ids, vec![note_a, note_c]); + assert_eq!( + decisions.get(¬e_b).and_then(|decision| decision.skipped_reason.as_deref()), + Some("similarity_threshold") + ); +} + +#[test] +fn diversity_selection_backfills_when_max_skips_is_reached() { + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let note_a = Uuid::new_v4(); + let note_b = Uuid::new_v4(); + let candidates = vec![test_scored_chunk(note_a, 1, now), test_scored_chunk(note_b, 2, now)]; + let mut vectors = HashMap::new(); + + vectors.insert(note_a, vec![1.0, 0.0]); + vectors.insert(note_b, vec![0.99, 0.01]); + + let policy = ResolvedDiversityPolicy { + enabled: true, + sim_threshold: 0.9, + mmr_lambda: 0.7, + max_skips: 0, + }; + let (selected, decisions) = ranking::select_diverse_results(candidates, 2, &policy, &vectors); + let selected_ids: Vec = selected.iter().map(|item| item.item.note.note_id).collect(); + let selected_reason = decisions.get(¬e_b).map(|decision| decision.selected_reason.as_str()); + + assert_eq!(selected_ids, vec![note_a, note_b]); + assert_eq!(selected_reason, Some("max_skips_backfill")); +} + +#[test] +fn replay_diversity_decisions_prefer_selected_entry_for_same_note() { + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let note_id = Uuid::new_v4(); + let first = TraceReplayCandidate { + note_id, + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "first".to_string(), + retrieval_rank: 2, + retrieval_score: None, + rerank_score: 0.2, + note_scope: "project_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: Some(false), + diversity_selected_rank: None, + diversity_selected_reason: Some("not_selected".to_string()), + diversity_skipped_reason: Some("lower_mmr".to_string()), + diversity_nearest_selected_note_id: None, + diversity_similarity: Some(0.95), + diversity_mmr_score: Some(0.12), + diversity_missing_embedding: Some(false), + }; + let second = TraceReplayCandidate { + note_id, + chunk_id: Uuid::new_v4(), + chunk_index: 1, + snippet: "second".to_string(), + retrieval_rank: 1, + retrieval_score: None, + rerank_score: 0.3, + note_scope: "project_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: Some(true), + diversity_selected_rank: Some(2), + diversity_selected_reason: Some("mmr".to_string()), + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: Some(0.35), + diversity_mmr_score: Some(0.44), + diversity_missing_embedding: Some(false), + }; + let decisions = ranking::extract_replay_diversity_decisions(&[first, second]); + let decision = decisions.get(¬e_id).expect("Expected merged decision."); + + assert!(decision.selected); + assert_eq!(decision.selected_rank, Some(2)); + assert_eq!(decision.selected_reason, "mmr"); +} + +fn parse_example_config() -> Config { + let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let path = root_dir.join("elf.example.toml"); + + elf_config::load(&path).expect("elf.example.toml must remain parseable and valid.") +} + +#[test] +fn ranking_policy_id_is_stable_and_has_expected_format() { + let cfg = parse_example_config(); + let id_a = search::ranking_policy_id(&cfg, None).expect("Expected policy id."); + let id_b = search::ranking_policy_id(&cfg, None).expect("Expected policy id."); + + assert_eq!(id_a, id_b); + assert!(id_a.starts_with("ranking_v2:"), "Unexpected policy id: {id_a}"); + assert_eq!(id_a.len(), "ranking_v2:".len() + 12, "Unexpected policy id: {id_a}"); +} + +#[test] +fn ranking_policy_id_changes_with_override() { + let cfg = parse_example_config(); + let base = search::ranking_policy_id(&cfg, None).expect("Expected base policy id."); + let override_ = RankingRequestOverride { + blend: Some(BlendRankingOverride { + enabled: Some(false), + rerank_normalization: None, + retrieval_normalization: None, + segments: None, + }), + diversity: None, + retrieval_sources: None, + }; + let overridden = + search::ranking_policy_id(&cfg, Some(&override_)).expect("Expected overridden policy id."); + + assert_ne!(base, overridden); +} + +#[test] +fn ranking_policy_id_changes_with_retrieval_source_override() { + let cfg = parse_example_config(); + let base = search::ranking_policy_id(&cfg, None).expect("Expected base policy id."); + let override_ = RankingRequestOverride { + blend: None, + diversity: None, + retrieval_sources: Some(RetrievalSourcesRankingOverride { + fusion_weight: Some(0.75), + structured_field_weight: Some(1.25), + recursive_weight: Some(0.0), + fusion_priority: Some(2), + structured_field_priority: Some(1), + recursive_priority: Some(0), + }), + }; + let overridden = + search::ranking_policy_id(&cfg, Some(&override_)).expect("Expected overridden policy id."); + + assert_ne!(base, overridden); +} + +#[test] +fn replay_ranking_policy_id_matches_ranking_policy_id() { + let cfg = parse_example_config(); + let expected = search::ranking_policy_id(&cfg, None).expect("Expected policy id."); + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let trace = TraceReplayContext { + trace_id: Uuid::new_v4(), + query: "deployment steps".to_string(), + candidate_count: 3, + top_k: 2, + created_at: now, + }; + let candidates = vec![ + TraceReplayCandidate { + note_id: Uuid::new_v4(), + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "deployment steps".to_string(), + retrieval_rank: 1, + retrieval_score: None, + rerank_score: 0.1, + note_scope: "project_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + TraceReplayCandidate { + note_id: Uuid::new_v4(), + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "deployment steps".to_string(), + retrieval_rank: 2, + retrieval_score: None, + rerank_score: 0.9, + note_scope: "project_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + TraceReplayCandidate { + note_id: Uuid::new_v4(), + chunk_id: Uuid::new_v4(), + chunk_index: 0, + snippet: "deployment steps".to_string(), + retrieval_rank: 3, + retrieval_score: None, + rerank_score: 0.2, + note_scope: "org_shared".to_string(), + note_importance: 0.1, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: None, + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: None, + diversity_missing_embedding: None, + }, + ]; + let out = search::replay_ranking_from_candidates(&cfg, &trace, None, &candidates, 2) + .expect("Expected replay output."); + + for item in out { + assert_eq!(item.explain.ranking.policy_id, expected); + } +} diff --git a/packages/elf-service/src/search/trace.rs b/packages/elf-service/src/search/trace.rs new file mode 100644 index 00000000..a90e05cb --- /dev/null +++ b/packages/elf-service/src/search/trace.rs @@ -0,0 +1,420 @@ +use crate::{ + Error, + search::{ + self, DEFAULT_BOUNDED_CANDIDATES_LIMIT, DEFAULT_BOUNDED_STAGE_ITEMS_LIMIT, + DEFAULT_FULL_CANDIDATES_LIMIT, DEFAULT_FULL_STAGE_ITEMS_LIMIT, DEFAULT_RECENT_TRACES_LIMIT, + ElfService, MAX_RECENT_TRACES_LIMIT, MAX_TRACE_BUNDLE_CANDIDATES_LIMIT, + MAX_TRACE_BUNDLE_ITEMS_LIMIT, OffsetDateTime, RECENT_TRACES_SCHEMA_V1, RecentTraceHeader, + Result, SearchExplain, SearchExplainItem, SearchExplainRequest, SearchExplainResponse, + SearchExplainTraceRow, SearchRecentTraceRow, SearchTrace, SearchTraceItemRow, + SearchTraceRow, SearchTrajectoryResponse, TRACE_BUNDLE_SCHEMA_V1, TraceBundleGetRequest, + TraceBundleMode, TraceBundleResponse, TraceCandidateSnapshotRow, TraceGetRequest, + TraceGetResponse, TraceRecentCursor, TraceRecentListRequest, TraceRecentListResponse, + TraceTrajectoryGetRequest, ranking, + }, +}; + +impl ElfService { + /// Loads the explain payload for one result handle. + pub async fn search_explain(&self, req: SearchExplainRequest) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id and project_id are required.".to_string(), + }); + } + + let row = sqlx::query_as::<_, SearchExplainTraceRow>( + "\ +SELECT + t.trace_id, + t.tenant_id, + t.project_id, + t.agent_id, + t.read_profile, + t.query, + t.expansion_mode, + t.expanded_queries, + t.allowed_scopes, + t.candidate_count, + t.top_k, + t.config_snapshot, + t.trace_version, + t.created_at, + i.item_id, + i.note_id, + i.chunk_id, + i.rank, + i.explain +FROM search_trace_items i +JOIN search_traces t ON i.trace_id = t.trace_id + +WHERE i.item_id = $1 AND t.tenant_id = $2 AND t.project_id = $3", + ) + .bind(req.result_handle) + .bind(tenant_id) + .bind(project_id) + .fetch_optional(&self.db.pool) + .await?; + let Some(row) = row else { + return Err(Error::InvalidRequest { + message: "Unknown result_handle or trace not yet persisted.".to_string(), + }); + }; + let expanded_queries: Vec = + ranking::decode_json(row.expanded_queries, "expanded_queries")?; + let allowed_scopes: Vec = + ranking::decode_json(row.allowed_scopes, "allowed_scopes")?; + let config_snapshot = row.config_snapshot; + let explain: SearchExplain = ranking::decode_json(row.explain, "explain")?; + let trace = SearchTrace { + trace_id: row.trace_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + read_profile: row.read_profile, + query: row.query, + expansion_mode: row.expansion_mode, + expanded_queries, + allowed_scopes, + candidate_count: row.candidate_count as u32, + top_k: row.top_k as u32, + config_snapshot, + created_at: row.created_at, + trace_version: row.trace_version, + }; + let item = SearchExplainItem { + result_handle: row.item_id, + note_id: row.note_id, + chunk_id: row.chunk_id, + rank: row.rank as u32, + explain, + }; + let trajectory = search::load_item_trajectory( + &self.db.pool, + row.trace_id, + row.item_id, + row.note_id, + row.chunk_id, + ) + .await?; + + Ok(SearchExplainResponse { trace, item, trajectory }) + } + + /// Loads trace metadata and explained items for one trace. + pub async fn trace_get(&self, req: TraceGetRequest) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + + if req.agent_id.trim().is_empty() { + return Err(Error::InvalidRequest { message: "agent_id is required.".to_string() }); + } + if tenant_id.is_empty() || project_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id and project_id are required.".to_string(), + }); + } + + let row = sqlx::query_as::<_, SearchTraceRow>( + "\ +SELECT + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + expansion_mode, + expanded_queries, + allowed_scopes, + candidate_count, + top_k, + config_snapshot, + trace_version, + created_at +FROM search_traces +WHERE trace_id = $1 AND tenant_id = $2 AND project_id = $3", + ) + .bind(req.trace_id) + .bind(tenant_id) + .bind(project_id) + .fetch_optional(&self.db.pool) + .await?; + let Some(row) = row else { + return Err(Error::InvalidRequest { message: "Unknown trace_id.".to_string() }); + }; + let expanded_queries: Vec = + ranking::decode_json(row.expanded_queries, "expanded_queries")?; + let allowed_scopes: Vec = + ranking::decode_json(row.allowed_scopes, "allowed_scopes")?; + let config_snapshot = row.config_snapshot; + let trace = SearchTrace { + trace_id: row.trace_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + read_profile: row.read_profile, + query: row.query, + expansion_mode: row.expansion_mode, + expanded_queries, + allowed_scopes, + candidate_count: row.candidate_count as u32, + top_k: row.top_k as u32, + config_snapshot, + created_at: row.created_at, + trace_version: row.trace_version, + }; + let item_rows = sqlx::query_as::<_, SearchTraceItemRow>( + "\ +SELECT + item_id, + note_id, + chunk_id, + rank, + explain +FROM search_trace_items +WHERE trace_id = $1 +ORDER BY rank ASC", + ) + .bind(req.trace_id) + .fetch_all(&self.db.pool) + .await?; + let mut items = Vec::with_capacity(item_rows.len()); + + for row in item_rows { + let explain: SearchExplain = ranking::decode_json(row.explain, "explain")?; + + items.push(SearchExplainItem { + result_handle: row.item_id, + note_id: row.note_id, + chunk_id: row.chunk_id, + rank: row.rank as u32, + explain, + }); + } + + let trajectory_summary = + search::load_trace_trajectory_summary(&self.db.pool, req.trace_id).await?; + + Ok(TraceGetResponse { trace, items, trajectory_summary }) + } + + /// Loads full trajectory stages for one trace. + pub async fn trace_trajectory_get( + &self, + req: TraceTrajectoryGetRequest, + ) -> Result { + let base = self + .trace_get(TraceGetRequest { + tenant_id: req.tenant_id, + project_id: req.project_id, + agent_id: req.agent_id, + trace_id: req.trace_id, + }) + .await?; + let stages = search::load_trace_trajectory_stages(&self.db.pool, req.trace_id).await?; + let trajectory = search::build_trajectory_summary_from_stages(stages.as_slice()); + + Ok(SearchTrajectoryResponse { trace: base.trace, trajectory, stages }) + } + + /// Lists recent traces with cursor-based pagination. + pub async fn trace_recent_list( + &self, + req: TraceRecentListRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let caller_agent_id = req.agent_id.trim(); + let cursor_created_at = req.cursor_created_at; + let cursor_trace_id = req.cursor_trace_id; + let agent_id_filter = req.agent_id_filter.map(|value| value.trim().to_string()); + let read_profile = req.read_profile.map(|value| value.trim().to_string()); + let limit = req.limit.unwrap_or(DEFAULT_RECENT_TRACES_LIMIT); + + if cursor_created_at.is_some() != cursor_trace_id.is_some() { + return Err(Error::InvalidRequest { + message: "cursor_created_at and cursor_trace_id must be both set or both omitted." + .to_string(), + }); + } + if caller_agent_id.is_empty() { + return Err(Error::InvalidRequest { message: "agent_id is required.".to_string() }); + } + if tenant_id.is_empty() || project_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id and project_id are required.".to_string(), + }); + } + if limit == 0 || limit > MAX_RECENT_TRACES_LIMIT { + return Err(Error::InvalidRequest { + message: format!("limit must be between 1 and {MAX_RECENT_TRACES_LIMIT}."), + }); + } + + if let (Some(created_after), Some(created_before)) = (req.created_after, req.created_before) + && created_after >= created_before + { + return Err(Error::InvalidRequest { + message: "created_after must be before created_before.".to_string(), + }); + } + + let agent_id_filter = agent_id_filter.as_deref(); + let read_profile = read_profile.as_deref(); + let fetch_limit = (limit + 1).min(MAX_RECENT_TRACES_LIMIT + 1); + let rows = sqlx::query_as::<_, SearchRecentTraceRow>( + "\ +SELECT + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + created_at +FROM search_traces +WHERE tenant_id = $1 + AND project_id = $2 + AND ($3::text IS NULL OR agent_id = $3) + AND ($4::text IS NULL OR read_profile = $4) + AND ($5::timestamptz IS NULL OR created_at > $5) + AND ($6::timestamptz IS NULL OR created_at < $6) + AND ($7::timestamptz IS NULL OR $8::uuid IS NULL OR (created_at, trace_id) < ($7, $8)) +ORDER BY created_at DESC, trace_id DESC +LIMIT $9 +", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id_filter) + .bind(read_profile) + .bind(req.created_after) + .bind(req.created_before) + .bind(cursor_created_at) + .bind(cursor_trace_id) + .bind(fetch_limit as i64) + .fetch_all(&self.db.pool) + .await?; + let next_cursor = if rows.len() > limit as usize { + let cursor_row = &rows[limit as usize - 1]; + + Some(TraceRecentCursor { + created_at: cursor_row.created_at, + trace_id: cursor_row.trace_id, + }) + } else { + None + }; + let mut response_rows = rows; + + response_rows.truncate(limit as usize); + + let mut traces = Vec::with_capacity(response_rows.len()); + + for row in response_rows { + traces.push(RecentTraceHeader { + trace_id: row.trace_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + read_profile: row.read_profile, + query: row.query, + created_at: row.created_at, + }); + } + + Ok(TraceRecentListResponse { + schema: RECENT_TRACES_SCHEMA_V1.to_string(), + traces, + next_cursor, + }) + } + + /// Loads a trace bundle with optional trajectory and replay candidates. + pub async fn trace_bundle_get( + &self, + req: TraceBundleGetRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + + if req.agent_id.trim().is_empty() { + return Err(Error::InvalidRequest { message: "agent_id is required.".to_string() }); + } + if tenant_id.is_empty() || project_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id and project_id are required.".to_string(), + }); + } + + let base = self + .trace_get(TraceGetRequest { + tenant_id: tenant_id.to_string(), + project_id: project_id.to_string(), + agent_id: req.agent_id.trim().to_string(), + trace_id: req.trace_id, + }) + .await?; + let default_stage_items_limit = match req.mode { + TraceBundleMode::Bounded => DEFAULT_BOUNDED_STAGE_ITEMS_LIMIT, + TraceBundleMode::Full => DEFAULT_FULL_STAGE_ITEMS_LIMIT, + }; + let default_candidates_limit = match req.mode { + TraceBundleMode::Bounded => DEFAULT_BOUNDED_CANDIDATES_LIMIT, + TraceBundleMode::Full => DEFAULT_FULL_CANDIDATES_LIMIT, + }; + let stage_items_limit = req + .stage_items_limit + .unwrap_or(default_stage_items_limit) + .min(MAX_TRACE_BUNDLE_ITEMS_LIMIT); + let candidates_limit = req + .candidates_limit + .unwrap_or(default_candidates_limit) + .min(MAX_TRACE_BUNDLE_CANDIDATES_LIMIT); + let mut stages = search::load_trace_trajectory_stages(&self.db.pool, req.trace_id).await?; + + for stage in stages.iter_mut() { + stage.items.truncate(stage_items_limit as usize); + } + + let candidates = if candidates_limit == 0 { + None + } else { + let candidate_rows = sqlx::query_as::<_, TraceCandidateSnapshotRow>( + "\ +SELECT candidate_snapshot +FROM search_trace_candidates +WHERE trace_id = $1 +ORDER BY retrieval_rank ASC, candidate_id ASC +LIMIT $2 +", + ) + .bind(req.trace_id) + .bind(candidates_limit as i32) + .fetch_all(&self.db.pool) + .await?; + let mut candidates = Vec::with_capacity(candidate_rows.len()); + + for row in candidate_rows { + candidates + .push(ranking::decode_json(row.candidate_snapshot, "candidate_snapshot")?); + } + + if candidates.is_empty() { None } else { Some(candidates) } + }; + + Ok(TraceBundleResponse { + schema: TRACE_BUNDLE_SCHEMA_V1.to_string(), + generated_at: OffsetDateTime::now_utc(), + trace: base.trace, + items: base.items, + trajectory_summary: base.trajectory_summary, + stages, + candidates, + }) + } +} diff --git a/packages/elf-service/src/search/trace_persistence.rs b/packages/elf-service/src/search/trace_persistence.rs new file mode 100644 index 00000000..461b3516 --- /dev/null +++ b/packages/elf-service/src/search/trace_persistence.rs @@ -0,0 +1,292 @@ +use crate::{ + Error, + search::{ + OffsetDateTime, PgConnection, PgExecutor, QueryBuilder, Result, TraceCandidateRecord, + TraceItemRecord, TracePayload, TraceRecord, TraceTrajectoryStageRecord, Uuid, + }, +}; + +pub(super) async fn enqueue_trace<'e, E>(executor: E, payload: TracePayload) -> Result<()> +where + E: PgExecutor<'e>, +{ + let now = OffsetDateTime::now_utc(); + let payload_json = serde_json::to_value(&payload).map_err(|err| Error::Storage { + message: format!("Failed to encode search trace payload: {err}"), + })?; + + sqlx::query( + "\ +INSERT INTO search_trace_outbox ( + outbox_id, + trace_id, + status, + attempts, + last_error, + available_at, + payload, + created_at, + updated_at +) +VALUES ($1, $2, 'PENDING', 0, NULL, $3, $4, $3, $3)", + ) + .bind(Uuid::new_v4()) + .bind(payload.trace.trace_id) + .bind(now) + .bind(payload_json) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn persist_trace_inline( + executor: &mut PgConnection, + payload: TracePayload, +) -> Result<()> { + let trace = payload.trace; + let items = payload.items; + let candidates = payload.candidates; + let stages = payload.stages; + let trace_id = trace.trace_id; + + persist_trace_inline_header(executor, &trace).await?; + persist_trace_inline_items(executor, trace_id, items).await?; + persist_trace_inline_stages(executor, trace_id, stages).await?; + persist_trace_inline_candidates(executor, trace_id, candidates).await?; + + Ok(()) +} + +pub(super) async fn persist_trace_inline_stages( + executor: &mut PgConnection, + trace_id: Uuid, + stages: Vec, +) -> Result<()> { + if stages.is_empty() { + return Ok(()); + } + + let mut item_records = Vec::new(); + let mut stage_builder = QueryBuilder::new( + "\ +INSERT INTO search_trace_stages ( + stage_id, + trace_id, + stage_order, + stage_name, + stage_payload, + created_at +) ", + ); + + stage_builder.push_values(stages, |mut b, stage| { + for item in stage.items { + item_records.push((stage.stage_id, item)); + } + + b.push_bind(stage.stage_id) + .push_bind(trace_id) + .push_bind(stage.stage_order as i32) + .push_bind(stage.stage_name) + .push_bind(stage.stage_payload) + .push_bind(stage.created_at); + }); + stage_builder.push(" ON CONFLICT (stage_id) DO NOTHING"); + stage_builder.build().execute(&mut *executor).await?; + + if item_records.is_empty() { + return Ok(()); + } + + let mut item_builder = QueryBuilder::new( + "\ +INSERT INTO search_trace_stage_items ( + id, + stage_id, + item_id, + note_id, + chunk_id, + metrics +) ", + ); + + item_builder.push_values(item_records, |mut b, (stage_id, item)| { + b.push_bind(item.id) + .push_bind(stage_id) + .push_bind(item.item_id) + .push_bind(item.note_id) + .push_bind(item.chunk_id) + .push_bind(item.metrics); + }); + item_builder.push(" ON CONFLICT (id) DO NOTHING"); + item_builder.build().execute(executor).await?; + + Ok(()) +} + +pub(super) async fn persist_trace_inline_header( + executor: &mut PgConnection, + trace: &TraceRecord, +) -> Result<()> { + let expanded_queries_json = serde_json::to_value(&trace.expanded_queries).map_err(|err| { + Error::Storage { message: format!("Failed to encode expanded_queries: {err}") } + })?; + let allowed_scopes_json = serde_json::to_value(&trace.allowed_scopes).map_err(|err| { + Error::Storage { message: format!("Failed to encode allowed_scopes: {err}") } + })?; + + sqlx::query( + "\ +INSERT INTO search_traces ( + trace_id, + tenant_id, + project_id, + agent_id, + read_profile, + query, + expansion_mode, + expanded_queries, + allowed_scopes, + candidate_count, + top_k, + config_snapshot, + trace_version, + created_at, + expires_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15 +) + ON CONFLICT (trace_id) DO NOTHING", + ) + .bind(trace.trace_id) + .bind(trace.tenant_id.as_str()) + .bind(trace.project_id.as_str()) + .bind(trace.agent_id.as_str()) + .bind(trace.read_profile.as_str()) + .bind(trace.query.as_str()) + .bind(trace.expansion_mode.as_str()) + .bind(expanded_queries_json) + .bind(allowed_scopes_json) + .bind(trace.candidate_count as i32) + .bind(trace.top_k as i32) + .bind(trace.config_snapshot.clone()) + .bind(trace.trace_version) + .bind(trace.created_at) + .bind(trace.expires_at) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn persist_trace_inline_items( + executor: &mut PgConnection, + trace_id: Uuid, + items: Vec, +) -> Result<()> { + if items.is_empty() { + return Ok(()); + } + + let mut builder = QueryBuilder::new( + "\ +INSERT INTO search_trace_items ( + item_id, + trace_id, + note_id, + chunk_id, + rank, + final_score, + explain +) ", + ); + + builder.push_values(items, |mut b, item| { + let explain_json = + serde_json::to_value(item.explain).expect("SearchExplain must be JSON-serializable."); + + b.push_bind(item.item_id) + .push_bind(trace_id) + .push_bind(item.note_id) + .push_bind(item.chunk_id) + .push_bind(item.rank as i32) + .push_bind(item.final_score) + .push_bind(explain_json); + }); + + builder.push(" ON CONFLICT (item_id) DO NOTHING"); + builder.build().execute(executor).await?; + + Ok(()) +} + +pub(super) async fn persist_trace_inline_candidates( + executor: &mut PgConnection, + trace_id: Uuid, + candidates: Vec, +) -> Result<()> { + if candidates.is_empty() { + return Ok(()); + } + + let mut builder = QueryBuilder::new( + "\ +INSERT INTO search_trace_candidates ( + candidate_id, + trace_id, + note_id, + chunk_id, + chunk_index, + snippet, + candidate_snapshot, + retrieval_rank, + rerank_score, + note_scope, + note_importance, + note_updated_at, + note_hit_count, + note_last_hit_at, + created_at, + expires_at +) ", + ); + + builder.push_values(candidates, |mut b, candidate| { + b.push_bind(candidate.candidate_id) + .push_bind(trace_id) + .push_bind(candidate.note_id) + .push_bind(candidate.chunk_id) + .push_bind(candidate.chunk_index) + .push_bind(candidate.snippet) + .push_bind(candidate.candidate_snapshot) + .push_bind(candidate.retrieval_rank as i32) + .push_bind(candidate.rerank_score) + .push_bind(candidate.note_scope) + .push_bind(candidate.note_importance) + .push_bind(candidate.note_updated_at) + .push_bind(candidate.note_hit_count) + .push_bind(candidate.note_last_hit_at) + .push_bind(candidate.created_at) + .push_bind(candidate.expires_at); + }); + builder.push(" ON CONFLICT (candidate_id) DO NOTHING"); + builder.build().execute(executor).await?; + + Ok(()) +} diff --git a/packages/elf-service/src/search/trace_stages.rs b/packages/elf-service/src/search/trace_stages.rs new file mode 100644 index 00000000..4b559dc7 --- /dev/null +++ b/packages/elf-service/src/search/trace_stages.rs @@ -0,0 +1,229 @@ +use crate::search::{ + self, BuildTraceArgs, MAX_TRAJECTORY_STAGE_ITEMS, SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, + TraceTrajectoryStageItemRecord, TraceTrajectoryStageRecord, Uuid, Value, ranking, +}; + +pub(super) fn build_trace_audit(actor_id: &str, token_id: Option<&str>) -> Value { + match token_id.map(str::trim).filter(|value| !value.is_empty()) { + Some(token_id) => serde_json::json!({ "actor_id": actor_id, "token_id": token_id }), + None => serde_json::json!({ "actor_id": actor_id }), + } +} + +pub(super) fn build_trace_trajectory_stages( + args: &BuildTraceArgs<'_>, +) -> Vec { + let path_label = search::raw_search_path_label(args.path); + + vec![ + build_trace_rewrite_stage(args, path_label), + build_trace_recall_stage(args, path_label), + build_trace_fusion_stage(args, path_label), + build_trace_rerank_stage(args, path_label), + build_trace_final_stage(args, path_label), + ] +} + +pub(super) fn build_trace_rewrite_stage( + args: &BuildTraceArgs<'_>, + path_label: &str, +) -> TraceTrajectoryStageRecord { + let expanded_queries = search::sorted_unique_strings(args.expanded_queries.clone()); + + TraceTrajectoryStageRecord { + stage_id: Uuid::new_v4(), + stage_order: 1, + stage_name: "rewrite.expansion".to_string(), + stage_payload: serde_json::json!({ + "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, + "path": path_label, + "inputs": { + "query": args.query, + "expansion_mode": ranking::expansion_mode_label(args.expansion_mode), + }, + "outputs": { + "expanded_queries": expanded_queries, + }, + "stats": { + "expanded_query_count": args.expanded_queries.len(), + }, + }), + created_at: args.now, + items: Vec::new(), + } +} + +pub(super) fn build_trace_recall_stage( + args: &BuildTraceArgs<'_>, + path_label: &str, +) -> TraceTrajectoryStageRecord { + let mut stage_payload = serde_json::json!({ + "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, + "path": path_label, + "stats": { + "candidate_count_before_filter": args.candidate_count, + "candidate_count_after_filter": args.filtered_candidate_count, + "snippet_count": args.snippet_count, + }, + }); + + if let Some(filter_impact) = &args.filter_impact + && let Some(payload) = stage_payload.as_object_mut() + { + payload.insert("filter_impact".to_string(), filter_impact.to_stage_payload()); + } + if let Some(recursive_retrieval) = args.recursive_retrieval + && recursive_retrieval.enabled + && let Some(payload) = stage_payload.as_object_mut() + { + payload.insert( + "recursive".to_string(), + serde_json::json!({ + "enabled": true, + "scopes_seeded": recursive_retrieval.scopes_seeded, + "scopes_queried": recursive_retrieval.scopes_queried, + "candidates_before": recursive_retrieval.candidates_before, + "candidates_added": recursive_retrieval.candidates_added, + "candidates_after": recursive_retrieval.candidates_after, + "rounds_executed": recursive_retrieval.rounds_executed, + "total_queries": recursive_retrieval.total_queries, + "stop_reason": recursive_retrieval + .stop_reason + .clone() + .unwrap_or_else(|| "converged".to_string()), + }), + ); + } + + let items: Vec = args + .recall_candidates + .iter() + .take(MAX_TRAJECTORY_STAGE_ITEMS) + .map(|candidate| TraceTrajectoryStageItemRecord { + id: Uuid::new_v4(), + item_id: None, + note_id: Some(candidate.note_id), + chunk_id: Some(candidate.chunk_id), + metrics: serde_json::json!({ + "retrieval_rank": candidate.retrieval_rank, + "chunk_index": candidate.chunk_index, + }), + }) + .collect(); + + TraceTrajectoryStageRecord { + stage_id: Uuid::new_v4(), + stage_order: 2, + stage_name: "recall.candidates".to_string(), + stage_payload, + created_at: args.now, + items, + } +} + +pub(super) fn build_trace_fusion_stage( + args: &BuildTraceArgs<'_>, + path_label: &str, +) -> TraceTrajectoryStageRecord { + let items: Vec = args + .fused_results + .iter() + .take(MAX_TRAJECTORY_STAGE_ITEMS) + .map(|scored| TraceTrajectoryStageItemRecord { + id: Uuid::new_v4(), + item_id: None, + note_id: Some(scored.item.note.note_id), + chunk_id: Some(scored.item.chunk.chunk_id), + metrics: serde_json::json!({ + "retrieval_rank": scored.item.retrieval_rank, + "final_score": scored.final_score, + }), + }) + .collect(); + + TraceTrajectoryStageRecord { + stage_id: Uuid::new_v4(), + stage_order: 3, + stage_name: "fusion.merge".to_string(), + stage_payload: serde_json::json!({ + "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, + "path": path_label, + "stats": { + "scored_count": args.scored_count, + "fused_count": args.fused_count, + }, + "decisions": { + "fusion_weight": args.policies.retrieval_sources_policy.fusion_weight, + "structured_field_weight": args.policies.retrieval_sources_policy.structured_field_weight, + "fusion_priority": args.policies.retrieval_sources_policy.fusion_priority, + "structured_field_priority": args.policies.retrieval_sources_policy.structured_field_priority, + }, + }), + created_at: args.now, + items, + } +} + +pub(super) fn build_trace_rerank_stage( + args: &BuildTraceArgs<'_>, + path_label: &str, +) -> TraceTrajectoryStageRecord { + let items: Vec = args + .fused_results + .iter() + .take(MAX_TRAJECTORY_STAGE_ITEMS) + .map(|scored| TraceTrajectoryStageItemRecord { + id: Uuid::new_v4(), + item_id: None, + note_id: Some(scored.item.note.note_id), + chunk_id: Some(scored.item.chunk.chunk_id), + metrics: serde_json::json!({ + "rerank_score": scored.rerank_score, + "rerank_rank": scored.rerank_rank, + "rerank_norm": scored.rerank_norm, + "retrieval_norm": scored.retrieval_norm, + "final_score": scored.final_score, + }), + }) + .collect(); + + TraceTrajectoryStageRecord { + stage_id: Uuid::new_v4(), + stage_order: 4, + stage_name: "rerank.score".to_string(), + stage_payload: serde_json::json!({ + "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, + "path": path_label, + "stats": { + "reranked_count": args.scored_count, + }, + "decisions": { + "blend_enabled": args.policies.blend_policy.enabled, + "diversity_enabled": args.policies.diversity_policy.enabled, + }, + }), + created_at: args.now, + items, + } +} + +pub(super) fn build_trace_final_stage( + args: &BuildTraceArgs<'_>, + path_label: &str, +) -> TraceTrajectoryStageRecord { + TraceTrajectoryStageRecord { + stage_id: Uuid::new_v4(), + stage_order: 5, + stage_name: "selection.final".to_string(), + stage_payload: serde_json::json!({ + "schema": SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, + "path": path_label, + "stats": { + "selected_count": args.selected_count, + "top_k": args.top_k, + }, + }), + created_at: args.now, + items: Vec::new(), + } +} diff --git a/packages/elf-service/src/search/trajectory_loaders.rs b/packages/elf-service/src/search/trajectory_loaders.rs new file mode 100644 index 00000000..a0324042 --- /dev/null +++ b/packages/elf-service/src/search/trajectory_loaders.rs @@ -0,0 +1,177 @@ +use sqlx::Row; + +use crate::search::{ + self, HashMap, PgPool, Result, SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1, SearchExplainTrajectory, + SearchExplainTrajectoryMatch, SearchExplainTrajectoryStage, SearchTrajectoryStage, + SearchTrajectoryStageItem, SearchTrajectorySummary, Uuid, Value, +}; + +pub(super) async fn load_trace_trajectory_summary( + pool: &PgPool, + trace_id: Uuid, +) -> Result> { + let stages = load_trace_trajectory_stages(pool, trace_id).await?; + + if stages.is_empty() { + Ok(None) + } else { + Ok(Some(search::build_trajectory_summary_from_stages(stages.as_slice()))) + } +} + +pub(super) async fn load_trace_trajectory_stages( + pool: &PgPool, + trace_id: Uuid, +) -> Result> { + let rows = sqlx::query( + "\ + SELECT + s.stage_id, + s.stage_order, + s.stage_name, + s.stage_payload, + i.item_id, + i.note_id, + i.chunk_id, + i.metrics +FROM search_trace_stages s +LEFT JOIN search_trace_stage_items i ON i.stage_id = s.stage_id +WHERE s.trace_id = $1 +ORDER BY s.stage_order ASC, i.item_id ASC NULLS LAST, i.note_id ASC NULLS LAST", + ) + .bind(trace_id) + .fetch_all(pool) + .await?; + let mut stages = Vec::new(); + let mut stage_pos_by_id: HashMap = HashMap::new(); + + for row in rows { + let stage_id: Uuid = row.try_get("stage_id")?; + let idx = if let Some(idx) = stage_pos_by_id.get(&stage_id).copied() { + idx + } else { + let stage_order: i32 = row.try_get("stage_order")?; + let stage_name: String = row.try_get("stage_name")?; + let stage_payload: Value = row.try_get("stage_payload")?; + let idx = stages.len(); + + stages.push(SearchTrajectoryStage { + stage_order: stage_order as u32, + stage_name, + stage_payload, + items: Vec::new(), + }); + stage_pos_by_id.insert(stage_id, idx); + + idx + }; + let item_metrics: Option = row.try_get("metrics")?; + + if let Some(metrics) = item_metrics { + stages[idx].items.push(SearchTrajectoryStageItem { + item_id: row.try_get("item_id")?, + note_id: row.try_get("note_id")?, + chunk_id: row.try_get("chunk_id")?, + metrics, + }); + } + } + + Ok(stages) +} + +pub(super) async fn load_item_trajectory( + pool: &PgPool, + trace_id: Uuid, + item_id: Uuid, + note_id: Uuid, + trace_item_chunk_id: Option, +) -> Result> { + let rows = sqlx::query( + "\ +SELECT + s.stage_order, + s.stage_name, + s.stage_payload, + i.item_id, + i.note_id, + i.chunk_id, + i.metrics +FROM search_trace_stages s +LEFT JOIN search_trace_stage_items i + ON i.stage_id = s.stage_id + AND ( + i.item_id = $2 + OR ( + i.item_id IS NULL + AND i.note_id = $3 + AND ($4 IS NULL OR i.chunk_id = $4) + ) + ) +WHERE s.trace_id = $1 +ORDER BY s.stage_order ASC, i.item_id ASC NULLS LAST, i.note_id ASC NULLS LAST", + ) + .bind(trace_id) + .bind(item_id) + .bind(note_id) + .bind(trace_item_chunk_id) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + return Ok(None); + } + + let mut stages = Vec::with_capacity(rows.len()); + let mut stage_pos_by_order: HashMap = HashMap::new(); + + for row in rows { + let stage_order: i32 = row.try_get("stage_order")?; + let stage_name: String = row.try_get("stage_name")?; + let stage_payload: Value = row.try_get("stage_payload")?; + let stage_order = stage_order as u32; + let idx = if let Some(idx) = stage_pos_by_order.get(&stage_order).copied() { + idx + } else { + let idx = stages.len(); + + stages.push(SearchExplainTrajectoryStage { + stage_order, + stage_name, + stage_payload, + metrics: serde_json::json!({}), + match_info: None, + }); + stage_pos_by_order.insert(stage_order, idx); + + idx + }; + let item_metrics: Option = row.try_get("metrics")?; + let matched_item_id: Option = row.try_get("item_id")?; + let matched_note_id: Option = row.try_get("note_id")?; + let matched_chunk_id: Option = row.try_get("chunk_id")?; + + if let Some(metrics) = item_metrics { + let match_kind = if matched_item_id.is_some() { + "item_id" + } else if trace_item_chunk_id.is_some() { + "note_chunk" + } else { + "note" + }; + + stages[idx].match_info = Some(SearchExplainTrajectoryMatch { + kind: match_kind.to_string(), + item_id: matched_item_id, + note_id: matched_note_id, + chunk_id: matched_chunk_id, + }); + stages[idx].metrics = metrics; + } + } + + Ok(Some(SearchExplainTrajectory { + schema: SEARCH_RETRIEVAL_TRAJECTORY_SCHEMA_V1.to_string(), + stages, + })) +} diff --git a/packages/elf-service/src/service.rs b/packages/elf-service/src/service.rs new file mode 100644 index 00000000..f487fd45 --- /dev/null +++ b/packages/elf-service/src/service.rs @@ -0,0 +1,26 @@ +use crate::Providers; +use elf_config::Config; +use elf_storage::{db::Db, qdrant::QdrantStore}; + +/// Main service container for ELF request handling. +pub struct ElfService { + /// Repository configuration snapshot. + pub cfg: Config, + /// Postgres storage handle. + pub db: Db, + /// Qdrant storage handle. + pub qdrant: QdrantStore, + /// External model-provider adapters. + pub providers: Providers, +} +impl ElfService { + /// Builds a service with the default provider adapters. + pub fn new(cfg: Config, db: Db, qdrant: QdrantStore) -> Self { + Self { cfg, db, qdrant, providers: Providers::default() } + } + + /// Builds a service with explicit provider adapters. + pub fn with_providers(cfg: Config, db: Db, qdrant: QdrantStore, providers: Providers) -> Self { + Self { cfg, db, qdrant, providers } + } +} diff --git a/packages/elf-service/src/sharing.rs b/packages/elf-service/src/sharing.rs index 7687f723..a8899ab4 100644 --- a/packages/elf-service/src/sharing.rs +++ b/packages/elf-service/src/sharing.rs @@ -1,723 +1,13 @@ //! Cross-agent sharing APIs. -use std::fmt::{Display, Formatter}; - -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -use crate::{ - ElfService, Error, InsertVersionArgs, - access::{self, ORG_PROJECT_ID}, +mod grants; +mod publish; +mod sql; +mod types; + +pub use types::{ + GranteeKind, PublishNoteRequest, PublishNoteResponse, ShareScope, SpaceGrantItem, + SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, + SpaceGrantUpsertResponse, SpaceGrantsListRequest, SpaceGrantsListResponse, + UnpublishNoteRequest, UnpublishNoteResponse, }; -use elf_storage::models::MemoryNote; - -const PROJECT_SPACE_GRANT_UPSERT_SQL: &str = "\ -INSERT INTO memory_space_grants ( - grant_id, - tenant_id, - project_id, - scope, - space_owner_agent_id, - grantee_kind, - grantee_agent_id, - granted_by_agent_id, - granted_at -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9 -) -ON CONFLICT (tenant_id, project_id, scope, space_owner_agent_id) -WHERE revoked_at IS NULL AND grantee_kind = 'project' -DO UPDATE -SET - granted_by_agent_id = EXCLUDED.granted_by_agent_id, - granted_at = EXCLUDED.granted_at, - revoked_at = NULL, - revoked_by_agent_id = NULL"; -const AGENT_SPACE_GRANT_UPSERT_SQL: &str = "\ -INSERT INTO memory_space_grants ( - grant_id, - tenant_id, - project_id, - scope, - space_owner_agent_id, - grantee_kind, - grantee_agent_id, - granted_by_agent_id, - granted_at -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9 -) -ON CONFLICT (tenant_id, project_id, scope, space_owner_agent_id, grantee_agent_id) -WHERE revoked_at IS NULL AND grantee_kind = 'agent' -DO UPDATE -SET - granted_by_agent_id = EXCLUDED.granted_by_agent_id, - granted_at = EXCLUDED.granted_at, - revoked_at = NULL, - revoked_by_agent_id = NULL"; - -/// Shareable scopes that can be published or granted. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ShareScope { - /// Project-shared scope. - ProjectShared, - /// Organization-shared scope. - OrgShared, -} -impl ShareScope { - fn as_str(&self) -> &'static str { - match self { - Self::ProjectShared => "project_shared", - Self::OrgShared => "org_shared", - } - } -} - -impl Display for ShareScope { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - self.as_str().fmt(f) - } -} - -/// Grantee classes supported by space grants. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GranteeKind { - /// Grant the scope to all project readers. - Project, - /// Grant the scope to one named agent. - Agent, -} - -/// Request payload for publishing a note into a shared scope. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct PublishNoteRequest { - /// Tenant that owns the note. - pub tenant_id: String, - /// Project that owns the note. - pub project_id: String, - /// Agent requesting the publish operation. - pub agent_id: String, - /// Identifier of the note to publish. - pub note_id: Uuid, - /// Target shared scope. - pub scope: ShareScope, -} - -/// Response payload for note publishing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct PublishNoteResponse { - /// Identifier of the affected note. - pub note_id: Uuid, - /// Effective scope after publishing. - pub scope: String, -} - -/// Request payload for returning a note to its non-shared scope. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UnpublishNoteRequest { - /// Tenant that owns the note. - pub tenant_id: String, - /// Project that owns the note. - pub project_id: String, - /// Agent requesting the unpublish operation. - pub agent_id: String, - /// Identifier of the note to unpublish. - pub note_id: Uuid, -} - -/// Response payload for note unpublishing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UnpublishNoteResponse { - /// Identifier of the affected note. - pub note_id: Uuid, - /// Effective scope after unpublishing. - pub scope: String, -} - -/// Request payload for granting a shared scope. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantUpsertRequest { - /// Tenant that owns the scope. - pub tenant_id: String, - /// Project that owns the scope. - pub project_id: String, - /// Agent requesting the grant. - pub agent_id: String, - /// Shared scope to grant. - pub scope: ShareScope, - /// Grantee class. - pub grantee_kind: GranteeKind, - /// Grantee agent identifier when `grantee_kind` is `agent`. - pub grantee_agent_id: Option, -} - -/// Response payload for grant upsert. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantUpsertResponse { - /// Granted scope. - pub scope: String, - /// Grantee class. - pub grantee_kind: GranteeKind, - /// Grantee agent identifier when applicable. - pub grantee_agent_id: Option, - /// Whether a grant row is active after the operation. - pub granted: bool, -} - -/// Request payload for revoking a shared-scope grant. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantRevokeRequest { - /// Tenant that owns the scope. - pub tenant_id: String, - /// Project that owns the scope. - pub project_id: String, - /// Agent requesting the revoke operation. - pub agent_id: String, - /// Shared scope to revoke. - pub scope: ShareScope, - /// Grantee class. - pub grantee_kind: GranteeKind, - /// Grantee agent identifier when `grantee_kind` is `agent`. - pub grantee_agent_id: Option, -} - -/// Response payload for grant revocation. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantRevokeResponse { - /// Whether an active grant was revoked. - pub revoked: bool, -} - -/// Request payload for listing shared-scope grants. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantsListRequest { - /// Tenant that owns the scope. - pub tenant_id: String, - /// Project that owns the scope. - pub project_id: String, - /// Agent requesting the list. - pub agent_id: String, - /// Shared scope to inspect. - pub scope: ShareScope, -} - -/// One active space grant returned by `space_grants_list`. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantItem { - /// Granted scope. - pub scope: ShareScope, - /// Grantee class. - pub grantee_kind: GranteeKind, - /// Grantee agent identifier when applicable. - pub grantee_agent_id: Option, - /// Agent that created the grant. - pub granted_by_agent_id: String, - /// Grant creation timestamp. - pub granted_at: time::OffsetDateTime, -} - -/// Response payload for grant listing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpaceGrantsListResponse { - /// Active grants visible to the caller. - pub grants: Vec, -} - -impl ElfService { - /// Publishes an owned note into a shared scope. - pub async fn publish_note( - &self, - req: PublishNoteRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let mut tx = self.db.pool.begin().await?; - let mut note: MemoryNote = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 - AND tenant_id = $2 - AND project_id IN ($3, $4) -FOR UPDATE", - ) - .bind(req.note_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| Error::InvalidRequest { message: "Note not found.".to_string() })?; - - if note.agent_id != agent_id { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - } - if note.status != "active" { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - } - if note.expires_at.map(|ts| ts <= time::OffsetDateTime::now_utc()).unwrap_or(false) { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - } - - let scope = req.scope.as_str(); - let scope_allowed = match scope { - "project_shared" => self.cfg.scopes.write_allowed.project_shared, - "org_shared" => self.cfg.scopes.write_allowed.org_shared, - _ => false, - }; - - if !scope_allowed { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - - let target_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; - - access::ensure_active_project_scope_grant( - &mut *tx, - tenant_id, - target_project_id, - scope, - agent_id, - ) - .await?; - - if note.scope == scope && note.project_id == target_project_id { - return Ok(PublishNoteResponse { note_id: note.note_id, scope: note.scope }); - } - - let now = time::OffsetDateTime::now_utc(); - let prev_snapshot = crate::note_snapshot(¬e); - - note.scope = scope.to_string(); - note.project_id = target_project_id.to_string(); - note.updated_at = now; - - crate::insert_version( - &mut *tx, - InsertVersionArgs { - note_id: note.note_id, - op: "PUBLISH", - prev_snapshot: Some(prev_snapshot), - new_snapshot: Some(crate::note_snapshot(¬e)), - reason: "publish_note", - actor: agent_id, - ts: now, - }, - ) - .await?; - sqlx::query( - "UPDATE memory_notes SET scope = $1, project_id = $2, updated_at = $3 WHERE note_id = $4", - ) - .bind(scope) - .bind(note.project_id.as_str()) - .bind(now) - .bind(note.note_id) - .execute(&mut *tx) - .await?; - crate::enqueue_outbox_tx(&mut *tx, note.note_id, "UPSERT", ¬e.embedding_version, now) - .await?; - - tx.commit().await?; - - Ok(PublishNoteResponse { note_id: note.note_id, scope: note.scope }) - } - - /// Returns a previously published note to its non-shared scope. - pub async fn unpublish_note( - &self, - req: UnpublishNoteRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let mut tx = self.db.pool.begin().await?; - let mut note: MemoryNote = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 - AND tenant_id = $2 - AND project_id IN ($3, $4) -FOR UPDATE", - ) - .bind(req.note_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| Error::InvalidRequest { message: "Note not found.".to_string() })?; - - if note.agent_id != agent_id { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - } - if note.status != "active" { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - } - if note.expires_at.map(|ts| ts <= time::OffsetDateTime::now_utc()).unwrap_or(false) { - return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); - } - if !self.cfg.scopes.write_allowed.agent_private { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - if note.scope == "agent_private" { - return Ok(UnpublishNoteResponse { note_id: note.note_id, scope: note.scope }); - } - - let now = time::OffsetDateTime::now_utc(); - let prev_snapshot = crate::note_snapshot(¬e); - - if note.scope == "org_shared" && note.project_id == ORG_PROJECT_ID { - note.project_id = project_id.to_string(); - } - - note.scope = "agent_private".to_string(); - note.updated_at = now; - - crate::insert_version( - &mut *tx, - InsertVersionArgs { - note_id: note.note_id, - op: "UNPUBLISH", - prev_snapshot: Some(prev_snapshot), - new_snapshot: Some(crate::note_snapshot(¬e)), - reason: "unpublish_note", - actor: agent_id, - ts: now, - }, - ) - .await?; - sqlx::query( - "UPDATE memory_notes SET scope = $1, project_id = $2, updated_at = $3 WHERE note_id = $4", - ) - .bind(note.scope.as_str()) - .bind(note.project_id.as_str()) - .bind(now) - .bind(note.note_id) - .execute(&mut *tx) - .await?; - crate::enqueue_outbox_tx(&mut *tx, note.note_id, "UPSERT", ¬e.embedding_version, now) - .await?; - - tx.commit().await?; - - Ok(UnpublishNoteResponse { note_id: note.note_id, scope: note.scope }) - } - - /// Creates or reactivates a shared-scope grant. - pub async fn space_grant_upsert( - &self, - req: SpaceGrantUpsertRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let scope = req.scope.as_str(); - let scope_allowed = match scope { - "project_shared" => self.cfg.scopes.write_allowed.project_shared, - "org_shared" => self.cfg.scopes.write_allowed.org_shared, - _ => false, - }; - - if !scope_allowed { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - if req.grantee_kind == GranteeKind::Agent - && req.grantee_agent_id.as_ref().is_none_or(|id| id.trim().is_empty()) - { - return Err(Error::InvalidRequest { - message: "grantee_agent_id is required for agent grantee_kind.".to_string(), - }); - } - - let grantee_agent_id = req - .grantee_agent_id - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - - if req.grantee_kind == GranteeKind::Project && grantee_agent_id.is_some() { - return Err(Error::InvalidRequest { - message: "grantee_agent_id must be empty for project grantee_kind.".to_string(), - }); - } - - let grantee_agent_id_ref = grantee_agent_id.as_deref(); - let now = time::OffsetDateTime::now_utc(); - let effective_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; - - if req.grantee_kind == GranteeKind::Project { - self.upsert_project_grant(tenant_id, effective_project_id, scope, agent_id, now) - .await?; - } else { - self.upsert_agent_grant( - tenant_id, - effective_project_id, - scope, - agent_id, - grantee_agent_id_ref, - now, - ) - .await?; - } - - Ok(SpaceGrantUpsertResponse { - scope: scope.to_string(), - grantee_kind: req.grantee_kind, - grantee_agent_id, - granted: true, - }) - } - - async fn upsert_project_grant( - &self, - tenant_id: &str, - project_id: &str, - scope: &str, - agent_id: &str, - now: time::OffsetDateTime, - ) -> crate::Result<()> { - sqlx::query(PROJECT_SPACE_GRANT_UPSERT_SQL) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(scope) - .bind(agent_id) - .bind("project") - .bind::>(None) - .bind(agent_id) - .bind(now) - .execute(&self.db.pool) - .await?; - - Ok(()) - } - - async fn upsert_agent_grant( - &self, - tenant_id: &str, - project_id: &str, - scope: &str, - agent_id: &str, - grantee_agent_id: Option<&str>, - now: time::OffsetDateTime, - ) -> crate::Result<()> { - sqlx::query(AGENT_SPACE_GRANT_UPSERT_SQL) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(scope) - .bind(agent_id) - .bind("agent") - .bind(grantee_agent_id) - .bind(agent_id) - .bind(now) - .execute(&self.db.pool) - .await?; - - Ok(()) - } - - /// Revokes a shared-scope grant. - pub async fn space_grant_revoke( - &self, - req: SpaceGrantRevokeRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let scope = req.scope.as_str(); - let grantee_agent_id = req - .grantee_agent_id - .as_deref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - - if req.grantee_kind == GranteeKind::Agent && grantee_agent_id.is_none() { - return Err(Error::InvalidRequest { - message: "grantee_agent_id is required for agent grantee_kind.".to_string(), - }); - } - if req.grantee_kind == GranteeKind::Project && grantee_agent_id.is_some() { - return Err(Error::InvalidRequest { - message: "grantee_agent_id must be empty for project grantee_kind.".to_string(), - }); - } - - let scope_allowed = match scope { - "project_shared" => self.cfg.scopes.write_allowed.project_shared, - "org_shared" => self.cfg.scopes.write_allowed.org_shared, - _ => false, - }; - - if !scope_allowed { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - - let effective_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; - let revocation = sqlx::query( - "\ -UPDATE memory_space_grants -SET revoked_at = $7, - revoked_by_agent_id = $8 -WHERE tenant_id = $1 - AND project_id = $2 - AND scope = $3 - AND space_owner_agent_id = $4 - AND grantee_kind = $5 - AND ((grantee_kind = 'project' AND grantee_agent_id IS NULL) - OR (grantee_kind = 'agent' AND grantee_agent_id = $6)) - AND revoked_at IS NULL", - ) - .bind(tenant_id) - .bind(effective_project_id) - .bind(scope) - .bind(agent_id) - .bind(match req.grantee_kind { - GranteeKind::Project => "project", - GranteeKind::Agent => "agent", - }) - .bind(grantee_agent_id) - .bind(time::OffsetDateTime::now_utc()) - .bind(agent_id) - .execute(&self.db.pool) - .await?; - - if revocation.rows_affected() == 0 { - return Err(Error::InvalidRequest { message: "No active grant found.".to_string() }); - } - - Ok(SpaceGrantRevokeResponse { revoked: true }) - } - - /// Lists active grants for a shared scope. - pub async fn space_grants_list( - &self, - req: SpaceGrantsListRequest, - ) -> crate::Result { - let tenant_id = req.tenant_id.trim(); - let project_id = req.project_id.trim(); - let agent_id = req.agent_id.trim(); - - if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, and agent_id are required.".to_string(), - }); - } - - let scope = req.scope.as_str(); - let scope_allowed = match scope { - "project_shared" => self.cfg.scopes.write_allowed.project_shared, - "org_shared" => self.cfg.scopes.write_allowed.org_shared, - _ => false, - }; - - if !scope_allowed { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - - let effective_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; - - #[derive(FromRow)] - struct Row { - scope: String, - grantee_kind: String, - grantee_agent_id: Option, - granted_by_agent_id: String, - granted_at: time::OffsetDateTime, - } - - let rows = sqlx::query_as::<_, Row>( - "\ -SELECT scope, grantee_kind, grantee_agent_id, granted_by_agent_id, granted_at -FROM memory_space_grants -WHERE tenant_id = $1 - AND project_id = $2 - AND space_owner_agent_id = $3 - AND scope = $4 - AND revoked_at IS NULL -ORDER BY granted_at DESC", - ) - .bind(tenant_id) - .bind(effective_project_id) - .bind(agent_id) - .bind(scope) - .fetch_all(&self.db.pool) - .await?; - let mut grants = Vec::with_capacity(rows.len()); - - for row in rows { - let grantee_kind = match row.grantee_kind.as_str() { - "agent" => GranteeKind::Agent, - "project" => GranteeKind::Project, - _ => continue, - }; - let scope = match row.scope.as_str() { - "project_shared" => ShareScope::ProjectShared, - "org_shared" => ShareScope::OrgShared, - _ => continue, - }; - - grants.push(SpaceGrantItem { - scope, - grantee_kind, - grantee_agent_id: row.grantee_agent_id, - granted_by_agent_id: row.granted_by_agent_id, - granted_at: row.granted_at, - }); - } - - Ok(SpaceGrantsListResponse { grants }) - } -} diff --git a/packages/elf-service/src/sharing/grants.rs b/packages/elf-service/src/sharing/grants.rs new file mode 100644 index 00000000..fe5bf0cf --- /dev/null +++ b/packages/elf-service/src/sharing/grants.rs @@ -0,0 +1,298 @@ +use sqlx::FromRow; +use uuid::Uuid; + +use crate::{ + ElfService, Error, Result, + access::ORG_PROJECT_ID, + sharing::{ + sql::{AGENT_SPACE_GRANT_UPSERT_SQL, PROJECT_SPACE_GRANT_UPSERT_SQL}, + types::{ + GranteeKind, ShareScope, SpaceGrantItem, SpaceGrantRevokeRequest, + SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, SpaceGrantUpsertResponse, + SpaceGrantsListRequest, SpaceGrantsListResponse, + }, + }, +}; + +impl ElfService { + /// Creates or reactivates a shared-scope grant. + pub async fn space_grant_upsert( + &self, + req: SpaceGrantUpsertRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let scope = req.scope.as_str(); + let scope_allowed = match scope { + "project_shared" => self.cfg.scopes.write_allowed.project_shared, + "org_shared" => self.cfg.scopes.write_allowed.org_shared, + _ => false, + }; + + if !scope_allowed { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + if req.grantee_kind == GranteeKind::Agent + && req.grantee_agent_id.as_ref().is_none_or(|id| id.trim().is_empty()) + { + return Err(Error::InvalidRequest { + message: "grantee_agent_id is required for agent grantee_kind.".to_string(), + }); + } + + let grantee_agent_id = req + .grantee_agent_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + if req.grantee_kind == GranteeKind::Project && grantee_agent_id.is_some() { + return Err(Error::InvalidRequest { + message: "grantee_agent_id must be empty for project grantee_kind.".to_string(), + }); + } + + let grantee_agent_id_ref = grantee_agent_id.as_deref(); + let now = time::OffsetDateTime::now_utc(); + let effective_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; + + if req.grantee_kind == GranteeKind::Project { + self.upsert_project_grant(tenant_id, effective_project_id, scope, agent_id, now) + .await?; + } else { + self.upsert_agent_grant( + tenant_id, + effective_project_id, + scope, + agent_id, + grantee_agent_id_ref, + now, + ) + .await?; + } + + Ok(SpaceGrantUpsertResponse { + scope: scope.to_string(), + grantee_kind: req.grantee_kind, + grantee_agent_id, + granted: true, + }) + } + + async fn upsert_project_grant( + &self, + tenant_id: &str, + project_id: &str, + scope: &str, + agent_id: &str, + now: time::OffsetDateTime, + ) -> Result<()> { + sqlx::query(PROJECT_SPACE_GRANT_UPSERT_SQL) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(scope) + .bind(agent_id) + .bind("project") + .bind::>(None) + .bind(agent_id) + .bind(now) + .execute(&self.db.pool) + .await?; + + Ok(()) + } + + async fn upsert_agent_grant( + &self, + tenant_id: &str, + project_id: &str, + scope: &str, + agent_id: &str, + grantee_agent_id: Option<&str>, + now: time::OffsetDateTime, + ) -> Result<()> { + sqlx::query(AGENT_SPACE_GRANT_UPSERT_SQL) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(scope) + .bind(agent_id) + .bind("agent") + .bind(grantee_agent_id) + .bind(agent_id) + .bind(now) + .execute(&self.db.pool) + .await?; + + Ok(()) + } + + /// Revokes a shared-scope grant. + pub async fn space_grant_revoke( + &self, + req: SpaceGrantRevokeRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let scope = req.scope.as_str(); + let grantee_agent_id = req + .grantee_agent_id + .as_deref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + if req.grantee_kind == GranteeKind::Agent && grantee_agent_id.is_none() { + return Err(Error::InvalidRequest { + message: "grantee_agent_id is required for agent grantee_kind.".to_string(), + }); + } + if req.grantee_kind == GranteeKind::Project && grantee_agent_id.is_some() { + return Err(Error::InvalidRequest { + message: "grantee_agent_id must be empty for project grantee_kind.".to_string(), + }); + } + + let scope_allowed = match scope { + "project_shared" => self.cfg.scopes.write_allowed.project_shared, + "org_shared" => self.cfg.scopes.write_allowed.org_shared, + _ => false, + }; + + if !scope_allowed { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + + let effective_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; + let revocation = sqlx::query( + "\ +UPDATE memory_space_grants +SET revoked_at = $7, + revoked_by_agent_id = $8 +WHERE tenant_id = $1 + AND project_id = $2 + AND scope = $3 + AND space_owner_agent_id = $4 + AND grantee_kind = $5 + AND ((grantee_kind = 'project' AND grantee_agent_id IS NULL) + OR (grantee_kind = 'agent' AND grantee_agent_id = $6)) + AND revoked_at IS NULL", + ) + .bind(tenant_id) + .bind(effective_project_id) + .bind(scope) + .bind(agent_id) + .bind(match req.grantee_kind { + GranteeKind::Project => "project", + GranteeKind::Agent => "agent", + }) + .bind(grantee_agent_id) + .bind(time::OffsetDateTime::now_utc()) + .bind(agent_id) + .execute(&self.db.pool) + .await?; + + if revocation.rows_affected() == 0 { + return Err(Error::InvalidRequest { message: "No active grant found.".to_string() }); + } + + Ok(SpaceGrantRevokeResponse { revoked: true }) + } + + /// Lists active grants for a shared scope. + pub async fn space_grants_list( + &self, + req: SpaceGrantsListRequest, + ) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let scope = req.scope.as_str(); + let scope_allowed = match scope { + "project_shared" => self.cfg.scopes.write_allowed.project_shared, + "org_shared" => self.cfg.scopes.write_allowed.org_shared, + _ => false, + }; + + if !scope_allowed { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + + let effective_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; + + #[derive(FromRow)] + struct Row { + scope: String, + grantee_kind: String, + grantee_agent_id: Option, + granted_by_agent_id: String, + granted_at: time::OffsetDateTime, + } + + let rows = sqlx::query_as::<_, Row>( + "\ +SELECT scope, grantee_kind, grantee_agent_id, granted_by_agent_id, granted_at +FROM memory_space_grants +WHERE tenant_id = $1 + AND project_id = $2 + AND space_owner_agent_id = $3 + AND scope = $4 + AND revoked_at IS NULL +ORDER BY granted_at DESC", + ) + .bind(tenant_id) + .bind(effective_project_id) + .bind(agent_id) + .bind(scope) + .fetch_all(&self.db.pool) + .await?; + let mut grants = Vec::with_capacity(rows.len()); + + for row in rows { + let grantee_kind = match row.grantee_kind.as_str() { + "agent" => GranteeKind::Agent, + "project" => GranteeKind::Project, + _ => continue, + }; + let scope = match row.scope.as_str() { + "project_shared" => ShareScope::ProjectShared, + "org_shared" => ShareScope::OrgShared, + _ => continue, + }; + + grants.push(SpaceGrantItem { + scope, + grantee_kind, + grantee_agent_id: row.grantee_agent_id, + granted_by_agent_id: row.granted_by_agent_id, + granted_at: row.granted_at, + }); + } + + Ok(SpaceGrantsListResponse { grants }) + } +} diff --git a/packages/elf-service/src/sharing/publish.rs b/packages/elf-service/src/sharing/publish.rs new file mode 100644 index 00000000..80ee853c --- /dev/null +++ b/packages/elf-service/src/sharing/publish.rs @@ -0,0 +1,201 @@ +use time::OffsetDateTime; + +use crate::{ + ElfService, Error, InsertVersionArgs, Result, + access::{self, ORG_PROJECT_ID}, + sharing::types::{ + PublishNoteRequest, PublishNoteResponse, UnpublishNoteRequest, UnpublishNoteResponse, + }, +}; +use elf_storage::models::MemoryNote; + +impl ElfService { + /// Publishes an owned note into a shared scope. + pub async fn publish_note(&self, req: PublishNoteRequest) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let mut tx = self.db.pool.begin().await?; + let mut note: MemoryNote = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 + AND tenant_id = $2 + AND project_id IN ($3, $4) +FOR UPDATE", + ) + .bind(req.note_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| Error::InvalidRequest { message: "Note not found.".to_string() })?; + + if note.agent_id != agent_id { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + } + if note.status != "active" { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + } + if note.expires_at.map(|ts| ts <= OffsetDateTime::now_utc()).unwrap_or(false) { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + } + + let scope = req.scope.as_str(); + let scope_allowed = match scope { + "project_shared" => self.cfg.scopes.write_allowed.project_shared, + "org_shared" => self.cfg.scopes.write_allowed.org_shared, + _ => false, + }; + + if !scope_allowed { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + + let target_project_id = if scope == "org_shared" { ORG_PROJECT_ID } else { project_id }; + + access::ensure_active_project_scope_grant( + &mut *tx, + tenant_id, + target_project_id, + scope, + agent_id, + ) + .await?; + + if note.scope == scope && note.project_id == target_project_id { + return Ok(PublishNoteResponse { note_id: note.note_id, scope: note.scope }); + } + + let now = OffsetDateTime::now_utc(); + let prev_snapshot = crate::note_snapshot(¬e); + + note.scope = scope.to_string(); + note.project_id = target_project_id.to_string(); + note.updated_at = now; + + crate::insert_version( + &mut *tx, + InsertVersionArgs { + note_id: note.note_id, + op: "PUBLISH", + prev_snapshot: Some(prev_snapshot), + new_snapshot: Some(crate::note_snapshot(¬e)), + reason: "publish_note", + actor: agent_id, + ts: now, + }, + ) + .await?; + sqlx::query( + "UPDATE memory_notes SET scope = $1, project_id = $2, updated_at = $3 WHERE note_id = $4", + ) + .bind(scope) + .bind(note.project_id.as_str()) + .bind(now) + .bind(note.note_id) + .execute(&mut *tx) + .await?; + crate::enqueue_outbox_tx(&mut *tx, note.note_id, "UPSERT", ¬e.embedding_version, now) + .await?; + + tx.commit().await?; + + Ok(PublishNoteResponse { note_id: note.note_id, scope: note.scope }) + } + + /// Returns a previously published note to its non-shared scope. + pub async fn unpublish_note(&self, req: UnpublishNoteRequest) -> Result { + let tenant_id = req.tenant_id.trim(); + let project_id = req.project_id.trim(); + let agent_id = req.agent_id.trim(); + + if tenant_id.is_empty() || project_id.is_empty() || agent_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, and agent_id are required.".to_string(), + }); + } + + let mut tx = self.db.pool.begin().await?; + let mut note: MemoryNote = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 + AND tenant_id = $2 + AND project_id IN ($3, $4) +FOR UPDATE", + ) + .bind(req.note_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| Error::InvalidRequest { message: "Note not found.".to_string() })?; + + if note.agent_id != agent_id { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + } + if note.status != "active" { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + } + if note.expires_at.map(|ts| ts <= OffsetDateTime::now_utc()).unwrap_or(false) { + return Err(Error::InvalidRequest { message: "Note not found.".to_string() }); + } + if !self.cfg.scopes.write_allowed.agent_private { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + if note.scope == "agent_private" { + return Ok(UnpublishNoteResponse { note_id: note.note_id, scope: note.scope }); + } + + let now = OffsetDateTime::now_utc(); + let prev_snapshot = crate::note_snapshot(¬e); + + if note.scope == "org_shared" && note.project_id == ORG_PROJECT_ID { + note.project_id = project_id.to_string(); + } + + note.scope = "agent_private".to_string(); + note.updated_at = now; + + crate::insert_version( + &mut *tx, + InsertVersionArgs { + note_id: note.note_id, + op: "UNPUBLISH", + prev_snapshot: Some(prev_snapshot), + new_snapshot: Some(crate::note_snapshot(¬e)), + reason: "unpublish_note", + actor: agent_id, + ts: now, + }, + ) + .await?; + sqlx::query( + "UPDATE memory_notes SET scope = $1, project_id = $2, updated_at = $3 WHERE note_id = $4", + ) + .bind(note.scope.as_str()) + .bind(note.project_id.as_str()) + .bind(now) + .bind(note.note_id) + .execute(&mut *tx) + .await?; + crate::enqueue_outbox_tx(&mut *tx, note.note_id, "UPSERT", ¬e.embedding_version, now) + .await?; + + tx.commit().await?; + + Ok(UnpublishNoteResponse { note_id: note.note_id, scope: note.scope }) + } +} diff --git a/packages/elf-service/src/sharing/sql.rs b/packages/elf-service/src/sharing/sql.rs new file mode 100644 index 00000000..b1b72397 --- /dev/null +++ b/packages/elf-service/src/sharing/sql.rs @@ -0,0 +1,62 @@ +pub(super) const PROJECT_SPACE_GRANT_UPSERT_SQL: &str = "\ +INSERT INTO memory_space_grants ( + grant_id, + tenant_id, + project_id, + scope, + space_owner_agent_id, + grantee_kind, + grantee_agent_id, + granted_by_agent_id, + granted_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +ON CONFLICT (tenant_id, project_id, scope, space_owner_agent_id) +WHERE revoked_at IS NULL AND grantee_kind = 'project' +DO UPDATE +SET + granted_by_agent_id = EXCLUDED.granted_by_agent_id, + granted_at = EXCLUDED.granted_at, + revoked_at = NULL, + revoked_by_agent_id = NULL"; +pub(super) const AGENT_SPACE_GRANT_UPSERT_SQL: &str = "\ +INSERT INTO memory_space_grants ( + grant_id, + tenant_id, + project_id, + scope, + space_owner_agent_id, + grantee_kind, + grantee_agent_id, + granted_by_agent_id, + granted_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +ON CONFLICT (tenant_id, project_id, scope, space_owner_agent_id, grantee_agent_id) +WHERE revoked_at IS NULL AND grantee_kind = 'agent' +DO UPDATE +SET + granted_by_agent_id = EXCLUDED.granted_by_agent_id, + granted_at = EXCLUDED.granted_at, + revoked_at = NULL, + revoked_by_agent_id = NULL"; diff --git a/packages/elf-service/src/sharing/types.rs b/packages/elf-service/src/sharing/types.rs new file mode 100644 index 00000000..b75c8e23 --- /dev/null +++ b/packages/elf-service/src/sharing/types.rs @@ -0,0 +1,174 @@ +use std::fmt::{Display, Formatter}; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Shareable scopes that can be published or granted. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShareScope { + /// Project-shared scope. + ProjectShared, + /// Organization-shared scope. + OrgShared, +} +impl ShareScope { + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::ProjectShared => "project_shared", + Self::OrgShared => "org_shared", + } + } +} + +impl Display for ShareScope { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +/// Grantee classes supported by space grants. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GranteeKind { + /// Grant the scope to all project readers. + Project, + /// Grant the scope to one named agent. + Agent, +} + +/// Request payload for publishing a note into a shared scope. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PublishNoteRequest { + /// Tenant that owns the note. + pub tenant_id: String, + /// Project that owns the note. + pub project_id: String, + /// Agent requesting the publish operation. + pub agent_id: String, + /// Identifier of the note to publish. + pub note_id: Uuid, + /// Target shared scope. + pub scope: ShareScope, +} + +/// Response payload for note publishing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PublishNoteResponse { + /// Identifier of the affected note. + pub note_id: Uuid, + /// Effective scope after publishing. + pub scope: String, +} + +/// Request payload for returning a note to its non-shared scope. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UnpublishNoteRequest { + /// Tenant that owns the note. + pub tenant_id: String, + /// Project that owns the note. + pub project_id: String, + /// Agent requesting the unpublish operation. + pub agent_id: String, + /// Identifier of the note to unpublish. + pub note_id: Uuid, +} + +/// Response payload for note unpublishing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UnpublishNoteResponse { + /// Identifier of the affected note. + pub note_id: Uuid, + /// Effective scope after unpublishing. + pub scope: String, +} + +/// Request payload for granting a shared scope. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantUpsertRequest { + /// Tenant that owns the scope. + pub tenant_id: String, + /// Project that owns the scope. + pub project_id: String, + /// Agent requesting the grant. + pub agent_id: String, + /// Shared scope to grant. + pub scope: ShareScope, + /// Grantee class. + pub grantee_kind: GranteeKind, + /// Grantee agent identifier when `grantee_kind` is `agent`. + pub grantee_agent_id: Option, +} + +/// Response payload for grant upsert. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantUpsertResponse { + /// Granted scope. + pub scope: String, + /// Grantee class. + pub grantee_kind: GranteeKind, + /// Grantee agent identifier when applicable. + pub grantee_agent_id: Option, + /// Whether a grant row is active after the operation. + pub granted: bool, +} + +/// Request payload for revoking a shared-scope grant. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantRevokeRequest { + /// Tenant that owns the scope. + pub tenant_id: String, + /// Project that owns the scope. + pub project_id: String, + /// Agent requesting the revoke operation. + pub agent_id: String, + /// Shared scope to revoke. + pub scope: ShareScope, + /// Grantee class. + pub grantee_kind: GranteeKind, + /// Grantee agent identifier when `grantee_kind` is `agent`. + pub grantee_agent_id: Option, +} + +/// Response payload for grant revocation. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantRevokeResponse { + /// Whether an active grant was revoked. + pub revoked: bool, +} + +/// Request payload for listing shared-scope grants. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantsListRequest { + /// Tenant that owns the scope. + pub tenant_id: String, + /// Project that owns the scope. + pub project_id: String, + /// Agent requesting the list. + pub agent_id: String, + /// Shared scope to inspect. + pub scope: ShareScope, +} + +/// One active space grant returned by `space_grants_list`. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantItem { + /// Granted scope. + pub scope: ShareScope, + /// Grantee class. + pub grantee_kind: GranteeKind, + /// Grantee agent identifier when applicable. + pub grantee_agent_id: Option, + /// Agent that created the grant. + pub granted_by_agent_id: String, + /// Grant creation timestamp. + pub granted_at: OffsetDateTime, +} + +/// Response payload for grant listing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SpaceGrantsListResponse { + /// Active grants visible to the caller. + pub grants: Vec, +} diff --git a/packages/elf-service/src/structured_fields.rs b/packages/elf-service/src/structured_fields.rs index 075de2bd..755eedf6 100644 --- a/packages/elf-service/src/structured_fields.rs +++ b/packages/elf-service/src/structured_fields.rs @@ -1,716 +1,13 @@ //! Structured-field validation and persistence helpers. -use std::{collections::HashMap, slice}; +mod persistence; +mod types; +mod validation; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{PgConnection, PgPool}; -use time::OffsetDateTime; -use uuid::Uuid; +pub use self::{ + persistence::{fetch_structured_fields, upsert_structured_fields_tx}, + types::{StructuredEntity, StructuredFields, StructuredRelation, StructuredRelationObject}, + validation::{event_evidence_quotes, validate_structured_fields}, +}; -use crate::{Error, Result}; -use elf_domain::{english_gate, evidence}; - -const MAX_LIST_ITEMS: usize = 64; -const MAX_ENTITIES: usize = 32; -const MAX_RELATIONS: usize = 64; -const MAX_ALIASES: usize = 16; -const MAX_ITEM_CHARS: usize = 1_000; - -/// Structured note fields emitted by extraction and stored alongside a note. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct StructuredFields { - /// Optional one-paragraph summary. - pub summary: Option, - /// Optional fact statements grounded in the note text. - pub facts: Option>, - /// Optional concept labels grounded in the note text. - pub concepts: Option>, - /// Optional graph entities extracted from the note. - pub entities: Option>, - /// Optional graph relations extracted from the note. - pub relations: Option>, -} -impl StructuredFields { - /// Returns `true` when no persisted summary, fact, or concept content is present. - pub fn is_effectively_empty(&self) -> bool { - let summary_empty = self.summary.as_ref().map(|v| v.trim().is_empty()).unwrap_or(true); - let facts_empty = self - .facts - .as_ref() - .map(|items| items.iter().all(|v| v.trim().is_empty())) - .unwrap_or(true); - let concepts_empty = self - .concepts - .as_ref() - .map(|items| items.iter().all(|v| v.trim().is_empty())) - .unwrap_or(true); - - summary_empty && facts_empty && concepts_empty - } - - /// Returns `true` when graph entities or relations are present. - pub fn has_graph_fields(&self) -> bool { - self.entities.as_ref().is_some_and(|entities| !entities.is_empty()) - || self.relations.as_ref().is_some_and(|relations| !relations.is_empty()) - } -} - -/// One extracted entity candidate. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct StructuredEntity { - /// Canonical surface for the entity. - pub canonical: Option, - /// Optional entity kind such as person or organization. - pub kind: Option, - /// Optional alternate surfaces for the entity. - pub aliases: Option>, -} - -/// One extracted relation candidate. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(default)] -pub struct StructuredRelation { - /// Relation subject entity. - pub subject: Option, - /// Predicate surface for the relation. - pub predicate: Option, - /// Relation object, either an entity or scalar value. - pub object: Option, - #[serde(with = "crate::time_serde::option")] - /// Optional validity-window start. - pub valid_from: Option, - #[serde(with = "crate::time_serde::option")] - /// Optional validity-window end. - pub valid_to: Option, -} - -/// Extracted relation object. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct StructuredRelationObject { - /// Entity-shaped object value. - pub entity: Option, - /// Scalar object value. - pub value: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct SourceRefEvidenceQuote { - quote: String, -} - -/// Validates structured fields against note text, evidence bindings, and size limits. -pub fn validate_structured_fields( - structured: &StructuredFields, - note_text: &str, - source_ref: &Value, - add_event_evidence: Option<&[(usize, String)]>, -) -> Result<()> { - let evidence_quotes: Vec = if let Some(event_evidence) = add_event_evidence { - event_evidence.iter().map(|(_, quote)| quote.clone()).collect() - } else { - extract_source_ref_quotes(source_ref) - }; - - if let Some(summary) = structured.summary.as_ref() { - validate_text_field(summary, "structured.summary")?; - } - if let Some(entities) = structured.entities.as_ref() { - validate_list_field_count(entities.len(), MAX_ENTITIES, "structured.entities")?; - - for (idx, entity) in entities.iter().enumerate() { - let base = format!("structured.entities[{idx}]"); - - validate_structured_entity(entity, &base, true)?; - } - } - if let Some(relations) = structured.relations.as_ref() { - validate_list_field_count(relations.len(), MAX_RELATIONS, "structured.relations")?; - - for (idx, relation) in relations.iter().enumerate() { - validate_structured_relation( - relation, - note_text, - &evidence_quotes, - &format!("structured.relations[{idx}]"), - )?; - } - } - if let Some(facts) = structured.facts.as_ref() { - validate_list_field(facts, "structured.facts")?; - - for (idx, fact) in facts.iter().enumerate() { - validate_text_field(fact, &format!("structured.facts[{idx}]"))?; - - if !fact_is_evidence_bound(fact, note_text, &evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!( - "structured.facts[{idx}] is not supported by note text or evidence quotes." - ), - }); - } - } - } - if let Some(concepts) = structured.concepts.as_ref() { - validate_list_field(concepts, "structured.concepts")?; - - for (idx, concept) in concepts.iter().enumerate() { - validate_text_field(concept, &format!("structured.concepts[{idx}]"))?; - } - } - - Ok(()) -} - -/// Validates event-evidence quotes against their source messages. -pub fn event_evidence_quotes(messages: &[String], evidence: &[(usize, String)]) -> Result<()> { - for (idx, (message_index, quote)) in evidence.iter().enumerate() { - if quote.trim().is_empty() { - return Err(Error::InvalidRequest { - message: format!("evidence[{idx}].quote must not be empty."), - }); - } - if !evidence::evidence_matches(messages, *message_index, quote) { - return Err(Error::InvalidRequest { - message: format!("evidence[{idx}] does not match its source message."), - }); - } - } - - Ok(()) -} - -/// Upserts summary, fact, and concept fields for one note inside an existing transaction. -pub async fn upsert_structured_fields_tx( - executor: &mut PgConnection, - note_id: Uuid, - structured: &StructuredFields, - now: OffsetDateTime, -) -> Result<()> { - if let Some(summary) = structured.summary.as_ref() { - replace_kind(executor, note_id, "summary", slice_single(summary), now).await?; - } - if let Some(facts) = structured.facts.as_ref() { - replace_kind(executor, note_id, "fact", facts.as_slice(), now).await?; - } - if let Some(concepts) = structured.concepts.as_ref() { - replace_kind(executor, note_id, "concept", concepts.as_slice(), now).await?; - } - - Ok(()) -} - -/// Fetches persisted structured fields for the provided note identifiers. -pub async fn fetch_structured_fields( - pool: &PgPool, - note_ids: &[Uuid], -) -> Result> { - if note_ids.is_empty() { - return Ok(HashMap::new()); - } - - let rows = sqlx::query_as::<_, (Uuid, String, i32, String)>( - "\ -SELECT - note_id, - field_kind, - item_index, - text -FROM memory_note_fields -WHERE note_id = ANY($1::uuid[]) -ORDER BY note_id ASC, field_kind ASC, item_index ASC", - ) - .bind(note_ids.to_vec()) - .fetch_all(pool) - .await?; - let mut out: HashMap = HashMap::new(); - - for row in rows { - let (note_id, field_kind, _item_index, text) = row; - let entry = out.entry(note_id).or_default(); - - match field_kind.as_str() { - "summary" => - if entry.summary.is_none() && !text.trim().is_empty() { - entry.summary = Some(text); - }, - "fact" => { - entry.facts.get_or_insert_with(Vec::new).push(text); - }, - "concept" => { - entry.concepts.get_or_insert_with(Vec::new).push(text); - }, - _ => {}, - } - } - - out.retain(|_, value| !value.is_effectively_empty()); - - Ok(out) -} - -fn validate_structured_entity( - entity: &StructuredEntity, - base: &str, - require_canonical: bool, -) -> Result<()> { - if require_canonical { - validate_required_text_field(entity.canonical.as_ref(), &format!("{base}.canonical"))?; - } - - if let Some(kind) = entity.kind.as_ref() { - validate_text_field(kind, &format!("{base}.kind"))?; - } - if let Some(aliases) = entity.aliases.as_ref() { - validate_list_field_count(aliases.len(), MAX_ALIASES, &format!("{base}.aliases"))?; - - for (alias_idx, alias) in aliases.iter().enumerate() { - validate_text_field(alias, &format!("{base}.aliases[{alias_idx}]"))?; - } - } - - Ok(()) -} - -fn validate_structured_relation( - relation: &StructuredRelation, - note_text: &str, - evidence_quotes: &[String], - base: &str, -) -> Result<()> { - if relation.predicate.is_none() { - return Err(Error::InvalidRequest { message: format!("{base}.predicate is required.") }); - } - - let subject = relation - .subject - .as_ref() - .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.subject is required.") })?; - - validate_structured_entity(subject, &format!("{base}.subject"), true)?; - - let predicate = relation.predicate.as_ref().ok_or_else(|| Error::InvalidRequest { - message: format!("{base}.predicate is required."), - })?; - - validate_text_field(predicate, &format!("{base}.predicate"))?; - - let object = relation - .object - .as_ref() - .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.object is required.") })?; - - match (&object.entity, object.value.as_ref()) { - (Some(entity), None) => { - validate_structured_entity(entity, &format!("{base}.object.entity"), true)?; - - let canonical = entity.canonical.as_deref().ok_or_else(|| Error::InvalidRequest { - message: format!("{base}.object.entity.canonical is required."), - })?; - - if !fact_is_evidence_bound(canonical, note_text, evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!( - "{base}.object.entity.canonical is not supported by note text or evidence quotes." - ), - }); - } - }, - (None, Some(value)) => { - validate_text_field(value, &format!("{base}.object.value"))?; - - if !fact_is_evidence_bound(value, note_text, evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!( - "{base}.object.value is not supported by note text or evidence quotes." - ), - }); - } - }, - (_, _) => { - return Err(Error::InvalidRequest { - message: format!("{base}.object must provide exactly one of entity or value."), - }); - }, - } - - if !fact_is_evidence_bound( - subject.canonical.as_deref().unwrap_or_default(), - note_text, - evidence_quotes, - ) { - return Err(Error::InvalidRequest { - message: format!( - "{base}.subject.canonical is not supported by note text or evidence quotes." - ), - }); - } - if !fact_is_evidence_bound(predicate, note_text, evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!("{base}.predicate is not supported by note text or evidence quotes."), - }); - } - - if let (Some(valid_from), Some(valid_to)) = (relation.valid_from, relation.valid_to) - && valid_to <= valid_from - { - return Err(Error::InvalidRequest { - message: format!("{base}.valid_to must be greater than valid_from."), - }); - } - - Ok(()) -} - -fn validate_list_field(items: &[String], label: &str) -> Result<()> { - if items.len() > MAX_LIST_ITEMS { - return Err(Error::InvalidRequest { - message: format!("{label} must have at most {MAX_LIST_ITEMS} items."), - }); - } - - Ok(()) -} - -fn validate_text_field(value: &str, label: &str) -> Result<()> { - let trimmed = value.trim(); - - if trimmed.is_empty() { - return Err(Error::InvalidRequest { message: format!("{label} must not be empty.") }); - } - if trimmed.chars().count() > MAX_ITEM_CHARS { - return Err(Error::InvalidRequest { - message: format!("{label} must be at most {MAX_ITEM_CHARS} characters."), - }); - } - if !english_gate::is_english_natural_language(trimmed) { - return Err(Error::NonEnglishInput { field: label.to_string() }); - } - - Ok(()) -} - -fn validate_required_text_field(value: Option<&String>, label: &str) -> Result<()> { - let Some(value) = value else { - return Err(Error::InvalidRequest { message: format!("{label} is required.") }); - }; - - validate_text_field(value, label) -} - -fn validate_list_field_count(len: usize, max: usize, label: &str) -> Result<()> { - if len > max { - return Err(Error::InvalidRequest { - message: format!("{label} must have at most {max} items."), - }); - } - - Ok(()) -} - -fn extract_source_ref_quotes(source_ref: &Value) -> Vec { - let Some(evidence) = source_ref.get("evidence") else { return Vec::new() }; - let Ok(quotes) = serde_json::from_value::>(evidence.clone()) else { - return Vec::new(); - }; - - quotes.into_iter().map(|q| q.quote).collect() -} - -fn fact_is_evidence_bound(fact: &str, note_text: &str, evidence_quotes: &[String]) -> bool { - let trimmed = fact.trim(); - - if trimmed.is_empty() { - return false; - } - if note_text.contains(trimmed) { - return true; - } - - for quote in evidence_quotes { - if quote.contains(trimmed) { - return true; - } - } - - false -} - -fn slice_single(value: &String) -> &[String] { - slice::from_ref(value) -} - -async fn replace_kind( - executor: &mut PgConnection, - note_id: Uuid, - kind: &str, - items: &[String], - now: OffsetDateTime, -) -> Result<()> { - sqlx::query("DELETE FROM memory_note_fields WHERE note_id = $1 AND field_kind = $2") - .bind(note_id) - .bind(kind) - .execute(&mut *executor) - .await?; - - for (idx, value) in items.iter().enumerate() { - let trimmed = value.trim(); - - if trimmed.is_empty() { - continue; - } - - sqlx::query( - "\ -INSERT INTO memory_note_fields ( - field_id, - note_id, - field_kind, - item_index, - text, - created_at, - updated_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7)", - ) - .bind(Uuid::new_v4()) - .bind(note_id) - .bind(kind) - .bind(idx as i32) - .bind(trimmed) - .bind(now) - .bind(now) - .execute(&mut *executor) - .await?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use time::OffsetDateTime; - - use crate::{ - Error, - structured_fields::{ - self, StructuredEntity, StructuredFields, StructuredRelation, StructuredRelationObject, - }, - }; - - fn structured_relation( - subject: &str, - predicate: &str, - object: StructuredRelationObject, - valid_from: Option, - valid_to: Option, - ) -> StructuredFields { - StructuredFields { - summary: None, - facts: None, - concepts: None, - entities: None, - relations: Some(vec![StructuredRelation { - subject: Some(StructuredEntity { - canonical: Some(subject.to_string()), - kind: None, - aliases: None, - }), - predicate: Some(predicate.to_string()), - object: Some(object), - valid_from, - valid_to, - }]), - } - } - - #[test] - fn fact_binding_accepts_note_text_substring() { - let structured = StructuredFields { - summary: None, - facts: Some(vec!["Deploy uses reranking".to_string()]), - concepts: None, - entities: None, - relations: None, - }; - let res = structured_fields::validate_structured_fields( - &structured, - "Deploy uses reranking after retrieval.", - &serde_json::json!({}), - None, - ); - - assert!(res.is_ok()); - } - - #[test] - fn fact_binding_rejects_without_text_or_evidence() { - let structured = StructuredFields { - summary: None, - facts: Some(vec!["Nonexistent claim.".to_string()]), - concepts: None, - entities: None, - relations: None, - }; - let res = structured_fields::validate_structured_fields( - &structured, - "Some note.", - &serde_json::json!({}), - None, - ); - - assert!(res.is_err()); - } - - #[test] - fn relation_object_requires_exactly_one_of_entity_or_value() { - let structured = structured_relation( - "alice", - "owns", - StructuredRelationObject { - entity: Some(StructuredEntity { - canonical: Some("Acme".to_string()), - kind: None, - aliases: None, - }), - value: Some("Acme corp".to_string()), - }, - None, - None, - ); - let res = structured_fields::validate_structured_fields( - &structured, - "alice owns Acme corp.", - &serde_json::json!({ - "evidence": [{"quote": "alice owns Acme"}] - }), - None, - ); - let err = res.expect_err("relation should reject object with both entity and value"); - let message = match err { - Error::InvalidRequest { message } => message, - _ => panic!("expected invalid request, got {err:?}"), - }; - - assert_eq!( - message, - "structured.relations[0].object must provide exactly one of entity or value." - ); - } - - #[test] - fn relation_rejects_valid_to_not_after_valid_from() { - let structured = structured_relation( - "alice", - "met", - StructuredRelationObject { entity: None, value: Some("bob".to_string()) }, - Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp")), - Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp")), - ); - let res = structured_fields::validate_structured_fields( - &structured, - "alice met bob", - &serde_json::json!({ - "evidence": [{"quote": "alice met bob"}] - }), - None, - ); - let err = res.expect_err("relation should require valid_to greater than valid_from"); - let message = match err { - Error::InvalidRequest { message } => message, - _ => panic!("expected invalid request, got {err:?}"), - }; - - assert_eq!(message, "structured.relations[0].valid_to must be greater than valid_from."); - } - - #[test] - fn relation_checks_subject_predicate_and_object_value_are_evidence_bound() { - let subject_message = match structured_fields::validate_structured_fields( - &structured_relation( - "alice", - "caused", - StructuredRelationObject { entity: None, value: Some("outage".to_string()) }, - None, - None, - ), - "a critical outage was logged.", - &serde_json::json!({"evidence": [{"quote": "caused an outage"}]}), - None, - ) { - Err(Error::InvalidRequest { message }) => message, - res => panic!("expected invalid request, got {res:?}"), - }; - - assert!( - subject_message.contains("structured.relations[0].subject.canonical is not supported") - ); - - let predicate_message = match structured_fields::validate_structured_fields( - &structured_relation( - "operator", - "discovered", - StructuredRelationObject { entity: None, value: Some("outage".to_string()) }, - None, - None, - ), - "operator monitored a system outage.", - &serde_json::json!({"evidence": [{"quote": "operator saw outage"}]}), - None, - ) { - Err(Error::InvalidRequest { message }) => message, - res => panic!("expected invalid request, got {res:?}"), - }; - - assert!(predicate_message.contains("structured.relations[0].predicate is not supported")); - - let object_message = match structured_fields::validate_structured_fields( - &structured_relation( - "operator", - "noticed", - StructuredRelationObject { - entity: None, - value: Some("service interruption".to_string()), - }, - None, - None, - ), - "The operator noticed service latency during testing.", - &serde_json::json!({"evidence": [{"quote": "The operator noticed service behavior"}]}), - None, - ) { - Err(Error::InvalidRequest { message }) => message, - res => panic!("expected invalid request, got {res:?}"), - }; - - assert!(object_message.contains("structured.relations[0].object.value is not supported")); - } - - #[test] - fn relation_accepts_valid_structured_relation() { - let structured = structured_relation( - "alice", - "works at", - StructuredRelationObject { - entity: Some(StructuredEntity { - canonical: Some("acme corp".to_string()), - kind: None, - aliases: None, - }), - value: None, - }, - Some(OffsetDateTime::from_unix_timestamp(1_699_900_000).expect("valid timestamp")), - Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp")), - ); - let res = structured_fields::validate_structured_fields( - &structured, - "alice works at acme corp and reported progress.", - &serde_json::json!({ - "evidence": [{"quote": "works at acme corp"}] - }), - None, - ); - - assert!(res.is_ok()); - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/structured_fields/persistence.rs b/packages/elf-service/src/structured_fields/persistence.rs new file mode 100644 index 00000000..988970b9 --- /dev/null +++ b/packages/elf-service/src/structured_fields/persistence.rs @@ -0,0 +1,127 @@ +use std::{collections::HashMap, slice}; + +use sqlx::{PgConnection, PgPool}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{Result, structured_fields::types::StructuredFields}; + +/// Upserts summary, fact, and concept fields for one note inside an existing transaction. +pub async fn upsert_structured_fields_tx( + executor: &mut PgConnection, + note_id: Uuid, + structured: &StructuredFields, + now: OffsetDateTime, +) -> Result<()> { + if let Some(summary) = structured.summary.as_ref() { + replace_kind(executor, note_id, "summary", slice_single(summary), now).await?; + } + if let Some(facts) = structured.facts.as_ref() { + replace_kind(executor, note_id, "fact", facts.as_slice(), now).await?; + } + if let Some(concepts) = structured.concepts.as_ref() { + replace_kind(executor, note_id, "concept", concepts.as_slice(), now).await?; + } + + Ok(()) +} + +/// Fetches persisted structured fields for the provided note identifiers. +pub async fn fetch_structured_fields( + pool: &PgPool, + note_ids: &[Uuid], +) -> Result> { + if note_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows = sqlx::query_as::<_, (Uuid, String, i32, String)>( + "\ +SELECT + note_id, + field_kind, + item_index, + text +FROM memory_note_fields +WHERE note_id = ANY($1::uuid[]) +ORDER BY note_id ASC, field_kind ASC, item_index ASC", + ) + .bind(note_ids.to_vec()) + .fetch_all(pool) + .await?; + let mut out: HashMap = HashMap::new(); + + for row in rows { + let (note_id, field_kind, _item_index, text) = row; + let entry = out.entry(note_id).or_default(); + + match field_kind.as_str() { + "summary" => + if entry.summary.is_none() && !text.trim().is_empty() { + entry.summary = Some(text); + }, + "fact" => { + entry.facts.get_or_insert_with(Vec::new).push(text); + }, + "concept" => { + entry.concepts.get_or_insert_with(Vec::new).push(text); + }, + _ => {}, + } + } + + out.retain(|_, value| !value.is_effectively_empty()); + + Ok(out) +} + +fn slice_single(value: &String) -> &[String] { + slice::from_ref(value) +} + +async fn replace_kind( + executor: &mut PgConnection, + note_id: Uuid, + kind: &str, + items: &[String], + now: OffsetDateTime, +) -> Result<()> { + sqlx::query("DELETE FROM memory_note_fields WHERE note_id = $1 AND field_kind = $2") + .bind(note_id) + .bind(kind) + .execute(&mut *executor) + .await?; + + for (idx, value) in items.iter().enumerate() { + let trimmed = value.trim(); + + if trimmed.is_empty() { + continue; + } + + sqlx::query( + "\ +INSERT INTO memory_note_fields ( + field_id, + note_id, + field_kind, + item_index, + text, + created_at, + updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7)", + ) + .bind(Uuid::new_v4()) + .bind(note_id) + .bind(kind) + .bind(idx as i32) + .bind(trimmed) + .bind(now) + .bind(now) + .execute(&mut *executor) + .await?; + } + + Ok(()) +} diff --git a/packages/elf-service/src/structured_fields/tests.rs b/packages/elf-service/src/structured_fields/tests.rs new file mode 100644 index 00000000..36221657 --- /dev/null +++ b/packages/elf-service/src/structured_fields/tests.rs @@ -0,0 +1,222 @@ +use time::OffsetDateTime; + +use crate::{ + Error, + structured_fields::{ + self, StructuredEntity, StructuredFields, StructuredRelation, StructuredRelationObject, + }, +}; + +fn structured_relation( + subject: &str, + predicate: &str, + object: StructuredRelationObject, + valid_from: Option, + valid_to: Option, +) -> StructuredFields { + StructuredFields { + summary: None, + facts: None, + concepts: None, + entities: None, + relations: Some(vec![StructuredRelation { + subject: Some(StructuredEntity { + canonical: Some(subject.to_string()), + kind: None, + aliases: None, + }), + predicate: Some(predicate.to_string()), + object: Some(object), + valid_from, + valid_to, + }]), + } +} + +#[test] +fn fact_binding_accepts_note_text_substring() { + let structured = StructuredFields { + summary: None, + facts: Some(vec!["Deploy uses reranking".to_string()]), + concepts: None, + entities: None, + relations: None, + }; + let res = structured_fields::validate_structured_fields( + &structured, + "Deploy uses reranking after retrieval.", + &serde_json::json!({}), + None, + ); + + assert!(res.is_ok()); +} + +#[test] +fn fact_binding_rejects_without_text_or_evidence() { + let structured = StructuredFields { + summary: None, + facts: Some(vec!["Nonexistent claim.".to_string()]), + concepts: None, + entities: None, + relations: None, + }; + let res = structured_fields::validate_structured_fields( + &structured, + "Some note.", + &serde_json::json!({}), + None, + ); + + assert!(res.is_err()); +} + +#[test] +fn relation_object_requires_exactly_one_of_entity_or_value() { + let structured = structured_relation( + "alice", + "owns", + StructuredRelationObject { + entity: Some(StructuredEntity { + canonical: Some("Acme".to_string()), + kind: None, + aliases: None, + }), + value: Some("Acme corp".to_string()), + }, + None, + None, + ); + let res = structured_fields::validate_structured_fields( + &structured, + "alice owns Acme corp.", + &serde_json::json!({ + "evidence": [{"quote": "alice owns Acme"}] + }), + None, + ); + let err = res.expect_err("relation should reject object with both entity and value"); + let message = match err { + Error::InvalidRequest { message } => message, + _ => panic!("expected invalid request, got {err:?}"), + }; + + assert_eq!( + message, + "structured.relations[0].object must provide exactly one of entity or value." + ); +} + +#[test] +fn relation_rejects_valid_to_not_after_valid_from() { + let structured = structured_relation( + "alice", + "met", + StructuredRelationObject { entity: None, value: Some("bob".to_string()) }, + Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp")), + Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp")), + ); + let res = structured_fields::validate_structured_fields( + &structured, + "alice met bob", + &serde_json::json!({ + "evidence": [{"quote": "alice met bob"}] + }), + None, + ); + let err = res.expect_err("relation should require valid_to greater than valid_from"); + let message = match err { + Error::InvalidRequest { message } => message, + _ => panic!("expected invalid request, got {err:?}"), + }; + + assert_eq!(message, "structured.relations[0].valid_to must be greater than valid_from."); +} + +#[test] +fn relation_checks_subject_predicate_and_object_value_are_evidence_bound() { + let subject_message = match structured_fields::validate_structured_fields( + &structured_relation( + "alice", + "caused", + StructuredRelationObject { entity: None, value: Some("outage".to_string()) }, + None, + None, + ), + "a critical outage was logged.", + &serde_json::json!({"evidence": [{"quote": "caused an outage"}]}), + None, + ) { + Err(Error::InvalidRequest { message }) => message, + res => panic!("expected invalid request, got {res:?}"), + }; + + assert!(subject_message.contains("structured.relations[0].subject.canonical is not supported")); + + let predicate_message = match structured_fields::validate_structured_fields( + &structured_relation( + "operator", + "discovered", + StructuredRelationObject { entity: None, value: Some("outage".to_string()) }, + None, + None, + ), + "operator monitored a system outage.", + &serde_json::json!({"evidence": [{"quote": "operator saw outage"}]}), + None, + ) { + Err(Error::InvalidRequest { message }) => message, + res => panic!("expected invalid request, got {res:?}"), + }; + + assert!(predicate_message.contains("structured.relations[0].predicate is not supported")); + + let object_message = match structured_fields::validate_structured_fields( + &structured_relation( + "operator", + "noticed", + StructuredRelationObject { + entity: None, + value: Some("service interruption".to_string()), + }, + None, + None, + ), + "The operator noticed service latency during testing.", + &serde_json::json!({"evidence": [{"quote": "The operator noticed service behavior"}]}), + None, + ) { + Err(Error::InvalidRequest { message }) => message, + res => panic!("expected invalid request, got {res:?}"), + }; + + assert!(object_message.contains("structured.relations[0].object.value is not supported")); +} + +#[test] +fn relation_accepts_valid_structured_relation() { + let structured = structured_relation( + "alice", + "works at", + StructuredRelationObject { + entity: Some(StructuredEntity { + canonical: Some("acme corp".to_string()), + kind: None, + aliases: None, + }), + value: None, + }, + Some(OffsetDateTime::from_unix_timestamp(1_699_900_000).expect("valid timestamp")), + Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp")), + ); + let res = structured_fields::validate_structured_fields( + &structured, + "alice works at acme corp and reported progress.", + &serde_json::json!({ + "evidence": [{"quote": "works at acme corp"}] + }), + None, + ); + + assert!(res.is_ok()); +} diff --git a/packages/elf-service/src/structured_fields/types.rs b/packages/elf-service/src/structured_fields/types.rs new file mode 100644 index 00000000..7aee7b80 --- /dev/null +++ b/packages/elf-service/src/structured_fields/types.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// Structured note fields emitted by extraction and stored alongside a note. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct StructuredFields { + /// Optional one-paragraph summary. + pub summary: Option, + /// Optional fact statements grounded in the note text. + pub facts: Option>, + /// Optional concept labels grounded in the note text. + pub concepts: Option>, + /// Optional graph entities extracted from the note. + pub entities: Option>, + /// Optional graph relations extracted from the note. + pub relations: Option>, +} +impl StructuredFields { + /// Returns `true` when no persisted summary, fact, or concept content is present. + pub fn is_effectively_empty(&self) -> bool { + let summary_empty = self.summary.as_ref().map(|v| v.trim().is_empty()).unwrap_or(true); + let facts_empty = self + .facts + .as_ref() + .map(|items| items.iter().all(|v| v.trim().is_empty())) + .unwrap_or(true); + let concepts_empty = self + .concepts + .as_ref() + .map(|items| items.iter().all(|v| v.trim().is_empty())) + .unwrap_or(true); + + summary_empty && facts_empty && concepts_empty + } + + /// Returns `true` when graph entities or relations are present. + pub fn has_graph_fields(&self) -> bool { + self.entities.as_ref().is_some_and(|entities| !entities.is_empty()) + || self.relations.as_ref().is_some_and(|relations| !relations.is_empty()) + } +} + +/// One extracted entity candidate. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct StructuredEntity { + /// Canonical surface for the entity. + pub canonical: Option, + /// Optional entity kind such as person or organization. + pub kind: Option, + /// Optional alternate surfaces for the entity. + pub aliases: Option>, +} + +/// One extracted relation candidate. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct StructuredRelation { + /// Relation subject entity. + pub subject: Option, + /// Predicate surface for the relation. + pub predicate: Option, + /// Relation object, either an entity or scalar value. + pub object: Option, + #[serde(with = "crate::time_serde::option")] + /// Optional validity-window start. + pub valid_from: Option, + #[serde(with = "crate::time_serde::option")] + /// Optional validity-window end. + pub valid_to: Option, +} + +/// Extracted relation object. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct StructuredRelationObject { + /// Entity-shaped object value. + pub entity: Option, + /// Scalar object value. + pub value: Option, +} diff --git a/packages/elf-service/src/structured_fields/validation.rs b/packages/elf-service/src/structured_fields/validation.rs new file mode 100644 index 00000000..57dcc9e8 --- /dev/null +++ b/packages/elf-service/src/structured_fields/validation.rs @@ -0,0 +1,287 @@ +use serde::Deserialize; +use serde_json::Value; + +use crate::{ + Error, Result, + structured_fields::types::{StructuredEntity, StructuredFields, StructuredRelation}, +}; +use elf_domain::{english_gate, evidence}; + +const MAX_LIST_ITEMS: usize = 64; +const MAX_ENTITIES: usize = 32; +const MAX_RELATIONS: usize = 64; +const MAX_ALIASES: usize = 16; +const MAX_ITEM_CHARS: usize = 1_000; + +#[derive(Clone, Debug, Deserialize)] +struct SourceRefEvidenceQuote { + quote: String, +} + +/// Validates structured fields against note text, evidence bindings, and size limits. +pub fn validate_structured_fields( + structured: &StructuredFields, + note_text: &str, + source_ref: &Value, + add_event_evidence: Option<&[(usize, String)]>, +) -> Result<()> { + let evidence_quotes: Vec = if let Some(event_evidence) = add_event_evidence { + event_evidence.iter().map(|(_, quote)| quote.clone()).collect() + } else { + extract_source_ref_quotes(source_ref) + }; + + if let Some(summary) = structured.summary.as_ref() { + validate_text_field(summary, "structured.summary")?; + } + if let Some(entities) = structured.entities.as_ref() { + validate_list_field_count(entities.len(), MAX_ENTITIES, "structured.entities")?; + + for (idx, entity) in entities.iter().enumerate() { + let base = format!("structured.entities[{idx}]"); + + validate_structured_entity(entity, &base, true)?; + } + } + if let Some(relations) = structured.relations.as_ref() { + validate_list_field_count(relations.len(), MAX_RELATIONS, "structured.relations")?; + + for (idx, relation) in relations.iter().enumerate() { + validate_structured_relation( + relation, + note_text, + &evidence_quotes, + &format!("structured.relations[{idx}]"), + )?; + } + } + if let Some(facts) = structured.facts.as_ref() { + validate_list_field(facts, "structured.facts")?; + + for (idx, fact) in facts.iter().enumerate() { + validate_text_field(fact, &format!("structured.facts[{idx}]"))?; + + if !fact_is_evidence_bound(fact, note_text, &evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!( + "structured.facts[{idx}] is not supported by note text or evidence quotes." + ), + }); + } + } + } + if let Some(concepts) = structured.concepts.as_ref() { + validate_list_field(concepts, "structured.concepts")?; + + for (idx, concept) in concepts.iter().enumerate() { + validate_text_field(concept, &format!("structured.concepts[{idx}]"))?; + } + } + + Ok(()) +} + +/// Validates event-evidence quotes against their source messages. +pub fn event_evidence_quotes(messages: &[String], evidence: &[(usize, String)]) -> Result<()> { + for (idx, (message_index, quote)) in evidence.iter().enumerate() { + if quote.trim().is_empty() { + return Err(Error::InvalidRequest { + message: format!("evidence[{idx}].quote must not be empty."), + }); + } + if !evidence::evidence_matches(messages, *message_index, quote) { + return Err(Error::InvalidRequest { + message: format!("evidence[{idx}] does not match its source message."), + }); + } + } + + Ok(()) +} + +fn validate_structured_entity( + entity: &StructuredEntity, + base: &str, + require_canonical: bool, +) -> Result<()> { + if require_canonical { + validate_required_text_field(entity.canonical.as_ref(), &format!("{base}.canonical"))?; + } + + if let Some(kind) = entity.kind.as_ref() { + validate_text_field(kind, &format!("{base}.kind"))?; + } + if let Some(aliases) = entity.aliases.as_ref() { + validate_list_field_count(aliases.len(), MAX_ALIASES, &format!("{base}.aliases"))?; + + for (alias_idx, alias) in aliases.iter().enumerate() { + validate_text_field(alias, &format!("{base}.aliases[{alias_idx}]"))?; + } + } + + Ok(()) +} + +fn validate_structured_relation( + relation: &StructuredRelation, + note_text: &str, + evidence_quotes: &[String], + base: &str, +) -> Result<()> { + if relation.predicate.is_none() { + return Err(Error::InvalidRequest { message: format!("{base}.predicate is required.") }); + } + + let subject = relation + .subject + .as_ref() + .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.subject is required.") })?; + + validate_structured_entity(subject, &format!("{base}.subject"), true)?; + + let predicate = relation.predicate.as_ref().ok_or_else(|| Error::InvalidRequest { + message: format!("{base}.predicate is required."), + })?; + + validate_text_field(predicate, &format!("{base}.predicate"))?; + + let object = relation + .object + .as_ref() + .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.object is required.") })?; + + match (&object.entity, object.value.as_ref()) { + (Some(entity), None) => { + validate_structured_entity(entity, &format!("{base}.object.entity"), true)?; + + let canonical = entity.canonical.as_deref().ok_or_else(|| Error::InvalidRequest { + message: format!("{base}.object.entity.canonical is required."), + })?; + + if !fact_is_evidence_bound(canonical, note_text, evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!( + "{base}.object.entity.canonical is not supported by note text or evidence quotes." + ), + }); + } + }, + (None, Some(value)) => { + validate_text_field(value, &format!("{base}.object.value"))?; + + if !fact_is_evidence_bound(value, note_text, evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!( + "{base}.object.value is not supported by note text or evidence quotes." + ), + }); + } + }, + (_, _) => { + return Err(Error::InvalidRequest { + message: format!("{base}.object must provide exactly one of entity or value."), + }); + }, + } + + if !fact_is_evidence_bound( + subject.canonical.as_deref().unwrap_or_default(), + note_text, + evidence_quotes, + ) { + return Err(Error::InvalidRequest { + message: format!( + "{base}.subject.canonical is not supported by note text or evidence quotes." + ), + }); + } + if !fact_is_evidence_bound(predicate, note_text, evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!("{base}.predicate is not supported by note text or evidence quotes."), + }); + } + + if let (Some(valid_from), Some(valid_to)) = (relation.valid_from, relation.valid_to) + && valid_to <= valid_from + { + return Err(Error::InvalidRequest { + message: format!("{base}.valid_to must be greater than valid_from."), + }); + } + + Ok(()) +} + +fn validate_list_field(items: &[String], label: &str) -> Result<()> { + if items.len() > MAX_LIST_ITEMS { + return Err(Error::InvalidRequest { + message: format!("{label} must have at most {MAX_LIST_ITEMS} items."), + }); + } + + Ok(()) +} + +fn validate_text_field(value: &str, label: &str) -> Result<()> { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{label} must not be empty.") }); + } + if trimmed.chars().count() > MAX_ITEM_CHARS { + return Err(Error::InvalidRequest { + message: format!("{label} must be at most {MAX_ITEM_CHARS} characters."), + }); + } + if !english_gate::is_english_natural_language(trimmed) { + return Err(Error::NonEnglishInput { field: label.to_string() }); + } + + Ok(()) +} + +fn validate_required_text_field(value: Option<&String>, label: &str) -> Result<()> { + let Some(value) = value else { + return Err(Error::InvalidRequest { message: format!("{label} is required.") }); + }; + + validate_text_field(value, label) +} + +fn validate_list_field_count(len: usize, max: usize, label: &str) -> Result<()> { + if len > max { + return Err(Error::InvalidRequest { + message: format!("{label} must have at most {max} items."), + }); + } + + Ok(()) +} + +fn extract_source_ref_quotes(source_ref: &Value) -> Vec { + let Some(evidence) = source_ref.get("evidence") else { return Vec::new() }; + let Ok(quotes) = serde_json::from_value::>(evidence.clone()) else { + return Vec::new(); + }; + + quotes.into_iter().map(|q| q.quote).collect() +} + +fn fact_is_evidence_bound(fact: &str, note_text: &str, evidence_quotes: &[String]) -> bool { + let trimmed = fact.trim(); + + if trimmed.is_empty() { + return false; + } + if note_text.contains(trimmed) { + return true; + } + + for quote in evidence_quotes { + if quote.contains(trimmed) { + return true; + } + } + + false +} diff --git a/packages/elf-service/src/update_resolution.rs b/packages/elf-service/src/update_resolution.rs new file mode 100644 index 00000000..2acb4738 --- /dev/null +++ b/packages/elf-service/src/update_resolution.rs @@ -0,0 +1,204 @@ +use sqlx::PgExecutor; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{Error, Providers, Result}; +use elf_config::Config; + +const RESOLVE_UPDATE_QUERY: &str = "\ +WITH key_match AS ( + SELECT note_id + FROM memory_notes + WHERE tenant_id = $1 + AND project_id = $2 + AND agent_id = $3 + AND scope = $4 + AND type = $5 + AND $6::text IS NOT NULL + AND key = $6 + AND status = 'active' + AND (expires_at IS NULL OR expires_at > $7) + LIMIT 1 +), +existing AS ( + SELECT note_id + FROM memory_notes + WHERE tenant_id = $1 + AND project_id = $2 + AND agent_id = $3 + AND scope = $4 + AND type = $5 + AND status = 'active' + AND (expires_at IS NULL OR expires_at > $7) +), +best AS ( + SELECT + note_id, + (1 - (vec <=> $8::text::vector))::real AS similarity + FROM note_embeddings + WHERE note_id = ANY(ARRAY(SELECT note_id FROM existing)) + AND embedding_version = $9 + ORDER BY similarity DESC + LIMIT 1 +) + SELECT + (SELECT note_id FROM key_match) AS key_note_id, + (SELECT note_id FROM best) AS best_note_id, + (SELECT similarity FROM best) AS best_similarity"; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum UpdateDecision { + Add { note_id: Uuid, metadata: UpdateDecisionMetadata }, + Update { note_id: Uuid, metadata: UpdateDecisionMetadata }, + None { note_id: Uuid, metadata: UpdateDecisionMetadata }, +} +impl UpdateDecision { + pub(crate) fn note_id(&self) -> Uuid { + match self { + Self::Add { note_id, .. } + | Self::Update { note_id, .. } + | Self::None { note_id, .. } => *note_id, + } + } + + pub(crate) fn metadata(&self) -> UpdateDecisionMetadata { + match self { + Self::Add { metadata, .. } + | Self::Update { metadata, .. } + | Self::None { metadata, .. } => *metadata, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct UpdateDecisionMetadata { + pub similarity_best: Option, + pub key_match: bool, + pub matched_dup: bool, +} + +pub(crate) struct ResolveUpdateArgs<'a> { + pub(crate) cfg: &'a Config, + pub(crate) providers: &'a Providers, + pub(crate) tenant_id: &'a str, + pub(crate) project_id: &'a str, + pub(crate) agent_id: &'a str, + pub(crate) scope: &'a str, + pub(crate) note_type: &'a str, + pub(crate) key: Option<&'a str>, + pub(crate) text: &'a str, + pub(crate) now: OffsetDateTime, +} + +pub(crate) async fn resolve_update<'e, E>( + executor: E, + args: ResolveUpdateArgs<'_>, +) -> Result +where + E: PgExecutor<'e>, +{ + let ResolveUpdateArgs { + cfg, + providers, + tenant_id, + project_id, + agent_id, + scope, + note_type, + key, + text, + now, + } = args; + let embeddings = + providers.embedding.embed(&cfg.providers.embedding, &[text.to_string()]).await?; + let Some(vec) = embeddings.into_iter().next() else { + return Err(Error::Provider { + message: "Embedding provider returned no vectors.".to_string(), + }); + }; + + if vec.len() != cfg.storage.qdrant.vector_dim as usize { + return Err(Error::Provider { + message: "Embedding vector dimension mismatch.".to_string(), + }); + } + + let vec_text = crate::vector_to_pg(&vec); + let embed_version = crate::embedding_version(cfg); + let key = key.map(|value| value.trim()).filter(|value| !value.is_empty()); + let row: (Option, Option, Option) = sqlx::query_as(RESOLVE_UPDATE_QUERY) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(scope) + .bind(note_type) + .bind(key) + .bind(now) + .bind(vec_text.as_str()) + .bind(embed_version.as_str()) + .fetch_one(executor) + .await?; + let (key_note_id, best_note_id, best_similarity) = row; + + if let Some(note_id) = key_note_id { + return Ok(UpdateDecision::Update { + note_id, + metadata: UpdateDecisionMetadata { + similarity_best: None, + key_match: true, + matched_dup: false, + }, + }); + } + + let Some(best_id) = best_note_id else { + return Ok(UpdateDecision::Add { + note_id: Uuid::new_v4(), + metadata: UpdateDecisionMetadata { + similarity_best: None, + key_match: false, + matched_dup: false, + }, + }); + }; + let Some(best_score) = best_similarity else { + return Ok(UpdateDecision::Add { + note_id: Uuid::new_v4(), + metadata: UpdateDecisionMetadata { + similarity_best: None, + key_match: false, + matched_dup: false, + }, + }); + }; + + if best_score >= cfg.memory.dup_sim_threshold { + return Ok(UpdateDecision::None { + note_id: best_id, + metadata: UpdateDecisionMetadata { + similarity_best: Some(best_score), + key_match: false, + matched_dup: true, + }, + }); + } + if best_score >= cfg.memory.update_sim_threshold { + return Ok(UpdateDecision::Update { + note_id: best_id, + metadata: UpdateDecisionMetadata { + similarity_best: Some(best_score), + key_match: false, + matched_dup: false, + }, + }); + } + + Ok(UpdateDecision::Add { + note_id: Uuid::new_v4(), + metadata: UpdateDecisionMetadata { + similarity_best: Some(best_score), + key_match: false, + matched_dup: false, + }, + }) +} diff --git a/packages/elf-service/src/vectors.rs b/packages/elf-service/src/vectors.rs new file mode 100644 index 00000000..5d9e7d75 --- /dev/null +++ b/packages/elf-service/src/vectors.rs @@ -0,0 +1,53 @@ +use crate::{Error, Result}; +use elf_config::Config; + +pub(crate) fn embedding_version(cfg: &Config) -> String { + format!( + "{}:{}:{}", + cfg.providers.embedding.provider_id, + cfg.providers.embedding.model, + cfg.storage.qdrant.vector_dim + ) +} + +pub(crate) fn vector_to_pg(vec: &[f32]) -> String { + let mut out = String::with_capacity(vec.len() * 8); + + out.push('['); + + for (i, value) in vec.iter().enumerate() { + if i > 0 { + out.push(','); + } + + out.push_str(&value.to_string()); + } + + out.push(']'); + + out +} + +pub(crate) fn parse_pg_vector(text: &str) -> Result> { + let trimmed = text.trim(); + let without_brackets = + trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')).ok_or_else(|| { + Error::InvalidRequest { message: "Vector text is not bracketed.".to_string() } + })?; + + if without_brackets.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut vec = Vec::new(); + + for part in without_brackets.split(',') { + let value: f32 = part.trim().parse().map_err(|_| Error::InvalidRequest { + message: "Vector text contains a non-numeric value.".to_string(), + })?; + + vec.push(value); + } + + Ok(vec) +} diff --git a/packages/elf-service/src/work_journal.rs b/packages/elf-service/src/work_journal.rs index e6c0eec5..ec163581 100644 --- a/packages/elf-service/src/work_journal.rs +++ b/packages/elf-service/src/work_journal.rs @@ -1,1153 +1,13 @@ //! Source-adjacent Work Journal capture and readback APIs. -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; -use serde_json::{self, Map, Value}; -use sqlx::PgConnection; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ - ElfService, Error, Result, - access::{self, ORG_PROJECT_ID, SharedSpaceGrantKey}, - search, -}; -use elf_config::Config; -use elf_domain::{ - english_gate, - writegate::{self, WritePolicy, WritePolicyAudit}, -}; -use elf_storage::{ - consolidation, - models::{MemoryNote, WorkJournalEntry}, - work_journal, +mod service; +mod types; +mod validation; + +pub use types::{ + ELF_WORK_JOURNAL_SCHEMA_V1, WorkJournalEntryCreateRequest, WorkJournalEntryCreateResponse, + WorkJournalEntryFamily, WorkJournalEntryGetRequest, WorkJournalEntryResponse, + WorkJournalSessionReadbackRequest, WorkJournalSessionReadbackResponse, WorkJournalWhereStopped, }; -/// Schema identifier for Work Journal readback. -pub const ELF_WORK_JOURNAL_SCHEMA_V1: &str = "elf.work_journal/v1"; - -const WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1: &str = "elf.work_journal.promotion_boundary/v1"; -const DEFAULT_SESSION_READBACK_LIMIT: u32 = 20; -const MAX_SESSION_READBACK_LIMIT: u32 = 100; -const MAX_STORAGE_SCAN_ROWS: i64 = 500; -const MAX_BODY_CHARS: usize = 16_384; -const MAX_SIDE_LIST_ITEMS: usize = 64; - -/// Work Journal entry family. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkJournalEntryFamily { - /// Session log captured alongside source work. - SessionLog, - /// Handoff brief for another agent or future session. - HandoffBrief, - /// Janitor or cleanup report. - JanitorReport, - /// Explicit next step stated in the source. - ExplicitNextStep, - /// Inferred next step retained as a non-authoritative hint. - InferredNextStep, - /// Option that was considered and rejected. - RejectedOption, -} -impl WorkJournalEntryFamily { - /// Returns the canonical API/storage string. - pub fn as_str(self) -> &'static str { - match self { - Self::SessionLog => "session_log", - Self::HandoffBrief => "handoff_brief", - Self::JanitorReport => "janitor_report", - Self::ExplicitNextStep => "explicit_next_step", - Self::InferredNextStep => "inferred_next_step", - Self::RejectedOption => "rejected_option", - } - } - - fn parse(raw: &str) -> Result { - match raw { - "session_log" => Ok(Self::SessionLog), - "handoff_brief" => Ok(Self::HandoffBrief), - "janitor_report" => Ok(Self::JanitorReport), - "explicit_next_step" => Ok(Self::ExplicitNextStep), - "inferred_next_step" => Ok(Self::InferredNextStep), - "rejected_option" => Ok(Self::RejectedOption), - _ => Err(Error::InvalidRequest { - message: "family must be one of: session_log, handoff_brief, janitor_report, explicit_next_step, inferred_next_step, rejected_option.".to_string(), - }), - } - } -} - -impl ElfService { - /// Captures one source-adjacent Work Journal entry. - pub async fn work_journal_entry_create( - &self, - req: WorkJournalEntryCreateRequest, - ) -> Result { - let mut validated = validate_work_journal_create(&self.cfg, &req)?; - let now = OffsetDateTime::now_utc(); - let effective_project_id = if validated.scope == "org_shared" { - ORG_PROJECT_ID.to_string() - } else { - req.project_id.trim().to_string() - }; - let mut tx = self.db.pool.begin().await?; - - validated.promotion_boundary = resolve_promotion_boundary_authority( - &mut tx, - &self.cfg, - validated.promotion_boundary, - req.tenant_id.trim(), - req.project_id.trim(), - req.agent_id.trim(), - now, - ) - .await?; - - let entry = WorkJournalEntry { - entry_id: validated.entry_id, - tenant_id: req.tenant_id.trim().to_string(), - project_id: effective_project_id.clone(), - agent_id: req.agent_id.trim().to_string(), - scope: validated.scope, - session_id: validated.session_id, - family: req.family.as_str().to_string(), - status: "active".to_string(), - title: validated.title, - body: validated.body, - source_refs: validated.source_refs, - explicit_next_steps: validated.explicit_next_steps, - inferred_next_steps: validated.inferred_next_steps, - rejected_options: validated.rejected_options, - promotion_boundary: validated.promotion_boundary, - redaction_audit: serde_json::to_value(validated.redaction_audit).map_err(|err| { - Error::InvalidRequest { message: format!("redaction audit is invalid: {err}") } - })?, - created_at: now, - updated_at: now, - }; - - work_journal::insert_work_journal_entry(&mut *tx, &entry).await?; - - if entry.scope != "agent_private" { - access::ensure_active_project_scope_grant( - &mut *tx, - entry.tenant_id.as_str(), - effective_project_id.as_str(), - entry.scope.as_str(), - entry.agent_id.as_str(), - ) - .await?; - } - - tx.commit().await?; - - Ok(WorkJournalEntryCreateResponse { entry: row_to_response(entry)? }) - } - - /// Reads one source-adjacent Work Journal entry. - pub async fn work_journal_entry_get( - &self, - req: WorkJournalEntryGetRequest, - ) -> Result { - validate_read_context( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.agent_id.as_str(), - req.read_profile.as_str(), - )?; - - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, req.read_profile.trim())?; - let shared_grants = load_work_journal_shared_grants( - self, - req.tenant_id.trim(), - req.project_id.trim(), - req.agent_id.trim(), - &allowed_scopes, - ) - .await?; - let row = - work_journal::get_work_journal_entry(&self.db.pool, req.tenant_id.trim(), req.entry_id) - .await? - .ok_or_else(|| Error::NotFound { - message: "Work Journal entry not found.".to_string(), - })?; - - if row.project_id != req.project_id.trim() && row.project_id != ORG_PROJECT_ID { - return Err(Error::NotFound { message: "Work Journal entry not found.".to_string() }); - } - if !work_journal_read_allowed(&row, req.agent_id.trim(), &allowed_scopes, &shared_grants) { - return Err(Error::ScopeDenied { - message: "Work Journal entry is not readable by this agent.".to_string(), - }); - } - - row_to_response(row) - } - - /// Reads newest-first Work Journal entries for one session. - pub async fn work_journal_session_readback( - &self, - req: WorkJournalSessionReadbackRequest, - ) -> Result { - validate_read_context( - req.tenant_id.as_str(), - req.project_id.as_str(), - req.agent_id.as_str(), - req.read_profile.as_str(), - )?; - validate_identifier(req.session_id.as_str(), "$.session_id")?; - - let limit = req - .limit - .unwrap_or(DEFAULT_SESSION_READBACK_LIMIT) - .clamp(1, MAX_SESSION_READBACK_LIMIT); - let allowed_scopes = - search::resolve_read_profile_scopes(&self.cfg, req.read_profile.trim())?; - let shared_grants = load_work_journal_shared_grants( - self, - req.tenant_id.trim(), - req.project_id.trim(), - req.agent_id.trim(), - &allowed_scopes, - ) - .await?; - let family_filter = - req.families.iter().copied().collect::>(); - let rows = work_journal::list_work_journal_entries_for_session( - &self.db.pool, - req.tenant_id.trim(), - req.project_id.trim(), - ORG_PROJECT_ID, - req.session_id.trim(), - MAX_STORAGE_SCAN_ROWS, - ) - .await?; - let mut items = Vec::new(); - - for row in rows { - if !family_filter.is_empty() - && !family_filter.contains(&WorkJournalEntryFamily::parse(row.family.as_str())?) - { - continue; - } - if !work_journal_read_allowed( - &row, - req.agent_id.trim(), - &allowed_scopes, - &shared_grants, - ) { - continue; - } - - items.push(row_to_response(row)?); - - if items.len() >= limit as usize { - break; - } - } - - let where_stopped = build_where_stopped(&items); - - Ok(WorkJournalSessionReadbackResponse { - schema: ELF_WORK_JOURNAL_SCHEMA_V1.to_string(), - session_id: req.session_id.trim().to_string(), - items, - where_stopped, - }) - } -} - -/// Request payload for source-adjacent Work Journal capture. -#[derive(Clone, Debug, Deserialize)] -pub struct WorkJournalEntryCreateRequest { - /// Tenant that owns the entry. - pub tenant_id: String, - /// Project that owns the entry. - pub project_id: String, - /// Agent capturing the entry. - pub agent_id: String, - /// Optional caller-supplied stable identifier. - pub entry_id: Option, - /// Visibility scope for readback. - pub scope: String, - /// Stable session identifier for grouping entries. - pub session_id: String, - /// Entry family. - pub family: WorkJournalEntryFamily, - /// Optional display title. - pub title: Option, - /// Journal body. This is source-adjacent, not authoritative memory. - pub body: String, - /// Source refs that support the journal entry. - pub source_refs: Vec, - /// Redaction/exclusion policy applied before persistence. - pub write_policy: Option, - #[serde(default)] - /// Explicit next steps stated by the captured source. - pub explicit_next_steps: Vec, - #[serde(default)] - /// Inferred next steps retained as non-authoritative hints. - pub inferred_next_steps: Vec, - #[serde(default)] - /// Options considered and rejected during the captured work. - pub rejected_options: Vec, - #[serde(default = "empty_object")] - /// Promotion boundary metadata. - pub promotion_boundary: Value, -} - -/// Response payload after Work Journal capture. -#[derive(Clone, Debug, Serialize)] -pub struct WorkJournalEntryCreateResponse { - /// Stored Work Journal entry. - pub entry: WorkJournalEntryResponse, -} - -/// Request payload for one Work Journal entry lookup. -#[derive(Clone, Debug, Deserialize)] -pub struct WorkJournalEntryGetRequest { - /// Tenant that owns the entry. - pub tenant_id: String, - /// Project used for read-profile and shared-grant checks. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Entry identifier. - pub entry_id: Uuid, -} - -/// Request payload for session-level Work Journal readback. -#[derive(Clone, Debug, Deserialize)] -pub struct WorkJournalSessionReadbackRequest { - /// Tenant that owns the session. - pub tenant_id: String, - /// Project used for read-profile and shared-grant checks. - pub project_id: String, - /// Agent requesting the read. - pub agent_id: String, - /// Read profile that determines visible scopes. - pub read_profile: String, - /// Stable session identifier to read. - pub session_id: String, - #[serde(default)] - /// Optional family filter. - pub families: Vec, - /// Maximum number of returned entries. - pub limit: Option, -} - -/// Session-level Work Journal readback. -#[derive(Clone, Debug, Serialize)] -pub struct WorkJournalSessionReadbackResponse { - /// Readback schema identifier. - pub schema: String, - /// Stable session identifier. - pub session_id: String, - /// Newest-first journal entries. - pub items: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - /// Compact "where did we stop" projection from the returned entries. - pub where_stopped: Option, -} - -/// One source-adjacent Work Journal entry returned by readback. -#[derive(Clone, Debug, Serialize)] -pub struct WorkJournalEntryResponse { - /// Readback schema identifier. - pub schema: String, - /// Journal entry identifier. - pub entry_id: Uuid, - /// Tenant that owns the entry. - pub tenant_id: String, - /// Project that owns the entry. - pub project_id: String, - /// Agent that captured the entry. - pub agent_id: String, - /// Visibility scope for readback. - pub scope: String, - /// Stable session identifier. - pub session_id: String, - /// Entry family. - pub family: WorkJournalEntryFamily, - /// Lifecycle status. - pub status: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional display title. - pub title: Option, - /// Redacted durable journal body. - pub body: String, - /// Source refs supporting the entry. - pub source_refs: Vec, - /// Explicit next steps stated by the captured source. - pub explicit_next_steps: Vec, - /// Inferred next steps retained as non-authoritative hints. - pub inferred_next_steps: Vec, - /// Rejected options captured by the journal. - pub rejected_options: Vec, - /// Promotion boundary metadata. - pub promotion_boundary: Value, - /// Redaction audit for the durable journal body. - pub redaction_audit: WritePolicyAudit, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Compact "where did we stop" projection for one journal session. -#[derive(Clone, Debug, Serialize)] -pub struct WorkJournalWhereStopped { - /// Latest returned entry identifier. - pub latest_entry_id: Uuid, - /// Latest returned entry family. - pub latest_family: WorkJournalEntryFamily, - /// Source refs associated with the latest returned entry. - pub source_refs: Vec, - /// Most recent explicit next steps in returned entries. - pub explicit_next_steps: Vec, - /// Most recent inferred next steps in returned entries. - pub inferred_next_steps: Vec, - /// Most recent rejected options in returned entries. - pub rejected_options: Vec, - /// Promotion boundary for the latest returned entry. - pub promotion_boundary: Value, -} - -struct ValidatedWorkJournalCreate { - entry_id: Uuid, - scope: String, - session_id: String, - title: Option, - body: String, - source_refs: Value, - explicit_next_steps: Value, - inferred_next_steps: Value, - rejected_options: Value, - promotion_boundary: Value, - redaction_audit: WritePolicyAudit, -} - -fn validate_work_journal_create( - cfg: &Config, - req: &WorkJournalEntryCreateRequest, -) -> Result { - validate_write_context( - cfg, - req.tenant_id.as_str(), - req.project_id.as_str(), - req.agent_id.as_str(), - req.scope.as_str(), - )?; - validate_identifier(req.session_id.as_str(), "$.session_id")?; - - if req.body.trim().is_empty() { - return Err(Error::InvalidRequest { message: "body must be non-empty.".to_string() }); - } - if req.body.chars().count() > MAX_BODY_CHARS { - return Err(Error::InvalidRequest { - message: "body exceeds max journal size.".to_string(), - }); - } - - let title = req.title.as_ref().map(|title| title.trim().to_string()).filter(|s| !s.is_empty()); - - if let Some(title) = title.as_ref() { - validate_natural_language(title.as_str(), "$.title")?; - - if writegate::contains_secrets(title.as_str()) { - return Err(Error::InvalidRequest { message: "title contains secrets.".to_string() }); - } - } - - let policy_result = writegate::apply_write_policy(req.body.as_str(), req.write_policy.as_ref()) - .map_err(|err| Error::InvalidRequest { - message: format!("write_policy is invalid: {err:?}"), - })?; - let body = policy_result.transformed; - - if body.trim().is_empty() { - return Err(Error::InvalidRequest { message: "body must be non-empty.".to_string() }); - } - - validate_natural_language(body.as_str(), "$.body")?; - - if writegate::contains_secrets(body.as_str()) { - return Err(Error::InvalidRequest { message: "body contains secrets.".to_string() }); - } - - validate_text_list(&req.explicit_next_steps, "$.explicit_next_steps")?; - validate_text_list(&req.inferred_next_steps, "$.inferred_next_steps")?; - validate_text_list(&req.rejected_options, "$.rejected_options")?; - - let source_refs = validate_source_refs(&req.source_refs)?; - let promotion_boundary = normalize_promotion_boundary(&req.promotion_boundary)?; - let explicit_next_steps = serde_json::to_value(&req.explicit_next_steps).map_err(|err| { - Error::InvalidRequest { message: format!("explicit_next_steps are invalid: {err}") } - })?; - let inferred_next_steps = serde_json::to_value(&req.inferred_next_steps).map_err(|err| { - Error::InvalidRequest { message: format!("inferred_next_steps are invalid: {err}") } - })?; - let rejected_options = serde_json::to_value(&req.rejected_options).map_err(|err| { - Error::InvalidRequest { message: format!("rejected_options are invalid: {err}") } - })?; - - Ok(ValidatedWorkJournalCreate { - entry_id: req.entry_id.unwrap_or_else(Uuid::new_v4), - scope: req.scope.trim().to_string(), - session_id: req.session_id.trim().to_string(), - title, - body, - source_refs, - explicit_next_steps, - inferred_next_steps, - rejected_options, - promotion_boundary, - redaction_audit: policy_result.audit, - }) -} - -fn validate_write_context( - cfg: &Config, - tenant_id: &str, - project_id: &str, - agent_id: &str, - scope: &str, -) -> Result<()> { - if tenant_id.trim().is_empty() - || project_id.trim().is_empty() - || agent_id.trim().is_empty() - || scope.trim().is_empty() - { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, agent_id, and scope are required.".to_string(), - }); - } - - validate_identifier(tenant_id, "$.tenant_id")?; - validate_identifier(project_id, "$.project_id")?; - validate_identifier(agent_id, "$.agent_id")?; - - if !cfg.scopes.allowed.iter().any(|allowed| allowed == scope.trim()) { - return Err(Error::ScopeDenied { message: "scope is not allowed.".to_string() }); - } - if !scope_write_allowed(cfg, scope.trim()) { - return Err(Error::ScopeDenied { message: "scope is not writable.".to_string() }); - } - - Ok(()) -} - -fn validate_read_context( - tenant_id: &str, - project_id: &str, - agent_id: &str, - read_profile: &str, -) -> Result<()> { - if tenant_id.trim().is_empty() - || project_id.trim().is_empty() - || agent_id.trim().is_empty() - || read_profile.trim().is_empty() - { - return Err(Error::InvalidRequest { - message: "tenant_id, project_id, agent_id, and read_profile are required.".to_string(), - }); - } - - validate_identifier(tenant_id, "$.tenant_id")?; - validate_identifier(project_id, "$.project_id")?; - validate_identifier(agent_id, "$.agent_id")?; - validate_identifier(read_profile, "$.read_profile")?; - - Ok(()) -} - -fn validate_text_list(values: &[String], path: &str) -> Result<()> { - if values.len() > MAX_SIDE_LIST_ITEMS { - return Err(Error::InvalidRequest { message: format!("{path} has too many items.") }); - } - - for (index, value) in values.iter().enumerate() { - if value.trim().is_empty() { - return Err(Error::InvalidRequest { - message: format!("{path}[{index}] must be non-empty."), - }); - } - - validate_natural_language(value.as_str(), format!("{path}[{index}]").as_str())?; - - if writegate::contains_secrets(value.as_str()) { - return Err(Error::InvalidRequest { - message: format!("{path}[{index}] contains secrets."), - }); - } - } - - Ok(()) -} - -fn validate_source_refs(source_refs: &[Value]) -> Result { - if source_refs.is_empty() { - return Err(Error::InvalidRequest { - message: "source_refs must be non-empty.".to_string(), - }); - } - if source_refs.len() > MAX_SIDE_LIST_ITEMS { - return Err(Error::InvalidRequest { - message: "source_refs has too many items.".to_string(), - }); - } - - for (index, source_ref) in source_refs.iter().enumerate() { - match source_ref { - Value::Object(map) if !map.is_empty() => {}, - _ => { - return Err(Error::InvalidRequest { - message: format!("source_refs[{index}] must be a non-empty object."), - }); - }, - } - } - - let value = Value::Array(source_refs.to_vec()); - - validate_json_strings(&value, "$.source_refs")?; - - Ok(value) -} - -fn validate_json_strings(value: &Value, path: &str) -> Result<()> { - match value { - Value::String(text) => { - validate_identifier(text.as_str(), path)?; - - if writegate::contains_secrets(text.as_str()) { - return Err(Error::InvalidRequest { message: format!("{path} contains secrets.") }); - } - }, - Value::Array(items) => - for (index, item) in items.iter().enumerate() { - validate_json_strings(item, format!("{path}[{index}]").as_str())?; - }, - Value::Object(map) => - for (key, item) in map { - validate_identifier(key.as_str(), format!("{path}.{key}").as_str())?; - validate_json_strings(item, format!("{path}.{key}").as_str())?; - }, - Value::Null | Value::Bool(_) | Value::Number(_) => {}, - } - - Ok(()) -} - -fn normalize_promotion_boundary(input: &Value) -> Result { - let map = match input { - Value::Null => Map::new(), - Value::Object(map) => map.clone(), - _ => { - return Err(Error::InvalidRequest { - message: "promotion_boundary must be a JSON object.".to_string(), - }); - }, - }; - - validate_json_strings(&Value::Object(map.clone()), "$.promotion_boundary")?; - - let accepted_memory_authority_ref = map.get("accepted_memory_authority_ref").cloned(); - let accepted_dreaming_review_ref = map.get("accepted_dreaming_review_ref").cloned(); - - if accepted_memory_authority_ref - .as_ref() - .is_some_and(|value| !value.is_null() && !is_valid_memory_authority_ref(value)) - { - return Err(Error::InvalidRequest { - message: - "accepted_memory_authority_ref must be an active elf.memory_record_ref/v1 note ref." - .to_string(), - }); - } - if accepted_dreaming_review_ref - .as_ref() - .is_some_and(|value| !value.is_null() && !is_valid_dreaming_review_ref(value)) - { - return Err(Error::InvalidRequest { - message: - "accepted_dreaming_review_ref must be an accepted elf.dreaming_review_queue/v1 ref." - .to_string(), - }); - } - - Ok(serde_json::json!({ - "schema": WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1, - "journal_entry_authority": "source_adjacent_only", - "authoritative_memory_allowed": false, - "promotion_required_for_current_facts": true, - "accepted_memory_authority_ref": accepted_memory_authority_ref.unwrap_or(Value::Null), - "accepted_dreaming_review_ref": accepted_dreaming_review_ref.unwrap_or(Value::Null), - "requested_authoritative_memory_allowed": map - .get("authoritative_memory_allowed") - .and_then(Value::as_bool) - .unwrap_or(false), - })) -} - -fn is_valid_memory_authority_ref(value: &Value) -> bool { - let Some(map) = value.as_object() else { - return false; - }; - let Some(id) = object_string(map, "id") else { - return false; - }; - - object_string(map, "schema") == Some("elf.memory_record_ref/v1") - && object_string(map, "kind") == Some("note") - && object_string(map, "status") == Some("active") - && Uuid::parse_str(id).is_ok() -} - -fn memory_ref_id(value: &Value) -> Option { - Uuid::parse_str(object_string(value.as_object()?, "id")?).ok() -} - -fn is_valid_dreaming_review_ref(value: &Value) -> bool { - let Some(map) = value.as_object() else { - return false; - }; - let Some(proposal_id) = object_string(map, "proposal_id") else { - return false; - }; - let review_state = object_string(map, "review_state"); - - object_string(map, "schema") == Some("elf.dreaming_review_queue/v1") - && Uuid::parse_str(proposal_id).is_ok() - && matches!(review_state, Some("approved" | "applied")) -} - -fn dreaming_ref_proposal_id(value: &Value) -> Option { - Uuid::parse_str(object_string(value.as_object()?, "proposal_id")?).ok() -} - -fn object_string<'a>(map: &'a Map, key: &str) -> Option<&'a str> { - map.get(key).and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()) -} - -fn validate_identifier(text: &str, field: &str) -> Result<()> { - if text.trim().is_empty() || !english_gate::is_english_identifier(text.trim()) { - return Err(Error::NonEnglishInput { field: field.to_string() }); - } - - Ok(()) -} - -fn validate_natural_language(text: &str, field: &str) -> Result<()> { - if !english_gate::is_english_natural_language(text) { - return Err(Error::NonEnglishInput { field: field.to_string() }); - } - - Ok(()) -} - -fn scope_write_allowed(cfg: &Config, scope: &str) -> bool { - match scope { - "agent_private" => cfg.scopes.write_allowed.agent_private, - "project_shared" => cfg.scopes.write_allowed.project_shared, - "org_shared" => cfg.scopes.write_allowed.org_shared, - _ => false, - } -} - -fn work_journal_read_allowed( - entry: &WorkJournalEntry, - requester_agent_id: &str, - allowed_scopes: &[String], - shared_grants: &HashSet, -) -> bool { - if entry.status != "active" { - return false; - } - if !allowed_scopes.iter().any(|scope| scope == &entry.scope) { - return false; - } - if entry.scope == "agent_private" { - return entry.agent_id == requester_agent_id; - } - if entry.agent_id == requester_agent_id { - return true; - } - - shared_grants.contains(&SharedSpaceGrantKey { - scope: entry.scope.clone(), - space_owner_agent_id: entry.agent_id.clone(), - }) -} - -fn row_to_response(row: WorkJournalEntry) -> Result { - let family = WorkJournalEntryFamily::parse(row.family.as_str())?; - let redaction_audit = serde_json::from_value::(row.redaction_audit.clone()) - .map_err(|err| Error::InvalidRequest { - message: format!("stored redaction audit is invalid: {err}"), - })?; - - Ok(WorkJournalEntryResponse { - schema: ELF_WORK_JOURNAL_SCHEMA_V1.to_string(), - entry_id: row.entry_id, - tenant_id: row.tenant_id, - project_id: row.project_id, - agent_id: row.agent_id, - scope: row.scope, - session_id: row.session_id, - family, - status: row.status, - title: row.title, - body: row.body, - source_refs: value_array(row.source_refs), - explicit_next_steps: string_array(row.explicit_next_steps), - inferred_next_steps: string_array(row.inferred_next_steps), - rejected_options: string_array(row.rejected_options), - promotion_boundary: row.promotion_boundary, - redaction_audit, - created_at: row.created_at, - updated_at: row.updated_at, - }) -} - -fn value_array(value: Value) -> Vec { - match value { - Value::Array(items) => items, - _ => Vec::new(), - } -} - -fn string_array(value: Value) -> Vec { - match value { - Value::Array(items) => - items.into_iter().filter_map(|item| item.as_str().map(str::to_string)).collect(), - _ => Vec::new(), - } -} - -fn build_where_stopped(items: &[WorkJournalEntryResponse]) -> Option { - let latest = items.first()?; - let explicit_next_steps = first_non_empty(items.iter().map(|item| &item.explicit_next_steps)); - let inferred_next_steps = first_non_empty(items.iter().map(|item| &item.inferred_next_steps)); - let rejected_options = first_non_empty(items.iter().map(|item| &item.rejected_options)); - - Some(WorkJournalWhereStopped { - latest_entry_id: latest.entry_id, - latest_family: latest.family, - source_refs: latest.source_refs.clone(), - explicit_next_steps, - inferred_next_steps, - rejected_options, - promotion_boundary: latest.promotion_boundary.clone(), - }) -} - -fn first_non_empty<'a>(mut lists: impl Iterator>) -> Vec { - lists.find(|items| !items.is_empty()).cloned().unwrap_or_default() -} - -fn empty_object() -> Value { - Value::Object(Map::new()) -} - -async fn resolve_promotion_boundary_authority( - executor: &mut PgConnection, - cfg: &Config, - mut boundary: Value, - tenant_id: &str, - project_id: &str, - agent_id: &str, - now: OffsetDateTime, -) -> Result { - let memory_ref = boundary.get("accepted_memory_authority_ref").cloned(); - let dreaming_ref = boundary.get("accepted_dreaming_review_ref").cloned(); - let mut has_accepted_ref = false; - - if let Some(memory_ref) = - memory_ref.as_ref().filter(|value| is_valid_memory_authority_ref(value)) - { - if !accepted_memory_authority_ref_is_readable( - &mut *executor, - cfg, - memory_ref, - tenant_id, - project_id, - agent_id, - now, - ) - .await? - { - return Err(Error::InvalidRequest { - message: "accepted_memory_authority_ref was not found or is not readable." - .to_string(), - }); - } - - has_accepted_ref = true; - } - if let Some(dreaming_ref) = - dreaming_ref.as_ref().filter(|value| is_valid_dreaming_review_ref(value)) - { - if !accepted_dreaming_review_ref_exists(&mut *executor, dreaming_ref, tenant_id, project_id) - .await? - { - return Err(Error::InvalidRequest { - message: "accepted_dreaming_review_ref was not found or is not accepted." - .to_string(), - }); - } - - has_accepted_ref = true; - } - - boundary["authoritative_memory_allowed"] = Value::Bool(has_accepted_ref); - boundary["promotion_required_for_current_facts"] = Value::Bool(!has_accepted_ref); - - Ok(boundary) -} - -async fn accepted_memory_authority_ref_is_readable( - executor: &mut PgConnection, - cfg: &Config, - memory_ref: &Value, - tenant_id: &str, - project_id: &str, - agent_id: &str, - now: OffsetDateTime, -) -> Result { - let Some(note_id) = memory_ref_id(memory_ref) else { - return Ok(false); - }; - let note = sqlx::query_as::<_, MemoryNote>( - "\ -SELECT * -FROM memory_notes -WHERE note_id = $1 - AND tenant_id = $2 - AND project_id IN ($3, $4) -LIMIT 1", - ) - .bind(note_id) - .bind(tenant_id) - .bind(project_id) - .bind(ORG_PROJECT_ID) - .fetch_optional(&mut *executor) - .await?; - let Some(note) = note else { - return Ok(false); - }; - let org_shared_allowed = cfg.scopes.allowed.iter().any(|scope| scope == "org_shared"); - let shared_grants = access::load_shared_read_grants_with_org_shared( - &mut *executor, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await?; - - Ok(access::note_read_allowed(¬e, agent_id, &cfg.scopes.allowed, &shared_grants, now)) -} - -async fn accepted_dreaming_review_ref_exists( - executor: &mut PgConnection, - dreaming_ref: &Value, - tenant_id: &str, - project_id: &str, -) -> Result { - let Some(proposal_id) = dreaming_ref_proposal_id(dreaming_ref) else { - return Ok(false); - }; - let Some(proposal) = consolidation::get_consolidation_proposal( - &mut *executor, - tenant_id, - project_id, - proposal_id, - ) - .await? - else { - return Ok(false); - }; - let Some(map) = dreaming_ref.as_object() else { - return Ok(false); - }; - - Ok(matches!(proposal.review_state.as_str(), "approved" | "applied") - && object_string(map, "review_state") == Some(proposal.review_state.as_str())) -} - -async fn load_work_journal_shared_grants( - service: &ElfService, - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], -) -> Result> { - let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); - - access::load_shared_read_grants_with_org_shared( - &service.db.pool, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use serde_json; - use time::OffsetDateTime; - use uuid::Uuid; - - use crate::{ - access::SharedSpaceGrantKey, - work_journal::{self, WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1}, - }; - use elf_storage::models::WorkJournalEntry; - - #[test] - fn promotion_boundary_flags_journal_only_without_accepted_ref() { - let boundary = work_journal::normalize_promotion_boundary(&serde_json::json!({ - "authoritative_memory_allowed": true - })) - .expect("boundary should normalize"); - - assert_eq!(boundary["schema"], WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1); - assert_eq!(boundary["authoritative_memory_allowed"], false); - assert_eq!(boundary["promotion_required_for_current_facts"], true); - assert_eq!(boundary["requested_authoritative_memory_allowed"], true); - } - - #[test] - fn promotion_boundary_preserves_memory_ref_without_granting_shape_only_authority() { - let boundary = work_journal::normalize_promotion_boundary(&serde_json::json!({ - "accepted_memory_authority_ref": { - "schema": "elf.memory_record_ref/v1", - "kind": "note", - "id": "11111111-1111-1111-1111-111111111111", - "status": "active" - } - })) - .expect("boundary should normalize"); - - assert_eq!(boundary["authoritative_memory_allowed"], false); - assert_eq!(boundary["promotion_required_for_current_facts"], true); - assert_eq!( - boundary["accepted_memory_authority_ref"]["id"], - serde_json::json!("11111111-1111-1111-1111-111111111111") - ); - } - - #[test] - fn promotion_boundary_preserves_dreaming_ref_without_granting_shape_only_authority() { - let boundary = work_journal::normalize_promotion_boundary(&serde_json::json!({ - "accepted_dreaming_review_ref": { - "schema": "elf.dreaming_review_queue/v1", - "proposal_id": "22222222-2222-4222-8222-222222222222", - "review_state": "applied" - } - })) - .expect("boundary should normalize"); - - assert_eq!(boundary["authoritative_memory_allowed"], false); - assert_eq!(boundary["promotion_required_for_current_facts"], true); - assert_eq!( - boundary["accepted_dreaming_review_ref"]["proposal_id"], - serde_json::json!("22222222-2222-4222-8222-222222222222") - ); - } - - #[test] - fn promotion_boundary_rejects_forged_accepted_refs() { - let primitive_result = work_journal::normalize_promotion_boundary(&serde_json::json!({ - "accepted_memory_authority_ref": true - })); - let object_result = work_journal::normalize_promotion_boundary(&serde_json::json!({ - "accepted_memory_authority_ref": { - "schema": "elf.memory_record_ref/v1", - "id": "11111111-1111-1111-1111-111111111111" - } - })); - - assert!(primitive_result.is_err()); - assert!(object_result.is_err()); - } - - #[test] - fn source_refs_reject_non_object_items() { - let result = work_journal::validate_source_refs(&[serde_json::json!("XY-1117")]); - - assert!(result.is_err()); - } - - #[test] - fn read_allowed_enforces_private_and_shared_grants() { - let allowed = vec!["agent_private".to_string(), "project_shared".to_string()]; - let no_grants = HashSet::new(); - let private = journal_row("agent_private", "agent-a", "active"); - let shared = journal_row("project_shared", "agent-a", "active"); - let inactive = journal_row("agent_private", "agent-a", "deleted"); - - assert!(work_journal::work_journal_read_allowed(&private, "agent-a", &allowed, &no_grants)); - assert!(!work_journal::work_journal_read_allowed( - &private, "agent-b", &allowed, &no_grants - )); - assert!(!work_journal::work_journal_read_allowed( - &inactive, "agent-a", &allowed, &no_grants - )); - assert!(work_journal::work_journal_read_allowed(&shared, "agent-a", &allowed, &no_grants)); - assert!(!work_journal::work_journal_read_allowed(&shared, "agent-b", &allowed, &no_grants)); - - let mut grants = HashSet::new(); - - grants.insert(SharedSpaceGrantKey { - scope: "project_shared".to_string(), - space_owner_agent_id: "agent-a".to_string(), - }); - - assert!(work_journal::work_journal_read_allowed(&shared, "agent-b", &allowed, &grants)); - - let private_only = vec!["agent_private".to_string()]; - - assert!(!work_journal::work_journal_read_allowed( - &shared, - "agent-b", - &private_only, - &grants - )); - } - - fn journal_row(scope: &str, agent_id: &str, status: &str) -> WorkJournalEntry { - let now = OffsetDateTime::now_utc(); - - WorkJournalEntry { - entry_id: Uuid::nil(), - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: agent_id.to_string(), - scope: scope.to_string(), - session_id: "session".to_string(), - family: "session_log".to_string(), - status: status.to_string(), - title: None, - body: "body".to_string(), - source_refs: serde_json::json!([{ "schema": "source_ref/v1" }]), - explicit_next_steps: serde_json::json!([]), - inferred_next_steps: serde_json::json!([]), - rejected_options: serde_json::json!([]), - promotion_boundary: serde_json::json!({}), - redaction_audit: serde_json::json!({}), - created_at: now, - updated_at: now, - } - } -} +#[cfg(test)] mod tests; diff --git a/packages/elf-service/src/work_journal/service.rs b/packages/elf-service/src/work_journal/service.rs new file mode 100644 index 00000000..0162f2d3 --- /dev/null +++ b/packages/elf-service/src/work_journal/service.rs @@ -0,0 +1,206 @@ +use std::collections::HashSet; + +use serde_json; +use time::OffsetDateTime; + +use crate::{ + ElfService, Error, Result, + access::{self, ORG_PROJECT_ID}, + search, + work_journal::{ + types::{ + DEFAULT_SESSION_READBACK_LIMIT, ELF_WORK_JOURNAL_SCHEMA_V1, MAX_SESSION_READBACK_LIMIT, + MAX_STORAGE_SCAN_ROWS, WorkJournalEntryCreateRequest, WorkJournalEntryCreateResponse, + WorkJournalEntryFamily, WorkJournalEntryGetRequest, WorkJournalEntryResponse, + WorkJournalSessionReadbackRequest, WorkJournalSessionReadbackResponse, + }, + validation::{self}, + }, +}; +use elf_storage::{models::WorkJournalEntry, work_journal}; + +impl ElfService { + /// Captures one source-adjacent Work Journal entry. + pub async fn work_journal_entry_create( + &self, + req: WorkJournalEntryCreateRequest, + ) -> Result { + let mut validated = validation::validate_work_journal_create(&self.cfg, &req)?; + let now = OffsetDateTime::now_utc(); + let effective_project_id = if validated.scope == "org_shared" { + ORG_PROJECT_ID.to_string() + } else { + req.project_id.trim().to_string() + }; + let mut tx = self.db.pool.begin().await?; + + validated.promotion_boundary = validation::resolve_promotion_boundary_authority( + &mut tx, + &self.cfg, + validated.promotion_boundary, + req.tenant_id.trim(), + req.project_id.trim(), + req.agent_id.trim(), + now, + ) + .await?; + + let entry = WorkJournalEntry { + entry_id: validated.entry_id, + tenant_id: req.tenant_id.trim().to_string(), + project_id: effective_project_id.clone(), + agent_id: req.agent_id.trim().to_string(), + scope: validated.scope, + session_id: validated.session_id, + family: req.family.as_str().to_string(), + status: "active".to_string(), + title: validated.title, + body: validated.body, + source_refs: validated.source_refs, + explicit_next_steps: validated.explicit_next_steps, + inferred_next_steps: validated.inferred_next_steps, + rejected_options: validated.rejected_options, + promotion_boundary: validated.promotion_boundary, + redaction_audit: serde_json::to_value(validated.redaction_audit).map_err(|err| { + Error::InvalidRequest { message: format!("redaction audit is invalid: {err}") } + })?, + created_at: now, + updated_at: now, + }; + + work_journal::insert_work_journal_entry(&mut *tx, &entry).await?; + + if entry.scope != "agent_private" { + access::ensure_active_project_scope_grant( + &mut *tx, + entry.tenant_id.as_str(), + effective_project_id.as_str(), + entry.scope.as_str(), + entry.agent_id.as_str(), + ) + .await?; + } + + tx.commit().await?; + + Ok(WorkJournalEntryCreateResponse { entry: validation::row_to_response(entry)? }) + } + + /// Reads one source-adjacent Work Journal entry. + pub async fn work_journal_entry_get( + &self, + req: WorkJournalEntryGetRequest, + ) -> Result { + validation::validate_read_context( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + req.read_profile.as_str(), + )?; + + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, req.read_profile.trim())?; + let shared_grants = validation::load_work_journal_shared_grants( + self, + req.tenant_id.trim(), + req.project_id.trim(), + req.agent_id.trim(), + &allowed_scopes, + ) + .await?; + let row = + work_journal::get_work_journal_entry(&self.db.pool, req.tenant_id.trim(), req.entry_id) + .await? + .ok_or_else(|| Error::NotFound { + message: "Work Journal entry not found.".to_string(), + })?; + + if row.project_id != req.project_id.trim() && row.project_id != ORG_PROJECT_ID { + return Err(Error::NotFound { message: "Work Journal entry not found.".to_string() }); + } + if !validation::work_journal_read_allowed( + &row, + req.agent_id.trim(), + &allowed_scopes, + &shared_grants, + ) { + return Err(Error::ScopeDenied { + message: "Work Journal entry is not readable by this agent.".to_string(), + }); + } + + validation::row_to_response(row) + } + + /// Reads newest-first Work Journal entries for one session. + pub async fn work_journal_session_readback( + &self, + req: WorkJournalSessionReadbackRequest, + ) -> Result { + validation::validate_read_context( + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + req.read_profile.as_str(), + )?; + validation::validate_identifier(req.session_id.as_str(), "$.session_id")?; + + let limit = req + .limit + .unwrap_or(DEFAULT_SESSION_READBACK_LIMIT) + .clamp(1, MAX_SESSION_READBACK_LIMIT); + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, req.read_profile.trim())?; + let shared_grants = validation::load_work_journal_shared_grants( + self, + req.tenant_id.trim(), + req.project_id.trim(), + req.agent_id.trim(), + &allowed_scopes, + ) + .await?; + let family_filter = + req.families.iter().copied().collect::>(); + let rows = work_journal::list_work_journal_entries_for_session( + &self.db.pool, + req.tenant_id.trim(), + req.project_id.trim(), + ORG_PROJECT_ID, + req.session_id.trim(), + MAX_STORAGE_SCAN_ROWS, + ) + .await?; + let mut items = Vec::new(); + + for row in rows { + if !family_filter.is_empty() + && !family_filter.contains(&WorkJournalEntryFamily::parse(row.family.as_str())?) + { + continue; + } + if !validation::work_journal_read_allowed( + &row, + req.agent_id.trim(), + &allowed_scopes, + &shared_grants, + ) { + continue; + } + + items.push(validation::row_to_response(row)?); + + if items.len() >= limit as usize { + break; + } + } + + let where_stopped = validation::build_where_stopped(&items); + + Ok(WorkJournalSessionReadbackResponse { + schema: ELF_WORK_JOURNAL_SCHEMA_V1.to_string(), + session_id: req.session_id.trim().to_string(), + items, + where_stopped, + }) + } +} diff --git a/packages/elf-service/src/work_journal/tests.rs b/packages/elf-service/src/work_journal/tests.rs new file mode 100644 index 00000000..99304e3e --- /dev/null +++ b/packages/elf-service/src/work_journal/tests.rs @@ -0,0 +1,139 @@ +use std::collections::HashSet; + +use serde_json; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + access::SharedSpaceGrantKey, + work_journal::{types::WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1, validation}, +}; +use elf_storage::models::WorkJournalEntry; + +#[test] +fn promotion_boundary_flags_journal_only_without_accepted_ref() { + let boundary = validation::normalize_promotion_boundary(&serde_json::json!({ + "authoritative_memory_allowed": true + })) + .expect("boundary should normalize"); + + assert_eq!(boundary["schema"], WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1); + assert_eq!(boundary["authoritative_memory_allowed"], false); + assert_eq!(boundary["promotion_required_for_current_facts"], true); + assert_eq!(boundary["requested_authoritative_memory_allowed"], true); +} + +#[test] +fn promotion_boundary_preserves_memory_ref_without_granting_shape_only_authority() { + let boundary = validation::normalize_promotion_boundary(&serde_json::json!({ + "accepted_memory_authority_ref": { + "schema": "elf.memory_record_ref/v1", + "kind": "note", + "id": "11111111-1111-1111-1111-111111111111", + "status": "active" + } + })) + .expect("boundary should normalize"); + + assert_eq!(boundary["authoritative_memory_allowed"], false); + assert_eq!(boundary["promotion_required_for_current_facts"], true); + assert_eq!( + boundary["accepted_memory_authority_ref"]["id"], + serde_json::json!("11111111-1111-1111-1111-111111111111") + ); +} + +#[test] +fn promotion_boundary_preserves_dreaming_ref_without_granting_shape_only_authority() { + let boundary = validation::normalize_promotion_boundary(&serde_json::json!({ + "accepted_dreaming_review_ref": { + "schema": "elf.dreaming_review_queue/v1", + "proposal_id": "22222222-2222-4222-8222-222222222222", + "review_state": "applied" + } + })) + .expect("boundary should normalize"); + + assert_eq!(boundary["authoritative_memory_allowed"], false); + assert_eq!(boundary["promotion_required_for_current_facts"], true); + assert_eq!( + boundary["accepted_dreaming_review_ref"]["proposal_id"], + serde_json::json!("22222222-2222-4222-8222-222222222222") + ); +} + +#[test] +fn promotion_boundary_rejects_forged_accepted_refs() { + let primitive_result = validation::normalize_promotion_boundary(&serde_json::json!({ + "accepted_memory_authority_ref": true + })); + let object_result = validation::normalize_promotion_boundary(&serde_json::json!({ + "accepted_memory_authority_ref": { + "schema": "elf.memory_record_ref/v1", + "id": "11111111-1111-1111-1111-111111111111" + } + })); + + assert!(primitive_result.is_err()); + assert!(object_result.is_err()); +} + +#[test] +fn source_refs_reject_non_object_items() { + let result = validation::validate_source_refs(&[serde_json::json!("XY-1117")]); + + assert!(result.is_err()); +} + +#[test] +fn read_allowed_enforces_private_and_shared_grants() { + let allowed = vec!["agent_private".to_string(), "project_shared".to_string()]; + let no_grants = HashSet::new(); + let private = journal_row("agent_private", "agent-a", "active"); + let shared = journal_row("project_shared", "agent-a", "active"); + let inactive = journal_row("agent_private", "agent-a", "deleted"); + + assert!(validation::work_journal_read_allowed(&private, "agent-a", &allowed, &no_grants)); + assert!(!validation::work_journal_read_allowed(&private, "agent-b", &allowed, &no_grants)); + assert!(!validation::work_journal_read_allowed(&inactive, "agent-a", &allowed, &no_grants)); + assert!(validation::work_journal_read_allowed(&shared, "agent-a", &allowed, &no_grants)); + assert!(!validation::work_journal_read_allowed(&shared, "agent-b", &allowed, &no_grants)); + + let mut grants = HashSet::new(); + + grants.insert(SharedSpaceGrantKey { + scope: "project_shared".to_string(), + space_owner_agent_id: "agent-a".to_string(), + }); + + assert!(validation::work_journal_read_allowed(&shared, "agent-b", &allowed, &grants)); + + let private_only = vec!["agent_private".to_string()]; + + assert!(!validation::work_journal_read_allowed(&shared, "agent-b", &private_only, &grants)); +} + +fn journal_row(scope: &str, agent_id: &str, status: &str) -> WorkJournalEntry { + let now = OffsetDateTime::now_utc(); + + WorkJournalEntry { + entry_id: Uuid::nil(), + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: agent_id.to_string(), + scope: scope.to_string(), + session_id: "session".to_string(), + family: "session_log".to_string(), + status: status.to_string(), + title: None, + body: "body".to_string(), + source_refs: serde_json::json!([{ "schema": "source_ref/v1" }]), + explicit_next_steps: serde_json::json!([]), + inferred_next_steps: serde_json::json!([]), + rejected_options: serde_json::json!([]), + promotion_boundary: serde_json::json!({}), + redaction_audit: serde_json::json!({}), + created_at: now, + updated_at: now, + } +} diff --git a/packages/elf-service/src/work_journal/types.rs b/packages/elf-service/src/work_journal/types.rs new file mode 100644 index 00000000..62c215fe --- /dev/null +++ b/packages/elf-service/src/work_journal/types.rs @@ -0,0 +1,239 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{Error, Result}; +use elf_domain::writegate::{WritePolicy, WritePolicyAudit}; + +/// Schema identifier for Work Journal readback. +pub const ELF_WORK_JOURNAL_SCHEMA_V1: &str = "elf.work_journal/v1"; + +pub(super) const WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1: &str = + "elf.work_journal.promotion_boundary/v1"; +pub(super) const DEFAULT_SESSION_READBACK_LIMIT: u32 = 20; +pub(super) const MAX_SESSION_READBACK_LIMIT: u32 = 100; +pub(super) const MAX_STORAGE_SCAN_ROWS: i64 = 500; +pub(super) const MAX_BODY_CHARS: usize = 16_384; +pub(super) const MAX_SIDE_LIST_ITEMS: usize = 64; + +/// Work Journal entry family. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkJournalEntryFamily { + /// Session log captured alongside source work. + SessionLog, + /// Handoff brief for another agent or future session. + HandoffBrief, + /// Janitor or cleanup report. + JanitorReport, + /// Explicit next step stated in the source. + ExplicitNextStep, + /// Inferred next step retained as a non-authoritative hint. + InferredNextStep, + /// Option that was considered and rejected. + RejectedOption, +} +impl WorkJournalEntryFamily { + /// Returns the canonical API/storage string. + pub fn as_str(self) -> &'static str { + match self { + Self::SessionLog => "session_log", + Self::HandoffBrief => "handoff_brief", + Self::JanitorReport => "janitor_report", + Self::ExplicitNextStep => "explicit_next_step", + Self::InferredNextStep => "inferred_next_step", + Self::RejectedOption => "rejected_option", + } + } + + pub(super) fn parse(raw: &str) -> Result { + match raw { + "session_log" => Ok(Self::SessionLog), + "handoff_brief" => Ok(Self::HandoffBrief), + "janitor_report" => Ok(Self::JanitorReport), + "explicit_next_step" => Ok(Self::ExplicitNextStep), + "inferred_next_step" => Ok(Self::InferredNextStep), + "rejected_option" => Ok(Self::RejectedOption), + _ => Err(Error::InvalidRequest { + message: "family must be one of: session_log, handoff_brief, janitor_report, explicit_next_step, inferred_next_step, rejected_option.".to_string(), + }), + } + } +} + +/// Request payload for source-adjacent Work Journal capture. +#[derive(Clone, Debug, Deserialize)] +pub struct WorkJournalEntryCreateRequest { + /// Tenant that owns the entry. + pub tenant_id: String, + /// Project that owns the entry. + pub project_id: String, + /// Agent capturing the entry. + pub agent_id: String, + /// Optional caller-supplied stable identifier. + pub entry_id: Option, + /// Visibility scope for readback. + pub scope: String, + /// Stable session identifier for grouping entries. + pub session_id: String, + /// Entry family. + pub family: WorkJournalEntryFamily, + /// Optional display title. + pub title: Option, + /// Journal body. This is source-adjacent, not authoritative memory. + pub body: String, + /// Source refs that support the journal entry. + pub source_refs: Vec, + /// Redaction/exclusion policy applied before persistence. + pub write_policy: Option, + #[serde(default)] + /// Explicit next steps stated by the captured source. + pub explicit_next_steps: Vec, + #[serde(default)] + /// Inferred next steps retained as non-authoritative hints. + pub inferred_next_steps: Vec, + #[serde(default)] + /// Options considered and rejected during the captured work. + pub rejected_options: Vec, + #[serde(default = "empty_object")] + /// Promotion boundary metadata. + pub promotion_boundary: Value, +} + +/// Response payload after Work Journal capture. +#[derive(Clone, Debug, Serialize)] +pub struct WorkJournalEntryCreateResponse { + /// Stored Work Journal entry. + pub entry: WorkJournalEntryResponse, +} + +/// Request payload for one Work Journal entry lookup. +#[derive(Clone, Debug, Deserialize)] +pub struct WorkJournalEntryGetRequest { + /// Tenant that owns the entry. + pub tenant_id: String, + /// Project used for read-profile and shared-grant checks. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Entry identifier. + pub entry_id: Uuid, +} + +/// Request payload for session-level Work Journal readback. +#[derive(Clone, Debug, Deserialize)] +pub struct WorkJournalSessionReadbackRequest { + /// Tenant that owns the session. + pub tenant_id: String, + /// Project used for read-profile and shared-grant checks. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Stable session identifier to read. + pub session_id: String, + #[serde(default)] + /// Optional family filter. + pub families: Vec, + /// Maximum number of returned entries. + pub limit: Option, +} + +/// Session-level Work Journal readback. +#[derive(Clone, Debug, Serialize)] +pub struct WorkJournalSessionReadbackResponse { + /// Readback schema identifier. + pub schema: String, + /// Stable session identifier. + pub session_id: String, + /// Newest-first journal entries. + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Compact "where did we stop" projection from the returned entries. + pub where_stopped: Option, +} + +/// One source-adjacent Work Journal entry returned by readback. +#[derive(Clone, Debug, Serialize)] +pub struct WorkJournalEntryResponse { + /// Readback schema identifier. + pub schema: String, + /// Journal entry identifier. + pub entry_id: Uuid, + /// Tenant that owns the entry. + pub tenant_id: String, + /// Project that owns the entry. + pub project_id: String, + /// Agent that captured the entry. + pub agent_id: String, + /// Visibility scope for readback. + pub scope: String, + /// Stable session identifier. + pub session_id: String, + /// Entry family. + pub family: WorkJournalEntryFamily, + /// Lifecycle status. + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional display title. + pub title: Option, + /// Redacted durable journal body. + pub body: String, + /// Source refs supporting the entry. + pub source_refs: Vec, + /// Explicit next steps stated by the captured source. + pub explicit_next_steps: Vec, + /// Inferred next steps retained as non-authoritative hints. + pub inferred_next_steps: Vec, + /// Rejected options captured by the journal. + pub rejected_options: Vec, + /// Promotion boundary metadata. + pub promotion_boundary: Value, + /// Redaction audit for the durable journal body. + pub redaction_audit: WritePolicyAudit, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Compact "where did we stop" projection for one journal session. +#[derive(Clone, Debug, Serialize)] +pub struct WorkJournalWhereStopped { + /// Latest returned entry identifier. + pub latest_entry_id: Uuid, + /// Latest returned entry family. + pub latest_family: WorkJournalEntryFamily, + /// Source refs associated with the latest returned entry. + pub source_refs: Vec, + /// Most recent explicit next steps in returned entries. + pub explicit_next_steps: Vec, + /// Most recent inferred next steps in returned entries. + pub inferred_next_steps: Vec, + /// Most recent rejected options in returned entries. + pub rejected_options: Vec, + /// Promotion boundary for the latest returned entry. + pub promotion_boundary: Value, +} + +pub(super) struct ValidatedWorkJournalCreate { + pub(super) entry_id: Uuid, + pub(super) scope: String, + pub(super) session_id: String, + pub(super) title: Option, + pub(super) body: String, + pub(super) source_refs: Value, + pub(super) explicit_next_steps: Value, + pub(super) inferred_next_steps: Value, + pub(super) rejected_options: Value, + pub(super) promotion_boundary: Value, + pub(super) redaction_audit: WritePolicyAudit, +} + +fn empty_object() -> Value { + Value::Object(Map::new()) +} diff --git a/packages/elf-service/src/work_journal/validation.rs b/packages/elf-service/src/work_journal/validation.rs new file mode 100644 index 00000000..d4ad4311 --- /dev/null +++ b/packages/elf-service/src/work_journal/validation.rs @@ -0,0 +1,49 @@ +mod common; +mod context; +mod create; +mod promotion; +mod read; +mod refs; + +pub(super) use self::{ + common::validate_identifier, + context::validate_read_context, + create::validate_work_journal_create, + promotion::{normalize_promotion_boundary, resolve_promotion_boundary_authority}, + read::{ + build_where_stopped, load_work_journal_shared_grants, row_to_response, + work_journal_read_allowed, + }, + refs::validate_source_refs, +}; + +use std::collections::HashSet; + +use serde_json::{self, Map, Value}; +use sqlx::PgConnection; +use time::OffsetDateTime; +use uuid::Uuid; + +use self::{ + common::{object_string, validate_json_strings, validate_natural_language}, + context::validate_write_context, +}; +use crate::{ + ElfService, Error, Result, + access::{ORG_PROJECT_ID, SharedSpaceGrantKey}, + work_journal::types::{ + ELF_WORK_JOURNAL_SCHEMA_V1, MAX_BODY_CHARS, MAX_SIDE_LIST_ITEMS, + ValidatedWorkJournalCreate, WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1, + WorkJournalEntryCreateRequest, WorkJournalEntryFamily, WorkJournalEntryResponse, + WorkJournalWhereStopped, + }, +}; +use elf_config::Config; +use elf_domain::{ + english_gate, + writegate::{self, WritePolicyAudit}, +}; +use elf_storage::{ + consolidation, + models::{MemoryNote, WorkJournalEntry}, +}; diff --git a/packages/elf-service/src/work_journal/validation/common.rs b/packages/elf-service/src/work_journal/validation/common.rs new file mode 100644 index 00000000..9e475c87 --- /dev/null +++ b/packages/elf-service/src/work_journal/validation/common.rs @@ -0,0 +1,45 @@ +use crate::work_journal::validation::{Error, Map, Result, Value, english_gate, writegate}; + +pub(in crate::work_journal) fn validate_identifier(text: &str, field: &str) -> Result<()> { + if text.trim().is_empty() || !english_gate::is_english_identifier(text.trim()) { + return Err(Error::NonEnglishInput { field: field.to_string() }); + } + + Ok(()) +} + +pub(super) fn validate_natural_language(text: &str, field: &str) -> Result<()> { + if !english_gate::is_english_natural_language(text) { + return Err(Error::NonEnglishInput { field: field.to_string() }); + } + + Ok(()) +} + +pub(super) fn validate_json_strings(value: &Value, path: &str) -> Result<()> { + match value { + Value::String(text) => { + validate_identifier(text.as_str(), path)?; + + if writegate::contains_secrets(text.as_str()) { + return Err(Error::InvalidRequest { message: format!("{path} contains secrets.") }); + } + }, + Value::Array(items) => + for (index, item) in items.iter().enumerate() { + validate_json_strings(item, format!("{path}[{index}]").as_str())?; + }, + Value::Object(map) => + for (key, item) in map { + validate_identifier(key.as_str(), format!("{path}.{key}").as_str())?; + validate_json_strings(item, format!("{path}.{key}").as_str())?; + }, + Value::Null | Value::Bool(_) | Value::Number(_) => {}, + } + + Ok(()) +} + +pub(super) fn object_string<'a>(map: &'a Map, key: &str) -> Option<&'a str> { + map.get(key).and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()) +} diff --git a/packages/elf-service/src/work_journal/validation/context.rs b/packages/elf-service/src/work_journal/validation/context.rs new file mode 100644 index 00000000..58689d3b --- /dev/null +++ b/packages/elf-service/src/work_journal/validation/context.rs @@ -0,0 +1,65 @@ +use crate::work_journal::validation::{self, Config, Error, Result}; + +pub(super) fn validate_write_context( + cfg: &Config, + tenant_id: &str, + project_id: &str, + agent_id: &str, + scope: &str, +) -> Result<()> { + if tenant_id.trim().is_empty() + || project_id.trim().is_empty() + || agent_id.trim().is_empty() + || scope.trim().is_empty() + { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, agent_id, and scope are required.".to_string(), + }); + } + + validation::validate_identifier(tenant_id, "$.tenant_id")?; + validation::validate_identifier(project_id, "$.project_id")?; + validation::validate_identifier(agent_id, "$.agent_id")?; + + if !cfg.scopes.allowed.iter().any(|allowed| allowed == scope.trim()) { + return Err(Error::ScopeDenied { message: "scope is not allowed.".to_string() }); + } + if !scope_write_allowed(cfg, scope.trim()) { + return Err(Error::ScopeDenied { message: "scope is not writable.".to_string() }); + } + + Ok(()) +} + +pub(in crate::work_journal) fn validate_read_context( + tenant_id: &str, + project_id: &str, + agent_id: &str, + read_profile: &str, +) -> Result<()> { + if tenant_id.trim().is_empty() + || project_id.trim().is_empty() + || agent_id.trim().is_empty() + || read_profile.trim().is_empty() + { + return Err(Error::InvalidRequest { + message: "tenant_id, project_id, agent_id, and read_profile are required.".to_string(), + }); + } + + validation::validate_identifier(tenant_id, "$.tenant_id")?; + validation::validate_identifier(project_id, "$.project_id")?; + validation::validate_identifier(agent_id, "$.agent_id")?; + validation::validate_identifier(read_profile, "$.read_profile")?; + + Ok(()) +} + +fn scope_write_allowed(cfg: &Config, scope: &str) -> bool { + match scope { + "agent_private" => cfg.scopes.write_allowed.agent_private, + "project_shared" => cfg.scopes.write_allowed.project_shared, + "org_shared" => cfg.scopes.write_allowed.org_shared, + _ => false, + } +} diff --git a/packages/elf-service/src/work_journal/validation/create.rs b/packages/elf-service/src/work_journal/validation/create.rs new file mode 100644 index 00000000..8ea78ba2 --- /dev/null +++ b/packages/elf-service/src/work_journal/validation/create.rs @@ -0,0 +1,107 @@ +use crate::work_journal::validation::{ + self, Config, Error, MAX_BODY_CHARS, MAX_SIDE_LIST_ITEMS, Result, Uuid, + ValidatedWorkJournalCreate, WorkJournalEntryCreateRequest, serde_json, writegate, +}; + +pub(in crate::work_journal) fn validate_work_journal_create( + cfg: &Config, + req: &WorkJournalEntryCreateRequest, +) -> Result { + validation::validate_write_context( + cfg, + req.tenant_id.as_str(), + req.project_id.as_str(), + req.agent_id.as_str(), + req.scope.as_str(), + )?; + validation::validate_identifier(req.session_id.as_str(), "$.session_id")?; + + if req.body.trim().is_empty() { + return Err(Error::InvalidRequest { message: "body must be non-empty.".to_string() }); + } + if req.body.chars().count() > MAX_BODY_CHARS { + return Err(Error::InvalidRequest { + message: "body exceeds max journal size.".to_string(), + }); + } + + let title = req.title.as_ref().map(|title| title.trim().to_string()).filter(|s| !s.is_empty()); + + if let Some(title) = title.as_ref() { + validation::validate_natural_language(title.as_str(), "$.title")?; + + if writegate::contains_secrets(title.as_str()) { + return Err(Error::InvalidRequest { message: "title contains secrets.".to_string() }); + } + } + + let policy_result = writegate::apply_write_policy(req.body.as_str(), req.write_policy.as_ref()) + .map_err(|err| Error::InvalidRequest { + message: format!("write_policy is invalid: {err:?}"), + })?; + let body = policy_result.transformed; + + if body.trim().is_empty() { + return Err(Error::InvalidRequest { message: "body must be non-empty.".to_string() }); + } + + validation::validate_natural_language(body.as_str(), "$.body")?; + + if writegate::contains_secrets(body.as_str()) { + return Err(Error::InvalidRequest { message: "body contains secrets.".to_string() }); + } + + validate_text_list(&req.explicit_next_steps, "$.explicit_next_steps")?; + validate_text_list(&req.inferred_next_steps, "$.inferred_next_steps")?; + validate_text_list(&req.rejected_options, "$.rejected_options")?; + + let source_refs = validation::validate_source_refs(&req.source_refs)?; + let promotion_boundary = validation::normalize_promotion_boundary(&req.promotion_boundary)?; + let explicit_next_steps = serde_json::to_value(&req.explicit_next_steps).map_err(|err| { + Error::InvalidRequest { message: format!("explicit_next_steps are invalid: {err}") } + })?; + let inferred_next_steps = serde_json::to_value(&req.inferred_next_steps).map_err(|err| { + Error::InvalidRequest { message: format!("inferred_next_steps are invalid: {err}") } + })?; + let rejected_options = serde_json::to_value(&req.rejected_options).map_err(|err| { + Error::InvalidRequest { message: format!("rejected_options are invalid: {err}") } + })?; + + Ok(ValidatedWorkJournalCreate { + entry_id: req.entry_id.unwrap_or_else(Uuid::new_v4), + scope: req.scope.trim().to_string(), + session_id: req.session_id.trim().to_string(), + title, + body, + source_refs, + explicit_next_steps, + inferred_next_steps, + rejected_options, + promotion_boundary, + redaction_audit: policy_result.audit, + }) +} + +fn validate_text_list(values: &[String], path: &str) -> Result<()> { + if values.len() > MAX_SIDE_LIST_ITEMS { + return Err(Error::InvalidRequest { message: format!("{path} has too many items.") }); + } + + for (index, value) in values.iter().enumerate() { + if value.trim().is_empty() { + return Err(Error::InvalidRequest { + message: format!("{path}[{index}] must be non-empty."), + }); + } + + validation::validate_natural_language(value.as_str(), format!("{path}[{index}]").as_str())?; + + if writegate::contains_secrets(value.as_str()) { + return Err(Error::InvalidRequest { + message: format!("{path}[{index}] contains secrets."), + }); + } + } + + Ok(()) +} diff --git a/packages/elf-service/src/work_journal/validation/promotion.rs b/packages/elf-service/src/work_journal/validation/promotion.rs new file mode 100644 index 00000000..79cb4290 --- /dev/null +++ b/packages/elf-service/src/work_journal/validation/promotion.rs @@ -0,0 +1,220 @@ +use crate::{ + access, + work_journal::validation::{ + self, Config, Error, Map, MemoryNote, ORG_PROJECT_ID, OffsetDateTime, PgConnection, Result, + Uuid, Value, WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1, consolidation, serde_json, + }, +}; + +pub(in crate::work_journal) fn normalize_promotion_boundary(input: &Value) -> Result { + let map = match input { + Value::Null => Map::new(), + Value::Object(map) => map.clone(), + _ => { + return Err(Error::InvalidRequest { + message: "promotion_boundary must be a JSON object.".to_string(), + }); + }, + }; + + validation::validate_json_strings(&Value::Object(map.clone()), "$.promotion_boundary")?; + + let accepted_memory_authority_ref = map.get("accepted_memory_authority_ref").cloned(); + let accepted_dreaming_review_ref = map.get("accepted_dreaming_review_ref").cloned(); + + if accepted_memory_authority_ref + .as_ref() + .is_some_and(|value| !value.is_null() && !is_valid_memory_authority_ref(value)) + { + return Err(Error::InvalidRequest { + message: + "accepted_memory_authority_ref must be an active elf.memory_record_ref/v1 note ref." + .to_string(), + }); + } + if accepted_dreaming_review_ref + .as_ref() + .is_some_and(|value| !value.is_null() && !is_valid_dreaming_review_ref(value)) + { + return Err(Error::InvalidRequest { + message: + "accepted_dreaming_review_ref must be an accepted elf.dreaming_review_queue/v1 ref." + .to_string(), + }); + } + + Ok(serde_json::json!({ + "schema": WORK_JOURNAL_PROMOTION_BOUNDARY_SCHEMA_V1, + "journal_entry_authority": "source_adjacent_only", + "authoritative_memory_allowed": false, + "promotion_required_for_current_facts": true, + "accepted_memory_authority_ref": accepted_memory_authority_ref.unwrap_or(Value::Null), + "accepted_dreaming_review_ref": accepted_dreaming_review_ref.unwrap_or(Value::Null), + "requested_authoritative_memory_allowed": map + .get("authoritative_memory_allowed") + .and_then(Value::as_bool) + .unwrap_or(false), + })) +} + +pub(in crate::work_journal) async fn resolve_promotion_boundary_authority( + executor: &mut PgConnection, + cfg: &Config, + mut boundary: Value, + tenant_id: &str, + project_id: &str, + agent_id: &str, + now: OffsetDateTime, +) -> Result { + let memory_ref = boundary.get("accepted_memory_authority_ref").cloned(); + let dreaming_ref = boundary.get("accepted_dreaming_review_ref").cloned(); + let mut has_accepted_ref = false; + + if let Some(memory_ref) = + memory_ref.as_ref().filter(|value| is_valid_memory_authority_ref(value)) + { + if !accepted_memory_authority_ref_is_readable( + &mut *executor, + cfg, + memory_ref, + tenant_id, + project_id, + agent_id, + now, + ) + .await? + { + return Err(Error::InvalidRequest { + message: "accepted_memory_authority_ref was not found or is not readable." + .to_string(), + }); + } + + has_accepted_ref = true; + } + if let Some(dreaming_ref) = + dreaming_ref.as_ref().filter(|value| is_valid_dreaming_review_ref(value)) + { + if !accepted_dreaming_review_ref_exists(&mut *executor, dreaming_ref, tenant_id, project_id) + .await? + { + return Err(Error::InvalidRequest { + message: "accepted_dreaming_review_ref was not found or is not accepted." + .to_string(), + }); + } + + has_accepted_ref = true; + } + + boundary["authoritative_memory_allowed"] = Value::Bool(has_accepted_ref); + boundary["promotion_required_for_current_facts"] = Value::Bool(!has_accepted_ref); + + Ok(boundary) +} + +fn is_valid_memory_authority_ref(value: &Value) -> bool { + let Some(map) = value.as_object() else { + return false; + }; + let Some(id) = validation::object_string(map, "id") else { + return false; + }; + + validation::object_string(map, "schema") == Some("elf.memory_record_ref/v1") + && validation::object_string(map, "kind") == Some("note") + && validation::object_string(map, "status") == Some("active") + && Uuid::parse_str(id).is_ok() +} + +fn memory_ref_id(value: &Value) -> Option { + Uuid::parse_str(validation::object_string(value.as_object()?, "id")?).ok() +} + +fn is_valid_dreaming_review_ref(value: &Value) -> bool { + let Some(map) = value.as_object() else { + return false; + }; + let Some(proposal_id) = validation::object_string(map, "proposal_id") else { + return false; + }; + let review_state = validation::object_string(map, "review_state"); + + validation::object_string(map, "schema") == Some("elf.dreaming_review_queue/v1") + && Uuid::parse_str(proposal_id).is_ok() + && matches!(review_state, Some("approved" | "applied")) +} + +fn dreaming_ref_proposal_id(value: &Value) -> Option { + Uuid::parse_str(validation::object_string(value.as_object()?, "proposal_id")?).ok() +} + +async fn accepted_memory_authority_ref_is_readable( + executor: &mut PgConnection, + cfg: &Config, + memory_ref: &Value, + tenant_id: &str, + project_id: &str, + agent_id: &str, + now: OffsetDateTime, +) -> Result { + let Some(note_id) = memory_ref_id(memory_ref) else { + return Ok(false); + }; + let note = sqlx::query_as::<_, MemoryNote>( + "\ +SELECT * +FROM memory_notes +WHERE note_id = $1 + AND tenant_id = $2 + AND project_id IN ($3, $4) +LIMIT 1", + ) + .bind(note_id) + .bind(tenant_id) + .bind(project_id) + .bind(ORG_PROJECT_ID) + .fetch_optional(&mut *executor) + .await?; + let Some(note) = note else { + return Ok(false); + }; + let org_shared_allowed = cfg.scopes.allowed.iter().any(|scope| scope == "org_shared"); + let shared_grants = access::load_shared_read_grants_with_org_shared( + &mut *executor, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await?; + + Ok(access::note_read_allowed(¬e, agent_id, &cfg.scopes.allowed, &shared_grants, now)) +} + +async fn accepted_dreaming_review_ref_exists( + executor: &mut PgConnection, + dreaming_ref: &Value, + tenant_id: &str, + project_id: &str, +) -> Result { + let Some(proposal_id) = dreaming_ref_proposal_id(dreaming_ref) else { + return Ok(false); + }; + let Some(proposal) = consolidation::get_consolidation_proposal( + &mut *executor, + tenant_id, + project_id, + proposal_id, + ) + .await? + else { + return Ok(false); + }; + let Some(map) = dreaming_ref.as_object() else { + return Ok(false); + }; + + Ok(matches!(proposal.review_state.as_str(), "approved" | "applied") + && validation::object_string(map, "review_state") == Some(proposal.review_state.as_str())) +} diff --git a/packages/elf-service/src/work_journal/validation/read.rs b/packages/elf-service/src/work_journal/validation/read.rs new file mode 100644 index 00000000..7c1f5e89 --- /dev/null +++ b/packages/elf-service/src/work_journal/validation/read.rs @@ -0,0 +1,122 @@ +use crate::{ + access, + work_journal::validation::{ + ELF_WORK_JOURNAL_SCHEMA_V1, ElfService, Error, HashSet, Result, SharedSpaceGrantKey, Value, + WorkJournalEntry, WorkJournalEntryFamily, WorkJournalEntryResponse, + WorkJournalWhereStopped, WritePolicyAudit, serde_json, + }, +}; + +pub(in crate::work_journal) fn work_journal_read_allowed( + entry: &WorkJournalEntry, + requester_agent_id: &str, + allowed_scopes: &[String], + shared_grants: &HashSet, +) -> bool { + if entry.status != "active" { + return false; + } + if !allowed_scopes.iter().any(|scope| scope == &entry.scope) { + return false; + } + if entry.scope == "agent_private" { + return entry.agent_id == requester_agent_id; + } + if entry.agent_id == requester_agent_id { + return true; + } + + shared_grants.contains(&SharedSpaceGrantKey { + scope: entry.scope.clone(), + space_owner_agent_id: entry.agent_id.clone(), + }) +} + +pub(in crate::work_journal) fn row_to_response( + row: WorkJournalEntry, +) -> Result { + let family = WorkJournalEntryFamily::parse(row.family.as_str())?; + let redaction_audit = serde_json::from_value::(row.redaction_audit.clone()) + .map_err(|err| Error::InvalidRequest { + message: format!("stored redaction audit is invalid: {err}"), + })?; + + Ok(WorkJournalEntryResponse { + schema: ELF_WORK_JOURNAL_SCHEMA_V1.to_string(), + entry_id: row.entry_id, + tenant_id: row.tenant_id, + project_id: row.project_id, + agent_id: row.agent_id, + scope: row.scope, + session_id: row.session_id, + family, + status: row.status, + title: row.title, + body: row.body, + source_refs: value_array(row.source_refs), + explicit_next_steps: string_array(row.explicit_next_steps), + inferred_next_steps: string_array(row.inferred_next_steps), + rejected_options: string_array(row.rejected_options), + promotion_boundary: row.promotion_boundary, + redaction_audit, + created_at: row.created_at, + updated_at: row.updated_at, + }) +} + +pub(in crate::work_journal) fn build_where_stopped( + items: &[WorkJournalEntryResponse], +) -> Option { + let latest = items.first()?; + let explicit_next_steps = first_non_empty(items.iter().map(|item| &item.explicit_next_steps)); + let inferred_next_steps = first_non_empty(items.iter().map(|item| &item.inferred_next_steps)); + let rejected_options = first_non_empty(items.iter().map(|item| &item.rejected_options)); + + Some(WorkJournalWhereStopped { + latest_entry_id: latest.entry_id, + latest_family: latest.family, + source_refs: latest.source_refs.clone(), + explicit_next_steps, + inferred_next_steps, + rejected_options, + promotion_boundary: latest.promotion_boundary.clone(), + }) +} + +pub(in crate::work_journal) async fn load_work_journal_shared_grants( + service: &ElfService, + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], +) -> Result> { + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope == "org_shared"); + + access::load_shared_read_grants_with_org_shared( + &service.db.pool, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await +} + +fn value_array(value: Value) -> Vec { + match value { + Value::Array(items) => items, + _ => Vec::new(), + } +} + +fn string_array(value: Value) -> Vec { + match value { + Value::Array(items) => + items.into_iter().filter_map(|item| item.as_str().map(str::to_string)).collect(), + _ => Vec::new(), + } +} + +fn first_non_empty<'a>(mut lists: impl Iterator>) -> Vec { + lists.find(|items| !items.is_empty()).cloned().unwrap_or_default() +} diff --git a/packages/elf-service/src/work_journal/validation/refs.rs b/packages/elf-service/src/work_journal/validation/refs.rs new file mode 100644 index 00000000..114a5b62 --- /dev/null +++ b/packages/elf-service/src/work_journal/validation/refs.rs @@ -0,0 +1,31 @@ +use crate::work_journal::validation::{self, Error, MAX_SIDE_LIST_ITEMS, Result, Value}; + +pub(in crate::work_journal) fn validate_source_refs(source_refs: &[Value]) -> Result { + if source_refs.is_empty() { + return Err(Error::InvalidRequest { + message: "source_refs must be non-empty.".to_string(), + }); + } + if source_refs.len() > MAX_SIDE_LIST_ITEMS { + return Err(Error::InvalidRequest { + message: "source_refs has too many items.".to_string(), + }); + } + + for (index, source_ref) in source_refs.iter().enumerate() { + match source_ref { + Value::Object(map) if !map.is_empty() => {}, + _ => { + return Err(Error::InvalidRequest { + message: format!("source_refs[{index}] must be a non-empty object."), + }); + }, + } + } + + let value = Value::Array(source_refs.to_vec()); + + validation::validate_json_strings(&value, "$.source_refs")?; + + Ok(value) +} diff --git a/packages/elf-service/src/write_policy.rs b/packages/elf-service/src/write_policy.rs new file mode 100644 index 00000000..087a035c --- /dev/null +++ b/packages/elf-service/src/write_policy.rs @@ -0,0 +1,12 @@ +use elf_domain::writegate::RejectCode; + +pub(crate) fn writegate_reason_code(code: RejectCode) -> &'static str { + match code { + RejectCode::RejectNonEnglish => "REJECT_NON_ENGLISH", + RejectCode::RejectTooLong => "REJECT_TOO_LONG", + RejectCode::RejectSecret => "REJECT_SECRET", + RejectCode::RejectInvalidType => "REJECT_INVALID_TYPE", + RejectCode::RejectScopeDenied => "REJECT_SCOPE_DENIED", + RejectCode::RejectEmpty => "REJECT_EMPTY", + } +} diff --git a/packages/elf-storage/src/consolidation.rs b/packages/elf-storage/src/consolidation.rs index baee8fff..3aa07b90 100644 --- a/packages/elf-storage/src/consolidation.rs +++ b/packages/elf-storage/src/consolidation.rs @@ -1,848 +1,29 @@ //! Consolidation run and proposal persistence queries. -use serde_json::Value; -use sqlx::PgExecutor; -use time::{Duration, OffsetDateTime}; -use uuid::Uuid; - -use crate::{ - Result, - db::Db, - models::{ - ConsolidationProposal, ConsolidationProposalReviewEvent, ConsolidationRun, - ConsolidationRunJob, +mod jobs; +mod proposals; +mod runs; +mod sql; +mod types; + +pub use self::{ + jobs::{ + claim_next_consolidation_run_job, insert_consolidation_run_job, + mark_consolidation_run_job_done, mark_consolidation_run_job_failed, + }, + proposals::{ + get_consolidation_proposal, insert_consolidation_proposal, + insert_consolidation_proposal_review_event, list_consolidation_proposal_review_events, + list_consolidation_proposals, lock_consolidation_proposal, + update_consolidation_proposal_review, update_consolidation_proposal_target_ref, + }, + runs::{ + get_consolidation_run, insert_consolidation_run, list_consolidation_runs, + update_consolidation_run_state, + }, + types::{ + ConsolidationProposalReviewEventInsert, ConsolidationProposalReviewUpdate, + ConsolidationProposalTargetRefUpdate, ConsolidationRunJobInsert, + ConsolidationRunStateUpdate, }, }; - -const CONSOLIDATION_RUN_SELECT: &str = "\ -SELECT - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - job_kind, - status, - input_refs, - source_snapshot, - lineage, - COALESCE(error, '{}'::jsonb) AS error, - created_at, - updated_at, - completed_at -FROM consolidation_runs -WHERE tenant_id = $1 AND project_id = $2 AND run_id = $3 -LIMIT 1"; -const CONSOLIDATION_PROPOSAL_SELECT: &str = "\ -SELECT - proposal_id, - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, - COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, - COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - reviewer_agent_id, - review_comment, - reviewed_at, - created_at, - updated_at -FROM consolidation_proposals -WHERE tenant_id = $1 AND project_id = $2 AND proposal_id = $3 -LIMIT 1"; - -/// Arguments for updating a consolidation run state. -pub struct ConsolidationRunStateUpdate<'a> { - /// Tenant that owns the run. - pub tenant_id: &'a str, - /// Project that owns the run. - pub project_id: &'a str, - /// Run identifier. - pub run_id: Uuid, - /// New run status. - pub status: &'a str, - /// Structured error payload for terminal failure states. - pub error: &'a Value, - /// Update timestamp. - pub now: OffsetDateTime, -} - -/// Arguments for updating a consolidation proposal review state. -pub struct ConsolidationProposalReviewUpdate<'a> { - /// Tenant that owns the proposal. - pub tenant_id: &'a str, - /// Project that owns the proposal. - pub project_id: &'a str, - /// Proposal identifier. - pub proposal_id: Uuid, - /// New review state. - pub review_state: &'a str, - /// Reviewing agent identifier. - pub reviewer_agent_id: &'a str, - /// Optional reviewer comment. - pub review_comment: Option<&'a str>, - /// Update timestamp. - pub now: OffsetDateTime, -} - -/// Arguments for updating a consolidation proposal target reference. -pub struct ConsolidationProposalTargetRefUpdate<'a> { - /// Tenant that owns the proposal. - pub tenant_id: &'a str, - /// Project that owns the proposal. - pub project_id: &'a str, - /// Proposal identifier. - pub proposal_id: Uuid, - /// New target reference. - pub target_ref: &'a Value, - /// Update timestamp. - pub now: OffsetDateTime, -} - -/// Arguments for inserting a consolidation proposal review event. -pub struct ConsolidationProposalReviewEventInsert<'a> { - /// Review event identifier. - pub review_id: Uuid, - /// Reviewed proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the proposal. - pub tenant_id: &'a str, - /// Project that owns the proposal. - pub project_id: &'a str, - /// Reviewing agent identifier. - pub reviewer_agent_id: &'a str, - /// Review action requested by the reviewer. - pub action: &'a str, - /// Review state before the transition. - pub from_review_state: &'a str, - /// Review state after the transition. - pub to_review_state: &'a str, - /// Optional reviewer comment. - pub review_comment: Option<&'a str>, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Arguments for inserting a consolidation worker job. -pub struct ConsolidationRunJobInsert<'a> { - /// Worker job identifier. - pub job_id: Uuid, - /// Consolidation run to materialize. - pub run_id: Uuid, - /// Tenant that owns the run. - pub tenant_id: &'a str, - /// Project that owns the run. - pub project_id: &'a str, - /// Agent that registered the run. - pub agent_id: &'a str, - /// Job kind, such as fixture or manual. - pub job_kind: &'a str, - /// Queued proposal payload. - pub payload: &'a Value, - /// Creation timestamp. - pub now: OffsetDateTime, -} - -/// Inserts one consolidation run. -pub async fn insert_consolidation_run<'e, E>(executor: E, run: &ConsolidationRun) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO consolidation_runs ( - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - job_kind, - status, - input_refs, - source_snapshot, - lineage, - error, - created_at, - updated_at, - completed_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", - ) - .bind(run.run_id) - .bind(run.tenant_id.as_str()) - .bind(run.project_id.as_str()) - .bind(run.agent_id.as_str()) - .bind(run.contract_schema.as_str()) - .bind(run.job_kind.as_str()) - .bind(run.status.as_str()) - .bind(&run.input_refs) - .bind(&run.source_snapshot) - .bind(&run.lineage) - .bind(&run.error) - .bind(run.created_at) - .bind(run.updated_at) - .bind(run.completed_at) - .execute(executor) - .await?; - - Ok(()) -} - -/// Enqueues one consolidation worker job. -pub async fn insert_consolidation_run_job<'e, E>( - executor: E, - args: ConsolidationRunJobInsert<'_>, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO consolidation_run_jobs ( - job_id, - run_id, - tenant_id, - project_id, - agent_id, - job_kind, - status, - payload, - available_at, - created_at, - updated_at -) -VALUES ($1,$2,$3,$4,$5,$6,'PENDING',$7,$8,$8,$8)", - ) - .bind(args.job_id) - .bind(args.run_id) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.agent_id) - .bind(args.job_kind) - .bind(args.payload) - .bind(args.now) - .execute(executor) - .await?; - - Ok(()) -} - -/// Fetches one consolidation run by tenant and run identifier. -pub async fn get_consolidation_run<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - run_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, ConsolidationRun>(CONSOLIDATION_RUN_SELECT) - .bind(tenant_id) - .bind(project_id) - .bind(run_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Lists consolidation runs for one tenant and project. -pub async fn list_consolidation_runs<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - limit: i64, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, ConsolidationRun>( - "\ -SELECT - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - job_kind, - status, - input_refs, - source_snapshot, - lineage, - COALESCE(error, '{}'::jsonb) AS error, - created_at, - updated_at, - completed_at -FROM consolidation_runs -WHERE tenant_id = $1 AND project_id = $2 -ORDER BY created_at DESC, run_id DESC -LIMIT $3", - ) - .bind(tenant_id) - .bind(project_id) - .bind(limit) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Claims the next due consolidation worker job and leases it until `lease_seconds`. -pub async fn claim_next_consolidation_run_job( - db: &Db, - now: OffsetDateTime, - lease_seconds: i64, -) -> Result> { - let mut tx = db.pool.begin().await?; - let row = sqlx::query_as::<_, ConsolidationRunJob>( - "\ -SELECT - job_id, - run_id, - tenant_id, - project_id, - agent_id, - job_kind, - status, - payload, - attempts, - last_error, - available_at, - created_at, - updated_at -FROM consolidation_run_jobs -WHERE status IN ('PENDING','FAILED','CLAIMED') AND available_at <= $1 -ORDER BY available_at ASC -LIMIT 1 -FOR UPDATE SKIP LOCKED", - ) - .bind(now) - .fetch_optional(&mut *tx) - .await?; - let job = if let Some(mut job) = row { - let lease_until = now + Duration::seconds(lease_seconds); - - sqlx::query( - "\ -UPDATE consolidation_run_jobs -SET status = 'CLAIMED', available_at = $1, updated_at = $2 -WHERE job_id = $3", - ) - .bind(lease_until) - .bind(now) - .bind(job.job_id) - .execute(&mut *tx) - .await?; - - job.status = "CLAIMED".to_string(); - job.available_at = lease_until; - job.updated_at = now; - - Some(job) - } else { - None - }; - - tx.commit().await?; - - Ok(job) -} - -/// Marks a consolidation worker job as completed. -pub async fn mark_consolidation_run_job_done<'e, E>( - executor: E, - job_id: Uuid, - now: OffsetDateTime, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -UPDATE consolidation_run_jobs -SET status = 'DONE', updated_at = $1 -WHERE job_id = $2", - ) - .bind(now) - .bind(job_id) - .execute(executor) - .await?; - - Ok(()) -} - -/// Marks a consolidation worker job as failed and schedules its retry. -pub async fn mark_consolidation_run_job_failed( - db: &Db, - job_id: Uuid, - attempts: i32, - error_text: &str, - available_at: OffsetDateTime, - now: OffsetDateTime, -) -> Result<()> { - sqlx::query( - "\ -UPDATE consolidation_run_jobs -SET status = 'FAILED', - attempts = $1, - last_error = $2, - available_at = $3, - updated_at = $4 -WHERE job_id = $5", - ) - .bind(attempts) - .bind(error_text) - .bind(available_at) - .bind(now) - .bind(job_id) - .execute(&db.pool) - .await?; - - Ok(()) -} - -/// Updates one consolidation run state. -pub async fn update_consolidation_run_state<'e, E>( - executor: E, - args: ConsolidationRunStateUpdate<'_>, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, ConsolidationRun>( - "\ -UPDATE consolidation_runs -SET - status = $1, - error = $2, - updated_at = $3, - completed_at = CASE - WHEN $1 IN ('completed', 'failed', 'cancelled') THEN $3 - ELSE completed_at - END -WHERE tenant_id = $4 AND project_id = $5 AND run_id = $6 -RETURNING - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - job_kind, - status, - input_refs, - source_snapshot, - lineage, - COALESCE(error, '{}'::jsonb) AS error, - created_at, - updated_at, - completed_at", - ) - .bind(args.status) - .bind(args.error) - .bind(args.now) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.run_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Inserts one consolidation proposal. -pub async fn insert_consolidation_proposal<'e, E>( - executor: E, - proposal: &ConsolidationProposal, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO consolidation_proposals ( - proposal_id, - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - unsupported_claim_flags, - contradiction_markers, - staleness_markers, - target_ref, - proposed_payload, - reviewer_agent_id, - review_comment, - reviewed_at, - created_at, - updated_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)", - ) - .bind(proposal.proposal_id) - .bind(proposal.run_id) - .bind(proposal.tenant_id.as_str()) - .bind(proposal.project_id.as_str()) - .bind(proposal.agent_id.as_str()) - .bind(proposal.contract_schema.as_str()) - .bind(proposal.proposal_kind.as_str()) - .bind(proposal.apply_intent.as_str()) - .bind(proposal.review_state.as_str()) - .bind(&proposal.source_refs) - .bind(&proposal.source_snapshot) - .bind(&proposal.lineage) - .bind(&proposal.diff) - .bind(proposal.confidence) - .bind(&proposal.unsupported_claim_flags) - .bind(&proposal.contradiction_markers) - .bind(&proposal.staleness_markers) - .bind(&proposal.target_ref) - .bind(&proposal.proposed_payload) - .bind(proposal.reviewer_agent_id.as_deref()) - .bind(proposal.review_comment.as_deref()) - .bind(proposal.reviewed_at) - .bind(proposal.created_at) - .bind(proposal.updated_at) - .execute(executor) - .await?; - - Ok(()) -} - -/// Fetches one consolidation proposal by tenant and proposal identifier. -pub async fn get_consolidation_proposal<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - proposal_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, ConsolidationProposal>(CONSOLIDATION_PROPOSAL_SELECT) - .bind(tenant_id) - .bind(project_id) - .bind(proposal_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Locks one consolidation proposal by tenant and proposal identifier. -pub async fn lock_consolidation_proposal<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - proposal_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, ConsolidationProposal>( - "\ -SELECT - proposal_id, - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, - COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, - COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - reviewer_agent_id, - review_comment, - reviewed_at, - created_at, - updated_at -FROM consolidation_proposals -WHERE tenant_id = $1 AND project_id = $2 AND proposal_id = $3 -LIMIT 1 -FOR UPDATE", - ) - .bind(tenant_id) - .bind(project_id) - .bind(proposal_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Lists consolidation proposals for one tenant and project. -pub async fn list_consolidation_proposals<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - run_id: Option, - review_state: Option<&str>, - limit: i64, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, ConsolidationProposal>( - "\ -SELECT - proposal_id, - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, - COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, - COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - reviewer_agent_id, - review_comment, - reviewed_at, - created_at, - updated_at -FROM consolidation_proposals -WHERE tenant_id = $1 - AND project_id = $2 - AND ($3::uuid IS NULL OR run_id = $3) - AND ($4::text IS NULL OR review_state = $4) -ORDER BY created_at DESC, proposal_id DESC -LIMIT $5", - ) - .bind(tenant_id) - .bind(project_id) - .bind(run_id) - .bind(review_state) - .bind(limit) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Updates one proposal review state. -pub async fn update_consolidation_proposal_review<'e, E>( - executor: E, - args: ConsolidationProposalReviewUpdate<'_>, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, ConsolidationProposal>( - "\ -UPDATE consolidation_proposals -SET - review_state = $1, - reviewer_agent_id = $2, - review_comment = $3, - reviewed_at = $4, - updated_at = $4 -WHERE tenant_id = $5 AND project_id = $6 AND proposal_id = $7 -RETURNING - proposal_id, - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, - COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, - COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - reviewer_agent_id, - review_comment, - reviewed_at, - created_at, - updated_at", - ) - .bind(args.review_state) - .bind(args.reviewer_agent_id) - .bind(args.review_comment) - .bind(args.now) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.proposal_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Updates one proposal target reference. -pub async fn update_consolidation_proposal_target_ref<'e, E>( - executor: E, - args: ConsolidationProposalTargetRefUpdate<'_>, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, ConsolidationProposal>( - "\ -UPDATE consolidation_proposals -SET target_ref = $1, updated_at = $2 -WHERE tenant_id = $3 AND project_id = $4 AND proposal_id = $5 -RETURNING - proposal_id, - run_id, - tenant_id, - project_id, - agent_id, - contract_schema, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, - COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, - COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - reviewer_agent_id, - review_comment, - reviewed_at, - created_at, - updated_at", - ) - .bind(args.target_ref) - .bind(args.now) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.proposal_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Inserts one proposal review audit event. -pub async fn insert_consolidation_proposal_review_event<'e, E>( - executor: E, - args: ConsolidationProposalReviewEventInsert<'_>, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO consolidation_proposal_reviews ( - review_id, - proposal_id, - run_id, - tenant_id, - project_id, - reviewer_agent_id, - action, - from_review_state, - to_review_state, - review_comment, - created_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", - ) - .bind(args.review_id) - .bind(args.proposal_id) - .bind(args.run_id) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.reviewer_agent_id) - .bind(args.action) - .bind(args.from_review_state) - .bind(args.to_review_state) - .bind(args.review_comment) - .bind(args.created_at) - .execute(executor) - .await?; - - Ok(()) -} - -/// Lists review events for one consolidation proposal. -pub async fn list_consolidation_proposal_review_events<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - proposal_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, ConsolidationProposalReviewEvent>( - "\ -SELECT - review_id, - proposal_id, - run_id, - tenant_id, - project_id, - reviewer_agent_id, - action, - from_review_state, - to_review_state, - review_comment, - created_at -FROM consolidation_proposal_reviews -WHERE tenant_id = $1 AND project_id = $2 AND proposal_id = $3 -ORDER BY created_at ASC, review_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(proposal_id) - .fetch_all(executor) - .await?; - - Ok(rows) -} diff --git a/packages/elf-storage/src/consolidation/jobs.rs b/packages/elf-storage/src/consolidation/jobs.rs new file mode 100644 index 00000000..b0122e2c --- /dev/null +++ b/packages/elf-storage/src/consolidation/jobs.rs @@ -0,0 +1,160 @@ +use sqlx::PgExecutor; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +use crate::{ + Result, consolidation::types::ConsolidationRunJobInsert, db::Db, models::ConsolidationRunJob, +}; + +/// Enqueues one consolidation worker job. +pub async fn insert_consolidation_run_job<'e, E>( + executor: E, + args: ConsolidationRunJobInsert<'_>, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO consolidation_run_jobs ( + job_id, + run_id, + tenant_id, + project_id, + agent_id, + job_kind, + status, + payload, + available_at, + created_at, + updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,'PENDING',$7,$8,$8,$8)", + ) + .bind(args.job_id) + .bind(args.run_id) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.agent_id) + .bind(args.job_kind) + .bind(args.payload) + .bind(args.now) + .execute(executor) + .await?; + + Ok(()) +} + +/// Claims the next due consolidation worker job and leases it until `lease_seconds`. +pub async fn claim_next_consolidation_run_job( + db: &Db, + now: OffsetDateTime, + lease_seconds: i64, +) -> Result> { + let mut tx = db.pool.begin().await?; + let row = sqlx::query_as::<_, ConsolidationRunJob>( + "\ +SELECT + job_id, + run_id, + tenant_id, + project_id, + agent_id, + job_kind, + status, + payload, + attempts, + last_error, + available_at, + created_at, + updated_at +FROM consolidation_run_jobs +WHERE status IN ('PENDING','FAILED','CLAIMED') AND available_at <= $1 +ORDER BY available_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED", + ) + .bind(now) + .fetch_optional(&mut *tx) + .await?; + let job = if let Some(mut job) = row { + let lease_until = now + Duration::seconds(lease_seconds); + + sqlx::query( + "\ +UPDATE consolidation_run_jobs +SET status = 'CLAIMED', available_at = $1, updated_at = $2 +WHERE job_id = $3", + ) + .bind(lease_until) + .bind(now) + .bind(job.job_id) + .execute(&mut *tx) + .await?; + + job.status = "CLAIMED".to_string(); + job.available_at = lease_until; + job.updated_at = now; + + Some(job) + } else { + None + }; + + tx.commit().await?; + + Ok(job) +} + +/// Marks a consolidation worker job as completed. +pub async fn mark_consolidation_run_job_done<'e, E>( + executor: E, + job_id: Uuid, + now: OffsetDateTime, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +UPDATE consolidation_run_jobs +SET status = 'DONE', updated_at = $1 +WHERE job_id = $2", + ) + .bind(now) + .bind(job_id) + .execute(executor) + .await?; + + Ok(()) +} + +/// Marks a consolidation worker job as failed and schedules its retry. +pub async fn mark_consolidation_run_job_failed( + db: &Db, + job_id: Uuid, + attempts: i32, + error_text: &str, + available_at: OffsetDateTime, + now: OffsetDateTime, +) -> Result<()> { + sqlx::query( + "\ +UPDATE consolidation_run_jobs +SET status = 'FAILED', + attempts = $1, + last_error = $2, + available_at = $3, + updated_at = $4 +WHERE job_id = $5", + ) + .bind(attempts) + .bind(error_text) + .bind(available_at) + .bind(now) + .bind(job_id) + .execute(&db.pool) + .await?; + + Ok(()) +} diff --git a/packages/elf-storage/src/consolidation/proposals.rs b/packages/elf-storage/src/consolidation/proposals.rs new file mode 100644 index 00000000..9135d256 --- /dev/null +++ b/packages/elf-storage/src/consolidation/proposals.rs @@ -0,0 +1,397 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{ + Result, + consolidation::{ + sql::CONSOLIDATION_PROPOSAL_SELECT, + types::{ + ConsolidationProposalReviewEventInsert, ConsolidationProposalReviewUpdate, + ConsolidationProposalTargetRefUpdate, + }, + }, + models::{ConsolidationProposal, ConsolidationProposalReviewEvent}, +}; + +/// Inserts one consolidation proposal. +pub async fn insert_consolidation_proposal<'e, E>( + executor: E, + proposal: &ConsolidationProposal, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO consolidation_proposals ( + proposal_id, + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + unsupported_claim_flags, + contradiction_markers, + staleness_markers, + target_ref, + proposed_payload, + reviewer_agent_id, + review_comment, + reviewed_at, + created_at, + updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)", + ) + .bind(proposal.proposal_id) + .bind(proposal.run_id) + .bind(proposal.tenant_id.as_str()) + .bind(proposal.project_id.as_str()) + .bind(proposal.agent_id.as_str()) + .bind(proposal.contract_schema.as_str()) + .bind(proposal.proposal_kind.as_str()) + .bind(proposal.apply_intent.as_str()) + .bind(proposal.review_state.as_str()) + .bind(&proposal.source_refs) + .bind(&proposal.source_snapshot) + .bind(&proposal.lineage) + .bind(&proposal.diff) + .bind(proposal.confidence) + .bind(&proposal.unsupported_claim_flags) + .bind(&proposal.contradiction_markers) + .bind(&proposal.staleness_markers) + .bind(&proposal.target_ref) + .bind(&proposal.proposed_payload) + .bind(proposal.reviewer_agent_id.as_deref()) + .bind(proposal.review_comment.as_deref()) + .bind(proposal.reviewed_at) + .bind(proposal.created_at) + .bind(proposal.updated_at) + .execute(executor) + .await?; + + Ok(()) +} + +/// Fetches one consolidation proposal by tenant and proposal identifier. +pub async fn get_consolidation_proposal<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + proposal_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, ConsolidationProposal>(CONSOLIDATION_PROPOSAL_SELECT) + .bind(tenant_id) + .bind(project_id) + .bind(proposal_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Locks one consolidation proposal by tenant and proposal identifier. +pub async fn lock_consolidation_proposal<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + proposal_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, ConsolidationProposal>( + "\ +SELECT + proposal_id, + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, + COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, + COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + reviewer_agent_id, + review_comment, + reviewed_at, + created_at, + updated_at +FROM consolidation_proposals +WHERE tenant_id = $1 AND project_id = $2 AND proposal_id = $3 +LIMIT 1 +FOR UPDATE", + ) + .bind(tenant_id) + .bind(project_id) + .bind(proposal_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Lists consolidation proposals for one tenant and project. +pub async fn list_consolidation_proposals<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + run_id: Option, + review_state: Option<&str>, + limit: i64, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, ConsolidationProposal>( + "\ +SELECT + proposal_id, + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, + COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, + COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + reviewer_agent_id, + review_comment, + reviewed_at, + created_at, + updated_at +FROM consolidation_proposals +WHERE tenant_id = $1 + AND project_id = $2 + AND ($3::uuid IS NULL OR run_id = $3) + AND ($4::text IS NULL OR review_state = $4) +ORDER BY created_at DESC, proposal_id DESC +LIMIT $5", + ) + .bind(tenant_id) + .bind(project_id) + .bind(run_id) + .bind(review_state) + .bind(limit) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Updates one proposal review state. +pub async fn update_consolidation_proposal_review<'e, E>( + executor: E, + args: ConsolidationProposalReviewUpdate<'_>, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, ConsolidationProposal>( + "\ +UPDATE consolidation_proposals +SET + review_state = $1, + reviewer_agent_id = $2, + review_comment = $3, + reviewed_at = $4, + updated_at = $4 +WHERE tenant_id = $5 AND project_id = $6 AND proposal_id = $7 +RETURNING + proposal_id, + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, + COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, + COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + reviewer_agent_id, + review_comment, + reviewed_at, + created_at, + updated_at", + ) + .bind(args.review_state) + .bind(args.reviewer_agent_id) + .bind(args.review_comment) + .bind(args.now) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.proposal_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Updates one proposal target reference. +pub async fn update_consolidation_proposal_target_ref<'e, E>( + executor: E, + args: ConsolidationProposalTargetRefUpdate<'_>, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, ConsolidationProposal>( + "\ +UPDATE consolidation_proposals +SET target_ref = $1, updated_at = $2 +WHERE tenant_id = $3 AND project_id = $4 AND proposal_id = $5 +RETURNING + proposal_id, + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, + COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, + COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + reviewer_agent_id, + review_comment, + reviewed_at, + created_at, + updated_at", + ) + .bind(args.target_ref) + .bind(args.now) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.proposal_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Inserts one proposal review audit event. +pub async fn insert_consolidation_proposal_review_event<'e, E>( + executor: E, + args: ConsolidationProposalReviewEventInsert<'_>, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO consolidation_proposal_reviews ( + review_id, + proposal_id, + run_id, + tenant_id, + project_id, + reviewer_agent_id, + action, + from_review_state, + to_review_state, + review_comment, + created_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + ) + .bind(args.review_id) + .bind(args.proposal_id) + .bind(args.run_id) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.reviewer_agent_id) + .bind(args.action) + .bind(args.from_review_state) + .bind(args.to_review_state) + .bind(args.review_comment) + .bind(args.created_at) + .execute(executor) + .await?; + + Ok(()) +} + +/// Lists review events for one consolidation proposal. +pub async fn list_consolidation_proposal_review_events<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + proposal_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, ConsolidationProposalReviewEvent>( + "\ +SELECT + review_id, + proposal_id, + run_id, + tenant_id, + project_id, + reviewer_agent_id, + action, + from_review_state, + to_review_state, + review_comment, + created_at +FROM consolidation_proposal_reviews +WHERE tenant_id = $1 AND project_id = $2 AND proposal_id = $3 +ORDER BY created_at ASC, review_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(proposal_id) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/consolidation/runs.rs b/packages/elf-storage/src/consolidation/runs.rs new file mode 100644 index 00000000..9ca5d086 --- /dev/null +++ b/packages/elf-storage/src/consolidation/runs.rs @@ -0,0 +1,162 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{ + Result, + consolidation::{sql::CONSOLIDATION_RUN_SELECT, types::ConsolidationRunStateUpdate}, + models::ConsolidationRun, +}; + +/// Inserts one consolidation run. +pub async fn insert_consolidation_run<'e, E>(executor: E, run: &ConsolidationRun) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO consolidation_runs ( + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + job_kind, + status, + input_refs, + source_snapshot, + lineage, + error, + created_at, + updated_at, + completed_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", + ) + .bind(run.run_id) + .bind(run.tenant_id.as_str()) + .bind(run.project_id.as_str()) + .bind(run.agent_id.as_str()) + .bind(run.contract_schema.as_str()) + .bind(run.job_kind.as_str()) + .bind(run.status.as_str()) + .bind(&run.input_refs) + .bind(&run.source_snapshot) + .bind(&run.lineage) + .bind(&run.error) + .bind(run.created_at) + .bind(run.updated_at) + .bind(run.completed_at) + .execute(executor) + .await?; + + Ok(()) +} + +/// Fetches one consolidation run by tenant and run identifier. +pub async fn get_consolidation_run<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + run_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, ConsolidationRun>(CONSOLIDATION_RUN_SELECT) + .bind(tenant_id) + .bind(project_id) + .bind(run_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Lists consolidation runs for one tenant and project. +pub async fn list_consolidation_runs<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + limit: i64, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, ConsolidationRun>( + "\ +SELECT + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + job_kind, + status, + input_refs, + source_snapshot, + lineage, + COALESCE(error, '{}'::jsonb) AS error, + created_at, + updated_at, + completed_at +FROM consolidation_runs +WHERE tenant_id = $1 AND project_id = $2 +ORDER BY created_at DESC, run_id DESC +LIMIT $3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(limit) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Updates one consolidation run state. +pub async fn update_consolidation_run_state<'e, E>( + executor: E, + args: ConsolidationRunStateUpdate<'_>, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, ConsolidationRun>( + "\ +UPDATE consolidation_runs +SET + status = $1, + error = $2, + updated_at = $3, + completed_at = CASE + WHEN $1 IN ('completed', 'failed', 'cancelled') THEN $3 + ELSE completed_at + END +WHERE tenant_id = $4 AND project_id = $5 AND run_id = $6 +RETURNING + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + job_kind, + status, + input_refs, + source_snapshot, + lineage, + COALESCE(error, '{}'::jsonb) AS error, + created_at, + updated_at, + completed_at", + ) + .bind(args.status) + .bind(args.error) + .bind(args.now) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.run_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} diff --git a/packages/elf-storage/src/consolidation/sql.rs b/packages/elf-storage/src/consolidation/sql.rs new file mode 100644 index 00000000..62d70fd3 --- /dev/null +++ b/packages/elf-storage/src/consolidation/sql.rs @@ -0,0 +1,48 @@ +pub(super) const CONSOLIDATION_RUN_SELECT: &str = "\ +SELECT + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + job_kind, + status, + input_refs, + source_snapshot, + lineage, + COALESCE(error, '{}'::jsonb) AS error, + created_at, + updated_at, + completed_at +FROM consolidation_runs +WHERE tenant_id = $1 AND project_id = $2 AND run_id = $3 +LIMIT 1"; +pub(super) const CONSOLIDATION_PROPOSAL_SELECT: &str = "\ +SELECT + proposal_id, + run_id, + tenant_id, + project_id, + agent_id, + contract_schema, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, + COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, + COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + reviewer_agent_id, + review_comment, + reviewed_at, + created_at, + updated_at +FROM consolidation_proposals +WHERE tenant_id = $1 AND project_id = $2 AND proposal_id = $3 +LIMIT 1"; diff --git a/packages/elf-storage/src/consolidation/types.rs b/packages/elf-storage/src/consolidation/types.rs new file mode 100644 index 00000000..d822d938 --- /dev/null +++ b/packages/elf-storage/src/consolidation/types.rs @@ -0,0 +1,97 @@ +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Arguments for updating a consolidation run state. +pub struct ConsolidationRunStateUpdate<'a> { + /// Tenant that owns the run. + pub tenant_id: &'a str, + /// Project that owns the run. + pub project_id: &'a str, + /// Run identifier. + pub run_id: Uuid, + /// New run status. + pub status: &'a str, + /// Structured error payload for terminal failure states. + pub error: &'a Value, + /// Update timestamp. + pub now: OffsetDateTime, +} + +/// Arguments for updating a consolidation proposal review state. +pub struct ConsolidationProposalReviewUpdate<'a> { + /// Tenant that owns the proposal. + pub tenant_id: &'a str, + /// Project that owns the proposal. + pub project_id: &'a str, + /// Proposal identifier. + pub proposal_id: Uuid, + /// New review state. + pub review_state: &'a str, + /// Reviewing agent identifier. + pub reviewer_agent_id: &'a str, + /// Optional reviewer comment. + pub review_comment: Option<&'a str>, + /// Update timestamp. + pub now: OffsetDateTime, +} + +/// Arguments for updating a consolidation proposal target reference. +pub struct ConsolidationProposalTargetRefUpdate<'a> { + /// Tenant that owns the proposal. + pub tenant_id: &'a str, + /// Project that owns the proposal. + pub project_id: &'a str, + /// Proposal identifier. + pub proposal_id: Uuid, + /// New target reference. + pub target_ref: &'a Value, + /// Update timestamp. + pub now: OffsetDateTime, +} + +/// Arguments for inserting a consolidation proposal review event. +pub struct ConsolidationProposalReviewEventInsert<'a> { + /// Review event identifier. + pub review_id: Uuid, + /// Reviewed proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the proposal. + pub tenant_id: &'a str, + /// Project that owns the proposal. + pub project_id: &'a str, + /// Reviewing agent identifier. + pub reviewer_agent_id: &'a str, + /// Review action requested by the reviewer. + pub action: &'a str, + /// Review state before the transition. + pub from_review_state: &'a str, + /// Review state after the transition. + pub to_review_state: &'a str, + /// Optional reviewer comment. + pub review_comment: Option<&'a str>, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Arguments for inserting a consolidation worker job. +pub struct ConsolidationRunJobInsert<'a> { + /// Worker job identifier. + pub job_id: Uuid, + /// Consolidation run to materialize. + pub run_id: Uuid, + /// Tenant that owns the run. + pub tenant_id: &'a str, + /// Project that owns the run. + pub project_id: &'a str, + /// Agent that registered the run. + pub agent_id: &'a str, + /// Job kind, such as fixture or manual. + pub job_kind: &'a str, + /// Queued proposal payload. + pub payload: &'a Value, + /// Creation timestamp. + pub now: OffsetDateTime, +} diff --git a/packages/elf-storage/src/graph.rs b/packages/elf-storage/src/graph.rs index 4bed5e36..26414c48 100644 --- a/packages/elf-storage/src/graph.rs +++ b/packages/elf-storage/src/graph.rs @@ -1,12 +1,29 @@ //! Graph entity, predicate, and fact storage helpers. +mod entity; +mod fact; +mod predicate; + +pub use self::{ + entity::{resolve_entity_by_surface, upsert_entity, upsert_entity_alias}, + fact::{ + fetch_active_facts_for_subject, insert_fact_with_evidence, + supersede_conflicting_active_facts, upsert_fact_with_evidence, + }, + predicate::{ + add_predicate_alias, get_predicate_by_id, list_predicate_aliases, + list_predicates_by_scope_keys, resolve_or_register_predicate, + resolve_predicate_no_register, update_predicate, update_predicate_guarded, + }, +}; + use sqlx::PgConnection; use time::OffsetDateTime; use uuid::Uuid; use crate::{ Error, Result, - models::{GraphEntity, GraphFact, GraphPredicate, GraphPredicateAlias}, + models::{GraphEntity, GraphFact}, }; const GRAPH_PREDICATE_SCOPE_GLOBAL: &str = "__global__"; @@ -22,958 +39,6 @@ pub fn normalize_predicate_name(input: &str) -> String { normalize_entity_name(input) } -/// Lists predicates visible within the provided scope keys. -pub async fn list_predicates_by_scope_keys( - executor: &mut PgConnection, - scope_keys: &[String], -) -> Result> { - if scope_keys.is_empty() { - return Ok(vec![]); - } - - let scope_keys = scope_keys.to_vec(); - let rows = sqlx::query_as::<_, GraphPredicate>( - "\ -SELECT - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at -FROM graph_predicates -WHERE scope_key = ANY($1::text[]) -ORDER BY scope_key, canonical_norm", - ) - .bind(&scope_keys) - .fetch_all(&mut *executor) - .await?; - - Ok(rows) -} - -/// Fetches one predicate by identifier. -pub async fn get_predicate_by_id( - executor: &mut PgConnection, - predicate_id: Uuid, -) -> Result> { - let row = sqlx::query_as::<_, GraphPredicate>( - "\ -SELECT - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at -FROM graph_predicates -WHERE predicate_id = $1", - ) - .bind(predicate_id) - .fetch_optional(&mut *executor) - .await?; - - Ok(row) -} - -/// Updates a predicate's mutable status and cardinality fields. -pub async fn update_predicate( - executor: &mut PgConnection, - predicate_id: Uuid, - status: Option<&str>, - cardinality: Option<&str>, -) -> Result { - let status = status.map(str::trim); - - if status.is_some_and(str::is_empty) { - return Err(Error::InvalidArgument("graph predicate status must not be empty".to_string())); - } - - let cardinality = cardinality.map(str::trim); - - if cardinality.is_some_and(str::is_empty) { - return Err(Error::InvalidArgument( - "graph predicate cardinality must not be empty".to_string(), - )); - } - - let row = sqlx::query_as::<_, GraphPredicate>( - "\ -UPDATE graph_predicates -SET - status = COALESCE($2, status), - cardinality = COALESCE($3, cardinality), - updated_at = now() -WHERE predicate_id = $1 -RETURNING - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at", - ) - .bind(predicate_id) - .bind(status) - .bind(cardinality) - .fetch_optional(&mut *executor) - .await?; - - row.ok_or_else(|| { - Error::NotFound(format!("graph predicate not found; predicate_id={predicate_id}")) - }) -} - -/// Updates a predicate only when its current state matches the expected guard values. -pub async fn update_predicate_guarded( - executor: &mut PgConnection, - predicate_id: Uuid, - expected_status: &str, - expected_cardinality: &str, - status: Option<&str>, - cardinality: Option<&str>, -) -> Result { - let expected_status = expected_status.trim(); - let expected_cardinality = expected_cardinality.trim(); - - if expected_status.is_empty() { - return Err(Error::InvalidArgument( - "graph predicate expected_status must not be empty".to_string(), - )); - } - if expected_cardinality.is_empty() { - return Err(Error::InvalidArgument( - "graph predicate expected_cardinality must not be empty".to_string(), - )); - } - if expected_status == "deprecated" { - return Err(Error::Conflict(format!( - "graph predicate is deprecated and cannot be modified; predicate_id={predicate_id}" - ))); - } - - let status = status.map(str::trim); - - if status.is_some_and(str::is_empty) { - return Err(Error::InvalidArgument("graph predicate status must not be empty".to_string())); - } - - let cardinality = cardinality.map(str::trim); - - if cardinality.is_some_and(str::is_empty) { - return Err(Error::InvalidArgument( - "graph predicate cardinality must not be empty".to_string(), - )); - } - - let row = sqlx::query_as::<_, GraphPredicate>( - "\ - UPDATE graph_predicates - SET - status = COALESCE($4, status), - cardinality = COALESCE($5, cardinality), - updated_at = now() - WHERE predicate_id = $1 - AND status = $2 - AND cardinality = $3 - RETURNING - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at", - ) - .bind(predicate_id) - .bind(expected_status) - .bind(expected_cardinality) - .bind(status) - .bind(cardinality) - .fetch_optional(&mut *executor) - .await?; - - if let Some(row) = row { - return Ok(row); - } - - let existing = get_predicate_by_id(executor, predicate_id).await?; - let Some(_) = existing else { - return Err(Error::NotFound(format!( - "graph predicate not found; predicate_id={predicate_id}" - ))); - }; - - Err(Error::Conflict(format!( - "graph predicate update conflict; predicate_id={predicate_id} expected_status={expected_status} expected_cardinality={expected_cardinality}" - ))) -} - -/// Registers an additional alias for an existing predicate. -pub async fn add_predicate_alias( - executor: &mut PgConnection, - predicate_id: Uuid, - alias: &str, -) -> Result<()> { - let alias = alias.trim(); - - if alias.is_empty() { - return Err(Error::InvalidArgument( - "graph predicate alias is required; alias must not be empty".to_string(), - )); - } - - let alias_norm = normalize_predicate_name(alias); - - if alias_norm.is_empty() { - return Err(Error::InvalidArgument( - "graph predicate alias is required; alias_norm must not be empty".to_string(), - )); - } - - let predicate_scope_key: Option<(String,)> = sqlx::query_as( - "\ -SELECT scope_key -FROM graph_predicates -WHERE predicate_id = $1", - ) - .bind(predicate_id) - .fetch_optional(&mut *executor) - .await?; - let Some((scope_key,)) = predicate_scope_key else { - return Err(Error::NotFound(format!( - "graph predicate not found; predicate_id={predicate_id}" - ))); - }; - let res = sqlx::query( - "\ -INSERT INTO graph_predicate_aliases ( - alias_id, - predicate_id, - scope_key, - alias, - alias_norm, - created_at -) -VALUES ($1, $2, $3, $4, $5, now()) -ON CONFLICT (scope_key, alias_norm) DO UPDATE -SET alias = EXCLUDED.alias -WHERE graph_predicate_aliases.predicate_id = EXCLUDED.predicate_id", - ) - .bind(Uuid::new_v4()) - .bind(predicate_id) - .bind(&scope_key) - .bind(alias) - .bind(&alias_norm) - .execute(&mut *executor) - .await?; - - if res.rows_affected() == 0 { - return Err(Error::Conflict(format!( - "graph predicate alias already bound; scope_key={scope_key} alias_norm={alias_norm}" - ))); - } - - Ok(()) -} - -/// Lists aliases bound to one predicate. -pub async fn list_predicate_aliases( - executor: &mut PgConnection, - predicate_id: Uuid, -) -> Result> { - let rows = sqlx::query_as::<_, GraphPredicateAlias>( - "\ -SELECT - alias_id, - predicate_id, - scope_key, - alias, - alias_norm, - created_at -FROM graph_predicate_aliases -WHERE predicate_id = $1 -ORDER BY created_at ASC, alias_norm ASC", - ) - .bind(predicate_id) - .fetch_all(&mut *executor) - .await?; - - Ok(rows) -} - -/// Resolves a predicate surface across visible scopes or registers a project-scoped predicate. -pub async fn resolve_or_register_predicate( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - predicate_surface: &str, -) -> Result { - let predicate_surface = predicate_surface.trim(); - - if predicate_surface.is_empty() { - return Err(Error::InvalidArgument( - "graph predicate is required; predicate_surface must not be empty".to_string(), - )); - } - - let alias_norm = normalize_predicate_name(predicate_surface); - let tenant_project_scope = predicate_scope_key_tenant_project(tenant_id, project_id); - let project_scope = predicate_scope_key_project(project_id); - let global_scope = GRAPH_PREDICATE_SCOPE_GLOBAL.to_string(); - - for scope_key in [&tenant_project_scope, &project_scope, &global_scope] { - if let Some(row) = sqlx::query_as::<_, GraphPredicate>( - "\ -SELECT - gp.predicate_id, - gp.scope_key, - gp.tenant_id, - gp.project_id, - gp.canonical, - gp.canonical_norm, - gp.cardinality, - gp.status, - gp.created_at, - gp.updated_at -FROM graph_predicate_aliases gpa -JOIN graph_predicates gp ON gp.predicate_id = gpa.predicate_id -WHERE gpa.scope_key = $1 - AND gpa.alias_norm = $2 -LIMIT 1", - ) - .bind(scope_key) - .bind(&alias_norm) - .fetch_optional(&mut *executor) - .await? - { - return Ok(row); - } - } - - let predicate_id = Uuid::new_v4(); - let predicate_row = sqlx::query_as::<_, GraphPredicate>( - "\ -INSERT INTO graph_predicates ( - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at -) -VALUES ($1, $2, $3, $4, $5, $6, 'multi', 'pending', now(), now()) -ON CONFLICT (scope_key, canonical_norm) -DO UPDATE -SET canonical = graph_predicates.canonical -RETURNING - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at", - ) - .bind(predicate_id) - .bind(&tenant_project_scope) - .bind(tenant_id) - .bind(project_id) - .bind(predicate_surface) - .bind(&alias_norm) - .fetch_one(&mut *executor) - .await?; - - sqlx::query( - "\ -INSERT INTO graph_predicate_aliases ( - alias_id, - predicate_id, - scope_key, - alias, - alias_norm, - created_at -) -VALUES ($1, $2, $3, $4, $5, now()) -ON CONFLICT (scope_key, alias_norm) DO NOTHING", - ) - .bind(Uuid::new_v4()) - .bind(predicate_row.predicate_id) - .bind(&tenant_project_scope) - .bind(predicate_surface) - .bind(&alias_norm) - .execute(&mut *executor) - .await?; - - Ok(predicate_row) -} - -/// Resolves a predicate surface across visible scopes without creating a new predicate. -pub async fn resolve_predicate_no_register( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - predicate_surface: &str, -) -> Result> { - let predicate_surface = predicate_surface.trim(); - - if predicate_surface.is_empty() { - return Err(Error::InvalidArgument( - "graph predicate is required; predicate_surface must not be empty".to_string(), - )); - } - - let alias_norm = normalize_predicate_name(predicate_surface); - let tenant_project_scope = predicate_scope_key_tenant_project(tenant_id, project_id); - let project_scope = predicate_scope_key_project(project_id); - let global_scope = GRAPH_PREDICATE_SCOPE_GLOBAL.to_string(); - - for scope_key in [&tenant_project_scope, &project_scope, &global_scope] { - if let Some(row) = sqlx::query_as::<_, GraphPredicate>( - "\ -SELECT - gp.predicate_id, - gp.scope_key, - gp.tenant_id, - gp.project_id, - gp.canonical, - gp.canonical_norm, - gp.cardinality, - gp.status, - gp.created_at, - gp.updated_at -FROM graph_predicate_aliases gpa -JOIN graph_predicates gp ON gp.predicate_id = gpa.predicate_id -WHERE gpa.scope_key = $1 - AND gpa.alias_norm = $2 -LIMIT 1", - ) - .bind(scope_key) - .bind(&alias_norm) - .fetch_optional(&mut *executor) - .await? - { - return Ok(Some(row)); - } - } - - Ok(None) -} - -/// Resolves an entity surface against canonical names and aliases within one tenant/project. -pub async fn resolve_entity_by_surface( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - entity_surface: &str, -) -> Result> { - let entity_surface = entity_surface.trim(); - - if entity_surface.is_empty() { - return Err(Error::InvalidArgument( - "graph entity is required; entity_surface must not be empty".to_string(), - )); - } - - let canonical_norm = normalize_entity_name(entity_surface); - let canonical = sqlx::query_as::<_, GraphEntity>( - "\ -SELECT - entity_id, - tenant_id, - project_id, - canonical, - canonical_norm, - kind, - created_at, - updated_at -FROM graph_entities -WHERE tenant_id = $1 - AND project_id = $2 - AND canonical_norm = $3", - ) - .bind(tenant_id) - .bind(project_id) - .bind(&canonical_norm) - .fetch_optional(&mut *executor) - .await?; - - if let Some(entity) = canonical { - return Ok(Some(entity)); - } - - let alias_matches = sqlx::query_as::<_, GraphEntity>( - "\ -SELECT - ge.entity_id, - ge.tenant_id, - ge.project_id, - ge.canonical, - ge.canonical_norm, - ge.kind, - ge.created_at, - ge.updated_at -FROM graph_entity_aliases gea -JOIN graph_entities ge ON ge.entity_id = gea.entity_id -WHERE ge.tenant_id = $1 - AND ge.project_id = $2 - AND gea.alias_norm = $3", - ) - .bind(tenant_id) - .bind(project_id) - .bind(&canonical_norm) - .fetch_all(&mut *executor) - .await?; - - if alias_matches.len() == 1 { - return Ok(alias_matches.into_iter().next()); - } - if alias_matches.len() > 1 { - let candidates = alias_matches - .iter() - .map(|entity| entity.entity_id.to_string()) - .collect::>() - .join(", "); - - return Err(Error::Conflict(format!( - "graph entity surface is ambiguous; entity_surface={entity_surface} alias_norm={canonical_norm} candidates=[{candidates}]" - ))); - } - - Ok(None) -} - -#[allow(clippy::too_many_arguments)] -/// Inserts a new graph fact row and attaches its evidence note identifiers. -pub async fn insert_fact_with_evidence( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - agent_id: &str, - scope: &str, - subject_entity_id: Uuid, - predicate: &str, - predicate_id: Uuid, - object_entity_id: Option, - object_value: Option<&str>, - valid_from: OffsetDateTime, - valid_to: Option, - evidence_note_ids: &[Uuid], -) -> Result { - if evidence_note_ids.is_empty() { - return Err(Error::InvalidArgument( - "graph fact evidence is required; evidence_note_ids must not be empty".to_string(), - )); - } - - match (object_entity_id, object_value) { - (Some(_), None) | (None, Some(_)) => (), - _ => { - return Err(Error::InvalidArgument( - "graph fact must provide exactly one of object_entity_id and object_value" - .to_string(), - )); - }, - } - - let row: (Uuid,) = sqlx::query_as( - "\ -INSERT INTO graph_facts ( - fact_id, - tenant_id, - project_id, - agent_id, - scope, - subject_entity_id, - predicate, - predicate_id, - object_entity_id, - object_value, - valid_from, - valid_to, - created_at, - updated_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) -RETURNING fact_id", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(scope) - .bind(subject_entity_id) - .bind(predicate) - .bind(predicate_id) - .bind(object_entity_id) - .bind(object_value) - .bind(valid_from) - .bind(valid_to) - .fetch_one(&mut *executor) - .await?; - let fact_id = row.0; - - for note_id in evidence_note_ids { - sqlx::query( - "\ -INSERT INTO graph_fact_evidence (evidence_id, fact_id, note_id, created_at) -VALUES ($1, $2, $3, now()) -ON CONFLICT (fact_id, note_id) DO NOTHING", - ) - .bind(Uuid::new_v4()) - .bind(fact_id) - .bind(*note_id) - .execute(&mut *executor) - .await?; - } - - Ok(fact_id) -} - -#[allow(clippy::too_many_arguments)] -/// Upserts an active graph fact row and ensures the provided evidence links exist. -pub async fn upsert_fact_with_evidence( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - agent_id: &str, - scope: &str, - subject_entity_id: Uuid, - predicate: &str, - predicate_id: Uuid, - object_entity_id: Option, - object_value: Option<&str>, - valid_from: OffsetDateTime, - valid_to: Option, - evidence_note_ids: &[Uuid], -) -> Result { - if evidence_note_ids.is_empty() { - return Err(Error::InvalidArgument( - "graph fact evidence is required; evidence_note_ids must not be empty".to_string(), - )); - } - - let fact_id = match (object_entity_id, object_value) { - (Some(object_entity_id), None) => { - let row: (Uuid,) = sqlx::query_as::<_, (Uuid,)>( - "\ -INSERT INTO graph_facts ( - fact_id, - tenant_id, - project_id, - agent_id, - scope, - subject_entity_id, - predicate, - predicate_id, - object_entity_id, - object_value, - valid_from, - valid_to, - created_at, - updated_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) -ON CONFLICT (tenant_id, project_id, scope, subject_entity_id, predicate_id, object_entity_id) -WHERE valid_to IS NULL AND object_entity_id IS NOT NULL -DO UPDATE -SET updated_at = graph_facts.updated_at -RETURNING fact_id", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(scope) - .bind(subject_entity_id) - .bind(predicate) - .bind(predicate_id) - .bind(Some(object_entity_id)) - .bind(None::) - .bind(valid_from) - .bind(valid_to) - .fetch_one(&mut *executor) - .await?; - - row.0 - }, - (None, Some(object_value)) => { - let row: (Uuid,) = sqlx::query_as::<_, (Uuid,)>( - "\ -INSERT INTO graph_facts ( - fact_id, - tenant_id, - project_id, - agent_id, - scope, - subject_entity_id, - predicate, - predicate_id, - object_entity_id, - object_value, - valid_from, - valid_to, - created_at, - updated_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) -ON CONFLICT (tenant_id, project_id, scope, subject_entity_id, predicate_id, object_value) -WHERE valid_to IS NULL AND object_value IS NOT NULL -DO UPDATE -SET updated_at = graph_facts.updated_at -RETURNING fact_id", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(scope) - .bind(subject_entity_id) - .bind(predicate) - .bind(predicate_id) - .bind(None::) - .bind(Some(object_value)) - .bind(valid_from) - .bind(valid_to) - .fetch_one(&mut *executor) - .await?; - - row.0 - }, - _ => { - return Err(Error::InvalidArgument( - "graph fact must provide exactly one of object_entity_id and object_value" - .to_string(), - )); - }, - }; - - for note_id in evidence_note_ids { - sqlx::query( - "\ -INSERT INTO graph_fact_evidence (evidence_id, fact_id, note_id, created_at) -VALUES ($1, $2, $3, now()) -ON CONFLICT (fact_id, note_id) DO NOTHING", - ) - .bind(Uuid::new_v4()) - .bind(fact_id) - .bind(*note_id) - .execute(&mut *executor) - .await?; - } - - Ok(fact_id) -} - -/// Upserts an entity by normalized canonical surface and returns its identifier. -pub async fn upsert_entity( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - canonical: &str, - kind: Option<&str>, -) -> Result { - let canonical_norm = normalize_entity_name(canonical); - let row: (Uuid,) = sqlx::query_as( - "\ -INSERT INTO graph_entities ( - entity_id, - tenant_id, - project_id, - canonical, - canonical_norm, - kind, - created_at, - updated_at -) -VALUES ( - $1, $2, $3, $4, $5, $6, now(), now() -) -ON CONFLICT (tenant_id, project_id, canonical_norm) -DO UPDATE -SET - canonical = EXCLUDED.canonical, - kind = COALESCE(EXCLUDED.kind, graph_entities.kind), - updated_at = now() -RETURNING entity_id", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(canonical) - .bind(&canonical_norm) - .bind(kind) - .fetch_one(executor) - .await?; - - Ok(row.0) -} - -/// Upserts an alias for an existing entity. -pub async fn upsert_entity_alias( - executor: &mut PgConnection, - entity_id: Uuid, - alias: &str, -) -> Result<()> { - let alias_norm = normalize_entity_name(alias); - - sqlx::query( - "\ -INSERT INTO graph_entity_aliases ( - alias_id, - entity_id, - alias, - alias_norm, - created_at -) -VALUES ($1, $2, $3, $4, now()) -ON CONFLICT (entity_id, alias_norm) -DO UPDATE SET alias = EXCLUDED.alias", - ) - .bind(Uuid::new_v4()) - .bind(entity_id) - .bind(alias) - .bind(&alias_norm) - .execute(executor) - .await?; - - Ok(()) -} - -/// Fetches active facts for one subject entity at the provided point in time. -pub async fn fetch_active_facts_for_subject( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - scope: &str, - subject_entity_id: Uuid, - now: OffsetDateTime, -) -> Result> { - let rows = sqlx::query_as::<_, GraphFact>( - "\ -SELECT - fact_id, - tenant_id, - project_id, - agent_id, - scope, - subject_entity_id, - predicate, - predicate_id, - object_entity_id, - object_value, - valid_from, - valid_to, - created_at, - updated_at -FROM graph_facts -WHERE tenant_id = $1 - AND project_id = $2 - AND scope = $3 - AND subject_entity_id = $4 - AND valid_from <= $5 - AND (valid_to IS NULL OR valid_to > $5)", - ) - .bind(tenant_id) - .bind(project_id) - .bind(scope) - .bind(subject_entity_id) - .bind(now) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -#[allow(clippy::too_many_arguments)] -/// Supersedes active facts that conflict with the replacement fact and records supersession rows. -pub async fn supersede_conflicting_active_facts( - executor: &mut PgConnection, - tenant_id: &str, - project_id: &str, - scope: &str, - subject_entity_id: Uuid, - predicate_id: Uuid, - to_fact_id: Uuid, - note_id: Uuid, - effective_at: OffsetDateTime, -) -> Result> { - let superseded: Vec<(Uuid,)> = sqlx::query_as( - "\ -UPDATE graph_facts -SET valid_to = $1, updated_at = now() -WHERE tenant_id = $2 - AND project_id = $3 - AND scope = $4 - AND subject_entity_id = $5 - AND predicate_id = $6 - AND valid_to IS NULL - AND valid_from <= $1 - AND fact_id <> $7 -RETURNING fact_id", - ) - .bind(effective_at) - .bind(tenant_id) - .bind(project_id) - .bind(scope) - .bind(subject_entity_id) - .bind(predicate_id) - .bind(to_fact_id) - .fetch_all(&mut *executor) - .await?; - - for (from_fact_id,) in &superseded { - sqlx::query( - "\ -INSERT INTO graph_fact_supersessions ( - supersession_id, - tenant_id, - project_id, - from_fact_id, - to_fact_id, - note_id, - effective_at, - created_at -) -VALUES ($1, $2, $3, $4, $5, $6, $7, now()) -ON CONFLICT (from_fact_id, to_fact_id, note_id) DO NOTHING", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(project_id) - .bind(*from_fact_id) - .bind(to_fact_id) - .bind(note_id) - .bind(effective_at) - .execute(&mut *executor) - .await?; - } - - Ok(superseded.into_iter().map(|(fact_id,)| fact_id).collect()) -} - fn predicate_scope_key_tenant_project(tenant_id: &str, project_id: &str) -> String { format!("{tenant_id}:{project_id}") } diff --git a/packages/elf-storage/src/graph/entity.rs b/packages/elf-storage/src/graph/entity.rs new file mode 100644 index 00000000..0b3925af --- /dev/null +++ b/packages/elf-storage/src/graph/entity.rs @@ -0,0 +1,159 @@ +use crate::graph::{self, Error, GraphEntity, PgConnection, Result, Uuid}; + +/// Resolves an entity surface against canonical names and aliases within one tenant/project. +pub async fn resolve_entity_by_surface( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + entity_surface: &str, +) -> Result> { + let entity_surface = entity_surface.trim(); + + if entity_surface.is_empty() { + return Err(Error::InvalidArgument( + "graph entity is required; entity_surface must not be empty".to_string(), + )); + } + + let canonical_norm = graph::normalize_entity_name(entity_surface); + let canonical = sqlx::query_as::<_, GraphEntity>( + "\ +SELECT + entity_id, + tenant_id, + project_id, + canonical, + canonical_norm, + kind, + created_at, + updated_at +FROM graph_entities +WHERE tenant_id = $1 + AND project_id = $2 + AND canonical_norm = $3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(&canonical_norm) + .fetch_optional(&mut *executor) + .await?; + + if let Some(entity) = canonical { + return Ok(Some(entity)); + } + + let alias_matches = sqlx::query_as::<_, GraphEntity>( + "\ +SELECT + ge.entity_id, + ge.tenant_id, + ge.project_id, + ge.canonical, + ge.canonical_norm, + ge.kind, + ge.created_at, + ge.updated_at +FROM graph_entity_aliases gea +JOIN graph_entities ge ON ge.entity_id = gea.entity_id +WHERE ge.tenant_id = $1 + AND ge.project_id = $2 + AND gea.alias_norm = $3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(&canonical_norm) + .fetch_all(&mut *executor) + .await?; + + if alias_matches.len() == 1 { + return Ok(alias_matches.into_iter().next()); + } + if alias_matches.len() > 1 { + let candidates = alias_matches + .iter() + .map(|entity| entity.entity_id.to_string()) + .collect::>() + .join(", "); + + return Err(Error::Conflict(format!( + "graph entity surface is ambiguous; entity_surface={entity_surface} alias_norm={canonical_norm} candidates=[{candidates}]" + ))); + } + + Ok(None) +} + +/// Upserts an entity by normalized canonical surface and returns its identifier. +pub async fn upsert_entity( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + canonical: &str, + kind: Option<&str>, +) -> Result { + let canonical_norm = graph::normalize_entity_name(canonical); + let row: (Uuid,) = sqlx::query_as( + "\ +INSERT INTO graph_entities ( + entity_id, + tenant_id, + project_id, + canonical, + canonical_norm, + kind, + created_at, + updated_at +) +VALUES ( + $1, $2, $3, $4, $5, $6, now(), now() +) +ON CONFLICT (tenant_id, project_id, canonical_norm) +DO UPDATE +SET + canonical = EXCLUDED.canonical, + kind = COALESCE(EXCLUDED.kind, graph_entities.kind), + updated_at = now() +RETURNING entity_id", + ) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(canonical) + .bind(&canonical_norm) + .bind(kind) + .fetch_one(executor) + .await?; + + Ok(row.0) +} + +/// Upserts an alias for an existing entity. +pub async fn upsert_entity_alias( + executor: &mut PgConnection, + entity_id: Uuid, + alias: &str, +) -> Result<()> { + let alias_norm = graph::normalize_entity_name(alias); + + sqlx::query( + "\ +INSERT INTO graph_entity_aliases ( + alias_id, + entity_id, + alias, + alias_norm, + created_at +) +VALUES ($1, $2, $3, $4, now()) +ON CONFLICT (entity_id, alias_norm) +DO UPDATE SET alias = EXCLUDED.alias", + ) + .bind(Uuid::new_v4()) + .bind(entity_id) + .bind(alias) + .bind(&alias_norm) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/packages/elf-storage/src/graph/fact.rs b/packages/elf-storage/src/graph/fact.rs new file mode 100644 index 00000000..30ce1419 --- /dev/null +++ b/packages/elf-storage/src/graph/fact.rs @@ -0,0 +1,335 @@ +use crate::graph::{Error, GraphFact, OffsetDateTime, PgConnection, Result, Uuid}; + +#[allow(clippy::too_many_arguments)] +/// Inserts a new graph fact row and attaches its evidence note identifiers. +pub async fn insert_fact_with_evidence( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + agent_id: &str, + scope: &str, + subject_entity_id: Uuid, + predicate: &str, + predicate_id: Uuid, + object_entity_id: Option, + object_value: Option<&str>, + valid_from: OffsetDateTime, + valid_to: Option, + evidence_note_ids: &[Uuid], +) -> Result { + if evidence_note_ids.is_empty() { + return Err(Error::InvalidArgument( + "graph fact evidence is required; evidence_note_ids must not be empty".to_string(), + )); + } + + match (object_entity_id, object_value) { + (Some(_), None) | (None, Some(_)) => (), + _ => { + return Err(Error::InvalidArgument( + "graph fact must provide exactly one of object_entity_id and object_value" + .to_string(), + )); + }, + } + + let row: (Uuid,) = sqlx::query_as( + "\ +INSERT INTO graph_facts ( + fact_id, + tenant_id, + project_id, + agent_id, + scope, + subject_entity_id, + predicate, + predicate_id, + object_entity_id, + object_value, + valid_from, + valid_to, + created_at, + updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) +RETURNING fact_id", + ) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(scope) + .bind(subject_entity_id) + .bind(predicate) + .bind(predicate_id) + .bind(object_entity_id) + .bind(object_value) + .bind(valid_from) + .bind(valid_to) + .fetch_one(&mut *executor) + .await?; + let fact_id = row.0; + + for note_id in evidence_note_ids { + sqlx::query( + "\ +INSERT INTO graph_fact_evidence (evidence_id, fact_id, note_id, created_at) +VALUES ($1, $2, $3, now()) +ON CONFLICT (fact_id, note_id) DO NOTHING", + ) + .bind(Uuid::new_v4()) + .bind(fact_id) + .bind(*note_id) + .execute(&mut *executor) + .await?; + } + + Ok(fact_id) +} + +#[allow(clippy::too_many_arguments)] +/// Upserts an active graph fact row and ensures the provided evidence links exist. +pub async fn upsert_fact_with_evidence( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + agent_id: &str, + scope: &str, + subject_entity_id: Uuid, + predicate: &str, + predicate_id: Uuid, + object_entity_id: Option, + object_value: Option<&str>, + valid_from: OffsetDateTime, + valid_to: Option, + evidence_note_ids: &[Uuid], +) -> Result { + if evidence_note_ids.is_empty() { + return Err(Error::InvalidArgument( + "graph fact evidence is required; evidence_note_ids must not be empty".to_string(), + )); + } + + let fact_id = match (object_entity_id, object_value) { + (Some(object_entity_id), None) => { + let row: (Uuid,) = sqlx::query_as::<_, (Uuid,)>( + "\ +INSERT INTO graph_facts ( + fact_id, + tenant_id, + project_id, + agent_id, + scope, + subject_entity_id, + predicate, + predicate_id, + object_entity_id, + object_value, + valid_from, + valid_to, + created_at, + updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) +ON CONFLICT (tenant_id, project_id, scope, subject_entity_id, predicate_id, object_entity_id) +WHERE valid_to IS NULL AND object_entity_id IS NOT NULL +DO UPDATE +SET updated_at = graph_facts.updated_at +RETURNING fact_id", + ) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(scope) + .bind(subject_entity_id) + .bind(predicate) + .bind(predicate_id) + .bind(Some(object_entity_id)) + .bind(None::) + .bind(valid_from) + .bind(valid_to) + .fetch_one(&mut *executor) + .await?; + + row.0 + }, + (None, Some(object_value)) => { + let row: (Uuid,) = sqlx::query_as::<_, (Uuid,)>( + "\ +INSERT INTO graph_facts ( + fact_id, + tenant_id, + project_id, + agent_id, + scope, + subject_entity_id, + predicate, + predicate_id, + object_entity_id, + object_value, + valid_from, + valid_to, + created_at, + updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) +ON CONFLICT (tenant_id, project_id, scope, subject_entity_id, predicate_id, object_value) +WHERE valid_to IS NULL AND object_value IS NOT NULL +DO UPDATE +SET updated_at = graph_facts.updated_at +RETURNING fact_id", + ) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(scope) + .bind(subject_entity_id) + .bind(predicate) + .bind(predicate_id) + .bind(None::) + .bind(Some(object_value)) + .bind(valid_from) + .bind(valid_to) + .fetch_one(&mut *executor) + .await?; + + row.0 + }, + _ => { + return Err(Error::InvalidArgument( + "graph fact must provide exactly one of object_entity_id and object_value" + .to_string(), + )); + }, + }; + + for note_id in evidence_note_ids { + sqlx::query( + "\ +INSERT INTO graph_fact_evidence (evidence_id, fact_id, note_id, created_at) +VALUES ($1, $2, $3, now()) +ON CONFLICT (fact_id, note_id) DO NOTHING", + ) + .bind(Uuid::new_v4()) + .bind(fact_id) + .bind(*note_id) + .execute(&mut *executor) + .await?; + } + + Ok(fact_id) +} + +/// Fetches active facts for one subject entity at the provided point in time. +pub async fn fetch_active_facts_for_subject( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + scope: &str, + subject_entity_id: Uuid, + now: OffsetDateTime, +) -> Result> { + let rows = sqlx::query_as::<_, GraphFact>( + "\ +SELECT + fact_id, + tenant_id, + project_id, + agent_id, + scope, + subject_entity_id, + predicate, + predicate_id, + object_entity_id, + object_value, + valid_from, + valid_to, + created_at, + updated_at +FROM graph_facts +WHERE tenant_id = $1 + AND project_id = $2 + AND scope = $3 + AND subject_entity_id = $4 + AND valid_from <= $5 + AND (valid_to IS NULL OR valid_to > $5)", + ) + .bind(tenant_id) + .bind(project_id) + .bind(scope) + .bind(subject_entity_id) + .bind(now) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +#[allow(clippy::too_many_arguments)] +/// Supersedes active facts that conflict with the replacement fact and records supersession rows. +pub async fn supersede_conflicting_active_facts( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + scope: &str, + subject_entity_id: Uuid, + predicate_id: Uuid, + to_fact_id: Uuid, + note_id: Uuid, + effective_at: OffsetDateTime, +) -> Result> { + let superseded: Vec<(Uuid,)> = sqlx::query_as( + "\ +UPDATE graph_facts +SET valid_to = $1, updated_at = now() +WHERE tenant_id = $2 + AND project_id = $3 + AND scope = $4 + AND subject_entity_id = $5 + AND predicate_id = $6 + AND valid_to IS NULL + AND valid_from <= $1 + AND fact_id <> $7 +RETURNING fact_id", + ) + .bind(effective_at) + .bind(tenant_id) + .bind(project_id) + .bind(scope) + .bind(subject_entity_id) + .bind(predicate_id) + .bind(to_fact_id) + .fetch_all(&mut *executor) + .await?; + + for (from_fact_id,) in &superseded { + sqlx::query( + "\ +INSERT INTO graph_fact_supersessions ( + supersession_id, + tenant_id, + project_id, + from_fact_id, + to_fact_id, + note_id, + effective_at, + created_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, now()) +ON CONFLICT (from_fact_id, to_fact_id, note_id) DO NOTHING", + ) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(project_id) + .bind(*from_fact_id) + .bind(to_fact_id) + .bind(note_id) + .bind(effective_at) + .execute(&mut *executor) + .await?; + } + + Ok(superseded.into_iter().map(|(fact_id,)| fact_id).collect()) +} diff --git a/packages/elf-storage/src/graph/predicate.rs b/packages/elf-storage/src/graph/predicate.rs new file mode 100644 index 00000000..9f619331 --- /dev/null +++ b/packages/elf-storage/src/graph/predicate.rs @@ -0,0 +1,11 @@ +mod aliases; +mod query; +mod resolution; +mod update; + +pub use self::{ + aliases::{add_predicate_alias, list_predicate_aliases}, + query::{get_predicate_by_id, list_predicates_by_scope_keys}, + resolution::{resolve_or_register_predicate, resolve_predicate_no_register}, + update::{update_predicate, update_predicate_guarded}, +}; diff --git a/packages/elf-storage/src/graph/predicate/aliases.rs b/packages/elf-storage/src/graph/predicate/aliases.rs new file mode 100644 index 00000000..c7a54602 --- /dev/null +++ b/packages/elf-storage/src/graph/predicate/aliases.rs @@ -0,0 +1,97 @@ +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{Error, Result, graph, models::GraphPredicateAlias}; + +/// Registers an additional alias for an existing predicate. +pub async fn add_predicate_alias( + executor: &mut PgConnection, + predicate_id: Uuid, + alias: &str, +) -> Result<()> { + let alias = alias.trim(); + + if alias.is_empty() { + return Err(Error::InvalidArgument( + "graph predicate alias is required; alias must not be empty".to_string(), + )); + } + + let alias_norm = graph::normalize_predicate_name(alias); + + if alias_norm.is_empty() { + return Err(Error::InvalidArgument( + "graph predicate alias is required; alias_norm must not be empty".to_string(), + )); + } + + let predicate_scope_key: Option<(String,)> = sqlx::query_as( + "\ +SELECT scope_key +FROM graph_predicates +WHERE predicate_id = $1", + ) + .bind(predicate_id) + .fetch_optional(&mut *executor) + .await?; + let Some((scope_key,)) = predicate_scope_key else { + return Err(Error::NotFound(format!( + "graph predicate not found; predicate_id={predicate_id}" + ))); + }; + let res = sqlx::query( + "\ +INSERT INTO graph_predicate_aliases ( + alias_id, + predicate_id, + scope_key, + alias, + alias_norm, + created_at +) +VALUES ($1, $2, $3, $4, $5, now()) +ON CONFLICT (scope_key, alias_norm) DO UPDATE +SET alias = EXCLUDED.alias +WHERE graph_predicate_aliases.predicate_id = EXCLUDED.predicate_id", + ) + .bind(Uuid::new_v4()) + .bind(predicate_id) + .bind(&scope_key) + .bind(alias) + .bind(&alias_norm) + .execute(&mut *executor) + .await?; + + if res.rows_affected() == 0 { + return Err(Error::Conflict(format!( + "graph predicate alias already bound; scope_key={scope_key} alias_norm={alias_norm}" + ))); + } + + Ok(()) +} + +/// Lists aliases bound to one predicate. +pub async fn list_predicate_aliases( + executor: &mut PgConnection, + predicate_id: Uuid, +) -> Result> { + let rows = sqlx::query_as::<_, GraphPredicateAlias>( + "\ +SELECT + alias_id, + predicate_id, + scope_key, + alias, + alias_norm, + created_at +FROM graph_predicate_aliases +WHERE predicate_id = $1 +ORDER BY created_at ASC, alias_norm ASC", + ) + .bind(predicate_id) + .fetch_all(&mut *executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/graph/predicate/query.rs b/packages/elf-storage/src/graph/predicate/query.rs new file mode 100644 index 00000000..e1ca9347 --- /dev/null +++ b/packages/elf-storage/src/graph/predicate/query.rs @@ -0,0 +1,66 @@ +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{Result, models::GraphPredicate}; + +/// Lists predicates visible within the provided scope keys. +pub async fn list_predicates_by_scope_keys( + executor: &mut PgConnection, + scope_keys: &[String], +) -> Result> { + if scope_keys.is_empty() { + return Ok(vec![]); + } + + let scope_keys = scope_keys.to_vec(); + let rows = sqlx::query_as::<_, GraphPredicate>( + "\ +SELECT + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at +FROM graph_predicates +WHERE scope_key = ANY($1::text[]) +ORDER BY scope_key, canonical_norm", + ) + .bind(&scope_keys) + .fetch_all(&mut *executor) + .await?; + + Ok(rows) +} + +/// Fetches one predicate by identifier. +pub async fn get_predicate_by_id( + executor: &mut PgConnection, + predicate_id: Uuid, +) -> Result> { + let row = sqlx::query_as::<_, GraphPredicate>( + "\ +SELECT + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at +FROM graph_predicates +WHERE predicate_id = $1", + ) + .bind(predicate_id) + .fetch_optional(&mut *executor) + .await?; + + Ok(row) +} diff --git a/packages/elf-storage/src/graph/predicate/resolution.rs b/packages/elf-storage/src/graph/predicate/resolution.rs new file mode 100644 index 00000000..9f70305f --- /dev/null +++ b/packages/elf-storage/src/graph/predicate/resolution.rs @@ -0,0 +1,173 @@ +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{ + Error, Result, + graph::{self, GRAPH_PREDICATE_SCOPE_GLOBAL}, + models::GraphPredicate, +}; + +/// Resolves a predicate surface across visible scopes or registers a project-scoped predicate. +pub async fn resolve_or_register_predicate( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + predicate_surface: &str, +) -> Result { + let predicate_surface = predicate_surface.trim(); + + if predicate_surface.is_empty() { + return Err(Error::InvalidArgument( + "graph predicate is required; predicate_surface must not be empty".to_string(), + )); + } + + let alias_norm = graph::normalize_predicate_name(predicate_surface); + let tenant_project_scope = graph::predicate_scope_key_tenant_project(tenant_id, project_id); + let project_scope = graph::predicate_scope_key_project(project_id); + let global_scope = GRAPH_PREDICATE_SCOPE_GLOBAL.to_string(); + + for scope_key in [&tenant_project_scope, &project_scope, &global_scope] { + if let Some(row) = sqlx::query_as::<_, GraphPredicate>( + "\ +SELECT + gp.predicate_id, + gp.scope_key, + gp.tenant_id, + gp.project_id, + gp.canonical, + gp.canonical_norm, + gp.cardinality, + gp.status, + gp.created_at, + gp.updated_at +FROM graph_predicate_aliases gpa +JOIN graph_predicates gp ON gp.predicate_id = gpa.predicate_id +WHERE gpa.scope_key = $1 + AND gpa.alias_norm = $2 +LIMIT 1", + ) + .bind(scope_key) + .bind(&alias_norm) + .fetch_optional(&mut *executor) + .await? + { + return Ok(row); + } + } + + let predicate_id = Uuid::new_v4(); + let predicate_row = sqlx::query_as::<_, GraphPredicate>( + "\ +INSERT INTO graph_predicates ( + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, 'multi', 'pending', now(), now()) +ON CONFLICT (scope_key, canonical_norm) +DO UPDATE +SET canonical = graph_predicates.canonical +RETURNING + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at", + ) + .bind(predicate_id) + .bind(&tenant_project_scope) + .bind(tenant_id) + .bind(project_id) + .bind(predicate_surface) + .bind(&alias_norm) + .fetch_one(&mut *executor) + .await?; + + sqlx::query( + "\ +INSERT INTO graph_predicate_aliases ( + alias_id, + predicate_id, + scope_key, + alias, + alias_norm, + created_at +) +VALUES ($1, $2, $3, $4, $5, now()) +ON CONFLICT (scope_key, alias_norm) DO NOTHING", + ) + .bind(Uuid::new_v4()) + .bind(predicate_row.predicate_id) + .bind(&tenant_project_scope) + .bind(predicate_surface) + .bind(&alias_norm) + .execute(&mut *executor) + .await?; + + Ok(predicate_row) +} + +/// Resolves a predicate surface across visible scopes without creating a new predicate. +pub async fn resolve_predicate_no_register( + executor: &mut PgConnection, + tenant_id: &str, + project_id: &str, + predicate_surface: &str, +) -> Result> { + let predicate_surface = predicate_surface.trim(); + + if predicate_surface.is_empty() { + return Err(Error::InvalidArgument( + "graph predicate is required; predicate_surface must not be empty".to_string(), + )); + } + + let alias_norm = graph::normalize_predicate_name(predicate_surface); + let tenant_project_scope = graph::predicate_scope_key_tenant_project(tenant_id, project_id); + let project_scope = graph::predicate_scope_key_project(project_id); + let global_scope = GRAPH_PREDICATE_SCOPE_GLOBAL.to_string(); + + for scope_key in [&tenant_project_scope, &project_scope, &global_scope] { + if let Some(row) = sqlx::query_as::<_, GraphPredicate>( + "\ +SELECT + gp.predicate_id, + gp.scope_key, + gp.tenant_id, + gp.project_id, + gp.canonical, + gp.canonical_norm, + gp.cardinality, + gp.status, + gp.created_at, + gp.updated_at +FROM graph_predicate_aliases gpa +JOIN graph_predicates gp ON gp.predicate_id = gpa.predicate_id +WHERE gpa.scope_key = $1 + AND gpa.alias_norm = $2 +LIMIT 1", + ) + .bind(scope_key) + .bind(&alias_norm) + .fetch_optional(&mut *executor) + .await? + { + return Ok(Some(row)); + } + } + + Ok(None) +} diff --git a/packages/elf-storage/src/graph/predicate/update.rs b/packages/elf-storage/src/graph/predicate/update.rs new file mode 100644 index 00000000..be56230c --- /dev/null +++ b/packages/elf-storage/src/graph/predicate/update.rs @@ -0,0 +1,144 @@ +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{Error, Result, graph::predicate::query, models::GraphPredicate}; + +/// Updates a predicate's mutable status and cardinality fields. +pub async fn update_predicate( + executor: &mut PgConnection, + predicate_id: Uuid, + status: Option<&str>, + cardinality: Option<&str>, +) -> Result { + let status = status.map(str::trim); + + if status.is_some_and(str::is_empty) { + return Err(Error::InvalidArgument("graph predicate status must not be empty".to_string())); + } + + let cardinality = cardinality.map(str::trim); + + if cardinality.is_some_and(str::is_empty) { + return Err(Error::InvalidArgument( + "graph predicate cardinality must not be empty".to_string(), + )); + } + + let row = sqlx::query_as::<_, GraphPredicate>( + "\ +UPDATE graph_predicates +SET + status = COALESCE($2, status), + cardinality = COALESCE($3, cardinality), + updated_at = now() +WHERE predicate_id = $1 +RETURNING + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at", + ) + .bind(predicate_id) + .bind(status) + .bind(cardinality) + .fetch_optional(&mut *executor) + .await?; + + row.ok_or_else(|| { + Error::NotFound(format!("graph predicate not found; predicate_id={predicate_id}")) + }) +} + +/// Updates a predicate only when its current state matches the expected guard values. +pub async fn update_predicate_guarded( + executor: &mut PgConnection, + predicate_id: Uuid, + expected_status: &str, + expected_cardinality: &str, + status: Option<&str>, + cardinality: Option<&str>, +) -> Result { + let expected_status = expected_status.trim(); + let expected_cardinality = expected_cardinality.trim(); + + if expected_status.is_empty() { + return Err(Error::InvalidArgument( + "graph predicate expected_status must not be empty".to_string(), + )); + } + if expected_cardinality.is_empty() { + return Err(Error::InvalidArgument( + "graph predicate expected_cardinality must not be empty".to_string(), + )); + } + if expected_status == "deprecated" { + return Err(Error::Conflict(format!( + "graph predicate is deprecated and cannot be modified; predicate_id={predicate_id}" + ))); + } + + let status = status.map(str::trim); + + if status.is_some_and(str::is_empty) { + return Err(Error::InvalidArgument("graph predicate status must not be empty".to_string())); + } + + let cardinality = cardinality.map(str::trim); + + if cardinality.is_some_and(str::is_empty) { + return Err(Error::InvalidArgument( + "graph predicate cardinality must not be empty".to_string(), + )); + } + + let row = sqlx::query_as::<_, GraphPredicate>( + "\ + UPDATE graph_predicates + SET + status = COALESCE($4, status), + cardinality = COALESCE($5, cardinality), + updated_at = now() + WHERE predicate_id = $1 + AND status = $2 + AND cardinality = $3 + RETURNING + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at", + ) + .bind(predicate_id) + .bind(expected_status) + .bind(expected_cardinality) + .bind(status) + .bind(cardinality) + .fetch_optional(&mut *executor) + .await?; + + if let Some(row) = row { + return Ok(row); + } + + let existing = query::get_predicate_by_id(executor, predicate_id).await?; + let Some(_) = existing else { + return Err(Error::NotFound(format!( + "graph predicate not found; predicate_id={predicate_id}" + ))); + }; + + Err(Error::Conflict(format!( + "graph predicate update conflict; predicate_id={predicate_id} expected_status={expected_status} expected_cardinality={expected_cardinality}" + ))) +} diff --git a/packages/elf-storage/src/knowledge.rs b/packages/elf-storage/src/knowledge.rs index 685f3d9a..07e5a724 100644 --- a/packages/elf-storage/src/knowledge.rs +++ b/packages/elf-storage/src/knowledge.rs @@ -1,1442 +1,31 @@ //! Derived knowledge page persistence and source-snapshot queries. -use serde_json::Value; -use sqlx::{FromRow, PgExecutor}; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ - Result, - models::{ - KnowledgePage, KnowledgePageLintFinding, KnowledgePageSection, KnowledgePageSourceRef, +mod queries; +mod sources; +mod types; +mod writes; + +pub use self::{ + queries::{ + get_knowledge_page, get_knowledge_page_by_key, list_knowledge_page_lint_findings, + list_knowledge_page_sections, list_knowledge_page_source_refs, + list_knowledge_page_source_refs_for_pages, list_knowledge_pages, + list_knowledge_pages_for_sources, search_knowledge_page_sections, + }, + sources::{ + fetch_knowledge_doc_chunk_sources, fetch_knowledge_doc_sources, + fetch_knowledge_event_sources, fetch_knowledge_note_sources, + fetch_knowledge_proposal_sources, fetch_knowledge_relation_sources, + }, + types::{ + KnowledgeDocChunkSource, KnowledgeDocSource, KnowledgeEventSource, KnowledgeNoteSource, + KnowledgePageLintFindingInsert, KnowledgePageSearchRow, KnowledgePageSectionInsert, + KnowledgePageSourceRefInsert, KnowledgePageUpsert, KnowledgeProposalSource, + KnowledgeRelationSource, KnowledgeRelationSourcesFetch, + }, + writes::{ + delete_knowledge_page_children, delete_knowledge_page_lint_findings, + insert_knowledge_page_lint_finding, insert_knowledge_page_section, + insert_knowledge_page_source_ref, upsert_knowledge_page, }, }; - -/// Arguments for upserting one derived knowledge page. -pub struct KnowledgePageUpsert<'a> { - /// Page identifier to use for a newly created page. - pub page_id: Uuid, - /// Tenant that owns the page. - pub tenant_id: &'a str, - /// Project that owns the page. - pub project_id: &'a str, - /// Page kind. - pub page_kind: &'a str, - /// Stable page key. - pub page_key: &'a str, - /// Page title. - pub title: &'a str, - /// Versioned page contract schema. - pub contract_schema: &'a str, - /// Page lifecycle status. - pub status: &'a str, - /// Canonical source snapshot hash. - pub rebuild_source_hash: &'a str, - /// Canonical page content hash. - pub content_hash: &'a str, - /// Source coverage metadata. - pub source_coverage: &'a Value, - /// Aggregate source snapshot metadata. - pub source_snapshot: &'a Value, - /// Rebuild metadata. - pub rebuild_metadata: &'a Value, - /// Rebuild timestamp. - pub now: OffsetDateTime, -} - -/// Arguments for inserting one knowledge page section. -pub struct KnowledgePageSectionInsert<'a> { - /// Section identifier. - pub section_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Stable section key. - pub section_key: &'a str, - /// Section heading. - pub heading: &'a str, - /// Section role. - pub role: &'a str, - /// Section content. - pub content: &'a str, - /// Section display order. - pub ordinal: i32, - /// Section citations. - pub citations: &'a Value, - /// Reason the section has no citations, when intentionally unsupported. - pub unsupported_reason: Option<&'a str>, - /// Section content hash. - pub content_hash: &'a str, - /// Creation/update timestamp. - pub now: OffsetDateTime, -} - -/// Arguments for inserting one normalized knowledge page citation. -pub struct KnowledgePageSourceRefInsert<'a> { - /// Source-reference row identifier. - pub ref_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Section that cites the source, if section-scoped. - pub section_id: Option, - /// Source kind. - pub source_kind: &'a str, - /// Authoritative source identifier. - pub source_id: Uuid, - /// Captured source status. - pub source_status: Option<&'a str>, - /// Captured source updated timestamp. - pub source_updated_at: Option, - /// Captured source content hash. - pub source_content_hash: Option<&'a str>, - /// Captured source snapshot. - pub source_snapshot: &'a Value, - /// Citation-local metadata. - pub citation_metadata: &'a Value, - /// Creation timestamp. - pub now: OffsetDateTime, -} - -/// Arguments for inserting one knowledge page lint finding. -pub struct KnowledgePageLintFindingInsert<'a> { - /// Lint finding identifier. - pub finding_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Section associated with the finding, when available. - pub section_id: Option, - /// Finding type. - pub finding_type: &'a str, - /// Finding severity. - pub severity: &'a str, - /// Source kind associated with the finding, when available. - pub source_kind: Option<&'a str>, - /// Source identifier associated with the finding, when available. - pub source_id: Option, - /// Human-readable finding message. - pub message: &'a str, - /// Structured finding details. - pub details: &'a Value, - /// Creation timestamp. - pub now: OffsetDateTime, -} - -/// Parameters for fetching graph relation sources for knowledge pages. -pub struct KnowledgeRelationSourcesFetch<'a> { - /// Tenant that owns the relation sources. - pub tenant_id: &'a str, - /// Project that owns the relation sources. - pub project_id: &'a str, - /// Agent requesting source readback, when visibility should be caller-scoped. - pub agent_id: Option<&'a str>, - /// Scopes allowed by the caller read profile. - pub allowed_scopes: &'a [String], - /// Shared owner/scope grant keys readable by the caller. - pub shared_scope_keys: &'a [String], - /// Whether private scope is readable by the caller. - pub private_allowed: bool, - /// Graph fact identifiers to fetch. - pub fact_ids: &'a [Uuid], -} - -/// Authoritative note source row used by the knowledge page rebuilder. -#[derive(Debug, FromRow)] -pub struct KnowledgeNoteSource { - /// Note identifier. - pub note_id: Uuid, - /// Agent that owns the note. - pub agent_id: String, - /// Note scope. - pub scope: String, - /// Note type. - pub note_type: String, - /// Optional note key. - pub key: Option, - /// Note text. - pub text: String, - /// Note importance. - pub importance: f32, - /// Note confidence. - pub confidence: f32, - /// Note status. - pub status: String, - /// Note creation timestamp. - pub created_at: OffsetDateTime, - /// Note update timestamp. - pub updated_at: OffsetDateTime, - /// Optional note expiry timestamp. - pub expires_at: Option, - /// Note embedding version. - pub embedding_version: String, - /// Opaque note source reference. - pub source_ref: Value, -} - -/// Durable add_event audit source row used by the knowledge page rebuilder. -#[derive(Debug, FromRow)] -pub struct KnowledgeEventSource { - /// Ingest decision identifier. - pub decision_id: Uuid, - /// Agent that wrote the audited event-derived note decision. - pub agent_id: String, - /// Scope associated with the audited decision. - pub scope: String, - /// Ingestion pipeline name. - pub pipeline: String, - /// Event-derived note type. - pub note_type: String, - /// Optional note key. - pub note_key: Option, - /// Note identifier affected by the decision, when persisted. - pub note_id: Option, - /// Policy decision. - pub policy_decision: String, - /// Note operation. - pub note_op: String, - /// Optional reason code. - pub reason_code: Option, - /// Structured audit details. - pub details: Value, - /// Audit timestamp. - pub ts: OffsetDateTime, -} - -/// Authoritative graph relation source row used by the knowledge page rebuilder. -#[derive(Debug, FromRow)] -pub struct KnowledgeRelationSource { - /// Graph fact identifier. - pub fact_id: Uuid, - /// Agent that wrote the fact. - pub agent_id: String, - /// Fact scope. - pub scope: String, - /// Subject canonical text. - pub subject: String, - /// Optional subject kind. - pub subject_kind: Option, - /// Predicate text. - pub predicate: String, - /// Optional object entity canonical text. - pub object_entity: Option, - /// Optional object entity kind. - pub object_kind: Option, - /// Optional scalar object value. - pub object_value: Option, - /// Fact validity window start. - pub valid_from: OffsetDateTime, - /// Fact validity window end, when historical. - pub valid_to: Option, - /// Fact update timestamp. - pub updated_at: OffsetDateTime, - /// Evidence notes linked to this fact. - pub evidence_notes: Value, -} - -/// Reviewed consolidation proposal source row used by the knowledge page rebuilder. -#[derive(Debug, FromRow)] -pub struct KnowledgeProposalSource { - /// Consolidation proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Agent that registered the proposal. - pub agent_id: String, - /// Proposal kind. - pub proposal_kind: String, - /// Proposal apply intent. - pub apply_intent: String, - /// Proposal review state. - pub review_state: String, - /// Serialized proposal source references. - pub source_refs: Value, - /// Serialized proposal source snapshot. - pub source_snapshot: Value, - /// Serialized proposal lineage. - pub lineage: Value, - /// Serialized proposal diff. - pub diff: Value, - /// Proposal confidence. - pub confidence: f32, - /// Unsupported claim flags. - pub unsupported_claim_flags: Value, - /// Contradiction markers. - pub contradiction_markers: Value, - /// Staleness markers. - pub staleness_markers: Value, - /// Derived target reference. - pub target_ref: Value, - /// Proposed derived payload. - pub proposed_payload: Value, - /// Proposal update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Source Library document row used by the knowledge page rebuilder. -#[derive(Debug, FromRow)] -pub struct KnowledgeDocSource { - /// Document identifier. - pub doc_id: Uuid, - /// Agent that captured the document. - pub agent_id: String, - /// Document scope. - pub scope: String, - /// Document type. - pub doc_type: String, - /// Document lifecycle status. - pub status: String, - /// Optional document title. - pub title: Option, - /// Document source reference. - pub source_ref: Value, - /// Persisted document content. - pub content: String, - /// Persisted byte length. - pub content_bytes: i32, - /// Whole-document content hash. - pub content_hash: String, - /// Document creation timestamp. - pub created_at: OffsetDateTime, - /// Document update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Source Library document chunk row used by the knowledge page rebuilder. -#[derive(Debug, FromRow)] -pub struct KnowledgeDocChunkSource { - /// Chunk identifier. - pub chunk_id: Uuid, - /// Parent document identifier. - pub doc_id: Uuid, - /// Agent that captured the document. - pub agent_id: String, - /// Document scope. - pub scope: String, - /// Document type. - pub doc_type: String, - /// Document lifecycle status. - pub status: String, - /// Optional document title. - pub title: Option, - /// Document source reference. - pub source_ref: Value, - /// Whole-document content hash. - pub doc_content_hash: String, - /// Document update timestamp. - pub doc_updated_at: OffsetDateTime, - /// Zero-based chunk index. - pub chunk_index: i32, - /// Inclusive start byte offset. - pub start_offset: i32, - /// Exclusive end byte offset. - pub end_offset: i32, - /// Chunk text. - pub chunk_text: String, - /// Chunk content hash. - pub chunk_hash: String, - /// Chunk creation timestamp. - pub chunk_created_at: OffsetDateTime, -} - -/// Searchable knowledge page section row with page and lint metadata. -#[derive(Debug, FromRow)] -pub struct KnowledgePageSearchRow { - /// Derived page identifier. - pub page_id: Uuid, - /// Page kind. - pub page_kind: String, - /// Stable page key. - pub page_key: String, - /// Page title. - pub title: String, - /// Page lifecycle status. - pub status: String, - /// Source coverage metadata. - pub source_coverage: Value, - /// Rebuild metadata. - pub rebuild_metadata: Value, - /// Page update timestamp. - pub page_updated_at: OffsetDateTime, - /// Page rebuild timestamp. - pub rebuilt_at: OffsetDateTime, - /// Section identifier. - pub section_id: Uuid, - /// Stable section key. - pub section_key: String, - /// Section heading. - pub heading: String, - /// Section role. - pub role: String, - /// Section content. - pub content: String, - /// Section display order. - pub ordinal: i32, - /// Section citations. - pub citations: Value, - /// Reason the section is unsupported, when present. - pub unsupported_reason: Option, - /// Number of error lint findings for the page. - pub lint_error_count: i64, - /// Number of warning lint findings for the page. - pub lint_warning_count: i64, - /// Number of info lint findings for the page. - pub lint_info_count: i64, - /// Number of normalized source refs for this section. - pub section_source_ref_count: i64, -} - -/// Upserts one derived knowledge page and returns the persisted row. -pub async fn upsert_knowledge_page<'e, E>( - executor: E, - args: KnowledgePageUpsert<'_>, -) -> Result -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, KnowledgePage>( - "\ -INSERT INTO knowledge_pages ( - page_id, - tenant_id, - project_id, - page_kind, - page_key, - title, - contract_schema, - status, - rebuild_source_hash, - content_hash, - source_coverage, - source_snapshot, - rebuild_metadata, - created_at, - updated_at, - rebuilt_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14,$14) -ON CONFLICT (tenant_id, project_id, page_kind, page_key) DO UPDATE -SET - title = EXCLUDED.title, - contract_schema = EXCLUDED.contract_schema, - status = EXCLUDED.status, - rebuild_source_hash = EXCLUDED.rebuild_source_hash, - content_hash = EXCLUDED.content_hash, - source_coverage = EXCLUDED.source_coverage, - source_snapshot = EXCLUDED.source_snapshot, - rebuild_metadata = EXCLUDED.rebuild_metadata, - updated_at = EXCLUDED.updated_at, - rebuilt_at = EXCLUDED.rebuilt_at -RETURNING - page_id, - tenant_id, - project_id, - page_kind, - page_key, - title, - contract_schema, - status, - rebuild_source_hash, - content_hash, - source_coverage, - source_snapshot, - rebuild_metadata, - created_at, - updated_at, - rebuilt_at", - ) - .bind(args.page_id) - .bind(args.tenant_id) - .bind(args.project_id) - .bind(args.page_kind) - .bind(args.page_key) - .bind(args.title) - .bind(args.contract_schema) - .bind(args.status) - .bind(args.rebuild_source_hash) - .bind(args.content_hash) - .bind(args.source_coverage) - .bind(args.source_snapshot) - .bind(args.rebuild_metadata) - .bind(args.now) - .fetch_one(executor) - .await?; - - Ok(row) -} - -/// Deletes all section, citation, and lint child rows for a page before rebuild. -pub async fn delete_knowledge_page_children<'e, E>(executor: E, page_id: Uuid) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ - WITH deleted_lint AS ( - DELETE FROM knowledge_page_lint_findings - WHERE page_id = $1 - ), - deleted_source_refs AS ( - DELETE FROM knowledge_page_source_refs - WHERE page_id = $1 - ) - DELETE FROM knowledge_page_sections - WHERE page_id = $1", - ) - .bind(page_id) - .execute(executor) - .await?; - - Ok(()) -} - -/// Inserts one derived knowledge page section. -pub async fn insert_knowledge_page_section<'e, E>( - executor: E, - args: KnowledgePageSectionInsert<'_>, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO knowledge_page_sections ( - section_id, - page_id, - section_key, - heading, - role, - content, - ordinal, - citations, - unsupported_reason, - content_hash, - created_at, - updated_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)", - ) - .bind(args.section_id) - .bind(args.page_id) - .bind(args.section_key) - .bind(args.heading) - .bind(args.role) - .bind(args.content) - .bind(args.ordinal) - .bind(args.citations) - .bind(args.unsupported_reason) - .bind(args.content_hash) - .bind(args.now) - .execute(executor) - .await?; - - Ok(()) -} - -/// Inserts one normalized knowledge page citation/source reference. -pub async fn insert_knowledge_page_source_ref<'e, E>( - executor: E, - args: KnowledgePageSourceRefInsert<'_>, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO knowledge_page_source_refs ( - ref_id, - page_id, - section_id, - source_kind, - source_id, - source_status, - source_updated_at, - source_content_hash, - source_snapshot, - citation_metadata, - created_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", - ) - .bind(args.ref_id) - .bind(args.page_id) - .bind(args.section_id) - .bind(args.source_kind) - .bind(args.source_id) - .bind(args.source_status) - .bind(args.source_updated_at) - .bind(args.source_content_hash) - .bind(args.source_snapshot) - .bind(args.citation_metadata) - .bind(args.now) - .execute(executor) - .await?; - - Ok(()) -} - -/// Inserts one knowledge page lint finding. -pub async fn insert_knowledge_page_lint_finding<'e, E>( - executor: E, - args: KnowledgePageLintFindingInsert<'_>, -) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -INSERT INTO knowledge_page_lint_findings ( - finding_id, - page_id, - section_id, - finding_type, - severity, - source_kind, - source_id, - message, - details, - created_at -) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", - ) - .bind(args.finding_id) - .bind(args.page_id) - .bind(args.section_id) - .bind(args.finding_type) - .bind(args.severity) - .bind(args.source_kind) - .bind(args.source_id) - .bind(args.message) - .bind(args.details) - .bind(args.now) - .execute(executor) - .await?; - - Ok(()) -} - -/// Deletes persisted lint findings for one page. -pub async fn delete_knowledge_page_lint_findings<'e, E>(executor: E, page_id: Uuid) -> Result<()> -where - E: PgExecutor<'e>, -{ - sqlx::query("DELETE FROM knowledge_page_lint_findings WHERE page_id = $1") - .bind(page_id) - .execute(executor) - .await?; - - Ok(()) -} - -/// Fetches one knowledge page by identifier. -pub async fn get_knowledge_page<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - page_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, KnowledgePage>( - "\ -SELECT - page_id, - tenant_id, - project_id, - page_kind, - page_key, - title, - contract_schema, - status, - rebuild_source_hash, - content_hash, - source_coverage, - source_snapshot, - rebuild_metadata, - created_at, - updated_at, - rebuilt_at -FROM knowledge_pages -WHERE tenant_id = $1 AND project_id = $2 AND page_id = $3 -LIMIT 1", - ) - .bind(tenant_id) - .bind(project_id) - .bind(page_id) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Fetches one knowledge page by stable page key. -pub async fn get_knowledge_page_by_key<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - page_kind: &str, - page_key: &str, -) -> Result> -where - E: PgExecutor<'e>, -{ - let row = sqlx::query_as::<_, KnowledgePage>( - "\ -SELECT - page_id, - tenant_id, - project_id, - page_kind, - page_key, - title, - contract_schema, - status, - rebuild_source_hash, - content_hash, - source_coverage, - source_snapshot, - rebuild_metadata, - created_at, - updated_at, - rebuilt_at -FROM knowledge_pages -WHERE tenant_id = $1 - AND project_id = $2 - AND page_kind = $3 - AND page_key = $4 -LIMIT 1", - ) - .bind(tenant_id) - .bind(project_id) - .bind(page_kind) - .bind(page_key) - .fetch_optional(executor) - .await?; - - Ok(row) -} - -/// Lists knowledge pages for a tenant and project. -pub async fn list_knowledge_pages<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - page_kind: Option<&str>, - limit: i64, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, KnowledgePage>( - "\ -SELECT - page_id, - tenant_id, - project_id, - page_kind, - page_key, - title, - contract_schema, - status, - rebuild_source_hash, - content_hash, - source_coverage, - source_snapshot, - rebuild_metadata, - created_at, - updated_at, - rebuilt_at -FROM knowledge_pages -WHERE tenant_id = $1 - AND project_id = $2 - AND ($3::text IS NULL OR page_kind = $3) -ORDER BY updated_at DESC, page_id DESC -LIMIT $4", - ) - .bind(tenant_id) - .bind(project_id) - .bind(page_kind) - .bind(limit) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Lists knowledge pages that cite at least one changed source. -pub async fn list_knowledge_pages_for_sources<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - page_kind: Option<&str>, - source_kinds: &[String], - source_ids: &[Uuid], - limit: i64, -) -> Result> -where - E: PgExecutor<'e>, -{ - if source_kinds.is_empty() || source_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgePage>( - "\ -SELECT DISTINCT - p.page_id, - p.tenant_id, - p.project_id, - p.page_kind, - p.page_key, - p.title, - p.contract_schema, - p.status, - p.rebuild_source_hash, - p.content_hash, - p.source_coverage, - p.source_snapshot, - p.rebuild_metadata, - p.created_at, - p.updated_at, - p.rebuilt_at -FROM knowledge_pages p -JOIN knowledge_page_source_refs r ON r.page_id = p.page_id -JOIN unnest($4::text[], $5::uuid[]) AS changed(source_kind, source_id) - ON changed.source_kind = r.source_kind - AND changed.source_id = r.source_id -WHERE p.tenant_id = $1 - AND p.project_id = $2 - AND ($3::text IS NULL OR p.page_kind = $3) - AND p.status IN ('active', 'stale') -ORDER BY p.updated_at DESC, p.page_id DESC -LIMIT $6", - ) - .bind(tenant_id) - .bind(project_id) - .bind(page_kind) - .bind(source_kinds) - .bind(source_ids) - .bind(limit) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Lists sections for one knowledge page. -pub async fn list_knowledge_page_sections<'e, E>( - executor: E, - page_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, KnowledgePageSection>( - "\ -SELECT - section_id, - page_id, - section_key, - heading, - role, - content, - ordinal, - citations, - unsupported_reason, - content_hash, - created_at, - updated_at -FROM knowledge_page_sections -WHERE page_id = $1 -ORDER BY ordinal ASC, section_key ASC", - ) - .bind(page_id) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Lists normalized source refs for one knowledge page. -pub async fn list_knowledge_page_source_refs<'e, E>( - executor: E, - page_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, KnowledgePageSourceRef>( - "\ -SELECT - ref_id, - page_id, - section_id, - source_kind, - source_id, - source_status, - source_updated_at, - source_content_hash, - source_snapshot, - citation_metadata, - created_at -FROM knowledge_page_source_refs -WHERE page_id = $1 -ORDER BY source_kind ASC, source_id ASC, ref_id ASC", - ) - .bind(page_id) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Lists normalized source refs for a set of knowledge pages. -pub async fn list_knowledge_page_source_refs_for_pages<'e, E>( - executor: E, - page_ids: &[Uuid], -) -> Result> -where - E: PgExecutor<'e>, -{ - if page_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgePageSourceRef>( - "\ -SELECT - ref_id, - page_id, - section_id, - source_kind, - source_id, - source_status, - source_updated_at, - source_content_hash, - source_snapshot, - citation_metadata, - created_at -FROM knowledge_page_source_refs -WHERE page_id = ANY($1::uuid[]) -ORDER BY page_id ASC, source_kind ASC, source_id ASC, ref_id ASC", - ) - .bind(page_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Lists lint findings for one knowledge page. -pub async fn list_knowledge_page_lint_findings<'e, E>( - executor: E, - page_id: Uuid, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, KnowledgePageLintFinding>( - "\ -SELECT - finding_id, - page_id, - section_id, - finding_type, - severity, - source_kind, - source_id, - message, - details, - created_at -FROM knowledge_page_lint_findings -WHERE page_id = $1 -ORDER BY severity DESC, created_at ASC, finding_id ASC", - ) - .bind(page_id) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Searches derived knowledge page sections by page and section text. -pub async fn search_knowledge_page_sections<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - page_kind: Option<&str>, - query_pattern: &str, - limit: i64, -) -> Result> -where - E: PgExecutor<'e>, -{ - let rows = sqlx::query_as::<_, KnowledgePageSearchRow>( - "\ -WITH page_lint AS ( - SELECT - page_id, - count(*) FILTER (WHERE severity = 'error') AS error_count, - count(*) FILTER (WHERE severity = 'warning') AS warning_count, - count(*) FILTER (WHERE severity = 'info') AS info_count - FROM knowledge_page_lint_findings - GROUP BY page_id -), -section_refs AS ( - SELECT section_id, count(*) AS source_ref_count - FROM knowledge_page_source_refs - GROUP BY section_id -) -SELECT - p.page_id, - p.page_kind, - p.page_key, - p.title, - p.status, - p.source_coverage, - p.rebuild_metadata, - p.updated_at AS page_updated_at, - p.rebuilt_at, - s.section_id, - s.section_key, - s.heading, - s.role, - s.content, - s.ordinal, - s.citations, - s.unsupported_reason, - COALESCE(page_lint.error_count, 0)::bigint AS lint_error_count, - COALESCE(page_lint.warning_count, 0)::bigint AS lint_warning_count, - COALESCE(page_lint.info_count, 0)::bigint AS lint_info_count, - COALESCE(section_refs.source_ref_count, 0)::bigint AS section_source_ref_count -FROM knowledge_pages p -JOIN knowledge_page_sections s ON s.page_id = p.page_id -LEFT JOIN page_lint ON page_lint.page_id = p.page_id -LEFT JOIN section_refs ON section_refs.section_id = s.section_id -WHERE p.tenant_id = $1 - AND p.project_id = $2 - AND p.status IN ('active', 'stale') - AND ($3::text IS NULL OR p.page_kind = $3) - AND ( - lower(p.title) LIKE $4 - OR lower(p.page_key) LIKE $4 - OR lower(s.heading) LIKE $4 - OR lower(s.content) LIKE $4 - ) -ORDER BY - CASE - WHEN lower(p.title) LIKE $4 THEN 4 - WHEN lower(s.heading) LIKE $4 THEN 3 - WHEN lower(p.page_key) LIKE $4 THEN 2 - ELSE 1 - END DESC, - p.updated_at DESC, - s.ordinal ASC, - p.page_id DESC -LIMIT $5", - ) - .bind(tenant_id) - .bind(project_id) - .bind(page_kind) - .bind(query_pattern) - .bind(limit) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Fetches note sources by identifier for a knowledge page rebuild. -pub async fn fetch_knowledge_note_sources<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - agent_id: Option<&str>, - allowed_scopes: &[String], - note_ids: &[Uuid], -) -> Result> -where - E: PgExecutor<'e>, -{ - if note_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgeNoteSource>( - "\ -SELECT - note_id, - agent_id, - scope, - type AS note_type, - key, - text, - importance, - confidence, - status, - created_at, - updated_at, - expires_at, - embedding_version, - source_ref -FROM memory_notes -WHERE tenant_id = $1 - AND project_id = $2 - AND ($3::text IS NULL OR scope <> 'agent_private' OR agent_id = $3) - AND scope = ANY($4::text[]) - AND note_id = ANY($5::uuid[]) - AND status = 'active' - AND (expires_at IS NULL OR expires_at > now()) -ORDER BY updated_at ASC, note_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(allowed_scopes) - .bind(note_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Fetches durable add_event audit sources by decision identifier. -pub async fn fetch_knowledge_event_sources<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - agent_id: Option<&str>, - allowed_scopes: &[String], - decision_ids: &[Uuid], -) -> Result> -where - E: PgExecutor<'e>, -{ - if decision_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgeEventSource>( - "\ -SELECT - memory_ingest_decisions.decision_id, - memory_ingest_decisions.agent_id, - memory_ingest_decisions.scope, - memory_ingest_decisions.pipeline, - memory_ingest_decisions.note_type, - memory_ingest_decisions.note_key, - memory_ingest_decisions.note_id, - memory_ingest_decisions.policy_decision, - memory_ingest_decisions.note_op, - memory_ingest_decisions.reason_code, - memory_ingest_decisions.details, - memory_ingest_decisions.ts -FROM memory_ingest_decisions -JOIN memory_notes note ON note.note_id = memory_ingest_decisions.note_id -WHERE memory_ingest_decisions.tenant_id = $1 - AND memory_ingest_decisions.project_id = $2 - AND ($3::text IS NULL OR memory_ingest_decisions.scope <> 'agent_private' OR memory_ingest_decisions.agent_id = $3) - AND memory_ingest_decisions.scope = ANY($4::text[]) - AND memory_ingest_decisions.decision_id = ANY($5::uuid[]) - AND memory_ingest_decisions.pipeline = 'add_event' - AND memory_ingest_decisions.policy_decision IN ('remember', 'update') - AND note.tenant_id = memory_ingest_decisions.tenant_id - AND note.project_id = memory_ingest_decisions.project_id - AND note.status = 'active' - AND (note.expires_at IS NULL OR note.expires_at > now()) - AND ($3::text IS NULL OR note.scope <> 'agent_private' OR note.agent_id = $3) - AND note.scope = ANY($4::text[]) -ORDER BY memory_ingest_decisions.ts ASC, memory_ingest_decisions.decision_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(allowed_scopes) - .bind(decision_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Fetches relation sources by graph fact identifier for a knowledge page rebuild. -pub async fn fetch_knowledge_relation_sources<'e, E>( - executor: E, - params: KnowledgeRelationSourcesFetch<'_>, -) -> Result> -where - E: PgExecutor<'e>, -{ - if params.fact_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgeRelationSource>( - "\ -SELECT - gf.fact_id, - gf.agent_id, - gf.scope, - subject.canonical AS subject, - subject.kind AS subject_kind, - gf.predicate, - object_entity.canonical AS object_entity, - object_entity.kind AS object_kind, - gf.object_value, - gf.valid_from, - gf.valid_to, - gf.updated_at, - COALESCE( - jsonb_agg( - jsonb_build_object( - 'note_id', evidence.note_id, - 'status', note.status, - 'updated_at', note.updated_at - ) - ORDER BY evidence.created_at ASC, evidence.note_id ASC - ) FILTER ( - WHERE evidence.note_id IS NOT NULL - AND note.tenant_id = gf.tenant_id - AND note.project_id = gf.project_id - AND note.status = 'active' - AND (note.expires_at IS NULL OR note.expires_at > now()) - AND note.scope = ANY($4::text[]) - AND ( - $3::text IS NULL - OR ($6 AND note.scope = 'agent_private' AND note.agent_id = $3) - OR ( - note.scope <> 'agent_private' - AND ( - note.agent_id = $3 - OR concat(note.scope, ':', note.agent_id) = ANY($5::text[]) - ) - ) - ) - ), - '[]'::jsonb - ) AS evidence_notes -FROM graph_facts gf -JOIN graph_entities subject ON subject.entity_id = gf.subject_entity_id -LEFT JOIN graph_entities object_entity ON object_entity.entity_id = gf.object_entity_id -LEFT JOIN graph_fact_evidence evidence ON evidence.fact_id = gf.fact_id -LEFT JOIN memory_notes note ON note.note_id = evidence.note_id -WHERE gf.tenant_id = $1 - AND gf.project_id = $2 - AND gf.scope = ANY($4::text[]) - AND ( - $3::text IS NULL - OR ($6 AND gf.scope = 'agent_private' AND gf.agent_id = $3) - OR ( - gf.scope <> 'agent_private' - AND ( - gf.agent_id = $3 - OR concat(gf.scope, ':', gf.agent_id) = ANY($5::text[]) - ) - ) - ) - AND gf.fact_id = ANY($7::uuid[]) - AND EXISTS ( - SELECT 1 - FROM graph_fact_evidence readable_evidence - JOIN memory_notes readable_note - ON readable_note.note_id = readable_evidence.note_id - WHERE readable_evidence.fact_id = gf.fact_id - AND readable_note.tenant_id = gf.tenant_id - AND readable_note.project_id = gf.project_id - AND readable_note.status = 'active' - AND (readable_note.expires_at IS NULL OR readable_note.expires_at > now()) - AND readable_note.scope = ANY($4::text[]) - AND ( - $3::text IS NULL - OR ($6 AND readable_note.scope = 'agent_private' AND readable_note.agent_id = $3) - OR ( - readable_note.scope <> 'agent_private' - AND ( - readable_note.agent_id = $3 - OR concat(readable_note.scope, ':', readable_note.agent_id) = ANY($5::text[]) - ) - ) - ) - ) -GROUP BY - gf.fact_id, - gf.agent_id, - gf.scope, - subject.canonical, - subject.kind, - gf.predicate, - object_entity.canonical, - object_entity.kind, - gf.object_value, - gf.valid_from, - gf.valid_to, - gf.updated_at -ORDER BY gf.updated_at ASC, gf.fact_id ASC", - ) - .bind(params.tenant_id) - .bind(params.project_id) - .bind(params.agent_id) - .bind(params.allowed_scopes) - .bind(params.shared_scope_keys) - .bind(params.private_allowed) - .bind(params.fact_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Fetches applied proposal sources by identifier for a knowledge page rebuild. -pub async fn fetch_knowledge_proposal_sources<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - proposal_ids: &[Uuid], -) -> Result> -where - E: PgExecutor<'e>, -{ - if proposal_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgeProposalSource>( - "\ -SELECT - proposal_id, - run_id, - agent_id, - proposal_kind, - apply_intent, - review_state, - source_refs, - source_snapshot, - lineage, - diff, - confidence, - COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, - COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, - COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, - COALESCE(target_ref, '{}'::jsonb) AS target_ref, - COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, - updated_at -FROM consolidation_proposals -WHERE tenant_id = $1 - AND project_id = $2 - AND proposal_id = ANY($3::uuid[]) - AND review_state = 'applied' -ORDER BY updated_at ASC, proposal_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(proposal_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Fetches active Source Library documents by identifier for a knowledge page rebuild. -pub async fn fetch_knowledge_doc_sources<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - agent_id: Option<&str>, - allowed_scopes: &[String], - doc_ids: &[Uuid], -) -> Result> -where - E: PgExecutor<'e>, -{ - if doc_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgeDocSource>( - "\ -SELECT - doc_id, - agent_id, - scope, - doc_type, - status, - title, - COALESCE(source_ref, '{}'::jsonb) AS source_ref, - content, - content_bytes, - content_hash, - created_at, - updated_at -FROM doc_documents -WHERE tenant_id = $1 - AND project_id = $2 - AND ($3::text IS NULL OR scope <> 'agent_private' OR agent_id = $3) - AND scope = ANY($4::text[]) - AND doc_id = ANY($5::uuid[]) - AND status = 'active' -ORDER BY updated_at ASC, doc_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(allowed_scopes) - .bind(doc_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} - -/// Fetches active Source Library document chunks by identifier for a knowledge page rebuild. -pub async fn fetch_knowledge_doc_chunk_sources<'e, E>( - executor: E, - tenant_id: &str, - project_id: &str, - agent_id: Option<&str>, - allowed_scopes: &[String], - chunk_ids: &[Uuid], -) -> Result> -where - E: PgExecutor<'e>, -{ - if chunk_ids.is_empty() { - return Ok(Vec::new()); - } - - let rows = sqlx::query_as::<_, KnowledgeDocChunkSource>( - "\ -SELECT - c.chunk_id, - c.doc_id, - d.agent_id, - d.scope, - d.doc_type, - d.status, - d.title, - COALESCE(d.source_ref, '{}'::jsonb) AS source_ref, - d.content_hash AS doc_content_hash, - d.updated_at AS doc_updated_at, - c.chunk_index, - c.start_offset, - c.end_offset, - c.chunk_text, - c.chunk_hash, - c.created_at AS chunk_created_at -FROM doc_chunks c -JOIN doc_documents d ON d.doc_id = c.doc_id -WHERE d.tenant_id = $1 - AND d.project_id = $2 - AND ($3::text IS NULL OR d.scope <> 'agent_private' OR d.agent_id = $3) - AND d.scope = ANY($4::text[]) - AND c.chunk_id = ANY($5::uuid[]) - AND d.status = 'active' -ORDER BY d.updated_at ASC, c.chunk_index ASC, c.chunk_id ASC", - ) - .bind(tenant_id) - .bind(project_id) - .bind(agent_id) - .bind(allowed_scopes) - .bind(chunk_ids) - .fetch_all(executor) - .await?; - - Ok(rows) -} diff --git a/packages/elf-storage/src/knowledge/queries.rs b/packages/elf-storage/src/knowledge/queries.rs new file mode 100644 index 00000000..1c6f2005 --- /dev/null +++ b/packages/elf-storage/src/knowledge/queries.rs @@ -0,0 +1,16 @@ +mod lint; +mod pages; +mod search; +mod sections; +mod sources; + +pub use self::{ + lint::list_knowledge_page_lint_findings, + pages::{ + get_knowledge_page, get_knowledge_page_by_key, list_knowledge_pages, + list_knowledge_pages_for_sources, + }, + search::search_knowledge_page_sections, + sections::list_knowledge_page_sections, + sources::{list_knowledge_page_source_refs, list_knowledge_page_source_refs_for_pages}, +}; diff --git a/packages/elf-storage/src/knowledge/queries/lint.rs b/packages/elf-storage/src/knowledge/queries/lint.rs new file mode 100644 index 00000000..478a76f1 --- /dev/null +++ b/packages/elf-storage/src/knowledge/queries/lint.rs @@ -0,0 +1,36 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{Result, models::KnowledgePageLintFinding}; + +/// Lists lint findings for one knowledge page. +pub async fn list_knowledge_page_lint_findings<'e, E>( + executor: E, + page_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, KnowledgePageLintFinding>( + "\ +SELECT + finding_id, + page_id, + section_id, + finding_type, + severity, + source_kind, + source_id, + message, + details, + created_at +FROM knowledge_page_lint_findings +WHERE page_id = $1 +ORDER BY severity DESC, created_at ASC, finding_id ASC", + ) + .bind(page_id) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/knowledge/queries/pages.rs b/packages/elf-storage/src/knowledge/queries/pages.rs new file mode 100644 index 00000000..13afd329 --- /dev/null +++ b/packages/elf-storage/src/knowledge/queries/pages.rs @@ -0,0 +1,200 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{Result, models::KnowledgePage}; + +/// Fetches one knowledge page by identifier. +pub async fn get_knowledge_page<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + page_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, KnowledgePage>( + "\ +SELECT + page_id, + tenant_id, + project_id, + page_kind, + page_key, + title, + contract_schema, + status, + rebuild_source_hash, + content_hash, + source_coverage, + source_snapshot, + rebuild_metadata, + created_at, + updated_at, + rebuilt_at +FROM knowledge_pages +WHERE tenant_id = $1 AND project_id = $2 AND page_id = $3 +LIMIT 1", + ) + .bind(tenant_id) + .bind(project_id) + .bind(page_id) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Fetches one knowledge page by stable page key. +pub async fn get_knowledge_page_by_key<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + page_kind: &str, + page_key: &str, +) -> Result> +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, KnowledgePage>( + "\ +SELECT + page_id, + tenant_id, + project_id, + page_kind, + page_key, + title, + contract_schema, + status, + rebuild_source_hash, + content_hash, + source_coverage, + source_snapshot, + rebuild_metadata, + created_at, + updated_at, + rebuilt_at +FROM knowledge_pages +WHERE tenant_id = $1 + AND project_id = $2 + AND page_kind = $3 + AND page_key = $4 +LIMIT 1", + ) + .bind(tenant_id) + .bind(project_id) + .bind(page_kind) + .bind(page_key) + .fetch_optional(executor) + .await?; + + Ok(row) +} + +/// Lists knowledge pages for a tenant and project. +pub async fn list_knowledge_pages<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + page_kind: Option<&str>, + limit: i64, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, KnowledgePage>( + "\ +SELECT + page_id, + tenant_id, + project_id, + page_kind, + page_key, + title, + contract_schema, + status, + rebuild_source_hash, + content_hash, + source_coverage, + source_snapshot, + rebuild_metadata, + created_at, + updated_at, + rebuilt_at +FROM knowledge_pages +WHERE tenant_id = $1 + AND project_id = $2 + AND ($3::text IS NULL OR page_kind = $3) +ORDER BY updated_at DESC, page_id DESC +LIMIT $4", + ) + .bind(tenant_id) + .bind(project_id) + .bind(page_kind) + .bind(limit) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Lists knowledge pages that cite at least one changed source. +pub async fn list_knowledge_pages_for_sources<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + page_kind: Option<&str>, + source_kinds: &[String], + source_ids: &[Uuid], + limit: i64, +) -> Result> +where + E: PgExecutor<'e>, +{ + if source_kinds.is_empty() || source_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgePage>( + "\ +SELECT DISTINCT + p.page_id, + p.tenant_id, + p.project_id, + p.page_kind, + p.page_key, + p.title, + p.contract_schema, + p.status, + p.rebuild_source_hash, + p.content_hash, + p.source_coverage, + p.source_snapshot, + p.rebuild_metadata, + p.created_at, + p.updated_at, + p.rebuilt_at +FROM knowledge_pages p +JOIN knowledge_page_source_refs r ON r.page_id = p.page_id +JOIN unnest($4::text[], $5::uuid[]) AS changed(source_kind, source_id) + ON changed.source_kind = r.source_kind + AND changed.source_id = r.source_id +WHERE p.tenant_id = $1 + AND p.project_id = $2 + AND ($3::text IS NULL OR p.page_kind = $3) + AND p.status IN ('active', 'stale') +ORDER BY p.updated_at DESC, p.page_id DESC +LIMIT $6", + ) + .bind(tenant_id) + .bind(project_id) + .bind(page_kind) + .bind(source_kinds) + .bind(source_ids) + .bind(limit) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/knowledge/queries/search.rs b/packages/elf-storage/src/knowledge/queries/search.rs new file mode 100644 index 00000000..b2f6dccc --- /dev/null +++ b/packages/elf-storage/src/knowledge/queries/search.rs @@ -0,0 +1,90 @@ +use sqlx::PgExecutor; + +use crate::{Result, knowledge::types::KnowledgePageSearchRow}; + +/// Searches derived knowledge page sections by page and section text. +pub async fn search_knowledge_page_sections<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + page_kind: Option<&str>, + query_pattern: &str, + limit: i64, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, KnowledgePageSearchRow>( + "\ +WITH page_lint AS ( + SELECT + page_id, + count(*) FILTER (WHERE severity = 'error') AS error_count, + count(*) FILTER (WHERE severity = 'warning') AS warning_count, + count(*) FILTER (WHERE severity = 'info') AS info_count + FROM knowledge_page_lint_findings + GROUP BY page_id +), +section_refs AS ( + SELECT section_id, count(*) AS source_ref_count + FROM knowledge_page_source_refs + GROUP BY section_id +) +SELECT + p.page_id, + p.page_kind, + p.page_key, + p.title, + p.status, + p.source_coverage, + p.rebuild_metadata, + p.updated_at AS page_updated_at, + p.rebuilt_at, + s.section_id, + s.section_key, + s.heading, + s.role, + s.content, + s.ordinal, + s.citations, + s.unsupported_reason, + COALESCE(page_lint.error_count, 0)::bigint AS lint_error_count, + COALESCE(page_lint.warning_count, 0)::bigint AS lint_warning_count, + COALESCE(page_lint.info_count, 0)::bigint AS lint_info_count, + COALESCE(section_refs.source_ref_count, 0)::bigint AS section_source_ref_count +FROM knowledge_pages p +JOIN knowledge_page_sections s ON s.page_id = p.page_id +LEFT JOIN page_lint ON page_lint.page_id = p.page_id +LEFT JOIN section_refs ON section_refs.section_id = s.section_id +WHERE p.tenant_id = $1 + AND p.project_id = $2 + AND p.status IN ('active', 'stale') + AND ($3::text IS NULL OR p.page_kind = $3) + AND ( + lower(p.title) LIKE $4 + OR lower(p.page_key) LIKE $4 + OR lower(s.heading) LIKE $4 + OR lower(s.content) LIKE $4 + ) +ORDER BY + CASE + WHEN lower(p.title) LIKE $4 THEN 4 + WHEN lower(s.heading) LIKE $4 THEN 3 + WHEN lower(p.page_key) LIKE $4 THEN 2 + ELSE 1 + END DESC, + p.updated_at DESC, + s.ordinal ASC, + p.page_id DESC +LIMIT $5", + ) + .bind(tenant_id) + .bind(project_id) + .bind(page_kind) + .bind(query_pattern) + .bind(limit) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/knowledge/queries/sections.rs b/packages/elf-storage/src/knowledge/queries/sections.rs new file mode 100644 index 00000000..35a35883 --- /dev/null +++ b/packages/elf-storage/src/knowledge/queries/sections.rs @@ -0,0 +1,38 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{Result, models::KnowledgePageSection}; + +/// Lists sections for one knowledge page. +pub async fn list_knowledge_page_sections<'e, E>( + executor: E, + page_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, KnowledgePageSection>( + "\ +SELECT + section_id, + page_id, + section_key, + heading, + role, + content, + ordinal, + citations, + unsupported_reason, + content_hash, + created_at, + updated_at +FROM knowledge_page_sections +WHERE page_id = $1 +ORDER BY ordinal ASC, section_key ASC", + ) + .bind(page_id) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/knowledge/queries/sources.rs b/packages/elf-storage/src/knowledge/queries/sources.rs new file mode 100644 index 00000000..57746731 --- /dev/null +++ b/packages/elf-storage/src/knowledge/queries/sources.rs @@ -0,0 +1,74 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{Result, models::KnowledgePageSourceRef}; + +/// Lists normalized source refs for one knowledge page. +pub async fn list_knowledge_page_source_refs<'e, E>( + executor: E, + page_id: Uuid, +) -> Result> +where + E: PgExecutor<'e>, +{ + let rows = sqlx::query_as::<_, KnowledgePageSourceRef>( + "\ +SELECT + ref_id, + page_id, + section_id, + source_kind, + source_id, + source_status, + source_updated_at, + source_content_hash, + source_snapshot, + citation_metadata, + created_at +FROM knowledge_page_source_refs +WHERE page_id = $1 +ORDER BY source_kind ASC, source_id ASC, ref_id ASC", + ) + .bind(page_id) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Lists normalized source refs for a set of knowledge pages. +pub async fn list_knowledge_page_source_refs_for_pages<'e, E>( + executor: E, + page_ids: &[Uuid], +) -> Result> +where + E: PgExecutor<'e>, +{ + if page_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgePageSourceRef>( + "\ +SELECT + ref_id, + page_id, + section_id, + source_kind, + source_id, + source_status, + source_updated_at, + source_content_hash, + source_snapshot, + citation_metadata, + created_at +FROM knowledge_page_source_refs +WHERE page_id = ANY($1::uuid[]) +ORDER BY page_id ASC, source_kind ASC, source_id ASC, ref_id ASC", + ) + .bind(page_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/knowledge/sources.rs b/packages/elf-storage/src/knowledge/sources.rs new file mode 100644 index 00000000..53f3ebcf --- /dev/null +++ b/packages/elf-storage/src/knowledge/sources.rs @@ -0,0 +1,407 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{ + Result, + knowledge::types::{ + KnowledgeDocChunkSource, KnowledgeDocSource, KnowledgeEventSource, KnowledgeNoteSource, + KnowledgeProposalSource, KnowledgeRelationSource, KnowledgeRelationSourcesFetch, + }, +}; + +/// Fetches note sources by identifier for a knowledge page rebuild. +pub async fn fetch_knowledge_note_sources<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + agent_id: Option<&str>, + allowed_scopes: &[String], + note_ids: &[Uuid], +) -> Result> +where + E: PgExecutor<'e>, +{ + if note_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgeNoteSource>( + "\ +SELECT + note_id, + agent_id, + scope, + type AS note_type, + key, + text, + importance, + confidence, + status, + created_at, + updated_at, + expires_at, + embedding_version, + source_ref +FROM memory_notes +WHERE tenant_id = $1 + AND project_id = $2 + AND ($3::text IS NULL OR scope <> 'agent_private' OR agent_id = $3) + AND scope = ANY($4::text[]) + AND note_id = ANY($5::uuid[]) + AND status = 'active' + AND (expires_at IS NULL OR expires_at > now()) +ORDER BY updated_at ASC, note_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(allowed_scopes) + .bind(note_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Fetches durable add_event audit sources by decision identifier. +pub async fn fetch_knowledge_event_sources<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + agent_id: Option<&str>, + allowed_scopes: &[String], + decision_ids: &[Uuid], +) -> Result> +where + E: PgExecutor<'e>, +{ + if decision_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgeEventSource>( + "\ +SELECT + memory_ingest_decisions.decision_id, + memory_ingest_decisions.agent_id, + memory_ingest_decisions.scope, + memory_ingest_decisions.pipeline, + memory_ingest_decisions.note_type, + memory_ingest_decisions.note_key, + memory_ingest_decisions.note_id, + memory_ingest_decisions.policy_decision, + memory_ingest_decisions.note_op, + memory_ingest_decisions.reason_code, + memory_ingest_decisions.details, + memory_ingest_decisions.ts +FROM memory_ingest_decisions +JOIN memory_notes note ON note.note_id = memory_ingest_decisions.note_id +WHERE memory_ingest_decisions.tenant_id = $1 + AND memory_ingest_decisions.project_id = $2 + AND ($3::text IS NULL OR memory_ingest_decisions.scope <> 'agent_private' OR memory_ingest_decisions.agent_id = $3) + AND memory_ingest_decisions.scope = ANY($4::text[]) + AND memory_ingest_decisions.decision_id = ANY($5::uuid[]) + AND memory_ingest_decisions.pipeline = 'add_event' + AND memory_ingest_decisions.policy_decision IN ('remember', 'update') + AND note.tenant_id = memory_ingest_decisions.tenant_id + AND note.project_id = memory_ingest_decisions.project_id + AND note.status = 'active' + AND (note.expires_at IS NULL OR note.expires_at > now()) + AND ($3::text IS NULL OR note.scope <> 'agent_private' OR note.agent_id = $3) + AND note.scope = ANY($4::text[]) +ORDER BY memory_ingest_decisions.ts ASC, memory_ingest_decisions.decision_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(allowed_scopes) + .bind(decision_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Fetches relation sources by graph fact identifier for a knowledge page rebuild. +pub async fn fetch_knowledge_relation_sources<'e, E>( + executor: E, + params: KnowledgeRelationSourcesFetch<'_>, +) -> Result> +where + E: PgExecutor<'e>, +{ + if params.fact_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgeRelationSource>( + "\ +SELECT + gf.fact_id, + gf.agent_id, + gf.scope, + subject.canonical AS subject, + subject.kind AS subject_kind, + gf.predicate, + object_entity.canonical AS object_entity, + object_entity.kind AS object_kind, + gf.object_value, + gf.valid_from, + gf.valid_to, + gf.updated_at, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'note_id', evidence.note_id, + 'status', note.status, + 'updated_at', note.updated_at + ) + ORDER BY evidence.created_at ASC, evidence.note_id ASC + ) FILTER ( + WHERE evidence.note_id IS NOT NULL + AND note.tenant_id = gf.tenant_id + AND note.project_id = gf.project_id + AND note.status = 'active' + AND (note.expires_at IS NULL OR note.expires_at > now()) + AND note.scope = ANY($4::text[]) + AND ( + $3::text IS NULL + OR ($6 AND note.scope = 'agent_private' AND note.agent_id = $3) + OR ( + note.scope <> 'agent_private' + AND ( + note.agent_id = $3 + OR concat(note.scope, ':', note.agent_id) = ANY($5::text[]) + ) + ) + ) + ), + '[]'::jsonb + ) AS evidence_notes +FROM graph_facts gf +JOIN graph_entities subject ON subject.entity_id = gf.subject_entity_id +LEFT JOIN graph_entities object_entity ON object_entity.entity_id = gf.object_entity_id +LEFT JOIN graph_fact_evidence evidence ON evidence.fact_id = gf.fact_id +LEFT JOIN memory_notes note ON note.note_id = evidence.note_id +WHERE gf.tenant_id = $1 + AND gf.project_id = $2 + AND gf.scope = ANY($4::text[]) + AND ( + $3::text IS NULL + OR ($6 AND gf.scope = 'agent_private' AND gf.agent_id = $3) + OR ( + gf.scope <> 'agent_private' + AND ( + gf.agent_id = $3 + OR concat(gf.scope, ':', gf.agent_id) = ANY($5::text[]) + ) + ) + ) + AND gf.fact_id = ANY($7::uuid[]) + AND EXISTS ( + SELECT 1 + FROM graph_fact_evidence readable_evidence + JOIN memory_notes readable_note + ON readable_note.note_id = readable_evidence.note_id + WHERE readable_evidence.fact_id = gf.fact_id + AND readable_note.tenant_id = gf.tenant_id + AND readable_note.project_id = gf.project_id + AND readable_note.status = 'active' + AND (readable_note.expires_at IS NULL OR readable_note.expires_at > now()) + AND readable_note.scope = ANY($4::text[]) + AND ( + $3::text IS NULL + OR ($6 AND readable_note.scope = 'agent_private' AND readable_note.agent_id = $3) + OR ( + readable_note.scope <> 'agent_private' + AND ( + readable_note.agent_id = $3 + OR concat(readable_note.scope, ':', readable_note.agent_id) = ANY($5::text[]) + ) + ) + ) + ) +GROUP BY + gf.fact_id, + gf.agent_id, + gf.scope, + subject.canonical, + subject.kind, + gf.predicate, + object_entity.canonical, + object_entity.kind, + gf.object_value, + gf.valid_from, + gf.valid_to, + gf.updated_at +ORDER BY gf.updated_at ASC, gf.fact_id ASC", + ) + .bind(params.tenant_id) + .bind(params.project_id) + .bind(params.agent_id) + .bind(params.allowed_scopes) + .bind(params.shared_scope_keys) + .bind(params.private_allowed) + .bind(params.fact_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Fetches applied proposal sources by identifier for a knowledge page rebuild. +pub async fn fetch_knowledge_proposal_sources<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + proposal_ids: &[Uuid], +) -> Result> +where + E: PgExecutor<'e>, +{ + if proposal_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgeProposalSource>( + "\ +SELECT + proposal_id, + run_id, + agent_id, + proposal_kind, + apply_intent, + review_state, + source_refs, + source_snapshot, + lineage, + diff, + confidence, + COALESCE(unsupported_claim_flags, '[]'::jsonb) AS unsupported_claim_flags, + COALESCE(contradiction_markers, '[]'::jsonb) AS contradiction_markers, + COALESCE(staleness_markers, '[]'::jsonb) AS staleness_markers, + COALESCE(target_ref, '{}'::jsonb) AS target_ref, + COALESCE(proposed_payload, '{}'::jsonb) AS proposed_payload, + updated_at +FROM consolidation_proposals +WHERE tenant_id = $1 + AND project_id = $2 + AND proposal_id = ANY($3::uuid[]) + AND review_state = 'applied' +ORDER BY updated_at ASC, proposal_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(proposal_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Fetches active Source Library documents by identifier for a knowledge page rebuild. +pub async fn fetch_knowledge_doc_sources<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + agent_id: Option<&str>, + allowed_scopes: &[String], + doc_ids: &[Uuid], +) -> Result> +where + E: PgExecutor<'e>, +{ + if doc_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgeDocSource>( + "\ +SELECT + doc_id, + agent_id, + scope, + doc_type, + status, + title, + COALESCE(source_ref, '{}'::jsonb) AS source_ref, + content, + content_bytes, + content_hash, + created_at, + updated_at +FROM doc_documents +WHERE tenant_id = $1 + AND project_id = $2 + AND ($3::text IS NULL OR scope <> 'agent_private' OR agent_id = $3) + AND scope = ANY($4::text[]) + AND doc_id = ANY($5::uuid[]) + AND status = 'active' +ORDER BY updated_at ASC, doc_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(allowed_scopes) + .bind(doc_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} + +/// Fetches active Source Library document chunks by identifier for a knowledge page rebuild. +pub async fn fetch_knowledge_doc_chunk_sources<'e, E>( + executor: E, + tenant_id: &str, + project_id: &str, + agent_id: Option<&str>, + allowed_scopes: &[String], + chunk_ids: &[Uuid], +) -> Result> +where + E: PgExecutor<'e>, +{ + if chunk_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, KnowledgeDocChunkSource>( + "\ +SELECT + c.chunk_id, + c.doc_id, + d.agent_id, + d.scope, + d.doc_type, + d.status, + d.title, + COALESCE(d.source_ref, '{}'::jsonb) AS source_ref, + d.content_hash AS doc_content_hash, + d.updated_at AS doc_updated_at, + c.chunk_index, + c.start_offset, + c.end_offset, + c.chunk_text, + c.chunk_hash, + c.created_at AS chunk_created_at +FROM doc_chunks c +JOIN doc_documents d ON d.doc_id = c.doc_id +WHERE d.tenant_id = $1 + AND d.project_id = $2 + AND ($3::text IS NULL OR d.scope <> 'agent_private' OR d.agent_id = $3) + AND d.scope = ANY($4::text[]) + AND c.chunk_id = ANY($5::uuid[]) + AND d.status = 'active' +ORDER BY d.updated_at ASC, c.chunk_index ASC, c.chunk_id ASC", + ) + .bind(tenant_id) + .bind(project_id) + .bind(agent_id) + .bind(allowed_scopes) + .bind(chunk_ids) + .fetch_all(executor) + .await?; + + Ok(rows) +} diff --git a/packages/elf-storage/src/knowledge/types.rs b/packages/elf-storage/src/knowledge/types.rs new file mode 100644 index 00000000..6e4fd3b2 --- /dev/null +++ b/packages/elf-storage/src/knowledge/types.rs @@ -0,0 +1,375 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Arguments for upserting one derived knowledge page. +pub struct KnowledgePageUpsert<'a> { + /// Page identifier to use for a newly created page. + pub page_id: Uuid, + /// Tenant that owns the page. + pub tenant_id: &'a str, + /// Project that owns the page. + pub project_id: &'a str, + /// Page kind. + pub page_kind: &'a str, + /// Stable page key. + pub page_key: &'a str, + /// Page title. + pub title: &'a str, + /// Versioned page contract schema. + pub contract_schema: &'a str, + /// Page lifecycle status. + pub status: &'a str, + /// Canonical source snapshot hash. + pub rebuild_source_hash: &'a str, + /// Canonical page content hash. + pub content_hash: &'a str, + /// Source coverage metadata. + pub source_coverage: &'a Value, + /// Aggregate source snapshot metadata. + pub source_snapshot: &'a Value, + /// Rebuild metadata. + pub rebuild_metadata: &'a Value, + /// Rebuild timestamp. + pub now: OffsetDateTime, +} + +/// Arguments for inserting one knowledge page section. +pub struct KnowledgePageSectionInsert<'a> { + /// Section identifier. + pub section_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Stable section key. + pub section_key: &'a str, + /// Section heading. + pub heading: &'a str, + /// Section role. + pub role: &'a str, + /// Section content. + pub content: &'a str, + /// Section display order. + pub ordinal: i32, + /// Section citations. + pub citations: &'a Value, + /// Reason the section has no citations, when intentionally unsupported. + pub unsupported_reason: Option<&'a str>, + /// Section content hash. + pub content_hash: &'a str, + /// Creation/update timestamp. + pub now: OffsetDateTime, +} + +/// Arguments for inserting one normalized knowledge page citation. +pub struct KnowledgePageSourceRefInsert<'a> { + /// Source-reference row identifier. + pub ref_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Section that cites the source, if section-scoped. + pub section_id: Option, + /// Source kind. + pub source_kind: &'a str, + /// Authoritative source identifier. + pub source_id: Uuid, + /// Captured source status. + pub source_status: Option<&'a str>, + /// Captured source updated timestamp. + pub source_updated_at: Option, + /// Captured source content hash. + pub source_content_hash: Option<&'a str>, + /// Captured source snapshot. + pub source_snapshot: &'a Value, + /// Citation-local metadata. + pub citation_metadata: &'a Value, + /// Creation timestamp. + pub now: OffsetDateTime, +} + +/// Arguments for inserting one knowledge page lint finding. +pub struct KnowledgePageLintFindingInsert<'a> { + /// Lint finding identifier. + pub finding_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Section associated with the finding, when available. + pub section_id: Option, + /// Finding type. + pub finding_type: &'a str, + /// Finding severity. + pub severity: &'a str, + /// Source kind associated with the finding, when available. + pub source_kind: Option<&'a str>, + /// Source identifier associated with the finding, when available. + pub source_id: Option, + /// Human-readable finding message. + pub message: &'a str, + /// Structured finding details. + pub details: &'a Value, + /// Creation timestamp. + pub now: OffsetDateTime, +} + +/// Parameters for fetching graph relation sources for knowledge pages. +pub struct KnowledgeRelationSourcesFetch<'a> { + /// Tenant that owns the relation sources. + pub tenant_id: &'a str, + /// Project that owns the relation sources. + pub project_id: &'a str, + /// Agent requesting source readback, when visibility should be caller-scoped. + pub agent_id: Option<&'a str>, + /// Scopes allowed by the caller read profile. + pub allowed_scopes: &'a [String], + /// Shared owner/scope grant keys readable by the caller. + pub shared_scope_keys: &'a [String], + /// Whether private scope is readable by the caller. + pub private_allowed: bool, + /// Graph fact identifiers to fetch. + pub fact_ids: &'a [Uuid], +} + +/// Authoritative note source row used by the knowledge page rebuilder. +#[derive(Debug, FromRow)] +pub struct KnowledgeNoteSource { + /// Note identifier. + pub note_id: Uuid, + /// Agent that owns the note. + pub agent_id: String, + /// Note scope. + pub scope: String, + /// Note type. + pub note_type: String, + /// Optional note key. + pub key: Option, + /// Note text. + pub text: String, + /// Note importance. + pub importance: f32, + /// Note confidence. + pub confidence: f32, + /// Note status. + pub status: String, + /// Note creation timestamp. + pub created_at: OffsetDateTime, + /// Note update timestamp. + pub updated_at: OffsetDateTime, + /// Optional note expiry timestamp. + pub expires_at: Option, + /// Note embedding version. + pub embedding_version: String, + /// Opaque note source reference. + pub source_ref: Value, +} + +/// Durable add_event audit source row used by the knowledge page rebuilder. +#[derive(Debug, FromRow)] +pub struct KnowledgeEventSource { + /// Ingest decision identifier. + pub decision_id: Uuid, + /// Agent that wrote the audited event-derived note decision. + pub agent_id: String, + /// Scope associated with the audited decision. + pub scope: String, + /// Ingestion pipeline name. + pub pipeline: String, + /// Event-derived note type. + pub note_type: String, + /// Optional note key. + pub note_key: Option, + /// Note identifier affected by the decision, when persisted. + pub note_id: Option, + /// Policy decision. + pub policy_decision: String, + /// Note operation. + pub note_op: String, + /// Optional reason code. + pub reason_code: Option, + /// Structured audit details. + pub details: Value, + /// Audit timestamp. + pub ts: OffsetDateTime, +} + +/// Authoritative graph relation source row used by the knowledge page rebuilder. +#[derive(Debug, FromRow)] +pub struct KnowledgeRelationSource { + /// Graph fact identifier. + pub fact_id: Uuid, + /// Agent that wrote the fact. + pub agent_id: String, + /// Fact scope. + pub scope: String, + /// Subject canonical text. + pub subject: String, + /// Optional subject kind. + pub subject_kind: Option, + /// Predicate text. + pub predicate: String, + /// Optional object entity canonical text. + pub object_entity: Option, + /// Optional object entity kind. + pub object_kind: Option, + /// Optional scalar object value. + pub object_value: Option, + /// Fact validity window start. + pub valid_from: OffsetDateTime, + /// Fact validity window end, when historical. + pub valid_to: Option, + /// Fact update timestamp. + pub updated_at: OffsetDateTime, + /// Evidence notes linked to this fact. + pub evidence_notes: Value, +} + +/// Reviewed consolidation proposal source row used by the knowledge page rebuilder. +#[derive(Debug, FromRow)] +pub struct KnowledgeProposalSource { + /// Consolidation proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Agent that registered the proposal. + pub agent_id: String, + /// Proposal kind. + pub proposal_kind: String, + /// Proposal apply intent. + pub apply_intent: String, + /// Proposal review state. + pub review_state: String, + /// Serialized proposal source references. + pub source_refs: Value, + /// Serialized proposal source snapshot. + pub source_snapshot: Value, + /// Serialized proposal lineage. + pub lineage: Value, + /// Serialized proposal diff. + pub diff: Value, + /// Proposal confidence. + pub confidence: f32, + /// Unsupported claim flags. + pub unsupported_claim_flags: Value, + /// Contradiction markers. + pub contradiction_markers: Value, + /// Staleness markers. + pub staleness_markers: Value, + /// Derived target reference. + pub target_ref: Value, + /// Proposed derived payload. + pub proposed_payload: Value, + /// Proposal update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Source Library document row used by the knowledge page rebuilder. +#[derive(Debug, FromRow)] +pub struct KnowledgeDocSource { + /// Document identifier. + pub doc_id: Uuid, + /// Agent that captured the document. + pub agent_id: String, + /// Document scope. + pub scope: String, + /// Document type. + pub doc_type: String, + /// Document lifecycle status. + pub status: String, + /// Optional document title. + pub title: Option, + /// Document source reference. + pub source_ref: Value, + /// Persisted document content. + pub content: String, + /// Persisted byte length. + pub content_bytes: i32, + /// Whole-document content hash. + pub content_hash: String, + /// Document creation timestamp. + pub created_at: OffsetDateTime, + /// Document update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Source Library document chunk row used by the knowledge page rebuilder. +#[derive(Debug, FromRow)] +pub struct KnowledgeDocChunkSource { + /// Chunk identifier. + pub chunk_id: Uuid, + /// Parent document identifier. + pub doc_id: Uuid, + /// Agent that captured the document. + pub agent_id: String, + /// Document scope. + pub scope: String, + /// Document type. + pub doc_type: String, + /// Document lifecycle status. + pub status: String, + /// Optional document title. + pub title: Option, + /// Document source reference. + pub source_ref: Value, + /// Whole-document content hash. + pub doc_content_hash: String, + /// Document update timestamp. + pub doc_updated_at: OffsetDateTime, + /// Zero-based chunk index. + pub chunk_index: i32, + /// Inclusive start byte offset. + pub start_offset: i32, + /// Exclusive end byte offset. + pub end_offset: i32, + /// Chunk text. + pub chunk_text: String, + /// Chunk content hash. + pub chunk_hash: String, + /// Chunk creation timestamp. + pub chunk_created_at: OffsetDateTime, +} + +/// Searchable knowledge page section row with page and lint metadata. +#[derive(Debug, FromRow)] +pub struct KnowledgePageSearchRow { + /// Derived page identifier. + pub page_id: Uuid, + /// Page kind. + pub page_kind: String, + /// Stable page key. + pub page_key: String, + /// Page title. + pub title: String, + /// Page lifecycle status. + pub status: String, + /// Source coverage metadata. + pub source_coverage: Value, + /// Rebuild metadata. + pub rebuild_metadata: Value, + /// Page update timestamp. + pub page_updated_at: OffsetDateTime, + /// Page rebuild timestamp. + pub rebuilt_at: OffsetDateTime, + /// Section identifier. + pub section_id: Uuid, + /// Stable section key. + pub section_key: String, + /// Section heading. + pub heading: String, + /// Section role. + pub role: String, + /// Section content. + pub content: String, + /// Section display order. + pub ordinal: i32, + /// Section citations. + pub citations: Value, + /// Reason the section is unsupported, when present. + pub unsupported_reason: Option, + /// Number of error lint findings for the page. + pub lint_error_count: i64, + /// Number of warning lint findings for the page. + pub lint_warning_count: i64, + /// Number of info lint findings for the page. + pub lint_info_count: i64, + /// Number of normalized source refs for this section. + pub section_source_ref_count: i64, +} diff --git a/packages/elf-storage/src/knowledge/writes.rs b/packages/elf-storage/src/knowledge/writes.rs new file mode 100644 index 00000000..80f62d03 --- /dev/null +++ b/packages/elf-storage/src/knowledge/writes.rs @@ -0,0 +1,253 @@ +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::{ + Result, + knowledge::types::{ + KnowledgePageLintFindingInsert, KnowledgePageSectionInsert, KnowledgePageSourceRefInsert, + KnowledgePageUpsert, + }, + models::KnowledgePage, +}; + +/// Upserts one derived knowledge page and returns the persisted row. +pub async fn upsert_knowledge_page<'e, E>( + executor: E, + args: KnowledgePageUpsert<'_>, +) -> Result +where + E: PgExecutor<'e>, +{ + let row = sqlx::query_as::<_, KnowledgePage>( + "\ +INSERT INTO knowledge_pages ( + page_id, + tenant_id, + project_id, + page_kind, + page_key, + title, + contract_schema, + status, + rebuild_source_hash, + content_hash, + source_coverage, + source_snapshot, + rebuild_metadata, + created_at, + updated_at, + rebuilt_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14,$14) +ON CONFLICT (tenant_id, project_id, page_kind, page_key) DO UPDATE +SET + title = EXCLUDED.title, + contract_schema = EXCLUDED.contract_schema, + status = EXCLUDED.status, + rebuild_source_hash = EXCLUDED.rebuild_source_hash, + content_hash = EXCLUDED.content_hash, + source_coverage = EXCLUDED.source_coverage, + source_snapshot = EXCLUDED.source_snapshot, + rebuild_metadata = EXCLUDED.rebuild_metadata, + updated_at = EXCLUDED.updated_at, + rebuilt_at = EXCLUDED.rebuilt_at +RETURNING + page_id, + tenant_id, + project_id, + page_kind, + page_key, + title, + contract_schema, + status, + rebuild_source_hash, + content_hash, + source_coverage, + source_snapshot, + rebuild_metadata, + created_at, + updated_at, + rebuilt_at", + ) + .bind(args.page_id) + .bind(args.tenant_id) + .bind(args.project_id) + .bind(args.page_kind) + .bind(args.page_key) + .bind(args.title) + .bind(args.contract_schema) + .bind(args.status) + .bind(args.rebuild_source_hash) + .bind(args.content_hash) + .bind(args.source_coverage) + .bind(args.source_snapshot) + .bind(args.rebuild_metadata) + .bind(args.now) + .fetch_one(executor) + .await?; + + Ok(row) +} + +/// Deletes all section, citation, and lint child rows for a page before rebuild. +pub async fn delete_knowledge_page_children<'e, E>(executor: E, page_id: Uuid) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ + WITH deleted_lint AS ( + DELETE FROM knowledge_page_lint_findings + WHERE page_id = $1 + ), + deleted_source_refs AS ( + DELETE FROM knowledge_page_source_refs + WHERE page_id = $1 + ) + DELETE FROM knowledge_page_sections + WHERE page_id = $1", + ) + .bind(page_id) + .execute(executor) + .await?; + + Ok(()) +} + +/// Inserts one derived knowledge page section. +pub async fn insert_knowledge_page_section<'e, E>( + executor: E, + args: KnowledgePageSectionInsert<'_>, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO knowledge_page_sections ( + section_id, + page_id, + section_key, + heading, + role, + content, + ordinal, + citations, + unsupported_reason, + content_hash, + created_at, + updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)", + ) + .bind(args.section_id) + .bind(args.page_id) + .bind(args.section_key) + .bind(args.heading) + .bind(args.role) + .bind(args.content) + .bind(args.ordinal) + .bind(args.citations) + .bind(args.unsupported_reason) + .bind(args.content_hash) + .bind(args.now) + .execute(executor) + .await?; + + Ok(()) +} + +/// Inserts one normalized knowledge page citation/source reference. +pub async fn insert_knowledge_page_source_ref<'e, E>( + executor: E, + args: KnowledgePageSourceRefInsert<'_>, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO knowledge_page_source_refs ( + ref_id, + page_id, + section_id, + source_kind, + source_id, + source_status, + source_updated_at, + source_content_hash, + source_snapshot, + citation_metadata, + created_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + ) + .bind(args.ref_id) + .bind(args.page_id) + .bind(args.section_id) + .bind(args.source_kind) + .bind(args.source_id) + .bind(args.source_status) + .bind(args.source_updated_at) + .bind(args.source_content_hash) + .bind(args.source_snapshot) + .bind(args.citation_metadata) + .bind(args.now) + .execute(executor) + .await?; + + Ok(()) +} + +/// Inserts one knowledge page lint finding. +pub async fn insert_knowledge_page_lint_finding<'e, E>( + executor: E, + args: KnowledgePageLintFindingInsert<'_>, +) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +INSERT INTO knowledge_page_lint_findings ( + finding_id, + page_id, + section_id, + finding_type, + severity, + source_kind, + source_id, + message, + details, + created_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", + ) + .bind(args.finding_id) + .bind(args.page_id) + .bind(args.section_id) + .bind(args.finding_type) + .bind(args.severity) + .bind(args.source_kind) + .bind(args.source_id) + .bind(args.message) + .bind(args.details) + .bind(args.now) + .execute(executor) + .await?; + + Ok(()) +} + +/// Deletes persisted lint findings for one page. +pub async fn delete_knowledge_page_lint_findings<'e, E>(executor: E, page_id: Uuid) -> Result<()> +where + E: PgExecutor<'e>, +{ + sqlx::query("DELETE FROM knowledge_page_lint_findings WHERE page_id = $1") + .bind(page_id) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/packages/elf-storage/src/models.rs b/packages/elf-storage/src/models.rs index 2eeda18f..4913cd96 100644 --- a/packages/elf-storage/src/models.rs +++ b/packages/elf-storage/src/models.rs @@ -1,680 +1,27 @@ //! Database row models shared across storage modules. -use serde_json::Value; -use sqlx::FromRow; -use time::OffsetDateTime; -use uuid::Uuid; - -/// Persisted memory note row. -#[derive(Debug, FromRow)] -pub struct MemoryNote { - /// Note identifier. - pub note_id: Uuid, - /// Tenant that owns the note. - pub tenant_id: String, - /// Project that owns the note. - pub project_id: String, - /// Agent that wrote the note. - pub agent_id: String, - /// Scope key for the note. - pub scope: String, - /// Note type discriminator. - pub r#type: String, - /// Optional application-defined key for deduplication or lookup. - pub key: Option, - /// Note body text. - pub text: String, - /// Importance score persisted for ranking. - pub importance: f32, - /// Confidence score persisted for ranking. - pub confidence: f32, - /// Lifecycle status for the note. - pub status: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, - /// Optional expiry timestamp. - pub expires_at: Option, - /// Embedding version associated with the stored note. - pub embedding_version: String, - /// Structured source reference metadata. - pub source_ref: Value, - /// Search hit counter. - pub hit_count: i64, - /// Timestamp of the most recent search hit. - pub last_hit_at: Option, -} - -/// Persisted chunk row for one memory note. -#[derive(Debug, FromRow)] -pub struct MemoryNoteChunk { - /// Chunk identifier. - pub chunk_id: Uuid, - /// Parent note identifier. - pub note_id: Uuid, - /// Zero-based chunk position within the note. - pub chunk_index: i32, - /// Inclusive start byte offset within the original note text. - pub start_offset: i32, - /// Exclusive end byte offset within the original note text. - pub end_offset: i32, - /// Chunk text. - pub text: String, - /// Embedding version associated with the chunk. - pub embedding_version: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted embedding row for one note chunk. -#[derive(Debug, FromRow)] -pub struct NoteChunkEmbedding { - /// Chunk identifier. - pub chunk_id: Uuid, - /// Embedding version associated with the vector. - pub embedding_version: String, - /// Embedding dimensionality. - pub embedding_dim: i32, - /// Embedding vector payload. - pub vec: Vec, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// In-memory embedding payload for a full note. -#[derive(Debug)] -pub struct NoteEmbedding { - /// Note identifier. - pub note_id: Uuid, - /// Embedding version associated with the vector. - pub embedding_version: String, - /// Embedding dimensionality. - pub embedding_dim: i32, - /// Embedding vector payload. - pub vec: Vec, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted note-indexing outbox row. -#[derive(Debug, FromRow)] -pub struct IndexingOutboxEntry { - /// Outbox identifier. - pub outbox_id: Uuid, - /// Note identifier queued for indexing. - pub note_id: Uuid, - /// Requested indexing operation. - pub op: String, - /// Embedding version the worker should use. - pub embedding_version: String, - /// Current outbox status. - pub status: String, - /// Number of attempts already made. - pub attempts: i32, - /// Most recent failure text, if any. - pub last_error: Option, - /// Earliest time the job may be claimed again. - pub available_at: OffsetDateTime, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted search-trace outbox job. -#[derive(Debug, FromRow)] -pub struct TraceOutboxJob { - /// Outbox identifier. - pub outbox_id: Uuid, - /// Trace identifier to export. - pub trace_id: Uuid, - /// Serialized trace payload. - pub payload: Value, - /// Number of attempts already made. - pub attempts: i32, -} - -/// Persisted graph entity row. -#[derive(Debug, FromRow)] -pub struct GraphEntity { - /// Entity identifier. - pub entity_id: Uuid, - /// Tenant that owns the entity. - pub tenant_id: String, - /// Project that owns the entity. - pub project_id: String, - /// Canonical entity surface. - pub canonical: String, - /// Normalized canonical entity surface. - pub canonical_norm: String, - /// Optional entity kind. - pub kind: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted alias row for a graph entity. -#[derive(Debug, FromRow)] -pub struct GraphEntityAlias { - /// Alias identifier. - pub alias_id: Uuid, - /// Entity identifier that owns the alias. - pub entity_id: Uuid, - /// Alias surface. - pub alias: String, - /// Normalized alias surface. - pub alias_norm: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted graph fact row. -#[derive(Debug, FromRow)] -pub struct GraphFact { - /// Fact identifier. - pub fact_id: Uuid, - /// Tenant that owns the fact. - pub tenant_id: String, - /// Project that owns the fact. - pub project_id: String, - /// Agent that emitted the fact. - pub agent_id: String, - /// Scope key for the fact. - pub scope: String, - /// Subject entity identifier. - pub subject_entity_id: Uuid, - /// Predicate surface captured with the fact. - pub predicate: String, - /// Resolved predicate identifier, when available. - pub predicate_id: Option, - /// Object entity identifier for entity-to-entity facts. - pub object_entity_id: Option, - /// Scalar object value for entity-to-value facts. - pub object_value: Option, - /// Start of the fact validity window. - pub valid_from: OffsetDateTime, - /// End of the fact validity window, if superseded. - pub valid_to: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Evidence link between one graph fact and one memory note. -#[derive(Debug, FromRow)] -pub struct GraphFactEvidence { - /// Evidence row identifier. - pub evidence_id: Uuid, - /// Fact identifier. - pub fact_id: Uuid, - /// Note identifier that supports the fact. - pub note_id: Uuid, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted graph predicate row. -#[derive(Debug, FromRow)] -pub struct GraphPredicate { - /// Predicate identifier. - pub predicate_id: Uuid, - /// Scope key where the predicate is visible. - pub scope_key: String, - /// Tenant scope, when tenant-specific. - pub tenant_id: Option, - /// Project scope, when project-specific. - pub project_id: Option, - /// Canonical predicate surface. - pub canonical: String, - /// Normalized canonical predicate surface. - pub canonical_norm: String, - /// Cardinality policy for the predicate. - pub cardinality: String, - /// Lifecycle status for the predicate. - pub status: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted alias row for a graph predicate. -#[derive(Debug, FromRow)] -pub struct GraphPredicateAlias { - /// Alias identifier. - pub alias_id: Uuid, - /// Predicate identifier that owns the alias. - pub predicate_id: Uuid, - /// Scope key where the alias resolves. - pub scope_key: String, - /// Alias surface. - pub alias: String, - /// Normalized alias surface. - pub alias_norm: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted source-adjacent Work Journal entry. -#[derive(Debug, FromRow)] -pub struct WorkJournalEntry { - /// Journal entry identifier. - pub entry_id: Uuid, - /// Tenant that owns the entry. - pub tenant_id: String, - /// Project that owns the entry. - pub project_id: String, - /// Agent that captured the entry. - pub agent_id: String, - /// Visibility scope for readback. - pub scope: String, - /// Stable external or session-local journal session identifier. - pub session_id: String, - /// Entry family discriminator. - pub family: String, - /// Lifecycle status for the journal entry. - pub status: String, - /// Optional display title. - pub title: Option, - /// Redacted durable journal body. - pub body: String, - /// Source references supporting this journal entry. - pub source_refs: Value, - /// Explicit next steps captured from the source. - pub explicit_next_steps: Value, - /// Inferred next steps captured as non-authoritative hints. - pub inferred_next_steps: Value, - /// Options rejected during the captured work session. - pub rejected_options: Value, - /// Promotion boundary metadata for Memory Authority and Dreaming Review. - pub promotion_boundary: Value, - /// Redaction audit for durable journal text. - pub redaction_audit: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted supersession row linking two facts. -#[derive(Debug, FromRow)] -pub struct GraphFactSupersession { - /// Supersession identifier. - pub supersession_id: Uuid, - /// Tenant that owns the supersession record. - pub tenant_id: String, - /// Project that owns the supersession record. - pub project_id: String, - /// Fact identifier that was superseded. - pub from_fact_id: Uuid, - /// Fact identifier that replaced the prior fact. - pub to_fact_id: Uuid, - /// Note identifier that justified the supersession. - pub note_id: Uuid, - /// Time the supersession took effect. - pub effective_at: OffsetDateTime, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted consolidation run row. -#[derive(Debug, FromRow)] -pub struct ConsolidationRun { - /// Consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the run. - pub tenant_id: String, - /// Project that owns the run. - pub project_id: String, - /// Agent that registered the run. - pub agent_id: String, - /// Versioned consolidation contract schema. - pub contract_schema: String, - /// Job kind, such as fixture, manual, or scheduled. - pub job_kind: String, - /// Current run status. - pub status: String, - /// Serialized input references. - pub input_refs: Value, - /// Aggregate source snapshot metadata. - pub source_snapshot: Value, - /// Serialized run lineage. - pub lineage: Value, - /// Structured error payload for failed runs. - pub error: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, - /// Completion timestamp for terminal runs. - pub completed_at: Option, -} - -/// Persisted consolidation proposal row. -#[derive(Debug, FromRow)] -pub struct ConsolidationProposal { - /// Consolidation proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the proposal. - pub tenant_id: String, - /// Project that owns the proposal. - pub project_id: String, - /// Agent that registered the proposal. - pub agent_id: String, - /// Versioned consolidation contract schema. - pub contract_schema: String, - /// Proposal kind, such as derived_note or knowledge_page. - pub proposal_kind: String, - /// Derived-output apply intent. - pub apply_intent: String, - /// Current review state. - pub review_state: String, - /// Serialized source references. - pub source_refs: Value, - /// Aggregate source snapshot metadata. - pub source_snapshot: Value, - /// Serialized proposal lineage. - pub lineage: Value, - /// Serialized reviewable diff. - pub diff: Value, - /// Proposal confidence score. - pub confidence: f32, - /// Serialized unsupported-claim flags. - pub unsupported_claim_flags: Value, - /// Serialized contradiction markers. - pub contradiction_markers: Value, - /// Serialized staleness markers. - pub staleness_markers: Value, - /// Serialized derived target reference. - pub target_ref: Value, - /// Serialized proposed derived output payload. - pub proposed_payload: Value, - /// Agent that last reviewed the proposal. - pub reviewer_agent_id: Option, - /// Optional reviewer comment. - pub review_comment: Option, - /// Timestamp of the last review transition. - pub reviewed_at: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted consolidation proposal review event row. -#[derive(Debug, FromRow)] -pub struct ConsolidationProposalReviewEvent { - /// Review event identifier. - pub review_id: Uuid, - /// Reviewed proposal identifier. - pub proposal_id: Uuid, - /// Parent consolidation run identifier. - pub run_id: Uuid, - /// Tenant that owns the proposal. - pub tenant_id: String, - /// Project that owns the proposal. - pub project_id: String, - /// Agent that performed the review action. - pub reviewer_agent_id: String, - /// Review action requested by the reviewer. - pub action: String, - /// Review state before the transition. - pub from_review_state: String, - /// Review state after the transition. - pub to_review_state: String, - /// Optional reviewer comment. - pub review_comment: Option, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted consolidation worker job row. -#[derive(Debug, FromRow)] -pub struct ConsolidationRunJob { - /// Worker job identifier. - pub job_id: Uuid, - /// Consolidation run to materialize. - pub run_id: Uuid, - /// Tenant that owns the run. - pub tenant_id: String, - /// Project that owns the run. - pub project_id: String, - /// Agent that registered the run. - pub agent_id: String, - /// Job kind, such as fixture or manual. - pub job_kind: String, - /// Current job status. - pub status: String, - /// Queued proposal payload. - pub payload: Value, - /// Number of attempts already made. - pub attempts: i32, - /// Most recent failure text, if any. - pub last_error: Option, - /// Earliest time the job may be claimed again. - pub available_at: OffsetDateTime, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted derived knowledge page row. -#[derive(Debug, FromRow)] -pub struct KnowledgePage { - /// Derived page identifier. - pub page_id: Uuid, - /// Tenant that owns the page. - pub tenant_id: String, - /// Project that owns the page. - pub project_id: String, - /// Page kind, such as project, entity, concept, issue, decision, author, or timeline. - pub page_kind: String, - /// Stable page key within the tenant/project/kind namespace. - pub page_key: String, - /// Human-readable page title. - pub title: String, - /// Versioned knowledge page contract schema. - pub contract_schema: String, - /// Derived page lifecycle status. - pub status: String, - /// BLAKE3 hash of the canonical source snapshot. - pub rebuild_source_hash: String, - /// BLAKE3 hash of the canonical page payload. - pub content_hash: String, - /// Source coverage metadata. - pub source_coverage: Value, - /// Aggregate source snapshot metadata captured during rebuild. - pub source_snapshot: Value, - /// Rebuild metadata, including deterministic/provider information. - pub rebuild_metadata: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, - /// Last rebuild timestamp. - pub rebuilt_at: OffsetDateTime, -} - -/// Persisted derived knowledge page section row. -#[derive(Debug, FromRow)] -pub struct KnowledgePageSection { - /// Section identifier. - pub section_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Stable section key within one page. - pub section_key: String, - /// Section heading. - pub heading: String, - /// Section role, such as current_truth, history, relations, or proposals. - pub role: String, - /// Section content. - pub content: String, - /// Display order within the page. - pub ordinal: i32, - /// Serialized citation array for this section. - pub citations: Value, - /// Reason a section lacks citations, when intentionally unsupported. - pub unsupported_reason: Option, - /// BLAKE3 hash of the section content and citations. - pub content_hash: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted normalized citation/source reference for a knowledge page. -#[derive(Debug, FromRow)] -pub struct KnowledgePageSourceRef { - /// Source-reference row identifier. - pub ref_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Section that cites the source, if section-scoped. - pub section_id: Option, - /// Source kind, such as doc, doc_chunk, note, relation, proposal, or event. - pub source_kind: String, - /// Authoritative source identifier. - pub source_id: Uuid, - /// Source lifecycle status captured during rebuild. - pub source_status: Option, - /// Source last-update timestamp captured during rebuild. - pub source_updated_at: Option, - /// Source content hash captured during rebuild. - pub source_content_hash: Option, - /// Full source snapshot captured during rebuild. - pub source_snapshot: Value, - /// Citation-local metadata. - pub citation_metadata: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted lint finding for one derived knowledge page. -#[derive(Debug, FromRow)] -pub struct KnowledgePageLintFinding { - /// Lint finding identifier. - pub finding_id: Uuid, - /// Parent page identifier. - pub page_id: Uuid, - /// Section associated with the finding, when available. - pub section_id: Option, - /// Finding type, such as stale_source_ref or unsupported_claim. - pub finding_type: String, - /// Finding severity. - pub severity: String, - /// Source kind associated with the finding, when available. - pub source_kind: Option, - /// Source identifier associated with the finding, when available. - pub source_id: Option, - /// Human-readable finding message. - pub message: String, - /// Structured finding details. - pub details: Value, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted document row. -#[derive(Debug, FromRow)] -pub struct DocDocument { - /// Document identifier. - pub doc_id: Uuid, - /// Tenant that owns the document. - pub tenant_id: String, - /// Project that owns the document. - pub project_id: String, - /// Agent that ingested the document. - pub agent_id: String, - /// Scope key for the document. - pub scope: String, - /// Document type discriminator. - pub doc_type: String, - /// Lifecycle status for the document. - pub status: String, - /// Optional document title. - pub title: Option, - /// Structured source reference metadata. - pub source_ref: Value, - /// Full document content. - pub content: String, - /// Byte length of the document content. - pub content_bytes: i32, - /// Content hash for deduplication and change detection. - pub content_hash: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} - -/// Persisted chunk row for one document. -#[derive(Debug, FromRow)] -pub struct DocChunk { - /// Chunk identifier. - pub chunk_id: Uuid, - /// Parent document identifier. - pub doc_id: Uuid, - /// Zero-based chunk position within the document. - pub chunk_index: i32, - /// Inclusive start byte offset within the original document content. - pub start_offset: i32, - /// Exclusive end byte offset within the original document content. - pub end_offset: i32, - /// Chunk text. - pub chunk_text: String, - /// Chunk content hash. - pub chunk_hash: String, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted embedding row for one document chunk. -#[derive(Debug, FromRow)] -pub struct DocChunkEmbedding { - /// Chunk identifier. - pub chunk_id: Uuid, - /// Embedding version associated with the vector. - pub embedding_version: String, - /// Embedding dimensionality. - pub embedding_dim: i32, - /// Embedding vector payload. - pub vec: Vec, - /// Creation timestamp. - pub created_at: OffsetDateTime, -} - -/// Persisted document-indexing outbox row. -#[derive(Debug, FromRow)] -pub struct DocIndexingOutboxEntry { - /// Outbox identifier. - pub outbox_id: Uuid, - /// Document identifier queued for indexing. - pub doc_id: Uuid, - /// Chunk identifier queued for indexing. - pub chunk_id: Uuid, - /// Requested indexing operation. - pub op: String, - /// Embedding version the worker should use. - pub embedding_version: String, - /// Current outbox status. - pub status: String, - /// Number of attempts already made. - pub attempts: i32, - /// Most recent failure text, if any. - pub last_error: Option, - /// Earliest time the job may be claimed again. - pub available_at: OffsetDateTime, - /// Creation timestamp. - pub created_at: OffsetDateTime, - /// Last update timestamp. - pub updated_at: OffsetDateTime, -} +mod consolidation; +mod docs; +mod graph; +mod knowledge; +mod notes; +mod outbox; +mod work_journal; + +pub use self::{ + consolidation::{ + ConsolidationProposal, ConsolidationProposalReviewEvent, ConsolidationRun, + ConsolidationRunJob, + }, + docs::{DocChunk, DocChunkEmbedding, DocDocument, DocIndexingOutboxEntry}, + graph::{ + GraphEntity, GraphEntityAlias, GraphFact, GraphFactEvidence, GraphFactSupersession, + GraphPredicate, GraphPredicateAlias, + }, + knowledge::{ + KnowledgePage, KnowledgePageLintFinding, KnowledgePageSection, KnowledgePageSourceRef, + }, + notes::{MemoryNote, MemoryNoteChunk, NoteChunkEmbedding, NoteEmbedding}, + outbox::{IndexingOutboxEntry, TraceOutboxJob}, + work_journal::WorkJournalEntry, +}; diff --git a/packages/elf-storage/src/models/consolidation.rs b/packages/elf-storage/src/models/consolidation.rs new file mode 100644 index 00000000..5d925288 --- /dev/null +++ b/packages/elf-storage/src/models/consolidation.rs @@ -0,0 +1,148 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted consolidation run row. +#[derive(Debug, FromRow)] +pub struct ConsolidationRun { + /// Consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the run. + pub tenant_id: String, + /// Project that owns the run. + pub project_id: String, + /// Agent that registered the run. + pub agent_id: String, + /// Versioned consolidation contract schema. + pub contract_schema: String, + /// Job kind, such as fixture, manual, or scheduled. + pub job_kind: String, + /// Current run status. + pub status: String, + /// Serialized input references. + pub input_refs: Value, + /// Aggregate source snapshot metadata. + pub source_snapshot: Value, + /// Serialized run lineage. + pub lineage: Value, + /// Structured error payload for failed runs. + pub error: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, + /// Completion timestamp for terminal runs. + pub completed_at: Option, +} + +/// Persisted consolidation proposal row. +#[derive(Debug, FromRow)] +pub struct ConsolidationProposal { + /// Consolidation proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the proposal. + pub tenant_id: String, + /// Project that owns the proposal. + pub project_id: String, + /// Agent that registered the proposal. + pub agent_id: String, + /// Versioned consolidation contract schema. + pub contract_schema: String, + /// Proposal kind, such as derived_note or knowledge_page. + pub proposal_kind: String, + /// Derived-output apply intent. + pub apply_intent: String, + /// Current review state. + pub review_state: String, + /// Serialized source references. + pub source_refs: Value, + /// Aggregate source snapshot metadata. + pub source_snapshot: Value, + /// Serialized proposal lineage. + pub lineage: Value, + /// Serialized reviewable diff. + pub diff: Value, + /// Proposal confidence score. + pub confidence: f32, + /// Serialized unsupported-claim flags. + pub unsupported_claim_flags: Value, + /// Serialized contradiction markers. + pub contradiction_markers: Value, + /// Serialized staleness markers. + pub staleness_markers: Value, + /// Serialized derived target reference. + pub target_ref: Value, + /// Serialized proposed derived output payload. + pub proposed_payload: Value, + /// Agent that last reviewed the proposal. + pub reviewer_agent_id: Option, + /// Optional reviewer comment. + pub review_comment: Option, + /// Timestamp of the last review transition. + pub reviewed_at: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Persisted consolidation proposal review event row. +#[derive(Debug, FromRow)] +pub struct ConsolidationProposalReviewEvent { + /// Review event identifier. + pub review_id: Uuid, + /// Reviewed proposal identifier. + pub proposal_id: Uuid, + /// Parent consolidation run identifier. + pub run_id: Uuid, + /// Tenant that owns the proposal. + pub tenant_id: String, + /// Project that owns the proposal. + pub project_id: String, + /// Agent that performed the review action. + pub reviewer_agent_id: String, + /// Review action requested by the reviewer. + pub action: String, + /// Review state before the transition. + pub from_review_state: String, + /// Review state after the transition. + pub to_review_state: String, + /// Optional reviewer comment. + pub review_comment: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted consolidation worker job row. +#[derive(Debug, FromRow)] +pub struct ConsolidationRunJob { + /// Worker job identifier. + pub job_id: Uuid, + /// Consolidation run to materialize. + pub run_id: Uuid, + /// Tenant that owns the run. + pub tenant_id: String, + /// Project that owns the run. + pub project_id: String, + /// Agent that registered the run. + pub agent_id: String, + /// Job kind, such as fixture or manual. + pub job_kind: String, + /// Current job status. + pub status: String, + /// Queued proposal payload. + pub payload: Value, + /// Number of attempts already made. + pub attempts: i32, + /// Most recent failure text, if any. + pub last_error: Option, + /// Earliest time the job may be claimed again. + pub available_at: OffsetDateTime, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} diff --git a/packages/elf-storage/src/models/docs.rs b/packages/elf-storage/src/models/docs.rs new file mode 100644 index 00000000..6508c954 --- /dev/null +++ b/packages/elf-storage/src/models/docs.rs @@ -0,0 +1,100 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted document row. +#[derive(Debug, FromRow)] +pub struct DocDocument { + /// Document identifier. + pub doc_id: Uuid, + /// Tenant that owns the document. + pub tenant_id: String, + /// Project that owns the document. + pub project_id: String, + /// Agent that ingested the document. + pub agent_id: String, + /// Scope key for the document. + pub scope: String, + /// Document type discriminator. + pub doc_type: String, + /// Lifecycle status for the document. + pub status: String, + /// Optional document title. + pub title: Option, + /// Structured source reference metadata. + pub source_ref: Value, + /// Full document content. + pub content: String, + /// Byte length of the document content. + pub content_bytes: i32, + /// Content hash for deduplication and change detection. + pub content_hash: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Persisted chunk row for one document. +#[derive(Debug, FromRow)] +pub struct DocChunk { + /// Chunk identifier. + pub chunk_id: Uuid, + /// Parent document identifier. + pub doc_id: Uuid, + /// Zero-based chunk position within the document. + pub chunk_index: i32, + /// Inclusive start byte offset within the original document content. + pub start_offset: i32, + /// Exclusive end byte offset within the original document content. + pub end_offset: i32, + /// Chunk text. + pub chunk_text: String, + /// Chunk content hash. + pub chunk_hash: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted embedding row for one document chunk. +#[derive(Debug, FromRow)] +pub struct DocChunkEmbedding { + /// Chunk identifier. + pub chunk_id: Uuid, + /// Embedding version associated with the vector. + pub embedding_version: String, + /// Embedding dimensionality. + pub embedding_dim: i32, + /// Embedding vector payload. + pub vec: Vec, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted document-indexing outbox row. +#[derive(Debug, FromRow)] +pub struct DocIndexingOutboxEntry { + /// Outbox identifier. + pub outbox_id: Uuid, + /// Document identifier queued for indexing. + pub doc_id: Uuid, + /// Chunk identifier queued for indexing. + pub chunk_id: Uuid, + /// Requested indexing operation. + pub op: String, + /// Embedding version the worker should use. + pub embedding_version: String, + /// Current outbox status. + pub status: String, + /// Number of attempts already made. + pub attempts: i32, + /// Most recent failure text, if any. + pub last_error: Option, + /// Earliest time the job may be claimed again. + pub available_at: OffsetDateTime, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} diff --git a/packages/elf-storage/src/models/graph.rs b/packages/elf-storage/src/models/graph.rs new file mode 100644 index 00000000..cfb4dd66 --- /dev/null +++ b/packages/elf-storage/src/models/graph.rs @@ -0,0 +1,148 @@ +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted graph entity row. +#[derive(Debug, FromRow)] +pub struct GraphEntity { + /// Entity identifier. + pub entity_id: Uuid, + /// Tenant that owns the entity. + pub tenant_id: String, + /// Project that owns the entity. + pub project_id: String, + /// Canonical entity surface. + pub canonical: String, + /// Normalized canonical entity surface. + pub canonical_norm: String, + /// Optional entity kind. + pub kind: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Persisted alias row for a graph entity. +#[derive(Debug, FromRow)] +pub struct GraphEntityAlias { + /// Alias identifier. + pub alias_id: Uuid, + /// Entity identifier that owns the alias. + pub entity_id: Uuid, + /// Alias surface. + pub alias: String, + /// Normalized alias surface. + pub alias_norm: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted graph fact row. +#[derive(Debug, FromRow)] +pub struct GraphFact { + /// Fact identifier. + pub fact_id: Uuid, + /// Tenant that owns the fact. + pub tenant_id: String, + /// Project that owns the fact. + pub project_id: String, + /// Agent that emitted the fact. + pub agent_id: String, + /// Scope key for the fact. + pub scope: String, + /// Subject entity identifier. + pub subject_entity_id: Uuid, + /// Predicate surface captured with the fact. + pub predicate: String, + /// Resolved predicate identifier, when available. + pub predicate_id: Option, + /// Object entity identifier for entity-to-entity facts. + pub object_entity_id: Option, + /// Scalar object value for entity-to-value facts. + pub object_value: Option, + /// Start of the fact validity window. + pub valid_from: OffsetDateTime, + /// End of the fact validity window, if superseded. + pub valid_to: Option, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Evidence link between one graph fact and one memory note. +#[derive(Debug, FromRow)] +pub struct GraphFactEvidence { + /// Evidence row identifier. + pub evidence_id: Uuid, + /// Fact identifier. + pub fact_id: Uuid, + /// Note identifier that supports the fact. + pub note_id: Uuid, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted graph predicate row. +#[derive(Debug, FromRow)] +pub struct GraphPredicate { + /// Predicate identifier. + pub predicate_id: Uuid, + /// Scope key where the predicate is visible. + pub scope_key: String, + /// Tenant scope, when tenant-specific. + pub tenant_id: Option, + /// Project scope, when project-specific. + pub project_id: Option, + /// Canonical predicate surface. + pub canonical: String, + /// Normalized canonical predicate surface. + pub canonical_norm: String, + /// Cardinality policy for the predicate. + pub cardinality: String, + /// Lifecycle status for the predicate. + pub status: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Persisted alias row for a graph predicate. +#[derive(Debug, FromRow)] +pub struct GraphPredicateAlias { + /// Alias identifier. + pub alias_id: Uuid, + /// Predicate identifier that owns the alias. + pub predicate_id: Uuid, + /// Scope key where the alias resolves. + pub scope_key: String, + /// Alias surface. + pub alias: String, + /// Normalized alias surface. + pub alias_norm: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted supersession row linking two facts. +#[derive(Debug, FromRow)] +pub struct GraphFactSupersession { + /// Supersession identifier. + pub supersession_id: Uuid, + /// Tenant that owns the supersession record. + pub tenant_id: String, + /// Project that owns the supersession record. + pub project_id: String, + /// Fact identifier that was superseded. + pub from_fact_id: Uuid, + /// Fact identifier that replaced the prior fact. + pub to_fact_id: Uuid, + /// Note identifier that justified the supersession. + pub note_id: Uuid, + /// Time the supersession took effect. + pub effective_at: OffsetDateTime, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} diff --git a/packages/elf-storage/src/models/knowledge.rs b/packages/elf-storage/src/models/knowledge.rs new file mode 100644 index 00000000..87ad0e9f --- /dev/null +++ b/packages/elf-storage/src/models/knowledge.rs @@ -0,0 +1,122 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted derived knowledge page row. +#[derive(Debug, FromRow)] +pub struct KnowledgePage { + /// Derived page identifier. + pub page_id: Uuid, + /// Tenant that owns the page. + pub tenant_id: String, + /// Project that owns the page. + pub project_id: String, + /// Page kind, such as project, entity, concept, issue, decision, author, or timeline. + pub page_kind: String, + /// Stable page key within the tenant/project/kind namespace. + pub page_key: String, + /// Human-readable page title. + pub title: String, + /// Versioned knowledge page contract schema. + pub contract_schema: String, + /// Derived page lifecycle status. + pub status: String, + /// BLAKE3 hash of the canonical source snapshot. + pub rebuild_source_hash: String, + /// BLAKE3 hash of the canonical page payload. + pub content_hash: String, + /// Source coverage metadata. + pub source_coverage: Value, + /// Aggregate source snapshot metadata captured during rebuild. + pub source_snapshot: Value, + /// Rebuild metadata, including deterministic/provider information. + pub rebuild_metadata: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, + /// Last rebuild timestamp. + pub rebuilt_at: OffsetDateTime, +} + +/// Persisted derived knowledge page section row. +#[derive(Debug, FromRow)] +pub struct KnowledgePageSection { + /// Section identifier. + pub section_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Stable section key within one page. + pub section_key: String, + /// Section heading. + pub heading: String, + /// Section role, such as current_truth, history, relations, or proposals. + pub role: String, + /// Section content. + pub content: String, + /// Display order within the page. + pub ordinal: i32, + /// Serialized citation array for this section. + pub citations: Value, + /// Reason a section lacks citations, when intentionally unsupported. + pub unsupported_reason: Option, + /// BLAKE3 hash of the section content and citations. + pub content_hash: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Persisted normalized citation/source reference for a knowledge page. +#[derive(Debug, FromRow)] +pub struct KnowledgePageSourceRef { + /// Source-reference row identifier. + pub ref_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Section that cites the source, if section-scoped. + pub section_id: Option, + /// Source kind, such as doc, doc_chunk, note, relation, proposal, or event. + pub source_kind: String, + /// Authoritative source identifier. + pub source_id: Uuid, + /// Source lifecycle status captured during rebuild. + pub source_status: Option, + /// Source last-update timestamp captured during rebuild. + pub source_updated_at: Option, + /// Source content hash captured during rebuild. + pub source_content_hash: Option, + /// Full source snapshot captured during rebuild. + pub source_snapshot: Value, + /// Citation-local metadata. + pub citation_metadata: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted lint finding for one derived knowledge page. +#[derive(Debug, FromRow)] +pub struct KnowledgePageLintFinding { + /// Lint finding identifier. + pub finding_id: Uuid, + /// Parent page identifier. + pub page_id: Uuid, + /// Section associated with the finding, when available. + pub section_id: Option, + /// Finding type, such as stale_source_ref or unsupported_claim. + pub finding_type: String, + /// Finding severity. + pub severity: String, + /// Source kind associated with the finding, when available. + pub source_kind: Option, + /// Source identifier associated with the finding, when available. + pub source_id: Option, + /// Human-readable finding message. + pub message: String, + /// Structured finding details. + pub details: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} diff --git a/packages/elf-storage/src/models/notes.rs b/packages/elf-storage/src/models/notes.rs new file mode 100644 index 00000000..56187d9d --- /dev/null +++ b/packages/elf-storage/src/models/notes.rs @@ -0,0 +1,96 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted memory note row. +#[derive(Debug, FromRow)] +pub struct MemoryNote { + /// Note identifier. + pub note_id: Uuid, + /// Tenant that owns the note. + pub tenant_id: String, + /// Project that owns the note. + pub project_id: String, + /// Agent that wrote the note. + pub agent_id: String, + /// Scope key for the note. + pub scope: String, + /// Note type discriminator. + pub r#type: String, + /// Optional application-defined key for deduplication or lookup. + pub key: Option, + /// Note body text. + pub text: String, + /// Importance score persisted for ranking. + pub importance: f32, + /// Confidence score persisted for ranking. + pub confidence: f32, + /// Lifecycle status for the note. + pub status: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, + /// Optional expiry timestamp. + pub expires_at: Option, + /// Embedding version associated with the stored note. + pub embedding_version: String, + /// Structured source reference metadata. + pub source_ref: Value, + /// Search hit counter. + pub hit_count: i64, + /// Timestamp of the most recent search hit. + pub last_hit_at: Option, +} + +/// Persisted chunk row for one memory note. +#[derive(Debug, FromRow)] +pub struct MemoryNoteChunk { + /// Chunk identifier. + pub chunk_id: Uuid, + /// Parent note identifier. + pub note_id: Uuid, + /// Zero-based chunk position within the note. + pub chunk_index: i32, + /// Inclusive start byte offset within the original note text. + pub start_offset: i32, + /// Exclusive end byte offset within the original note text. + pub end_offset: i32, + /// Chunk text. + pub text: String, + /// Embedding version associated with the chunk. + pub embedding_version: String, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// Persisted embedding row for one note chunk. +#[derive(Debug, FromRow)] +pub struct NoteChunkEmbedding { + /// Chunk identifier. + pub chunk_id: Uuid, + /// Embedding version associated with the vector. + pub embedding_version: String, + /// Embedding dimensionality. + pub embedding_dim: i32, + /// Embedding vector payload. + pub vec: Vec, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} + +/// In-memory embedding payload for a full note. +#[derive(Debug)] +pub struct NoteEmbedding { + /// Note identifier. + pub note_id: Uuid, + /// Embedding version associated with the vector. + pub embedding_version: String, + /// Embedding dimensionality. + pub embedding_dim: i32, + /// Embedding vector payload. + pub vec: Vec, + /// Creation timestamp. + pub created_at: OffsetDateTime, +} diff --git a/packages/elf-storage/src/models/outbox.rs b/packages/elf-storage/src/models/outbox.rs new file mode 100644 index 00000000..37f66b73 --- /dev/null +++ b/packages/elf-storage/src/models/outbox.rs @@ -0,0 +1,42 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted note-indexing outbox row. +#[derive(Debug, FromRow)] +pub struct IndexingOutboxEntry { + /// Outbox identifier. + pub outbox_id: Uuid, + /// Note identifier queued for indexing. + pub note_id: Uuid, + /// Requested indexing operation. + pub op: String, + /// Embedding version the worker should use. + pub embedding_version: String, + /// Current outbox status. + pub status: String, + /// Number of attempts already made. + pub attempts: i32, + /// Most recent failure text, if any. + pub last_error: Option, + /// Earliest time the job may be claimed again. + pub available_at: OffsetDateTime, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +} + +/// Persisted search-trace outbox job. +#[derive(Debug, FromRow)] +pub struct TraceOutboxJob { + /// Outbox identifier. + pub outbox_id: Uuid, + /// Trace identifier to export. + pub trace_id: Uuid, + /// Serialized trace payload. + pub payload: Value, + /// Number of attempts already made. + pub attempts: i32, +} diff --git a/packages/elf-storage/src/models/work_journal.rs b/packages/elf-storage/src/models/work_journal.rs new file mode 100644 index 00000000..dc9131a0 --- /dev/null +++ b/packages/elf-storage/src/models/work_journal.rs @@ -0,0 +1,45 @@ +use serde_json::Value; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Persisted source-adjacent Work Journal entry. +#[derive(Debug, FromRow)] +pub struct WorkJournalEntry { + /// Journal entry identifier. + pub entry_id: Uuid, + /// Tenant that owns the entry. + pub tenant_id: String, + /// Project that owns the entry. + pub project_id: String, + /// Agent that captured the entry. + pub agent_id: String, + /// Visibility scope for readback. + pub scope: String, + /// Stable external or session-local journal session identifier. + pub session_id: String, + /// Entry family discriminator. + pub family: String, + /// Lifecycle status for the journal entry. + pub status: String, + /// Optional display title. + pub title: Option, + /// Redacted durable journal body. + pub body: String, + /// Source references supporting this journal entry. + pub source_refs: Value, + /// Explicit next steps captured from the source. + pub explicit_next_steps: Value, + /// Inferred next steps captured as non-authoritative hints. + pub inferred_next_steps: Value, + /// Options rejected during the captured work session. + pub rejected_options: Value, + /// Promotion boundary metadata for Memory Authority and Dreaming Review. + pub promotion_boundary: Value, + /// Redaction audit for durable journal text. + pub redaction_audit: Value, + /// Creation timestamp. + pub created_at: OffsetDateTime, + /// Last update timestamp. + pub updated_at: OffsetDateTime, +}