Skip to main content

reddb_server/
catalog.rs

1//! Logical catalog structures for the unified multi-structure model.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::time::SystemTime;
5
6use crate::api::{CatalogSnapshot, CollectionStats};
7use crate::index::{IndexCatalog, IndexCatalogSnapshot, IndexKind};
8use crate::physical::{
9    CollectionContract, PhysicalAnalyticsJob, PhysicalGraphProjection, PhysicalIndexState,
10};
11use crate::storage::queue::QueueMode;
12use crate::storage::unified::UnifiedStore;
13use crate::storage::{EntityKind, SegmentManager, UnifiedEntity};
14use reddb_types::Value;
15
16pub use reddb_engine::catalog::{AnalyticalStorageConfig, SchemaMode};
17
18// Catalog AST-leaf descriptors re-homed to the neutral keystone crate (ADR
19// 0053, RQL Phase 2 S4b) so the canonical SQL AST
20// (`CreateTableQuery.{subscriptions, analytics_config}`) resolves them without a
21// `reddb-server` edge. This shim keeps `crate::catalog::{...}` valid for
22// existing call-sites.
23pub use reddb_types::catalog::{
24    AiPolicy, AnalyticsOutput, AnalyticsViewDescriptor, CollectionModel, EmbedPolicy,
25    ModerateDegradedMode, ModeratePolicy, ModerateRejectAction, SubscriptionDescriptor,
26    SubscriptionOperation, VisionPolicy,
27};
28
29#[derive(Debug, Clone)]
30pub struct CollectionDescriptor {
31    pub name: String,
32    pub model: CollectionModel,
33    pub schema_mode: SchemaMode,
34    pub contract_present: bool,
35    pub contract_origin: Option<crate::physical::ContractOrigin>,
36    pub declared_model: Option<CollectionModel>,
37    pub observed_model: CollectionModel,
38    pub queue_mode: Option<QueueMode>,
39    /// MAX_ATTEMPTS per-queue policy. Hot-fields tier — populated for
40    /// `model = queue` from the catalog snapshot for sub-ms reads by
41    /// `QueueLifecycle`. `None` for non-queue collections.
42    pub queue_max_attempts: Option<u32>,
43    /// LOCK_DEADLINE_MS per-queue policy. Hot-fields tier.
44    pub queue_lock_deadline_ms: Option<u64>,
45    /// IN_FLIGHT_CAP_PER_GROUP per-queue policy. Hot-fields tier.
46    pub queue_in_flight_cap_per_group: Option<u32>,
47    /// DLQ target collection name. `None` means on-max drop.
48    pub queue_dlq_target: Option<String>,
49    pub vector_dimension: Option<usize>,
50    pub vector_metric: Option<crate::storage::engine::distance::DistanceMetric>,
51    /// `WITH SESSION_KEY <col>` declared on the timeseries collection.
52    /// `None` for non-timeseries and for timeseries without the clause.
53    /// Issue #576 slice 1.
54    pub session_key: Option<String>,
55    /// `SESSION_GAP <duration>` in milliseconds declared on the
56    /// timeseries collection. Issue #576 slice 1.
57    pub session_gap_ms: Option<u64>,
58    /// Declarative retention duration in milliseconds. `None` means no
59    /// retention policy is enforced. Set via `ALTER COLLECTION ... SET
60    /// RETENTION <duration>`. Issue #580 slice 1.
61    pub retention_duration_ms: Option<u64>,
62    pub declared_schema_mode: Option<SchemaMode>,
63    pub observed_schema_mode: SchemaMode,
64    pub entities: usize,
65    pub cross_refs: usize,
66    pub segments: usize,
67    pub indices: Vec<String>,
68    pub declared_indices: Vec<String>,
69    pub operational_indices: Vec<String>,
70    pub indexes_in_sync: bool,
71    pub missing_operational_indices: Vec<String>,
72    pub undeclared_operational_indices: Vec<String>,
73    pub queryable_index_count: usize,
74    pub indexes_requiring_rebuild_count: usize,
75    pub queryable_graph_projection_count: usize,
76    pub graph_projections_requiring_rematerialization_count: usize,
77    pub executable_analytics_job_count: usize,
78    pub analytics_jobs_requiring_rerun_count: usize,
79    pub subscriptions: Vec<SubscriptionDescriptor>,
80    /// Analytics views declared by `WITH ANALYTICS (...)`. Empty for
81    /// collections without the clause. Sourced from the persisted
82    /// `CollectionContract` so it survives restarts (issue #800).
83    pub analytics_config: Vec<AnalyticsViewDescriptor>,
84    /// Per-collection AI policy declared by `WITH (EMBED|MODERATE|VISION
85    /// (...))`. `None` when no AI clause is present. Sourced from the
86    /// persisted `CollectionContract` so introspection survives restarts
87    /// (issue #1271).
88    pub ai_policy: Option<AiPolicy>,
89    pub resources_in_sync: bool,
90    pub attention_required: bool,
91    pub attention_score: usize,
92    pub attention_reasons: Vec<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct CatalogModelSnapshot {
97    pub summary: CatalogSnapshot,
98    pub collections: Vec<CollectionDescriptor>,
99    pub indices: IndexCatalogSnapshot,
100    pub declared_indexes: Vec<PhysicalIndexState>,
101    pub declared_graph_projections: Vec<PhysicalGraphProjection>,
102    pub declared_analytics_jobs: Vec<PhysicalAnalyticsJob>,
103    pub operational_indexes: Vec<PhysicalIndexState>,
104    pub operational_graph_projections: Vec<PhysicalGraphProjection>,
105    pub operational_analytics_jobs: Vec<PhysicalAnalyticsJob>,
106    pub index_statuses: Vec<CatalogIndexStatus>,
107    pub graph_projection_statuses: Vec<CatalogGraphProjectionStatus>,
108    pub analytics_job_statuses: Vec<CatalogAnalyticsJobStatus>,
109    pub queryable_index_count: usize,
110    pub indexes_requiring_rebuild_count: usize,
111    pub queryable_graph_projection_count: usize,
112    pub graph_projections_requiring_rematerialization_count: usize,
113    pub executable_analytics_job_count: usize,
114    pub analytics_jobs_requiring_rerun_count: usize,
115}
116
117#[derive(Debug, Clone)]
118pub struct CatalogAttentionSummary {
119    pub collections_requiring_attention: usize,
120    pub indexes_requiring_attention: usize,
121    pub graph_projections_requiring_attention: usize,
122    pub analytics_jobs_requiring_attention: usize,
123    pub top_collection: Option<CollectionDescriptor>,
124    pub top_index: Option<CatalogIndexStatus>,
125    pub top_graph_projection: Option<CatalogGraphProjectionStatus>,
126    pub top_analytics_job: Option<CatalogAnalyticsJobStatus>,
127}
128
129#[derive(Debug, Clone, Default)]
130pub struct CatalogDeclarations {
131    pub declared_indexes: Vec<PhysicalIndexState>,
132    pub declared_graph_projections: Vec<PhysicalGraphProjection>,
133    pub declared_analytics_jobs: Vec<PhysicalAnalyticsJob>,
134    pub operational_indexes: Vec<PhysicalIndexState>,
135    pub operational_graph_projections: Vec<PhysicalGraphProjection>,
136    pub operational_analytics_jobs: Vec<PhysicalAnalyticsJob>,
137}
138
139#[derive(Debug, Clone)]
140pub struct CatalogGraphProjectionStatus {
141    pub name: String,
142    pub source: Option<String>,
143    pub lifecycle_state: String,
144    pub declared: bool,
145    pub operational: bool,
146    pub in_sync: bool,
147    pub last_materialized_sequence: Option<u64>,
148    pub queryable: bool,
149    pub requires_rematerialization: bool,
150    pub dependent_job_count: usize,
151    pub active_dependent_job_count: usize,
152    pub stale_dependent_job_count: usize,
153    pub dependent_jobs_in_sync: bool,
154    pub rerun_required: bool,
155    pub attention_score: usize,
156    pub attention_reasons: Vec<String>,
157}
158
159#[derive(Debug, Clone)]
160pub struct CatalogIndexStatus {
161    pub name: String,
162    pub collection: Option<String>,
163    pub kind: String,
164    pub declared: bool,
165    pub operational: bool,
166    pub enabled: bool,
167    pub build_state: Option<String>,
168    pub artifact_state: crate::physical::ArtifactState,
169    pub lifecycle_state: String,
170    pub in_sync: bool,
171    pub queryable: bool,
172    pub requires_rebuild: bool,
173    pub attention_score: usize,
174    pub attention_reasons: Vec<String>,
175}
176
177#[derive(Debug, Clone)]
178pub struct CatalogAnalyticsJobStatus {
179    pub id: String,
180    pub kind: String,
181    pub projection: Option<String>,
182    pub state: String,
183    pub lifecycle_state: String,
184    pub declared: bool,
185    pub operational: bool,
186    pub in_sync: bool,
187    pub last_run_sequence: Option<u64>,
188    pub projection_declared: Option<bool>,
189    pub projection_operational: Option<bool>,
190    pub projection_lifecycle_state: Option<String>,
191    pub dependency_in_sync: Option<bool>,
192    pub executable: bool,
193    pub requires_rerun: bool,
194    pub attention_score: usize,
195    pub attention_reasons: Vec<String>,
196}
197
198#[derive(Debug, Clone, Default)]
199pub struct CatalogConsistencyReport {
200    pub declared_index_count: usize,
201    pub operational_index_count: usize,
202    pub declared_graph_projection_count: usize,
203    pub operational_graph_projection_count: usize,
204    pub declared_analytics_job_count: usize,
205    pub operational_analytics_job_count: usize,
206    pub missing_operational_indexes: Vec<String>,
207    pub undeclared_operational_indexes: Vec<String>,
208    pub missing_operational_graph_projections: Vec<String>,
209    pub undeclared_operational_graph_projections: Vec<String>,
210    pub missing_operational_analytics_jobs: Vec<String>,
211    pub undeclared_operational_analytics_jobs: Vec<String>,
212}
213
214pub fn snapshot_store(
215    name: &str,
216    store: &UnifiedStore,
217    index_catalog: Option<&IndexCatalog>,
218) -> CatalogModelSnapshot {
219    snapshot_store_with_declarations(name, store, index_catalog, None, None)
220}
221
222pub fn snapshot_store_with_declarations(
223    name: &str,
224    store: &UnifiedStore,
225    index_catalog: Option<&IndexCatalog>,
226    declarations: Option<&CatalogDeclarations>,
227    contracts: Option<&[CollectionContract]>,
228) -> CatalogModelSnapshot {
229    let index_statuses = index_statuses(declarations);
230    let graph_projection_statuses = graph_projection_statuses(declarations);
231    let analytics_job_statuses = analytics_job_statuses(declarations);
232
233    let queue_policies = store
234        .get_collection("red_queue_meta")
235        .map(|manager| queue_policies_from_entities(&manager.query_all(|_| true)))
236        .unwrap_or_default();
237
238    let mut stats_by_collection = BTreeMap::new();
239    let mut collections = Vec::new();
240
241    for collection_name in store.list_collections() {
242        let manager = store.get_collection(&collection_name);
243        let (inferred_model, entity_count, cross_refs) = manager
244            .as_ref()
245            .map(|manager| collection_observations(manager))
246            .unwrap_or((CollectionModel::Mixed, 0, 0));
247        let inferred_schema_mode = infer_schema_mode(inferred_model);
248        let contract = collection_contract(&collection_name, contracts);
249        let model = contract
250            .map(|contract| contract.declared_model)
251            .unwrap_or(inferred_model);
252        let manager_stats = manager.map(|manager| manager.stats());
253        let segments = manager_stats
254            .map(|stats| stats.growing_count + stats.sealed_count + stats.archived_count)
255            .unwrap_or(0);
256
257        stats_by_collection.insert(
258            collection_name.clone(),
259            CollectionStats {
260                entities: entity_count,
261                cross_refs,
262                segments,
263            },
264        );
265
266        let declared_indices = declared_indices(&collection_name, declarations);
267        let operational_indices =
268            operational_indices(&collection_name, index_catalog, declarations);
269        let (indexes_in_sync, missing_operational_indices, undeclared_operational_indices) =
270            collection_index_consistency(&declared_indices, &operational_indices);
271        let (queryable_index_count, indexes_requiring_rebuild_count, indexes_locally_in_sync) =
272            collection_index_readiness(&collection_name, &index_statuses);
273        let (
274            queryable_graph_projection_count,
275            graph_projections_requiring_rematerialization_count,
276            graph_projections_locally_in_sync,
277        ) = collection_graph_projection_readiness(&collection_name, &graph_projection_statuses);
278        let (
279            executable_analytics_job_count,
280            analytics_jobs_requiring_rerun_count,
281            analytics_jobs_locally_in_sync,
282        ) = collection_analytics_job_readiness(
283            &collection_name,
284            &graph_projection_statuses,
285            &analytics_job_statuses,
286        );
287        let resources_in_sync = indexes_in_sync
288            && indexes_locally_in_sync
289            && graph_projections_locally_in_sync
290            && analytics_jobs_locally_in_sync;
291        let attention_required = !resources_in_sync
292            || indexes_requiring_rebuild_count > 0
293            || graph_projections_requiring_rematerialization_count > 0
294            || analytics_jobs_requiring_rerun_count > 0;
295        let mut attention_reasons = Vec::new();
296        if !indexes_in_sync || !indexes_locally_in_sync {
297            attention_reasons.push("index_drift".to_string());
298        }
299        if indexes_requiring_rebuild_count > 0 {
300            attention_reasons.push("indexes_require_rebuild".to_string());
301        }
302        if !graph_projections_locally_in_sync {
303            attention_reasons.push("graph_projection_drift".to_string());
304        }
305        if graph_projections_requiring_rematerialization_count > 0 {
306            attention_reasons.push("graph_projections_require_rematerialization".to_string());
307        }
308        if !analytics_jobs_locally_in_sync {
309            attention_reasons.push("analytics_job_drift".to_string());
310        }
311        if analytics_jobs_requiring_rerun_count > 0 {
312            attention_reasons.push("analytics_jobs_require_rerun".to_string());
313        }
314        let attention_score = indexes_requiring_rebuild_count.saturating_mul(3)
315            + graph_projections_requiring_rematerialization_count.saturating_mul(4)
316            + analytics_jobs_requiring_rerun_count.saturating_mul(2)
317            + usize::from(!resources_in_sync);
318
319        collections.push(CollectionDescriptor {
320            name: collection_name.clone(),
321            model,
322            schema_mode: contract
323                .map(|contract| contract.schema_mode)
324                .unwrap_or(inferred_schema_mode),
325            contract_present: contract.is_some(),
326            contract_origin: contract.map(|contract| contract.origin),
327            declared_model: contract.map(|contract| contract.declared_model),
328            observed_model: inferred_model,
329            queue_mode: if model == CollectionModel::Queue {
330                Some(
331                    queue_policies
332                        .get(&collection_name)
333                        .map(|p| p.mode)
334                        .unwrap_or_default(),
335                )
336            } else {
337                None
338            },
339            queue_max_attempts: if model == CollectionModel::Queue {
340                Some(
341                    queue_policies
342                        .get(&collection_name)
343                        .map(|p| p.max_attempts)
344                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS),
345                )
346            } else {
347                None
348            },
349            queue_lock_deadline_ms: if model == CollectionModel::Queue {
350                Some(
351                    queue_policies
352                        .get(&collection_name)
353                        .map(|p| p.lock_deadline_ms)
354                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS),
355                )
356            } else {
357                None
358            },
359            queue_in_flight_cap_per_group: if model == CollectionModel::Queue {
360                Some(
361                    queue_policies
362                        .get(&collection_name)
363                        .map(|p| p.in_flight_cap_per_group)
364                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP),
365                )
366            } else {
367                None
368            },
369            queue_dlq_target: if model == CollectionModel::Queue {
370                queue_policies
371                    .get(&collection_name)
372                    .and_then(|p| p.dlq_target.clone())
373            } else {
374                None
375            },
376            vector_dimension: contract.and_then(|contract| contract.vector_dimension),
377            vector_metric: contract.and_then(|contract| contract.vector_metric),
378            session_key: contract.and_then(|contract| contract.session_key.clone()),
379            session_gap_ms: contract.and_then(|contract| contract.session_gap_ms),
380            retention_duration_ms: contract.and_then(|contract| contract.retention_duration_ms),
381            declared_schema_mode: contract.map(|contract| contract.schema_mode),
382            observed_schema_mode: inferred_schema_mode,
383            entities: entity_count,
384            cross_refs,
385            segments,
386            indices: infer_indices(&collection_name, model, index_catalog, declarations),
387            declared_indices,
388            operational_indices,
389            indexes_in_sync,
390            missing_operational_indices,
391            undeclared_operational_indices,
392            queryable_index_count,
393            indexes_requiring_rebuild_count,
394            queryable_graph_projection_count,
395            graph_projections_requiring_rematerialization_count,
396            executable_analytics_job_count,
397            analytics_jobs_requiring_rerun_count,
398            subscriptions: contract
399                .map(|contract| contract.subscriptions.clone())
400                .unwrap_or_default(),
401            analytics_config: contract
402                .map(|contract| contract.analytics_config.clone())
403                .unwrap_or_default(),
404            ai_policy: contract.and_then(|contract| contract.ai_policy.clone()),
405            resources_in_sync,
406            attention_required,
407            attention_score,
408            attention_reasons,
409        });
410    }
411
412    collections.sort_by(|left, right| left.name.cmp(&right.name));
413
414    let summary = CatalogSnapshot {
415        name: name.to_string(),
416        total_entities: stats_by_collection
417            .values()
418            .map(|stats| stats.entities)
419            .sum(),
420        total_collections: stats_by_collection.len(),
421        stats_by_collection,
422        updated_at: SystemTime::now(),
423    };
424
425    CatalogModelSnapshot {
426        summary,
427        collections,
428        indices: index_catalog
429            .map(IndexCatalog::snapshot)
430            .unwrap_or_default(),
431        declared_indexes: declarations
432            .map(|declarations| declarations.declared_indexes.clone())
433            .unwrap_or_default(),
434        declared_graph_projections: declarations
435            .map(|declarations| declarations.declared_graph_projections.clone())
436            .unwrap_or_default(),
437        declared_analytics_jobs: declarations
438            .map(|declarations| declarations.declared_analytics_jobs.clone())
439            .unwrap_or_default(),
440        operational_indexes: declarations
441            .map(|declarations| declarations.operational_indexes.clone())
442            .unwrap_or_default(),
443        operational_graph_projections: declarations
444            .map(|declarations| declarations.operational_graph_projections.clone())
445            .unwrap_or_default(),
446        operational_analytics_jobs: declarations
447            .map(|declarations| declarations.operational_analytics_jobs.clone())
448            .unwrap_or_default(),
449        queryable_index_count: index_statuses
450            .iter()
451            .filter(|status| status.queryable)
452            .count(),
453        indexes_requiring_rebuild_count: index_statuses
454            .iter()
455            .filter(|status| status.requires_rebuild)
456            .count(),
457        queryable_graph_projection_count: graph_projection_statuses
458            .iter()
459            .filter(|status| status.queryable)
460            .count(),
461        graph_projections_requiring_rematerialization_count: graph_projection_statuses
462            .iter()
463            .filter(|status| status.requires_rematerialization)
464            .count(),
465        executable_analytics_job_count: analytics_job_statuses
466            .iter()
467            .filter(|status| status.executable)
468            .count(),
469        analytics_jobs_requiring_rerun_count: analytics_job_statuses
470            .iter()
471            .filter(|status| status.requires_rerun)
472            .count(),
473        index_statuses,
474        graph_projection_statuses,
475        analytics_job_statuses,
476    }
477}
478
479/// Hot-fields snapshot of per-queue policy, materialised at catalog
480/// snapshot time so `QueueLifecycle` (and the descriptor) can read
481/// values without re-scanning `red_queue_meta`.
482#[derive(Debug, Clone)]
483pub struct QueuePolicySnapshot {
484    pub mode: QueueMode,
485    pub max_attempts: u32,
486    pub lock_deadline_ms: u64,
487    pub in_flight_cap_per_group: u32,
488    pub dlq_target: Option<String>,
489}
490
491fn queue_policies_from_entities(meta: &[UnifiedEntity]) -> HashMap<String, QueuePolicySnapshot> {
492    let mut policies = HashMap::new();
493    for entity in meta {
494        let Some(row) = entity.data.as_row() else {
495            continue;
496        };
497        if row_text(row, "kind").as_deref() != Some("queue_config") {
498            continue;
499        }
500        let Some(queue) = row_text(row, "queue") else {
501            continue;
502        };
503        let mode = row_text(row, "mode")
504            .as_deref()
505            .and_then(QueueMode::parse)
506            .unwrap_or_default();
507        let max_attempts = row_u64(row, "max_attempts")
508            .map(|v| v as u32)
509            .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS);
510        let lock_deadline_ms = row_u64(row, "lock_deadline_ms")
511            .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS);
512        let in_flight_cap_per_group = row_u64(row, "in_flight_cap_per_group")
513            .map(|v| v as u32)
514            .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP);
515        let dlq_target = row_text(row, "dlq");
516        policies.insert(
517            queue,
518            QueuePolicySnapshot {
519                mode,
520                max_attempts,
521                lock_deadline_ms,
522                in_flight_cap_per_group,
523                dlq_target,
524            },
525        );
526    }
527    policies
528}
529
530fn row_u64(row: &crate::storage::unified::entity::RowData, key: &str) -> Option<u64> {
531    match row.get_field(key) {
532        Some(Value::UnsignedInteger(v)) => Some(*v),
533        Some(Value::Integer(v)) if *v >= 0 => Some(*v as u64),
534        _ => None,
535    }
536}
537
538fn row_text(row: &crate::storage::unified::entity::RowData, key: &str) -> Option<String> {
539    match row.get_field(key) {
540        Some(Value::Text(value)) => Some(value.to_string()),
541        _ => None,
542    }
543}
544
545fn collection_contract<'a>(
546    collection_name: &str,
547    contracts: Option<&'a [CollectionContract]>,
548) -> Option<&'a CollectionContract> {
549    contracts.and_then(|contracts| {
550        contracts
551            .iter()
552            .find(|contract| contract.name == collection_name)
553    })
554}
555
556fn collection_observations(manager: &SegmentManager) -> (CollectionModel, usize, usize) {
557    let mut has_table = false;
558    let mut has_graph = false;
559    let mut has_vector = false;
560    let mut entity_count = 0;
561    let mut cross_refs = 0;
562
563    manager.for_each_entity(|entity| {
564        entity_count += 1;
565        cross_refs += entity.cross_refs().len();
566        match &entity.kind {
567            EntityKind::TableRow { .. } => has_table = true,
568            EntityKind::GraphNode(_) | EntityKind::GraphEdge(_) => has_graph = true,
569            EntityKind::Vector { .. } => has_vector = true,
570            EntityKind::TimeSeriesPoint(_) | EntityKind::QueueMessage { .. } => {}
571        }
572        true
573    });
574
575    let model = match (has_table, has_graph, has_vector) {
576        (true, false, false) => CollectionModel::Table,
577        (false, true, false) => CollectionModel::Graph,
578        (false, false, true) => CollectionModel::Vector,
579        _ => CollectionModel::Mixed,
580    };
581    (model, entity_count, cross_refs)
582}
583
584fn infer_schema_mode(model: CollectionModel) -> SchemaMode {
585    match model {
586        CollectionModel::Table => SchemaMode::Strict,
587        CollectionModel::Graph | CollectionModel::Vector => SchemaMode::SemiStructured,
588        CollectionModel::Document
589        | CollectionModel::Hll
590        | CollectionModel::Sketch
591        | CollectionModel::Filter
592        | CollectionModel::Kv
593        | CollectionModel::Config
594        | CollectionModel::Vault
595        | CollectionModel::Mixed => SchemaMode::Dynamic,
596        CollectionModel::TimeSeries | CollectionModel::Metrics => SchemaMode::SemiStructured,
597        CollectionModel::Queue => SchemaMode::Dynamic,
598    }
599}
600
601fn infer_indices(
602    collection_name: &str,
603    model: CollectionModel,
604    index_catalog: Option<&IndexCatalog>,
605    declarations: Option<&CatalogDeclarations>,
606) -> Vec<String> {
607    let declared = declared_indices(collection_name, declarations);
608    if !declared.is_empty() {
609        return declared;
610    }
611
612    let available = index_catalog
613        .map(IndexCatalog::snapshot)
614        .unwrap_or_default();
615    let mut selected = Vec::new();
616
617    for metric in available {
618        let relevant = matches!(
619            (model, metric.kind),
620            (_, IndexKind::BTree)
621                | (CollectionModel::Graph, IndexKind::GraphAdjacency)
622                | (CollectionModel::Vector, IndexKind::VectorHnsw)
623                | (CollectionModel::Vector, IndexKind::VectorInverted)
624                | (CollectionModel::Vector, IndexKind::VectorTurbo)
625                | (CollectionModel::Document, IndexKind::FullText)
626                | (CollectionModel::Document, IndexKind::DocumentPathValue)
627                | (CollectionModel::Kv, IndexKind::Hash)
628                | (CollectionModel::Config, IndexKind::Hash)
629                | (CollectionModel::Vault, IndexKind::Hash)
630                | (CollectionModel::Mixed, _)
631        );
632
633        if relevant && metric.enabled {
634            selected.push(metric.name);
635        }
636    }
637
638    selected
639}
640
641fn declared_indices(
642    collection_name: &str,
643    declarations: Option<&CatalogDeclarations>,
644) -> Vec<String> {
645    let mut selected = declarations
646        .map(|declarations| {
647            declarations
648                .declared_indexes
649                .iter()
650                .filter(|index| index.collection.as_deref() == Some(collection_name))
651                .map(|index| index.name.clone())
652                .collect::<Vec<_>>()
653        })
654        .unwrap_or_default();
655    selected.sort();
656    selected.dedup();
657    selected
658}
659
660fn operational_indices(
661    collection_name: &str,
662    index_catalog: Option<&IndexCatalog>,
663    declarations: Option<&CatalogDeclarations>,
664) -> Vec<String> {
665    if let Some(declarations) = declarations {
666        let mut selected = declarations
667            .operational_indexes
668            .iter()
669            .filter(|index| index.collection.as_deref() == Some(collection_name))
670            .map(|index| index.name.clone())
671            .collect::<Vec<_>>();
672        selected.sort();
673        selected.dedup();
674        return selected;
675    }
676
677    let mut selected = index_catalog
678        .map(IndexCatalog::snapshot)
679        .unwrap_or_default()
680        .into_iter()
681        .filter(|metric| metric.enabled)
682        .filter_map(|metric| {
683            if metric.name.starts_with(collection_name) {
684                Some(metric.name)
685            } else {
686                None
687            }
688        })
689        .collect::<Vec<_>>();
690    selected.sort();
691    selected.dedup();
692    selected
693}
694
695fn collection_index_consistency(
696    declared_indices: &[String],
697    operational_indices: &[String],
698) -> (bool, Vec<String>, Vec<String>) {
699    let declared = declared_indices.iter().cloned().collect::<BTreeSet<_>>();
700    let operational = operational_indices.iter().cloned().collect::<BTreeSet<_>>();
701    let missing_operational_indices = declared
702        .difference(&operational)
703        .cloned()
704        .collect::<Vec<_>>();
705    let undeclared_operational_indices = operational
706        .difference(&declared)
707        .cloned()
708        .collect::<Vec<_>>();
709    (
710        missing_operational_indices.is_empty() && undeclared_operational_indices.is_empty(),
711        missing_operational_indices,
712        undeclared_operational_indices,
713    )
714}
715
716fn collection_index_readiness(
717    collection_name: &str,
718    statuses: &[CatalogIndexStatus],
719) -> (usize, usize, bool) {
720    let local = statuses
721        .iter()
722        .filter(|status| status.collection.as_deref() == Some(collection_name))
723        .collect::<Vec<_>>();
724    let queryable_count = local.iter().filter(|status| status.queryable).count();
725    let requires_rebuild_count = local
726        .iter()
727        .filter(|status| status.requires_rebuild)
728        .count();
729    let locally_in_sync = local.iter().all(|status| status.in_sync);
730    (queryable_count, requires_rebuild_count, locally_in_sync)
731}
732
733fn collection_graph_projection_readiness(
734    collection_name: &str,
735    statuses: &[CatalogGraphProjectionStatus],
736) -> (usize, usize, bool) {
737    let local = statuses
738        .iter()
739        .filter(|status| status.source.as_deref() == Some(collection_name))
740        .collect::<Vec<_>>();
741    let queryable_count = local.iter().filter(|status| status.queryable).count();
742    let requires_rematerialization_count = local
743        .iter()
744        .filter(|status| status.requires_rematerialization)
745        .count();
746    let locally_in_sync = local
747        .iter()
748        .all(|status| status.in_sync && status.dependent_jobs_in_sync);
749    (
750        queryable_count,
751        requires_rematerialization_count,
752        locally_in_sync,
753    )
754}
755
756fn collection_analytics_job_readiness(
757    collection_name: &str,
758    projection_statuses: &[CatalogGraphProjectionStatus],
759    job_statuses: &[CatalogAnalyticsJobStatus],
760) -> (usize, usize, bool) {
761    let local = job_statuses
762        .iter()
763        .filter(|status| {
764            let Some(projection_name) = status.projection.as_deref() else {
765                return false;
766            };
767            projection_statuses
768                .iter()
769                .find(|projection| projection.name == projection_name)
770                .and_then(|projection| projection.source.as_deref())
771                == Some(collection_name)
772        })
773        .collect::<Vec<_>>();
774    let executable_count = local.iter().filter(|status| status.executable).count();
775    let requires_rerun_count = local.iter().filter(|status| status.requires_rerun).count();
776    let locally_in_sync = local
777        .iter()
778        .all(|status| status.in_sync && status.dependency_in_sync.unwrap_or(true));
779    (executable_count, requires_rerun_count, locally_in_sync)
780}
781
782fn graph_projection_statuses(
783    declarations: Option<&CatalogDeclarations>,
784) -> Vec<CatalogGraphProjectionStatus> {
785    let declared = declarations
786        .map(|declarations| declarations.declared_graph_projections.as_slice())
787        .unwrap_or(&[]);
788    let operational = declarations
789        .map(|declarations| declarations.operational_graph_projections.as_slice())
790        .unwrap_or(&[]);
791    let declared_jobs = declarations
792        .map(|declarations| declarations.declared_analytics_jobs.as_slice())
793        .unwrap_or(&[]);
794    let operational_jobs = declarations
795        .map(|declarations| declarations.operational_analytics_jobs.as_slice())
796        .unwrap_or(&[]);
797
798    let mut names = declared
799        .iter()
800        .map(|projection| projection.name.clone())
801        .chain(operational.iter().map(|projection| projection.name.clone()))
802        .collect::<Vec<_>>();
803    names.sort();
804    names.dedup();
805
806    names
807        .into_iter()
808        .map(|name| {
809            let declared_projection = declared.iter().find(|projection| projection.name == name);
810            let operational_projection = operational
811                .iter()
812                .find(|projection| projection.name == name);
813            let declared_present = declared_projection.is_some();
814            let operational_present = operational_projection.is_some();
815            let mut dependent_job_ids = BTreeSet::new();
816            let mut dependent_job_count = 0usize;
817            let mut active_dependent_job_count = 0usize;
818            let mut stale_dependent_job_count = 0usize;
819            for job in declared_jobs
820                .iter()
821                .chain(operational_jobs.iter())
822                .filter(|job| job.projection.as_deref() == Some(name.as_str()))
823            {
824                if !dependent_job_ids.insert(job.id.clone()) {
825                    continue;
826                }
827                dependent_job_count += 1;
828                if matches!(job.state.as_str(), "queued" | "running" | "completed") {
829                    active_dependent_job_count += 1;
830                }
831                if job.state == "stale" {
832                    stale_dependent_job_count += 1;
833                }
834            }
835            let lifecycle_state = match (
836                declared_present,
837                operational_present,
838                declared_projection
839                    .map(|projection| projection.state.as_str())
840                    .or_else(|| operational_projection.map(|projection| projection.state.as_str()))
841                    .unwrap_or_default(),
842            ) {
843                (true, _, "failed") => "failed",
844                (true, true, "stale") => "stale",
845                (true, _, "materializing") => "materializing",
846                (true, true, "materialized") => "materialized",
847                (true, true, _) => "materialized",
848                (false, true, _) => "orphaned-operational",
849                (true, false, _) => "declared",
850                (false, false, _) => "unknown",
851            }
852            .to_string();
853            let queryable = declared_present
854                && operational_present
855                && matches!(
856                    declared_projection
857                        .map(|projection| projection.state.as_str())
858                        .or_else(
859                            || operational_projection.map(|projection| projection.state.as_str())
860                        )
861                        .unwrap_or_default(),
862                    "materialized"
863                );
864            let requires_rematerialization = matches!(
865                declared_projection
866                    .map(|projection| projection.state.as_str())
867                    .or_else(|| operational_projection.map(|projection| projection.state.as_str()))
868                    .unwrap_or_default(),
869                "declared" | "materializing" | "failed" | "stale"
870            );
871            let mut attention_reasons = Vec::new();
872            if !declared_present || !operational_present {
873                attention_reasons.push("declaration_drift".to_string());
874            }
875            if requires_rematerialization {
876                attention_reasons.push("requires_rematerialization".to_string());
877            }
878            if stale_dependent_job_count > 0 {
879                attention_reasons.push("dependent_jobs_stale".to_string());
880            }
881            let attention_score = stale_dependent_job_count.saturating_mul(2)
882                + usize::from(requires_rematerialization).saturating_mul(4)
883                + usize::from(!declared_present || !operational_present)
884                + usize::from(!queryable);
885            CatalogGraphProjectionStatus {
886                name,
887                source: declared_projection
888                    .map(|projection| projection.source.clone())
889                    .or_else(|| operational_projection.map(|projection| projection.source.clone())),
890                lifecycle_state,
891                declared: declared_present,
892                operational: operational_present,
893                in_sync: declared_present == operational_present,
894                last_materialized_sequence: declared_projection
895                    .and_then(|projection| projection.last_materialized_sequence)
896                    .or_else(|| {
897                        operational_projection
898                            .and_then(|projection| projection.last_materialized_sequence)
899                    }),
900                queryable,
901                requires_rematerialization,
902                dependent_job_count,
903                active_dependent_job_count,
904                stale_dependent_job_count,
905                dependent_jobs_in_sync: stale_dependent_job_count == 0,
906                rerun_required: stale_dependent_job_count > 0,
907                attention_score,
908                attention_reasons,
909            }
910        })
911        .collect()
912}
913
914fn index_statuses(declarations: Option<&CatalogDeclarations>) -> Vec<CatalogIndexStatus> {
915    let declared = declarations
916        .map(|declarations| declarations.declared_indexes.as_slice())
917        .unwrap_or(&[]);
918    let operational = declarations
919        .map(|declarations| declarations.operational_indexes.as_slice())
920        .unwrap_or(&[]);
921
922    let mut names = declared
923        .iter()
924        .map(|index| index.name.clone())
925        .chain(operational.iter().map(|index| index.name.clone()))
926        .collect::<Vec<_>>();
927    names.sort();
928    names.dedup();
929
930    names
931        .into_iter()
932        .map(|name| {
933            let declared_index = declared.iter().find(|index| index.name == name);
934            let operational_index = operational.iter().find(|index| index.name == name);
935            let collection = declared_index
936                .and_then(|index| index.collection.clone())
937                .or_else(|| operational_index.and_then(|index| index.collection.clone()));
938            let kind = declared_index
939                .map(|index| index_kind_string(index.kind))
940                .or_else(|| operational_index.map(|index| index_kind_string(index.kind)))
941                .unwrap_or_default();
942            let enabled = declared_index
943                .map(|index| index.enabled)
944                .or_else(|| operational_index.map(|index| index.enabled))
945                .unwrap_or(false);
946            let build_state = operational_index
947                .map(|index| index.build_state.clone())
948                .or_else(|| declared_index.map(|index| index.build_state.clone()));
949            let declared_present = declared_index.is_some();
950            let operational_present = operational_index.is_some();
951            let lifecycle_state = index_lifecycle_state(
952                declared_present,
953                operational_present,
954                enabled,
955                build_state.as_deref(),
956            );
957
958            let mut attention_reasons = Vec::new();
959            if declared_present != operational_present {
960                attention_reasons.push("declaration_drift".to_string());
961            }
962            if !enabled && declared_present {
963                attention_reasons.push("disabled".to_string());
964            }
965            if matches!(build_state.as_deref().unwrap_or_default(), "failed") {
966                attention_reasons.push("failed".to_string());
967            }
968            if matches!(build_state.as_deref().unwrap_or_default(), "stale") {
969                attention_reasons.push("stale".to_string());
970            }
971            if matches!(
972                build_state.as_deref().unwrap_or_default(),
973                "building" | "declared-unbuilt"
974            ) {
975                attention_reasons.push("requires_rebuild".to_string());
976            }
977            let queryable = declared_present
978                && operational_present
979                && enabled
980                && matches!(build_state.as_deref().unwrap_or_default(), "ready");
981            let requires_rebuild = matches!(
982                build_state.as_deref().unwrap_or_default(),
983                "declared-unbuilt" | "building" | "stale" | "failed"
984            );
985            let attention_score = usize::from(requires_rebuild).saturating_mul(3)
986                + usize::from(!enabled && declared_present)
987                + usize::from(declared_present != operational_present)
988                + usize::from(!queryable);
989
990            let artifact_state = crate::physical::ArtifactState::from_build_state(
991                build_state.as_deref().unwrap_or("declared-unbuilt"),
992                enabled,
993            );
994            CatalogIndexStatus {
995                name,
996                collection,
997                kind,
998                declared: declared_present,
999                operational: operational_present,
1000                enabled,
1001                build_state,
1002                artifact_state,
1003                lifecycle_state,
1004                in_sync: declared_present == operational_present,
1005                queryable,
1006                requires_rebuild,
1007                attention_score,
1008                attention_reasons,
1009            }
1010        })
1011        .collect()
1012}
1013
1014fn index_lifecycle_state(
1015    declared: bool,
1016    operational: bool,
1017    enabled: bool,
1018    build_state: Option<&str>,
1019) -> String {
1020    if !declared && operational {
1021        return "orphaned-operational".to_string();
1022    }
1023    if declared && !enabled {
1024        return "disabled".to_string();
1025    }
1026    if !declared {
1027        return "unknown".to_string();
1028    }
1029    if !operational {
1030        return "declared".to_string();
1031    }
1032
1033    match build_state.unwrap_or_default() {
1034        "ready" => "ready".to_string(),
1035        "failed" => "failed".to_string(),
1036        "stale" => "stale".to_string(),
1037        "declared-unbuilt" => "declared".to_string(),
1038        "catalog-derived" | "metadata-only" | "artifact-published" | "registry-loaded" => {
1039            "building".to_string()
1040        }
1041        _ => "building".to_string(),
1042    }
1043}
1044
1045fn index_kind_string(kind: IndexKind) -> String {
1046    match kind {
1047        IndexKind::BTree => "btree",
1048        IndexKind::Hash => "hash",
1049        IndexKind::Bitmap => "bitmap",
1050        IndexKind::VectorHnsw => "vector.hnsw",
1051        IndexKind::VectorInverted => "vector.inverted",
1052        IndexKind::VectorTurbo => "vector.turbo",
1053        IndexKind::GraphAdjacency => "graph.adjacency",
1054        IndexKind::FullText => "text.fulltext",
1055        IndexKind::DocumentPathValue => "document.pathvalue",
1056        IndexKind::HybridSearch => "search.hybrid",
1057    }
1058    .to_string()
1059}
1060
1061fn analytics_job_statuses(
1062    declarations: Option<&CatalogDeclarations>,
1063) -> Vec<CatalogAnalyticsJobStatus> {
1064    let declared = declarations
1065        .map(|declarations| declarations.declared_analytics_jobs.as_slice())
1066        .unwrap_or(&[]);
1067    let operational = declarations
1068        .map(|declarations| declarations.operational_analytics_jobs.as_slice())
1069        .unwrap_or(&[]);
1070    let declared_projections = declarations
1071        .map(|declarations| declarations.declared_graph_projections.as_slice())
1072        .unwrap_or(&[]);
1073    let operational_projections = declarations
1074        .map(|declarations| declarations.operational_graph_projections.as_slice())
1075        .unwrap_or(&[]);
1076
1077    let mut ids = declared
1078        .iter()
1079        .map(|job| job.id.clone())
1080        .chain(operational.iter().map(|job| job.id.clone()))
1081        .collect::<Vec<_>>();
1082    ids.sort();
1083    ids.dedup();
1084
1085    ids.into_iter()
1086        .map(|id| {
1087            let declared_job = declared.iter().find(|job| job.id == id);
1088            let operational_job = operational.iter().find(|job| job.id == id);
1089            let kind = declared_job
1090                .map(|job| job.kind.clone())
1091                .or_else(|| operational_job.map(|job| job.kind.clone()))
1092                .unwrap_or_default();
1093            let projection = declared_job
1094                .and_then(|job| job.projection.clone())
1095                .or_else(|| operational_job.and_then(|job| job.projection.clone()));
1096            let state = declared_job
1097                .map(|job| job.state.clone())
1098                .or_else(|| operational_job.map(|job| job.state.clone()))
1099                .unwrap_or_default();
1100            let declared_present = declared_job.is_some();
1101            let operational_present = operational_job.is_some();
1102            let last_run_sequence = declared_job
1103                .and_then(|job| job.last_run_sequence)
1104                .or_else(|| operational_job.and_then(|job| job.last_run_sequence));
1105            let projection_declared = projection.as_ref().map(|projection_name| {
1106                declared_projections
1107                    .iter()
1108                    .any(|projection| projection.name == *projection_name)
1109            });
1110            let projection_operational = projection.as_ref().map(|projection_name| {
1111                operational_projections
1112                    .iter()
1113                    .any(|projection| projection.name == *projection_name)
1114            });
1115            let projection_lifecycle = projection.as_ref().and_then(|projection_name| {
1116                declared_projections
1117                    .iter()
1118                    .find(|projection| projection.name == *projection_name)
1119                    .map(|projection| projection.state.as_str())
1120                    .or_else(|| {
1121                        operational_projections
1122                            .iter()
1123                            .find(|projection| projection.name == *projection_name)
1124                            .map(|projection| projection.state.as_str())
1125                    })
1126            });
1127            let dependency_in_sync = projection.as_ref().map(|_| {
1128                matches!(projection_lifecycle, Some("materialized"))
1129                    && projection_operational == Some(true)
1130            });
1131            let lifecycle_state = match (declared_present, operational_present, state.as_str()) {
1132                (true, false, _) => "declared",
1133                (true, true, _)
1134                    if matches!(
1135                        projection_lifecycle,
1136                        Some("stale" | "failed" | "materializing" | "declared")
1137                    ) =>
1138                {
1139                    "stale"
1140                }
1141                (true, true, "completed") => "completed",
1142                (true, true, "running") => "running",
1143                (true, true, "failed") => "failed",
1144                (true, true, "queued") => "queued",
1145                (true, true, "stale") => "stale",
1146                (true, true, _) => "materialized",
1147                (false, true, _) => "orphaned-operational",
1148                (false, false, _) => "unknown",
1149            }
1150            .to_string();
1151            let executable = declared_present
1152                && operational_present
1153                && !matches!(state.as_str(), "failed" | "stale")
1154                && dependency_in_sync.unwrap_or(true);
1155            let requires_rerun =
1156                matches!(state.as_str(), "stale" | "failed") || dependency_in_sync == Some(false);
1157            let mut attention_reasons = Vec::new();
1158            if declared_present != operational_present {
1159                attention_reasons.push("declaration_drift".to_string());
1160            }
1161            if matches!(state.as_str(), "failed") {
1162                attention_reasons.push("failed".to_string());
1163            }
1164            if matches!(state.as_str(), "stale") {
1165                attention_reasons.push("stale".to_string());
1166            }
1167            if dependency_in_sync == Some(false) {
1168                attention_reasons.push("dependency_drift".to_string());
1169            }
1170            if requires_rerun {
1171                attention_reasons.push("requires_rerun".to_string());
1172            }
1173            let attention_score = usize::from(requires_rerun).saturating_mul(3)
1174                + usize::from(dependency_in_sync == Some(false)).saturating_mul(2)
1175                + usize::from(declared_present != operational_present)
1176                + usize::from(!executable);
1177            CatalogAnalyticsJobStatus {
1178                id,
1179                kind,
1180                projection,
1181                state: state.clone(),
1182                lifecycle_state,
1183                declared: declared_present,
1184                operational: operational_present,
1185                in_sync: declared_present == operational_present,
1186                last_run_sequence,
1187                projection_declared,
1188                projection_operational,
1189                projection_lifecycle_state: projection_lifecycle.map(str::to_string),
1190                dependency_in_sync,
1191                executable,
1192                requires_rerun,
1193                attention_score,
1194                attention_reasons,
1195            }
1196        })
1197        .collect()
1198}
1199
1200pub fn consistency_report(snapshot: &CatalogModelSnapshot) -> CatalogConsistencyReport {
1201    let declared_indexes = snapshot
1202        .declared_indexes
1203        .iter()
1204        .map(|index| index.name.clone())
1205        .collect::<BTreeSet<_>>();
1206    let operational_indexes = snapshot
1207        .operational_indexes
1208        .iter()
1209        .map(|index| index.name.clone())
1210        .collect::<BTreeSet<_>>();
1211    let declared_graph_projections = snapshot
1212        .declared_graph_projections
1213        .iter()
1214        .map(|projection| projection.name.clone())
1215        .collect::<BTreeSet<_>>();
1216    let operational_graph_projections = snapshot
1217        .operational_graph_projections
1218        .iter()
1219        .map(|projection| projection.name.clone())
1220        .collect::<BTreeSet<_>>();
1221    let declared_analytics_jobs = snapshot
1222        .declared_analytics_jobs
1223        .iter()
1224        .map(|job| job.id.clone())
1225        .collect::<BTreeSet<_>>();
1226    let operational_analytics_jobs = snapshot
1227        .operational_analytics_jobs
1228        .iter()
1229        .map(|job| job.id.clone())
1230        .collect::<BTreeSet<_>>();
1231
1232    CatalogConsistencyReport {
1233        declared_index_count: declared_indexes.len(),
1234        operational_index_count: operational_indexes.len(),
1235        declared_graph_projection_count: declared_graph_projections.len(),
1236        operational_graph_projection_count: operational_graph_projections.len(),
1237        declared_analytics_job_count: declared_analytics_jobs.len(),
1238        operational_analytics_job_count: operational_analytics_jobs.len(),
1239        missing_operational_indexes: declared_indexes
1240            .difference(&operational_indexes)
1241            .cloned()
1242            .collect(),
1243        undeclared_operational_indexes: operational_indexes
1244            .difference(&declared_indexes)
1245            .cloned()
1246            .collect(),
1247        missing_operational_graph_projections: declared_graph_projections
1248            .difference(&operational_graph_projections)
1249            .cloned()
1250            .collect(),
1251        undeclared_operational_graph_projections: operational_graph_projections
1252            .difference(&declared_graph_projections)
1253            .cloned()
1254            .collect(),
1255        missing_operational_analytics_jobs: declared_analytics_jobs
1256            .difference(&operational_analytics_jobs)
1257            .cloned()
1258            .collect(),
1259        undeclared_operational_analytics_jobs: operational_analytics_jobs
1260            .difference(&declared_analytics_jobs)
1261            .cloned()
1262            .collect(),
1263    }
1264}
1265
1266pub fn attention_summary(snapshot: &CatalogModelSnapshot) -> CatalogAttentionSummary {
1267    CatalogAttentionSummary {
1268        collections_requiring_attention: snapshot
1269            .collections
1270            .iter()
1271            .filter(|collection| collection.attention_required)
1272            .count(),
1273        indexes_requiring_attention: snapshot
1274            .index_statuses
1275            .iter()
1276            .filter(|status| status.attention_score > 0)
1277            .count(),
1278        graph_projections_requiring_attention: snapshot
1279            .graph_projection_statuses
1280            .iter()
1281            .filter(|status| status.attention_score > 0)
1282            .count(),
1283        analytics_jobs_requiring_attention: snapshot
1284            .analytics_job_statuses
1285            .iter()
1286            .filter(|status| status.attention_score > 0)
1287            .count(),
1288        top_collection: snapshot
1289            .collections
1290            .iter()
1291            .filter(|collection| collection.attention_score > 0)
1292            .max_by_key(|collection| collection.attention_score)
1293            .cloned(),
1294        top_index: snapshot
1295            .index_statuses
1296            .iter()
1297            .filter(|status| status.attention_score > 0)
1298            .max_by_key(|status| status.attention_score)
1299            .cloned(),
1300        top_graph_projection: snapshot
1301            .graph_projection_statuses
1302            .iter()
1303            .filter(|status| status.attention_score > 0)
1304            .max_by_key(|status| status.attention_score)
1305            .cloned(),
1306        top_analytics_job: snapshot
1307            .analytics_job_statuses
1308            .iter()
1309            .filter(|status| status.attention_score > 0)
1310            .max_by_key(|status| status.attention_score)
1311            .cloned(),
1312    }
1313}
1314
1315pub fn collection_attention(snapshot: &CatalogModelSnapshot) -> Vec<CollectionDescriptor> {
1316    let mut collections = snapshot
1317        .collections
1318        .iter()
1319        .filter(|collection| collection.attention_required)
1320        .cloned()
1321        .collect::<Vec<_>>();
1322    collections.sort_by(|left, right| {
1323        right
1324            .attention_score
1325            .cmp(&left.attention_score)
1326            .then_with(|| left.name.cmp(&right.name))
1327    });
1328    collections
1329}
1330
1331pub fn index_attention(snapshot: &CatalogModelSnapshot) -> Vec<CatalogIndexStatus> {
1332    let mut statuses = snapshot
1333        .index_statuses
1334        .iter()
1335        .filter(|status| status.attention_score > 0)
1336        .cloned()
1337        .collect::<Vec<_>>();
1338    statuses.sort_by(|left, right| {
1339        right
1340            .attention_score
1341            .cmp(&left.attention_score)
1342            .then_with(|| left.name.cmp(&right.name))
1343    });
1344    statuses
1345}
1346
1347pub fn graph_projection_attention(
1348    snapshot: &CatalogModelSnapshot,
1349) -> Vec<CatalogGraphProjectionStatus> {
1350    let mut statuses = snapshot
1351        .graph_projection_statuses
1352        .iter()
1353        .filter(|status| status.attention_score > 0)
1354        .cloned()
1355        .collect::<Vec<_>>();
1356    statuses.sort_by(|left, right| {
1357        right
1358            .attention_score
1359            .cmp(&left.attention_score)
1360            .then_with(|| left.name.cmp(&right.name))
1361    });
1362    statuses
1363}
1364
1365pub fn analytics_job_attention(snapshot: &CatalogModelSnapshot) -> Vec<CatalogAnalyticsJobStatus> {
1366    let mut statuses = snapshot
1367        .analytics_job_statuses
1368        .iter()
1369        .filter(|status| status.attention_score > 0)
1370        .cloned()
1371        .collect::<Vec<_>>();
1372    statuses.sort_by(|left, right| {
1373        right
1374            .attention_score
1375            .cmp(&left.attention_score)
1376            .then_with(|| left.id.cmp(&right.id))
1377    });
1378    statuses
1379}
1380
1381#[cfg(test)]
1382mod observation_tests {
1383    use super::*;
1384    use crate::storage::{CrossRef, EntityId, RefType};
1385
1386    #[test]
1387    fn borrowed_catalog_preserves_models_counts_and_cross_references() {
1388        let store = UnifiedStore::new();
1389        for name in ["rows", "graph", "vectors", "mixed", "empty"] {
1390            store.create_collection(name).expect("collection");
1391        }
1392        let mut row = UnifiedEntity::table_row(
1393            EntityId::new(1),
1394            "rows",
1395            1,
1396            vec![Value::text("payload".repeat(10_000))],
1397        );
1398        row.add_cross_ref(CrossRef::new(
1399            EntityId::new(1),
1400            EntityId::new(2),
1401            "graph",
1402            RefType::RowToNode,
1403        ));
1404        store.insert("rows", row).expect("row");
1405        store
1406            .insert(
1407                "graph",
1408                UnifiedEntity::graph_node(EntityId::new(2), "label", "type", HashMap::new()),
1409            )
1410            .expect("node");
1411        store
1412            .insert(
1413                "vectors",
1414                UnifiedEntity::vector(EntityId::new(3), "vectors", vec![1.0, 0.0]),
1415            )
1416            .expect("vector");
1417        store
1418            .insert(
1419                "mixed",
1420                UnifiedEntity::table_row(EntityId::new(4), "mixed", 4, vec![Value::Integer(4)]),
1421            )
1422            .expect("mixed row");
1423        store
1424            .insert(
1425                "mixed",
1426                UnifiedEntity::vector(EntityId::new(5), "mixed", vec![0.0, 1.0]),
1427            )
1428            .expect("mixed vector");
1429
1430        let snapshot = snapshot_store("test", &store, None);
1431        assert_eq!(snapshot.summary.total_entities, 5);
1432        for (name, model, count, references) in [
1433            ("rows", CollectionModel::Table, 1, 1),
1434            ("graph", CollectionModel::Graph, 1, 0),
1435            ("vectors", CollectionModel::Vector, 1, 0),
1436            ("mixed", CollectionModel::Mixed, 2, 0),
1437            ("empty", CollectionModel::Mixed, 0, 0),
1438        ] {
1439            let descriptor = snapshot
1440                .collections
1441                .iter()
1442                .find(|item| item.name == name)
1443                .expect("descriptor");
1444            assert_eq!(
1445                (
1446                    descriptor.observed_model,
1447                    descriptor.entities,
1448                    descriptor.cross_refs
1449                ),
1450                (model, count, references)
1451            );
1452        }
1453    }
1454}