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