coding-agent-search 0.7.0

Unified TUI search over local coding agent histories
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
// Dead-code tolerated module-wide: the precise semantic-readiness reason
// vocabulary lands here ahead of the status/health/triage/search-metadata
// surfaces that will project it and the model-acquisition layer (.5.5) that
// will populate the richer checksum/model-file signals. Downstream beads
// (.5.4 truthful hybrid fallback, .5.5 model acquisition hardening) consume
// these types.
#![allow(dead_code)]

//! Precise semantic readiness reasons and tier state (bead
//! cass-fleet-resilience-20260608-uojcg.5.1).
//!
//! Every reachable fleet node in the 2026-06-08 report had semantic search
//! unavailable, yet the project contract is that lexical search *fail-opens*
//! and semantic/model acquisition is opt-in. Today status collapses every
//! one of those situations into a vague "semantic unavailable", so an agent
//! cannot tell "operator disabled it" from "model never downloaded" from
//! "vectors are stale against the current DB" from "backfill is mid-flight".
//!
//! This module defines the single precise [`SemanticReadinessReason`] every
//! surface reports, plus the derived [`SemanticReadinessReport`] carrying
//! the JSON fields the report calls for: `available`, `fallback_mode`,
//! `quality_tier` / `fast_tier` readiness, `semantic_only_search_available`,
//! `state_detail`, `next_step`, and the realized search refinement.
//!
//! The classifier operates on an explicit [`SemanticSignals`] input (the
//! contract every surface populates) rather than reaching into the model
//! layer, so it is fully testable without downloading any model. All enums
//! serialize as snake_case, matching the readiness vocabulary in
//! [`crate::search::readiness`].

use serde::{Deserialize, Serialize};

use crate::search::readiness::SearchRefinementLevel;

/// The single precise reason semantic refinement is, or is not, available.
/// Ordered from "intentionally off" through acquisition/build problems to
/// "ready"; the classifier reports the first applicable reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SemanticReadinessReason {
    /// The operator explicitly disabled semantic search via policy.
    PolicyDisabled,
    /// A baseline build with semantic never configured (opt-in not taken);
    /// lexical-only by design, not a failure.
    BaselineNoSemantic,
    /// No embedding model has been acquired (no model dir / embedder).
    ModelNotAcquired,
    /// The model directory exists but required files are missing/incomplete.
    ModelFilesMissing,
    /// Model files are present but failed checksum verification.
    ChecksumMismatch,
    /// Model is ready but no vector index file has been built yet.
    VectorIndexMissing,
    /// A vector index exists but was built against a different DB
    /// generation (fingerprint mismatch) — stale, must not be trusted.
    DbFingerprintMismatch,
    /// Embeddings are actively backfilling; refinement will improve as it
    /// completes.
    BackfillInProgress,
    /// The fast tier is queryable; the quality tier is not yet published.
    FastTierReady,
    /// The quality tier is published and matches the current DB.
    QualityTierReady,
}

impl SemanticReadinessReason {
    /// Whether this reason means semantic refinement can contribute to a
    /// query right now (only the two ready tiers).
    pub(crate) fn is_available(self) -> bool {
        matches!(self, Self::FastTierReady | Self::QualityTierReady)
    }
}

/// Search fallback mode when semantic refinement cannot contribute.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum FallbackMode {
    /// Semantic refinement is available; no fallback is in effect.
    None,
    /// Search falls back to lexical-only results.
    Lexical,
}

/// The operator/agent next step to improve semantic readiness.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SemanticNextStep {
    /// Nothing to do; a semantic tier is ready and no maintenance is active.
    None,
    /// Re-enable semantic search in policy/config.
    EnableSemanticPolicy,
    /// Acquire the embedding model (opt-in install).
    InstallModel,
    /// Re-acquire / repair the incomplete model files.
    RepairModelFiles,
    /// Re-download the model; checksum verification failed.
    ReacquireModelChecksumFailed,
    /// Build the vector index from the acquired model.
    BuildVectorIndex,
    /// Rebuild embeddings for the current DB generation (stale fingerprint).
    RebuildForCurrentDb,
    /// Wait for the in-progress backfill to converge.
    WaitForBackfill,
}

