frigg 0.9.2

Frigg gives AI agents local, source-backed code search and navigation without sending whole repositories through every prompt.
Documentation
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
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
//! Public query and result types for Frigg's retrieval layer. These records keep the searcher
//! boundary explicit so MCP handlers, playbooks, and tests can all talk about the same execution
//! semantics.

use std::collections::BTreeSet;
use std::path::PathBuf;

use crate::domain::{
    ChannelDiagnostic, ChannelHealth, ChannelHealthStatus, ChannelResult, ChannelStats,
    EvidenceAnchor, EvidenceChannel, EvidenceDocumentRef, EvidenceHit, FriggError, FriggResult,
    model::TextMatch,
};
use crate::indexer::PhpDeclarationRelation;
use crate::languages::{BladeSourceEvidence, PhpSourceEvidence, SymbolLanguage};

use super::attribution::SearchStageAttribution;
use super::policy::PostSelectionTrace;

#[derive(Debug, Clone)]
/// Input for direct lexical search when callers want raw text recall without the hybrid ranking
/// stack.
pub struct SearchTextQuery {
    /// Literal or regex pattern text, depending on the search entry point invoked.
    pub query: String,
    /// Optional repository-relative path filter applied before scanning candidates.
    pub path_regex: Option<regex::Regex>,
    /// Maximum number of matches to retain after deterministic ordering.
    pub limit: usize,
}

/// Path and row-shaping policy applied by the lexical engine before it counts or retains rows.
///
/// The MCP layer deliberately compiles globs before constructing this value. Keeping the compiled
/// predicate at the searcher boundary ensures manifest, walk, native, ripgrep, and mixed paths
/// all operate over the same candidate set.
#[derive(Debug, Clone, Default)]
pub struct SearchTextExecutionOptions {
    /// Optional include glob compiled as a repository-relative path regex.
    pub include_glob: Option<regex::Regex>,
    /// Optional exclusion glob compiled as a repository-relative path regex.
    pub exclude_glob: Option<regex::Regex>,
    /// Row unit retained after all eligible occurrences have been collected.
    pub row_mode: SearchTextRowMode,
}

impl SearchTextExecutionOptions {
    /// Returns true only when a repository-relative candidate path satisfies the full path
    /// predicate. `path_regex` remains on [`SearchTextQuery`] for existing callers.
    pub(crate) fn allows_path(&self, path: &str) -> bool {
        self.include_glob
            .as_ref()
            .is_none_or(|glob| glob.is_match(path))
            && self
                .exclude_glob
                .as_ref()
                .is_none_or(|glob| !glob.is_match(path))
    }
}

/// The unit produced from eligible lexical occurrences before a requested page bound is applied.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SearchTextRowMode {
    /// One row per matching occurrence.
    #[default]
    Occurrence,
    /// One deterministic representative row per file with at least one occurrence.
    UniqueFile,
    /// At most this many deterministic occurrence rows per matching file.
    PerFileCapped { max_count_per_file: usize },
}

#[derive(Debug, Clone)]
/// Shared repository-level filters used to scope both lexical and hybrid retrieval paths.
pub struct SearchFilters {
    /// Restrict retrieval to one configured repository id when set.
    pub repository_id: Option<String>,
    /// Restrict retrieval to files classified as one supported source language when set.
    pub language: Option<String>,
    /// Include hidden repository paths during candidate intake.
    pub include_hidden: bool,
}

impl Default for SearchFilters {
    fn default() -> Self {
        Self {
            repository_id: None,
            language: None,
            include_hidden: true,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
/// Category of non-fatal issue encountered while walking or reading repository candidates.
pub enum SearchDiagnosticKind {
    /// Candidate discovery failed for a subtree or repository root.
    Walk,
    /// A candidate file could not be read during scanning.
    Read,
}

#[derive(Debug, Clone, PartialEq, Eq)]
/// One diagnostic emitted while building or scanning the candidate universe.
pub struct SearchDiagnostic {
    /// Repository that produced the diagnostic.
    pub repository_id: String,
    /// Candidate path when the issue is file-specific.
    pub path: Option<String>,
    /// Whether the issue occurred during discovery or file read.
    pub kind: SearchDiagnosticKind,
    /// Human-readable explanation suitable for surfacing to callers.
    pub message: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// Aggregated diagnostics from candidate intake and lexical scanning.
pub struct SearchExecutionDiagnostics {
    /// Ordered diagnostic entries collected across repositories.
    pub entries: Vec<SearchDiagnostic>,
}

impl SearchExecutionDiagnostics {
    /// Total number of diagnostic entries recorded for the run.
    pub fn total_count(&self) -> usize {
        self.entries.len()
    }

    /// Count of diagnostics matching one [`SearchDiagnosticKind`].
    pub fn count_by_kind(&self, kind: SearchDiagnosticKind) -> usize {
        self.entries
            .iter()
            .filter(|diagnostic| diagnostic.kind == kind)
            .count()
    }
}

#[derive(Debug, Clone, Default)]
/// Output of a lexical-only search pass, including diagnostics that explain degraded or partial
/// coverage.
pub struct SearchExecutionOutput {
    /// Number of matches before caller-side truncation.
    pub total_matches: usize,
    /// Bounded, deterministically ordered lexical matches.
    pub matches: Vec<TextMatch>,
    /// Walk and read issues encountered while scanning candidates.
    pub diagnostics: SearchExecutionDiagnostics,
    /// Backend that produced lexical hits when an accelerator was selected.
    pub lexical_backend: Option<SearchLexicalBackend>,
    /// Optional explanation when the backend fell back or mixed native and ripgrep paths.
    pub lexical_backend_note: Option<String>,
}

/// Exhaustive lexical execution after candidate filtering and row shaping.
///
/// This is intentionally separate from [`SearchExecutionOutput`], whose compatibility callers
/// only consume bounded occurrence rows. New exact public surfaces use this record so page
/// limits cannot alter their cardinality truth.
#[derive(Debug, Clone, Default)]
pub struct ExactSearchExecutionOutput {
    /// Exact eligible occurrence count before row shaping or page retention. Absent when a
    /// diagnostic can hide qualifying occurrences.
    pub total_matches: Option<usize>,
    /// Exact selected row-unit count before page retention. Absent when a diagnostic can hide
    /// qualifying rows.
    pub total_rows: Option<usize>,
    /// Deterministically ordered page rows in the selected row unit.
    pub matches: Vec<TextMatch>,
    /// Candidate intake, read, and backend diagnostics.
    pub diagnostics: SearchExecutionDiagnostics,
    /// Backend used across the complete candidate universe.
    pub lexical_backend: Option<SearchLexicalBackend>,
    /// Optional backend fallback or mixed-execution explanation.
    pub lexical_backend_note: Option<String>,
    /// Whether diagnostics can hide otherwise eligible rows or counts.
    pub coverage: SearchExecutionCoverage,
}

/// Whether a lexical execution covered every eligible candidate.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SearchExecutionCoverage {
    /// Every eligible candidate was accounted for; both totals are exact.
    #[default]
    Exact,
    /// A walk, read, or backend diagnostic may have hidden qualifying rows.
    Incomplete,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Lexical scan backend used for a search execution.
pub enum SearchLexicalBackend {
    /// Frigg's streaming native scanner over the candidate universe.
    Native,
    /// External `rg` accelerator over non-scrubbed candidates.
    Ripgrep,
    /// Ripgrep for most candidates with native fallback for scrubbed markdown content.
    Mixed,
}

impl SearchLexicalBackend {
    /// Stable snake_case label for diagnostics and MCP payloads.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Native => "native",
            Self::Ripgrep => "ripgrep",
            Self::Mixed => "mixed",
        }
    }
}

/// One filesystem candidate admitted into lexical or hybrid scan scope.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SearchCandidateFile {
    pub(crate) relative_path: String,
    pub(crate) absolute_path: PathBuf,
}

/// Per-repository candidate set, optionally pinned to a validated manifest snapshot.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RepositoryCandidateUniverse {
    pub(crate) repository_id: String,
    pub(crate) root: PathBuf,
    pub(crate) snapshot_id: Option<String>,
    pub(crate) candidates: Vec<SearchCandidateFile>,
}

/// Multi-repo candidate universe plus walk/read diagnostics from universe construction.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SearchCandidateUniverse {
    pub(crate) repositories: Vec<RepositoryCandidateUniverse>,
    pub(crate) diagnostics: SearchExecutionDiagnostics,
}

/// Candidate-universe build result with intake timing and manifest-backed repository counts.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SearchCandidateUniverseBuild {
    pub(crate) universe: SearchCandidateUniverse,
    pub(crate) repository_count: usize,
    pub(crate) candidate_count: usize,
    pub(crate) manifest_backed_repository_count: usize,
    pub(crate) candidate_intake_elapsed_us: u64,
    pub(crate) freshness_validation_elapsed_us: u64,
}