/// The signals a surface supplies to classify semantic readiness. This is
/// the stable contract; the model-acquisition layer and `SemanticAssetState`
/// adapter populate it. Kept minimal and `Copy` so fixtures are cheap.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SemanticSignals {
    /// Semantic search is enabled by policy/config.
    pub policy_enabled: bool,
    /// This is a baseline build with semantic never configured.
    pub baseline_only: bool,
    /// An embedding model has been acquired (model dir + embedder present).
    pub model_present: bool,
    /// The acquired model's files are complete.
    pub model_files_complete: bool,
    /// The acquired model passed checksum verification.
    pub checksum_ok: bool,
    /// A vector index file exists on disk.
    pub vector_index_present: bool,
    /// Whether the vector index matches the current DB fingerprint.
    /// `None` when not yet evaluable.
    pub db_fingerprint_matches: Option<bool>,
    /// Embeddings are actively backfilling.
    pub backfill_in_progress: bool,
    /// The fast tier is queryable against the current DB.
    pub fast_tier_ready: bool,
    /// The quality tier is published and queryable against the current DB.
    pub quality_tier_ready: bool,
}

impl SemanticSignals {
    /// Classify the single precise reason from these signals, evaluated in
    /// priority order (intentional-off → acquisition → build → stale →
    /// queryable tiers → backfill-only).
    pub(crate) fn reason(&self) -> SemanticReadinessReason {
        use SemanticReadinessReason as R;
        if !self.policy_enabled {
            return R::PolicyDisabled;
        }
        if self.baseline_only {
            return R::BaselineNoSemantic;
        }
        if !self.model_present {
            return R::ModelNotAcquired;
        }
        if !self.model_files_complete {
            return R::ModelFilesMissing;
        }
        if !self.checksum_ok {
            return R::ChecksumMismatch;
        }
        if !self.vector_index_present {
            return R::VectorIndexMissing;
        }
        if self.db_fingerprint_matches == Some(false) {
            return R::DbFingerprintMismatch;
        }
        // A queryable quality tier may establish readiness while an independent
        // fingerprint check is still pending, but never after an explicit
        // mismatch or when the vector index itself is absent.
        if self.quality_tier_ready {
            return R::QualityTierReady;
        }
        if self.fast_tier_ready {
            return R::FastTierReady;
        }
        if self.backfill_in_progress {
            return R::BackfillInProgress;
        }
        // Index present, fingerprint not disproven, nothing ready yet and no
        // active backfill flag: treat as backfill-pending rather than ready.
        R::BackfillInProgress
    }

    /// Derive the full readiness report for these signals.
    pub(crate) fn report(&self) -> SemanticReadinessReport {
        use SemanticReadinessReason as R;
        let reason = self.reason();
        let available = reason.is_available();
        let quality_tier_ready = matches!(reason, R::QualityTierReady);

        let realized_refinement = if quality_tier_ready {
            SearchRefinementLevel::FullyHybridRefined
        } else if matches!(reason, R::FastTierReady) {
            SearchRefinementLevel::FastTierRefined
        } else {
            SearchRefinementLevel::LexicalOnly
        };

        // Fallback describes whether semantic refinement is unavailable, not
        // whether the highest-quality tier is ready. Either queryable tier can
        // refine a search; only non-queryable verdicts are lexical-only.
        let fallback_mode = if available {
            FallbackMode::None
        } else {
            FallbackMode::Lexical
        };

        let next_step = match reason {
            R::QualityTierReady if self.backfill_in_progress => {
                SemanticNextStep::WaitForBackfill
            }
            R::QualityTierReady => SemanticNextStep::None,
            R::PolicyDisabled => SemanticNextStep::EnableSemanticPolicy,
            R::BaselineNoSemantic | R::ModelNotAcquired => SemanticNextStep::InstallModel,
            R::ModelFilesMissing => SemanticNextStep::RepairModelFiles,
            R::ChecksumMismatch => SemanticNextStep::ReacquireModelChecksumFailed,
            R::VectorIndexMissing => SemanticNextStep::BuildVectorIndex,
            R::DbFingerprintMismatch => SemanticNextStep::RebuildForCurrentDb,
            R::BackfillInProgress => SemanticNextStep::WaitForBackfill,
            R::FastTierReady if self.backfill_in_progress => {
                SemanticNextStep::WaitForBackfill
            }
            R::FastTierReady => SemanticNextStep::None,
        };

        let state_detail = match (reason, self.backfill_in_progress) {
            (R::QualityTierReady, true) => {
                "quality semantic tier ready; residual semantic backfill is still finishing"
            }
            (R::FastTierReady, false) => {
                "fast semantic tier ready; quality tier is not published and no backfill is active"
            }
            _ => reason.state_detail(),
        };

        SemanticReadinessReport {
            reason,
            available,
            // `semantic --mode` can run only when the realized verdict says a
            // current tier is queryable. Raw tier flags may be stale or
            // policy-blocked and must not override that verdict.
            semantic_only_search_available: available,
            fallback_mode,
            fast_tier_ready: available && self.fast_tier_ready,
            quality_tier_ready,
            state_detail: state_detail.to_string(),
            next_step,
            realized_refinement,
        }
    }
}

impl SemanticReadinessReason {
    /// A stable one-line human explanation for this reason.
    fn state_detail(self) -> &'static str {
        match self {
            Self::PolicyDisabled => "semantic search disabled by policy; lexical search only",
            Self::BaselineNoSemantic => {
                "baseline build without semantic configured; install a model to enable hybrid refinement"
            }
            Self::ModelNotAcquired => {
                "no embedding model acquired; lexical search works, hybrid refinement is opt-in"
            }
            Self::ModelFilesMissing => "embedding model files are incomplete; re-acquire the model",
            Self::ChecksumMismatch => "embedding model failed checksum verification; re-acquire it",
            Self::VectorIndexMissing => "model ready but no vector index built yet",
            Self::DbFingerprintMismatch => {
                "vector index is stale against the current database; rebuild embeddings"
            }
            Self::BackfillInProgress => {
                "semantic backfill in progress; hybrid refinement improves as it completes"
            }
            Self::FastTierReady => "fast semantic tier ready; quality tier still backfilling",
            Self::QualityTierReady => {
                "quality semantic tier ready; full hybrid refinement available"
            }
        }
    }
}

/// The derived semantic readiness report every status/health/triage/search
/// surface projects. Carries exactly the fields the .5.1 acceptance names.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SemanticReadinessReport {
    pub reason: SemanticReadinessReason,
    /// `semantic.available`: hybrid refinement can contribute now.
    pub available: bool,
    pub semantic_only_search_available: bool,
    pub fallback_mode: FallbackMode,
    pub fast_tier_ready: bool,
    pub quality_tier_ready: bool,
    pub state_detail: String,
    pub next_step: SemanticNextStep,
    /// The search refinement a query would realize at this readiness.
    pub realized_refinement: SearchRefinementLevel,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A fully-ready quality-tier baseline; individual tests flip the one
    /// signal under test.
    fn ready() -> SemanticSignals {
        SemanticSignals {
            policy_enabled: true,
            baseline_only: false,
            model_present: true,
            model_files_complete: true,
            checksum_ok: true,
            vector_index_present: true,
            db_fingerprint_matches: Some(true),
            backfill_in_progress: false,
            fast_tier_ready: true,
            quality_tier_ready: true,
        }
    }

    #[test]
    fn enums_serialize_as_snake_case() {
        assert_eq!(
            serde_json::to_string(&SemanticReadinessReason::DbFingerprintMismatch).unwrap(),
            "\"db_fingerprint_mismatch\""
        );
        assert_eq!(
            serde_json::to_string(&FallbackMode::Lexical).unwrap(),
            "\"lexical\""
        );
        assert_eq!(
            serde_json::to_string(&SemanticNextStep::ReacquireModelChecksumFailed).unwrap(),
            "\"reacquire_model_checksum_failed\""
        );
    }

    #[test]
    fn quality_tier_ready_is_fully_hybrid_with_no_fallback() {
        let r = ready().report();
        assert_eq!(r.reason, SemanticReadinessReason::QualityTierReady);
        assert!(r.available);
        assert!(r.quality_tier_ready);
        assert_eq!(r.fallback_mode, FallbackMode::None);
        assert_eq!(
            r.realized_refinement,
            SearchRefinementLevel::FullyHybridRefined
        );
        assert_eq!(r.next_step, SemanticNextStep::None);
        assert!(r.semantic_only_search_available);
    }

    #[test]
    fn policy_disabled_dominates_every_other_signal() {
        let mut s = ready();
        s.policy_enabled = false;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::PolicyDisabled);
        assert!(!r.available);
        assert!(!r.semantic_only_search_available);
        assert!(!r.fast_tier_ready);
        assert!(!r.quality_tier_ready);
        assert_eq!(r.fallback_mode, FallbackMode::Lexical);
        assert_eq!(r.next_step, SemanticNextStep::EnableSemanticPolicy);
    }

    #[test]
    fn baseline_without_semantic_suggests_install() {
        let mut s = ready();
        s.baseline_only = true;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::BaselineNoSemantic);
        assert_eq!(r.next_step, SemanticNextStep::InstallModel);
        assert_eq!(r.realized_refinement, SearchRefinementLevel::LexicalOnly);
    }

    #[test]
    fn acquisition_chain_reasons_are_reported_in_priority_order() {
        // model not acquired
        let mut s = ready();
        s.model_present = false;
        assert_eq!(s.reason(), SemanticReadinessReason::ModelNotAcquired);
        assert_eq!(s.report().next_step, SemanticNextStep::InstallModel);

        // model present but files incomplete
        let mut s = ready();
        s.model_files_complete = false;
        assert_eq!(s.reason(), SemanticReadinessReason::ModelFilesMissing);
        assert_eq!(s.report().next_step, SemanticNextStep::RepairModelFiles);

        // files complete but checksum failed
        let mut s = ready();
        s.checksum_ok = false;
        assert_eq!(s.reason(), SemanticReadinessReason::ChecksumMismatch);
        assert_eq!(
            s.report().next_step,
            SemanticNextStep::ReacquireModelChecksumFailed
        );
    }

    #[test]
    fn vector_index_missing_when_model_ok_but_no_index() {
        let mut s = ready();
        s.quality_tier_ready = false;
        s.fast_tier_ready = false;
        s.vector_index_present = false;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::VectorIndexMissing);
        assert_eq!(r.next_step, SemanticNextStep::BuildVectorIndex);
        assert!(!r.available);
        assert!(!r.semantic_only_search_available);
    }

    #[test]
    fn stale_fingerprint_is_flagged_and_must_rebuild() {
        let mut s = ready();
        s.quality_tier_ready = false;
        s.fast_tier_ready = false;
        s.db_fingerprint_matches = Some(false);
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::DbFingerprintMismatch);
        assert_eq!(r.next_step, SemanticNextStep::RebuildForCurrentDb);
    }

    #[test]
    fn explicit_staleness_dominates_claimed_ready_tiers() {
        let mut s = ready();
        s.db_fingerprint_matches = Some(false);
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::DbFingerprintMismatch);
        assert!(!r.available);
        assert!(!r.semantic_only_search_available);
        assert!(!r.fast_tier_ready);
        assert!(!r.quality_tier_ready);
        assert_eq!(r.fallback_mode, FallbackMode::Lexical);
        assert_eq!(r.realized_refinement, SearchRefinementLevel::LexicalOnly);
    }

    #[test]
    fn missing_vector_index_dominates_claimed_quality_tier() {
        let mut s = ready();
        s.vector_index_present = false;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::VectorIndexMissing);
        assert!(!r.available);
        assert!(!r.semantic_only_search_available);
        assert!(!r.quality_tier_ready);
    }

    #[test]
    fn backfill_in_progress_waits() {
        let mut s = ready();
        s.quality_tier_ready = false;
        s.fast_tier_ready = false;
        s.backfill_in_progress = true;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::BackfillInProgress);
        assert_eq!(r.next_step, SemanticNextStep::WaitForBackfill);
    }

    #[test]
    fn fast_tier_ready_serves_search_while_quality_backfills() {
        let mut s = ready();
        s.quality_tier_ready = false;
        s.backfill_in_progress = true;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::FastTierReady);
        assert!(r.available);
        assert!(r.semantic_only_search_available);
        assert_eq!(r.fallback_mode, FallbackMode::None);
        assert_eq!(
            r.realized_refinement,
            SearchRefinementLevel::FastTierRefined
        );
        assert_eq!(r.next_step, SemanticNextStep::WaitForBackfill);
        assert!(r.state_detail.contains("backfilling"));
    }

    #[test]
    fn fast_tier_without_active_backfill_has_no_wait_or_build_action() {
        let mut s = ready();
        s.quality_tier_ready = false;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::FastTierReady);
        assert!(r.available);
        assert!(r.semantic_only_search_available);
        assert!(r.fast_tier_ready);
        assert!(!r.quality_tier_ready);
        assert_eq!(r.fallback_mode, FallbackMode::None);
        assert_eq!(
            r.realized_refinement,
            SearchRefinementLevel::FastTierRefined
        );
        assert_eq!(r.next_step, SemanticNextStep::None);
        assert!(r.state_detail.contains("no backfill is active"));
    }

    #[test]
    fn quality_tier_remains_queryable_while_residual_backfill_finishes() {
        let mut s = ready();
        s.backfill_in_progress = true;
        let r = s.report();
        assert_eq!(r.reason, SemanticReadinessReason::QualityTierReady);
        assert!(r.available);
        assert!(r.semantic_only_search_available);
        assert!(r.quality_tier_ready);
        assert_eq!(r.fallback_mode, FallbackMode::None);
        assert_eq!(
            r.realized_refinement,
            SearchRefinementLevel::FullyHybridRefined
        );
        assert_eq!(r.next_step, SemanticNextStep::WaitForBackfill);
        assert!(r.state_detail.contains("residual semantic backfill"));
    }

    #[test]
    fn report_round_trips_through_json_with_expected_fields() {
        let r = ready().report();
        let json = serde_json::to_string(&r).unwrap();
        for needle in [
            "\"reason\":\"quality_tier_ready\"",
            "\"available\":true",
            "\"semantic_only_search_available\":true",
            "\"fallback_mode\":\"none\"",
            "\"next_step\":\"none\"",
            "\"realized_refinement\":\"fully_hybrid_refined\"",
        ] {
            assert!(json.contains(needle), "missing {needle} in {json}");
        }
        let parsed: SemanticReadinessReport = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, r);
    }

    #[test]
    fn every_reason_is_reachable_from_some_signal_set() {
        use SemanticReadinessReason as R;
        let reached: std::collections::BTreeSet<R> = [
            {
                let mut s = ready();
                s.policy_enabled = false;
                s.reason()
            },
            {
                let mut s = ready();
                s.baseline_only = true;
                s.reason()
            },
            {
                let mut s = ready();
                s.model_present = false;
                s.reason()
            },
            {
                let mut s = ready();
                s.model_files_complete = false;
                s.reason()
            },
            {
                let mut s = ready();
                s.checksum_ok = false;
                s.reason()
            },
            {
                let mut s = ready();
                s.quality_tier_ready = false;
                s.fast_tier_ready = false;
                s.vector_index_present = false;
                s.reason()
            },
            {
                let mut s = ready();
                s.quality_tier_ready = false;
                s.fast_tier_ready = false;
                s.db_fingerprint_matches = Some(false);
                s.reason()
            },
            {
                let mut s = ready();
                s.quality_tier_ready = false;
                s.fast_tier_ready = false;
                s.backfill_in_progress = true;
                s.reason()
            },
            {
                let mut s = ready();
                s.quality_tier_ready = false;
                s.reason()
            },
            ready().reason(),
        ]
        .into_iter()
        .collect();
        assert_eq!(reached.len(), 10, "all ten reasons must be reachable");
    }
}