/// Manifest-derived candidate paths for one repository after freshness validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManifestCandidateFilesBuild {
    pub(crate) snapshot_id: String,
    pub(crate) candidates: Vec<(String, PathBuf)>,
    pub(crate) candidate_intake_elapsed_us: u64,
    pub(crate) freshness_validation_elapsed_us: u64,
}

/// Repository document identity shared by hybrid channel hits and ranked evidence.
pub type HybridDocumentRef = EvidenceDocumentRef;
/// Single retrieval hit from one hybrid channel before ranker blending.
pub type HybridChannelHit = EvidenceHit;

#[derive(Debug, Clone, Copy, PartialEq)]
/// Relative influence assigned to each hybrid retrieval channel before result diversification.
pub struct HybridChannelWeights {
    /// Weight applied to lexical and path-witness family scores.
    pub lexical: f32,
    /// Weight applied to graph-precise expansion hits.
    pub graph: f32,
    /// Weight applied to semantic vector retrieval hits.
    pub semantic: f32,
}

impl Default for HybridChannelWeights {
    fn default() -> Self {
        Self {
            lexical: 0.5,
            graph: 0.3,
            semantic: 0.2,
        }
    }
}

impl HybridChannelWeights {
    /// Rejects negative weights and the all-zero configuration that would leave fusion with no channel signal.
    pub fn validate(self) -> FriggResult<Self> {
        if self.lexical < 0.0 || self.graph < 0.0 || self.semantic < 0.0 {
            return Err(FriggError::InvalidInput(
                "hybrid channel weights must be >= 0".to_owned(),
            ));
        }
        if self.lexical == 0.0 && self.graph == 0.0 && self.semantic == 0.0 {
            return Err(FriggError::InvalidInput(
                "hybrid channel weights must include at least one non-zero channel".to_owned(),
            ));
        }

        Ok(self)
    }
}

#[derive(Debug, Clone)]
/// Input for Frigg's multi-signal retrieval path that can combine lexical, graph, and semantic
/// evidence behind one call.
pub struct SearchHybridQuery {
    /// Natural-language or keyword query text driving all retrieval channels.
    pub query: String,
    /// Maximum diversified matches to return after post-selection guardrails.
    pub limit: usize,
    /// Relative channel weights validated before ranker fusion.
    pub weights: HybridChannelWeights,
    /// Explicit semantic on/off override; defaults to runtime configuration when unset.
    pub semantic: Option<bool>,
}

/// Semantic channel health status surfaced on hybrid execution notes.
pub type HybridSemanticStatus = ChannelHealthStatus;

#[derive(Debug, Clone, PartialEq, Eq)]
/// Execution-side explanation of how the hybrid search actually ran, including whether semantic
/// recall participated or the query fell back to a narrower mode.
pub struct HybridExecutionNote {
    /// Whether the caller or runtime asked for semantic retrieval.
    pub semantic_requested: bool,
    /// Whether semantic retrieval produced at least one fused match.
    pub semantic_enabled: bool,
    /// Semantic channel health after embedding and vector lookup.
    pub semantic_status: HybridSemanticStatus,
    /// Disabled or degraded reason when semantic recall did not run cleanly.
    pub semantic_reason: Option<String>,
    /// Semantic candidates considered before relative score retention.
    pub semantic_candidate_count: usize,
    /// Semantic hits retained for ranker fusion.
    pub semantic_hit_count: usize,
    /// Semantic hits that survived into the final diversified match list.
    pub semantic_match_count: usize,
    /// True when semantic recall did not contribute usable pre-fusion hits.
    pub lexical_only_mode: bool,
    /// Lexical backend used while seeding hybrid channels.
    pub lexical_backend: Option<SearchLexicalBackend>,
    /// Optional note when lexical seeding mixed or fell back across backends.
    pub lexical_backend_note: Option<String>,
}

impl Default for HybridExecutionNote {
    fn default() -> Self {
        Self {
            semantic_requested: false,
            semantic_enabled: false,
            semantic_status: HybridSemanticStatus::Disabled,
            semantic_reason: None,
            semantic_candidate_count: 0,
            semantic_hit_count: 0,
            semantic_match_count: 0,
            lexical_only_mode: true,
            lexical_backend: None,
            lexical_backend_note: None,
        }
    }
}

#[derive(Debug, Clone, Default)]
/// Top-level result of a hybrid retrieval run, pairing final matches with diagnostics, channel
/// health, and execution attribution.
pub struct SearchHybridExecutionOutput {
    /// Final diversified matches delivered to callers.
    pub matches: Vec<HybridRankedEvidence>,
    /// Pre-diversification ranked anchors retained for inspection and tooling.
    pub ranked_anchors: Vec<HybridRankedEvidence>,
    #[allow(dead_code)]
    pub(crate) coverage_grouped_pool: Vec<HybridRankedEvidence>,
    /// Walk and read issues encountered while scanning candidates.
    pub diagnostics: SearchExecutionDiagnostics,
    /// Per-channel hit counts, health, and diagnostics after fan-out.
    pub channel_results: Vec<ChannelResult>,
    /// Summary of semantic participation and lexical backend behavior.
    pub note: HybridExecutionNote,
    /// Optional stage timing and cardinality samples for hybrid profiling.
    pub stage_attribution: Option<SearchStageAttribution>,
    #[allow(dead_code)]
    pub(crate) post_selection_trace: Option<PostSelectionTrace>,
}

#[derive(Debug, Clone, PartialEq)]
/// A ranked anchor after Frigg has merged evidence from multiple retrieval channels around one
/// repository location.
pub struct HybridRankedEvidence {
    /// Repository and path identity for the matched document.
    pub document: HybridDocumentRef,
    /// Line- or symbol-scoped anchor within the document.
    pub anchor: EvidenceAnchor,
    /// Excerpt chosen from the highest-priority contributing channel.
    pub excerpt: String,
    /// Weighted blend of channel scores after policy multipliers.
    pub blended_score: f32,
    /// Lexical manifest channel contribution.
    pub lexical_score: f32,
    /// Path-surface witness channel contribution.
    pub witness_score: f32,
    /// Graph-precise expansion contribution.
    pub graph_score: f32,
    /// Semantic vector retrieval contribution.
    pub semantic_score: f32,
    /// Source labels explaining lexical score components.
    pub lexical_sources: Vec<String>,
    /// Source labels explaining path-witness score components.
    pub witness_sources: Vec<String>,
    /// Source labels explaining graph score components.
    pub graph_sources: Vec<String>,
    /// Source labels explaining semantic score components.
    pub semantic_sources: Vec<String>,
}

/// Caller filters normalized once before candidate intake and channel fan-out.
#[derive(Debug, Clone, Default)]
pub(crate) struct NormalizedSearchFilters {
    pub(crate) repository_id: Option<String>,
    pub(crate) language: Option<SymbolLanguage>,
    pub(crate) include_hidden: bool,
}

/// Cache key for durable path-witness projections keyed by snapshot and heuristic version.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct HybridPathWitnessProjectionCacheKey {
    pub(crate) repository_id: String,
    pub(crate) root: PathBuf,
    pub(crate) snapshot_id: String,
    pub(crate) heuristic_version: i64,
}

/// Cache key for per-file hybrid graph analysis invalidated on mtime or size change.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct HybridGraphFileAnalysisCacheKey {
    pub(crate) path: PathBuf,
    pub(crate) modified_unix_nanos: u128,
    pub(crate) size_bytes: u64,
}

/// Cached language-specific graph facts reused while expanding hybrid graph neighbors.
#[derive(Debug, Clone, Default)]
pub(crate) struct HybridGraphFileAnalysis {
    pub(crate) symbols: Vec<crate::indexer::SymbolDefinition>,
    pub(crate) php_declaration_relations: Option<Vec<PhpDeclarationRelation>>,
    pub(crate) php_evidence: Option<PhpSourceEvidence>,
    pub(crate) blade_evidence: Option<BladeSourceEvidence>,
}

pub(crate) fn search_diagnostics_to_channel_diagnostics(
    diagnostics: &SearchExecutionDiagnostics,
) -> Vec<ChannelDiagnostic> {
    diagnostics
        .entries
        .iter()
        .map(|entry| ChannelDiagnostic {
            code: match entry.kind {
                SearchDiagnosticKind::Walk => "walk".to_owned(),
                SearchDiagnosticKind::Read => "read".to_owned(),
            },
            message: entry.message.clone(),
        })
        .collect()
}

pub(crate) fn empty_channel_result(
    channel: EvidenceChannel,
    status: ChannelHealthStatus,
    reason: Option<String>,
) -> ChannelResult {
    ChannelResult::new(
        channel,
        Vec::new(),
        ChannelHealth::new(status, reason),
        Vec::new(),
        ChannelStats::default(),
    )
}

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

    #[test]
    fn projection_cache_key_includes_heuristic_version() {
        let base = HybridPathWitnessProjectionCacheKey {
            repository_id: "repo-001".to_owned(),
            root: PathBuf::from("/tmp/repo"),
            snapshot_id: "snapshot-001".to_owned(),
            heuristic_version: 1,
        };
        let upgraded = HybridPathWitnessProjectionCacheKey {
            heuristic_version: 2,
            ..base.clone()
        };

        assert_ne!(base, upgraded);
    }
}

fn channel_result_by_channel(
    channel_results: &[ChannelResult],
    channel: EvidenceChannel,
) -> Option<&ChannelResult> {
    channel_results
        .iter()
        .find(|result| result.channel == channel)
}

fn hybrid_semantic_status_from_channel_health(status: ChannelHealthStatus) -> HybridSemanticStatus {
    match status {
        ChannelHealthStatus::Filtered => ChannelHealthStatus::Disabled,
        other => other,
    }
}

pub(crate) fn hybrid_lexical_only_mode(
    semantic_status: ChannelHealthStatus,
    semantic_hit_count: usize,
) -> bool {
    semantic_status != ChannelHealthStatus::Ok || semantic_hit_count == 0
}

pub(crate) fn hybrid_execution_note_from_channel_results(
    query_semantic: Option<bool>,
    semantic_runtime_enabled: bool,
    channel_results: &[ChannelResult],
) -> HybridExecutionNote {
    let semantic = channel_result_by_channel(channel_results, EvidenceChannel::Semantic);
    let semantic_requested = query_semantic.unwrap_or(semantic_runtime_enabled);
    let semantic_status = semantic
        .map(|result| hybrid_semantic_status_from_channel_health(result.health.status))
        .unwrap_or(HybridSemanticStatus::Disabled);
    let semantic_reason = semantic.and_then(|result| result.health.reason.clone());
    let semantic_candidate_count = semantic.map_or(0, |result| result.stats.candidate_count);
    let semantic_hit_count = semantic.map_or(0, |result| result.stats.hit_count);
    let semantic_match_count = semantic.map_or(0, |result| result.stats.match_count);
    let lexical_only_mode = hybrid_lexical_only_mode(semantic_status, semantic_hit_count);

    HybridExecutionNote {
        semantic_requested,
        semantic_enabled: semantic_match_count > 0,
        semantic_status,
        semantic_reason,
        semantic_candidate_count,
        semantic_hit_count,
        semantic_match_count,
        lexical_only_mode,
        lexical_backend: None,
        lexical_backend_note: None,
    }
}

pub(crate) fn match_count_for_hits(
    matches: &[HybridRankedEvidence],
    hits: &[HybridChannelHit],
) -> usize {
    if matches.is_empty() || hits.is_empty() {
        return 0;
    }

    let matched_documents = matches
        .iter()
        .map(|entry| (&entry.document.repository_id, &entry.document.path))
        .collect::<BTreeSet<_>>();
    hits.iter()
        .map(|hit| (&hit.document.repository_id, &hit.document.path))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .filter(|document| matched_documents.contains(document))
        .count()
}

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

    fn semantic_channel_result(
        status: ChannelHealthStatus,
        hit_count: usize,
        match_count: usize,
    ) -> ChannelResult {
        ChannelResult::new(
            EvidenceChannel::Semantic,
            Vec::new(),
            ChannelHealth::new(status, None),
            Vec::new(),
            ChannelStats {
                candidate_count: hit_count,
                hit_count,
                match_count,
            },
        )
    }

    #[test]
    fn lexical_only_mode_keys_on_pre_fusion_hits_not_post_fusion_matches() {
        assert!(!hybrid_lexical_only_mode(ChannelHealthStatus::Ok, 5));
        assert!(hybrid_lexical_only_mode(ChannelHealthStatus::Ok, 0));
        assert!(hybrid_lexical_only_mode(ChannelHealthStatus::Disabled, 5));
    }

    #[test]
    fn execution_note_lexical_only_mode_matches_pipeline_guardrail_on_dropped_hits() {
        let channel_results = vec![semantic_channel_result(ChannelHealthStatus::Ok, 5, 0)];
        let note = hybrid_execution_note_from_channel_results(Some(true), true, &channel_results);
        assert_eq!(note.semantic_hit_count, 5);
        assert_eq!(note.semantic_match_count, 0);
        assert!(
            !note.lexical_only_mode,
            "a healthy semantic channel with pre-fusion hits is not lexical-only, matching guardrails"
        );
    }

    #[test]
    fn execution_note_lexical_only_mode_true_when_semantic_produced_no_hits() {
        let channel_results = vec![semantic_channel_result(ChannelHealthStatus::Ok, 0, 0)];
        let note = hybrid_execution_note_from_channel_results(Some(true), true, &channel_results);
        assert!(note.lexical_only_mode);
    }
}