Skip to main content

kranz_engine/
gate_score_flags.rs

1//! Gate score distribution flags (ticket
2//! `.kranz/tickets/gate-score-distribution-flags.md`, KRZ-316 — the
3//! scored-gates addendum's second half): per gate, fold the score
4//! distribution across missions and flag two smells — scores that never
5//! approach the threshold over a meaningful sample, and near-constant scores
6//! — surfaced on the outcomes report beside the rubber-stamp signal and
7//! carried into the escalation ledger fold as a summary field.
8//!
9//! WHY this is the rubber-stamp flag's COMPLEMENT, not an alternative
10//! (presented together, always): block-to-grant timing (KRZ-323) catches an
11//! inattentive HUMAN — grants approved faster than anyone could have read
12//! them. These flags catch a mis-specified GATE — one that passes everything
13//! because its threshold is meaningless. A gate whose scores cluster far
14//! from their threshold and never approach it is either genuinely safe or
15//! measuring nothing, and the verdict alone cannot distinguish the two;
16//! neither can the flag — it ROUTES the distribution to a human, exactly
17//! like the rubber-stamp share does. A flag is never an enforcement and
18//! never re-derives a verdict ([`crate::gate`]: the stated verdict is
19//! authoritative; there is no path from score to verdict, so there is none
20//! from distribution to verdict either).
21//!
22//! The two rules, with their exact constants (each gate is assessed only at
23//! or above [`MIN_SAMPLE_COUNT`] scored evaluations):
24//!
25//! - **`never-approaches-threshold`** — the closest any score came to its
26//!   threshold (min |score − threshold| over the sample) is STRICTLY beyond
27//!   [`NEVER_APPROACHES_MARGIN`]. At the margin exactly is within reach and
28//!   does not flag. The margin is calibrated to the conventional 0.0..=1.0
29//!   score scale ([`crate::gate::GateScore`]): 0.1 is one tenth of it. A
30//!   gate defining a different scale gets the same constant — documented,
31//!   and acceptable for a smell: the flag says "nothing ever came near the
32//!   line", which only reads falsely if the gate's scale dwarfs its
33//!   threshold spacing, and the carried distribution lets the reader judge.
34//! - **`near-constant`** — the population variance (÷n) of the scores is
35//!   STRICTLY below [`NEAR_CONSTANT_VARIANCE_EPSILON`]: a standard deviation
36//!   under 0.001 on the conventional scale. At or above the epsilon does not
37//!   flag. The fold cannot see a gate's INPUT, so the rule asserts only
38//!   "the scores do not move" — whether the input ever varied is the
39//!   investigation the flag routes, not a claim the fold makes.
40//!
41//! WHY the minimum sample exists: a handful of evaluations can sit far from
42//! a threshold or agree with each other by chance; ten cannot plausibly do
43//! either without saying something about the gate. Below the minimum the
44//! fold renders the gate's assessment ABSENT — no flags and no zero-filled
45//! distribution (the house no-fabricated-numbers rule): an honest "not
46//! enough evidence", never a fabricated clean bill.
47//!
48//! WHY distance is per-point: the (score, threshold) pair travels together
49//! from each `gate.result` event (events.rs), and a gate MAY state a
50//! different threshold per evaluation — so `score - threshold` is computed
51//! per point, never against one assumed constant threshold. WHY the two
52//! surfaces pool: the same gate id is evaluated at approval and at the
53//! final gate (gate.rs); folding both into one series treats the GATE's
54//! scoring behavior as the unit, and a gate that scores differently per
55//! surface shows up as variance — biasing AWAY from `near-constant`, the
56//! conservative direction.
57//!
58//! Gates that emit no score are excluded entirely: a boolean-only gate
59//! produces no sample, so it can never reach the distribution or the flags
60//! (KRZ-315's absence-is-the-normal-case rule carried through: a gate that
61//! says nothing about confidence says NOTHING, and the flags never invent a
62//! reading for it).
63//!
64//! Pure-fold idiom, mirroring [`crate::gate_scores`] (whose per-event
65//! extraction this shares): [`score_distribution_report`] is a pure function
66//! over the collected samples, and the samples are folded from the same
67//! event logs by the caller — the outcomes fold collects them per mission
68//! through its memoized per-mission pass ([`crate::outcomes`]), so the same
69//! logs always yield an identical report. No persisted state, no clock, no
70//! reads outside the logs.
71
72use crate::events::Event;
73use crate::events::EventKind;
74use serde::{Deserialize, Serialize};
75
76/// Minimum scored evaluations before either rule assesses a gate: 10. WHY
77/// ten: below it, "all far from the threshold" or "all alike" is still a
78/// plausible accident of a young series; at it, the distribution itself is
79/// the evidence. Boundary-tested: exactly 10 assesses, 9 does not.
80pub const MIN_SAMPLE_COUNT: u64 = 10;
81
82/// The `never-approaches-threshold` margin: 0.1 — one tenth of the
83/// conventional 0.0..=1.0 score scale (see the module docs). The closest
84/// approach must be STRICTLY beyond this to flag; exactly at it is within
85/// reach.
86pub const NEVER_APPROACHES_MARGIN: f64 = 0.1;
87
88/// The `near-constant` variance epsilon: 1e-6 on the population variance —
89/// a standard deviation under 0.001 on the conventional scale (see the
90/// module docs). STRICTLY below flags; at or above does not.
91pub const NEAR_CONSTANT_VARIANCE_EPSILON: f64 = 1e-6;
92
93/// One scored evaluation of one gate: the (score, threshold) pair a
94/// `gate.result` event stated, verbatim (kranz records, never normalizes —
95/// the [`crate::gate_scores`] contract), keyed by the gate identity. The
96/// distribution fold's atom; the replay identity (mission/seq/ts/surface/
97/// verdict) the series carries is not read here, so it is not carried.
98/// Fold-internal — the report structs are the wire surface.
99#[derive(Debug, Clone, PartialEq)]
100pub struct GateScoreSample {
101    pub gate: String,
102    pub score: f64,
103    pub threshold: f64,
104}
105
106/// The smell a [`GateScoreFlag`] names. Serde kebab-case (the GateKind
107/// idiom) so the wire form IS the ticket's vocabulary.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum GateScoreFlagKind {
111    /// Over a meaningful sample, no score ever came within
112    /// [`NEVER_APPROACHES_MARGIN`] of its threshold — the threshold is
113    /// never exercised, so it cannot distinguish anything.
114    NeverApproachesThreshold,
115    /// Over a meaningful sample, the scores do not move (variance below
116    /// [`NEAR_CONSTANT_VARIANCE_EPSILON`]) — the score carries no
117    /// information about whatever the gate evaluated.
118    NearConstant,
119}
120
121impl GateScoreFlagKind {
122    /// The wire/serde form (`never-approaches-threshold`/`near-constant`)
123    /// for text surfaces (the EscalationKind::as_str idiom).
124    pub fn as_str(&self) -> &'static str {
125        match self {
126            Self::NeverApproachesThreshold => "never-approaches-threshold",
127            Self::NearConstant => "near-constant",
128        }
129    }
130}
131
132/// The score distribution of one gate over its scored evaluations: the
133/// summary a flag carries so the reader can judge the smell without
134/// re-walking the series. All f64 fields are computed over exactly
135/// `samples` points — there is no empty distribution (a gate reaches here
136/// only at or above [`MIN_SAMPLE_COUNT`]).
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct ScoreDistribution {
140    /// Scored evaluations folded (always >= [`MIN_SAMPLE_COUNT`]).
141    pub samples: u64,
142    pub min_score: f64,
143    pub max_score: f64,
144    pub mean_score: f64,
145    /// Population variance (÷n) of the scores — the `near-constant` input.
146    pub variance: f64,
147    /// Endpoints of the signed distance-to-threshold distribution
148    /// (score − threshold, per point — thresholds may vary per evaluation).
149    pub min_distance: f64,
150    pub max_distance: f64,
151    /// min |score − threshold| over the sample — how close ANY evaluation
152    /// ever came to its threshold; the `never-approaches-threshold` input.
153    pub closest_approach: f64,
154}
155
156impl ScoreDistribution {
157    /// Fold one gate's samples into its distribution. Order-independent in
158    /// value up to float summation order; the caller's sample order is fixed
159    /// by the logs, so the report is a pure function of the logs.
160    fn from_samples(samples: &[&GateScoreSample]) -> Self {
161        let n = samples.len() as f64;
162        let mut min_score = f64::INFINITY;
163        let mut max_score = f64::NEG_INFINITY;
164        let mut sum = 0.0;
165        let mut min_distance = f64::INFINITY;
166        let mut max_distance = f64::NEG_INFINITY;
167        let mut closest_approach = f64::INFINITY;
168        for sample in samples {
169            min_score = min_score.min(sample.score);
170            max_score = max_score.max(sample.score);
171            sum += sample.score;
172            let distance = sample.score - sample.threshold;
173            min_distance = min_distance.min(distance);
174            max_distance = max_distance.max(distance);
175            closest_approach = closest_approach.min(distance.abs());
176        }
177        let mean_score = sum / n;
178        let variance = samples
179            .iter()
180            .map(|s| (s.score - mean_score).powi(2))
181            .sum::<f64>()
182            / n;
183        Self {
184            samples: samples.len() as u64,
185            min_score,
186            max_score,
187            mean_score,
188            variance,
189            min_distance,
190            max_distance,
191            closest_approach,
192        }
193    }
194}
195
196/// One flag against one gate: the gate identity, the smell kind, and the
197/// distribution summary that triggered it (the ticket's contract — a flag
198/// names the gate and carries its evidence).
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct GateScoreFlag {
202    pub gate: String,
203    pub kind: GateScoreFlagKind,
204    pub distribution: ScoreDistribution,
205}
206
207/// The gate score distribution flag report (KRZ-316), folded across all
208/// missions and surfaced beside the rubber-stamp signal on the outcomes
209/// report. The rule constants ride along in effect (the RubberStampReport
210/// idiom: the wire self-describes what judged it).
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase", default)]
213pub struct GateScoreFlagsReport {
214    /// The minimum sample in effect ([`MIN_SAMPLE_COUNT`]).
215    pub min_samples: u64,
216    /// The margin in effect ([`NEVER_APPROACHES_MARGIN`]).
217    pub never_approaches_margin: f64,
218    /// The variance epsilon in effect ([`NEAR_CONSTANT_VARIANCE_EPSILON`]).
219    pub near_constant_variance_epsilon: f64,
220    /// Gates with at least one scored evaluation (the population the
221    /// assessment draws from — includes gates below the minimum sample).
222    pub scored_gates: u64,
223    /// Gates at or above the minimum sample — the ones either rule could
224    /// flag. Below the minimum a gate is unassessed, never flagged.
225    pub assessed_gates: u64,
226    /// Every flag that fired, grouped by gate (a gate's kinds adjacent —
227    /// `never-approaches-threshold` before `near-constant`), gates ordered
228    /// by identity (BTreeMap: the report's order is a function of the log,
229    /// never of hash iteration). Empty when nothing smells — never a
230    /// zero-filled row for an unassessed or unscored gate.
231    pub flags: Vec<GateScoreFlag>,
232}
233
234impl Default for GateScoreFlagsReport {
235    /// The serde-backfill / empty-history default carries the DOCUMENTED
236    /// rule constants, never zeros that would misstate what judged the
237    /// report (the RubberStampReport::default discipline).
238    fn default() -> Self {
239        Self {
240            min_samples: MIN_SAMPLE_COUNT,
241            never_approaches_margin: NEVER_APPROACHES_MARGIN,
242            near_constant_variance_epsilon: NEAR_CONSTANT_VARIANCE_EPSILON,
243            scored_gates: 0,
244            assessed_gates: 0,
245            flags: Vec::new(),
246        }
247    }
248}
249
250/// The `never-approaches-threshold` rule as one named predicate, so the
251/// strictness lives in exactly one place: STRICTLY beyond the margin flags;
252/// at the margin is within reach.
253fn never_approaches(closest_approach: f64) -> bool {
254    closest_approach > NEVER_APPROACHES_MARGIN
255}
256
257/// The `near-constant` rule as one named predicate: STRICTLY below the
258/// epsilon flags; at or above it does not.
259fn near_constant(variance: f64) -> bool {
260    variance < NEAR_CONSTANT_VARIANCE_EPSILON
261}
262
263/// Collect the scored samples from one mission's already-filtered event
264/// slice (the outcomes fold hands in its per-mission `mission_events`, whose
265/// same-mission and seq-order invariants match the
266/// [`crate::gate_scores::gate_score_series`] discipline). Only `gate.result`
267/// events carrying the score pair contribute — a boolean-only gate's events
268/// yield NO sample (absence is the normal case, never a zero), so an
269/// unscored gate can never reach the distribution or its flags.
270pub fn collect_scored_samples(mission_events: &[&Event]) -> Vec<GateScoreSample> {
271    let mut samples = Vec::new();
272    for event in mission_events {
273        let EventKind::GateResult {
274            gate,
275            score: Some(score),
276            threshold: Some(threshold),
277            ..
278        } = &event.kind
279        else {
280            continue;
281        };
282        samples.push(GateScoreSample {
283            gate: gate.clone(),
284            score: *score,
285            threshold: *threshold,
286        });
287    }
288    samples
289}
290
291/// The pure fold: every scored sample grouped by gate, each sufficiently
292/// sampled gate's distribution computed and judged against the two rules.
293/// Gates below [`MIN_SAMPLE_COUNT`] count toward `scored_gates` only — no
294/// assessment, no flags. The same samples in the same order always yield an
295/// identical report (no clock, no hash iteration).
296pub fn score_distribution_report(samples: &[GateScoreSample]) -> GateScoreFlagsReport {
297    let mut by_gate: std::collections::BTreeMap<&str, Vec<&GateScoreSample>> =
298        std::collections::BTreeMap::new();
299    for sample in samples {
300        by_gate
301            .entry(sample.gate.as_str())
302            .or_default()
303            .push(sample);
304    }
305
306    let mut assessed_gates = 0;
307    let mut flags = Vec::new();
308    for (gate, gate_samples) in &by_gate {
309        if (gate_samples.len() as u64) < MIN_SAMPLE_COUNT {
310            continue;
311        }
312        assessed_gates += 1;
313        let distribution = ScoreDistribution::from_samples(gate_samples);
314        if never_approaches(distribution.closest_approach) {
315            flags.push(GateScoreFlag {
316                gate: gate.to_string(),
317                kind: GateScoreFlagKind::NeverApproachesThreshold,
318                distribution: distribution.clone(),
319            });
320        }
321        if near_constant(distribution.variance) {
322            flags.push(GateScoreFlag {
323                gate: gate.to_string(),
324                kind: GateScoreFlagKind::NearConstant,
325                distribution,
326            });
327        }
328    }
329
330    GateScoreFlagsReport {
331        scored_gates: by_gate.len() as u64,
332        assessed_gates,
333        flags,
334        ..GateScoreFlagsReport::default()
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::gate::{GateKind, GateSurface, GateVerdict};
342    use chrono::DateTime;
343
344    fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
345        Event {
346            seq,
347            ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
348            mission_id: mission_id.to_string(),
349            kind,
350        }
351    }
352
353    /// A `gate.result` payload; `score` is the (score, threshold) pair a
354    /// scored gate reports, `None` for a boolean-only gate (the
355    /// gate_scores.rs fixture idiom).
356    fn gate_result(gate: &str, verdict: GateVerdict, score: Option<(f64, f64)>) -> EventKind {
357        EventKind::GateResult {
358            gate: gate.to_string(),
359            surface: GateSurface::Approval,
360            kind: GateKind::Deterministic,
361            index: 0,
362            verdict,
363            artefact_ref: format!("contract gate {gate}"),
364            artefact_detail: None,
365            score: score.map(|(score, _)| score),
366            threshold: score.map(|(_, threshold)| threshold),
367            rule_ids: Vec::new(),
368        }
369    }
370
371    fn sample(gate: &str, score: f64, threshold: f64) -> GateScoreSample {
372        GateScoreSample {
373            gate: gate.to_string(),
374            score,
375            threshold,
376        }
377    }
378
379    /// N identical samples for one gate — the unmoving-series fixture.
380    fn repeated(gate: &str, n: usize, score: f64, threshold: f64) -> Vec<GateScoreSample> {
381        (0..n).map(|_| sample(gate, score, threshold)).collect()
382    }
383
384    fn kinds_of(report: &GateScoreFlagsReport, gate: &str) -> Vec<GateScoreFlagKind> {
385        report
386            .flags
387            .iter()
388            .filter(|f| f.gate == gate)
389            .map(|f| f.kind)
390            .collect()
391    }
392
393    /// The distribution fold itself: min/max/mean score, population
394    /// variance, and the per-point signed distance endpoints plus the
395    /// closest approach — over values exact in f64 so the assertions are
396    /// arithmetic, not approximations. Scores 0.0/0.25/0.5/0.75/1.0 twice
397    /// against threshold 0.5: mean 0.5, variance 0.125 (Σ(s−mean)²/n =
398    /// 1.25/10), distances -0.5..0.5, closest 0.0 — a series that both
399    /// approaches its threshold and moves, so neither flag fires.
400    #[test]
401    fn score_distribution_flag_stats_fold_min_max_mean_variance_distances() {
402        let mut samples = Vec::new();
403        for score in [0.0, 0.25, 0.5, 0.75, 1.0] {
404            samples.push(sample("vacuous-filter", score, 0.5));
405            samples.push(sample("vacuous-filter", score, 0.5));
406        }
407        let report = score_distribution_report(&samples);
408        assert_eq!(report.scored_gates, 1);
409        assert_eq!(report.assessed_gates, 1);
410        assert!(
411            report.flags.is_empty(),
412            "this spread approaches the threshold and moves: {report:?}"
413        );
414        // The distribution the report WOULD carry is observable through a
415        // flag; fold the same samples directly for the stats assertions.
416        let distribution = ScoreDistribution::from_samples(&samples.iter().collect::<Vec<_>>());
417        assert_eq!(distribution.samples, 10);
418        assert_eq!(distribution.min_score, 0.0);
419        assert_eq!(distribution.max_score, 1.0);
420        assert_eq!(distribution.mean_score, 0.5);
421        assert_eq!(distribution.variance, 0.125);
422        assert_eq!(distribution.min_distance, -0.5);
423        assert_eq!(distribution.max_distance, 0.5);
424        assert_eq!(distribution.closest_approach, 0.0);
425    }
426
427    /// never-approaches-threshold: ten evaluations, none closer than 0.5 to
428    /// the threshold — the flag fires, names the gate, and carries the
429    /// distribution that triggered it (the ticket's contract).
430    #[test]
431    fn score_distribution_flag_never_approaches_fires_and_carries_evidence() {
432        let report = score_distribution_report(&repeated("vacuous-filter", 10, 0.5, 1.0));
433        assert_eq!(
434            kinds_of(&report, "vacuous-filter"),
435            [
436                GateScoreFlagKind::NeverApproachesThreshold,
437                GateScoreFlagKind::NearConstant,
438            ]
439        );
440        let flag = &report.flags[0];
441        assert_eq!(flag.gate, "vacuous-filter");
442        assert_eq!(flag.kind.as_str(), "never-approaches-threshold");
443        let d = &flag.distribution;
444        assert_eq!(d.samples, 10);
445        assert_eq!(d.closest_approach, 0.5);
446        assert_eq!(d.min_score, 0.5);
447        assert_eq!(d.max_score, 0.5);
448        assert_eq!(d.mean_score, 0.5);
449        assert_eq!(d.variance, 0.0);
450        assert_eq!(d.min_distance, -0.5);
451        assert_eq!(d.max_distance, -0.5);
452    }
453
454    /// The CLOSEST score governs, not the bulk: nine evaluations far from
455    /// the threshold and one within the margin means the threshold WAS
456    /// approached — no flag. (0.95 vs 1.0: distance 0.05 < 0.1.)
457    #[test]
458    fn score_distribution_flag_never_approaches_closest_score_within_margin_clears() {
459        let mut samples = repeated("vacuous-filter", 9, 0.5, 1.0);
460        samples.push(sample("vacuous-filter", 0.95, 1.0));
461        let report = score_distribution_report(&samples);
462        assert_eq!(report.assessed_gates, 1);
463        assert!(
464            !kinds_of(&report, "vacuous-filter")
465                .contains(&GateScoreFlagKind::NeverApproachesThreshold),
466            "one approach within the margin clears the smell: {report:?}"
467        );
468        // And the moving scores keep near-constant off too.
469        assert!(report.flags.is_empty(), "{report:?}");
470    }
471
472    /// The margin is a STRICT boundary (documented): a closest approach
473    /// exactly AT the margin is within reach and does not flag; beyond it
474    /// does. 0.1 − 0.0 is exactly the margin constant in f64, so the at-case
475    /// is exact, not approximate.
476    #[test]
477    fn score_distribution_flag_never_approaches_margin_boundary_is_strict() {
478        // Predicate level: at the margin does not flag, beyond does.
479        assert!(!never_approaches(NEVER_APPROACHES_MARGIN));
480        assert!(never_approaches(NEVER_APPROACHES_MARGIN * 2.0));
481
482        // Fold level: every score exactly the margin away (threshold 0.0,
483        // score 0.1) — no flag; every score 0.2 away — flag.
484        let at = score_distribution_report(&repeated("gate-a", 10, 0.1, 0.0));
485        assert!(
486            !kinds_of(&at, "gate-a").contains(&GateScoreFlagKind::NeverApproachesThreshold),
487            "at the margin is within reach: {at:?}"
488        );
489        let beyond = score_distribution_report(&repeated("gate-a", 10, 0.2, 0.0));
490        assert!(
491            kinds_of(&beyond, "gate-a").contains(&GateScoreFlagKind::NeverApproachesThreshold),
492            "beyond the margin flags: {beyond:?}"
493        );
494    }
495
496    /// near-constant: ten identical scores — the series does not move at
497    /// all, whatever the input was — flag fires with variance 0.0 carried.
498    #[test]
499    fn score_distribution_flag_near_constant_fires_on_unmoving_scores() {
500        let report = score_distribution_report(&repeated("vacuous-filter", 10, 0.75, 1.0));
501        assert_eq!(
502            kinds_of(&report, "vacuous-filter"),
503            [
504                GateScoreFlagKind::NeverApproachesThreshold,
505                GateScoreFlagKind::NearConstant,
506            ]
507        );
508        let flag = report
509            .flags
510            .iter()
511            .find(|f| f.kind == GateScoreFlagKind::NearConstant)
512            .unwrap();
513        assert_eq!(flag.gate, "vacuous-filter");
514        assert_eq!(flag.kind.as_str(), "near-constant");
515        assert_eq!(flag.distribution.variance, 0.0);
516        // A gate's two kinds land adjacent, never-approaches first — the
517        // documented grouping the text surface relies on.
518        assert_eq!(
519            report.flags[0].kind,
520            GateScoreFlagKind::NeverApproachesThreshold
521        );
522        assert_eq!(report.flags[1].kind, GateScoreFlagKind::NearConstant);
523    }
524
525    /// The variance epsilon is a STRICT boundary (documented): variance
526    /// exactly at the epsilon does not flag; below does. Fold level: scores
527    /// alternating 0.0/0.5 (variance 0.0625, far above the epsilon) stay
528    /// clear of near-constant while still firing never-approaches — proving
529    /// the two rules judge independently.
530    #[test]
531    fn score_distribution_flag_near_constant_epsilon_boundary_is_strict() {
532        // Predicate level: at the epsilon does not flag, below does.
533        assert!(!near_constant(NEAR_CONSTANT_VARIANCE_EPSILON));
534        assert!(near_constant(NEAR_CONSTANT_VARIANCE_EPSILON / 2.0));
535
536        // Fold level: a moving series (0.0/0.5 alternating vs threshold
537        // 0.25 — distances ±0.25, variance 0.0625).
538        let mut samples = Vec::new();
539        for i in 0..10 {
540            let score = if i % 2 == 0 { 0.0 } else { 0.5 };
541            samples.push(sample("gate-b", score, 0.25));
542        }
543        let report = score_distribution_report(&samples);
544        assert_eq!(
545            kinds_of(&report, "gate-b"),
546            [GateScoreFlagKind::NeverApproachesThreshold]
547        );
548    }
549
550    /// The minimum sample gates assessment, boundary-tested: nine identical
551    /// far-from-threshold scores fold to an UNASSESSED gate — scored but
552    /// with no flags and no distribution — and the tenth identical score
553    /// turns the same series into a double flag. Below the minimum the
554    /// report is absent, never a zero-filled clean bill.
555    #[test]
556    fn score_distribution_flag_minimum_sample_boundary_at_and_under() {
557        let under = score_distribution_report(&repeated(
558            "vacuous-filter",
559            (MIN_SAMPLE_COUNT - 1) as usize,
560            0.5,
561            1.0,
562        ));
563        assert_eq!(under.scored_gates, 1, "the gate IS counted as scored");
564        assert_eq!(under.assessed_gates, 0, "but never assessed");
565        assert!(under.flags.is_empty(), "no flags below the minimum");
566
567        let at = score_distribution_report(&repeated(
568            "vacuous-filter",
569            MIN_SAMPLE_COUNT as usize,
570            0.5,
571            1.0,
572        ));
573        assert_eq!(at.assessed_gates, 1);
574        assert_eq!(at.flags.len(), 2, "both rules fire at the minimum");
575    }
576
577    /// Gates that emit no score are excluded, never flagged: boolean-only
578    /// `gate.result` events (and non-gate events) yield no samples, so the
579    /// report has no population and no flags — and in a mixed log the
580    /// unscored gate appears NOWHERE while the scored one is judged.
581    #[test]
582    fn score_distribution_flag_unscored_gates_excluded_never_flagged() {
583        let events = [
584            ev(
585                1,
586                "m-1",
587                1_000,
588                gate_result("env-sensitive", GateVerdict::Pass, None),
589            ),
590            ev(
591                2,
592                "m-1",
593                2_000,
594                gate_result("env-sensitive", GateVerdict::Fail, None),
595            ),
596            ev(3, "m-1", 3_000, EventKind::MissionCompleted {}),
597        ];
598        let refs: Vec<&Event> = events.iter().collect();
599        let samples = collect_scored_samples(&refs);
600        assert!(samples.is_empty(), "an unscored gate emits no sample");
601        let report = score_distribution_report(&samples);
602        assert_eq!(report.scored_gates, 0);
603        assert_eq!(report.assessed_gates, 0);
604        assert!(report.flags.is_empty());
605
606        // Mixed: ten scored events for one gate beside the unscored one.
607        let mut events = Vec::new();
608        for i in 0..10 {
609            events.push(ev(
610                i + 1,
611                "m-1",
612                1_000 + i as i64,
613                gate_result("vacuous-filter", GateVerdict::Pass, Some((0.5, 1.0))),
614            ));
615        }
616        events.push(ev(
617            11,
618            "m-1",
619            2_000,
620            gate_result("env-sensitive", GateVerdict::Pass, None),
621        ));
622        let refs: Vec<&Event> = events.iter().collect();
623        let samples = collect_scored_samples(&refs);
624        assert_eq!(samples.len(), 10);
625        let report = score_distribution_report(&samples);
626        assert_eq!(report.scored_gates, 1);
627        assert_eq!(report.assessed_gates, 1);
628        assert!(
629            report.flags.iter().all(|f| f.gate == "vacuous-filter"),
630            "the unscored gate is never named: {report:?}"
631        );
632    }
633
634    /// Pure-fold contract: the same samples fold to a byte-identical report
635    /// on repetition (no clock, no hash iteration) — and the wire form
636    /// carries the rule constants that judged it.
637    #[test]
638    fn score_distribution_flag_pure_fold_repeat_is_byte_identical() {
639        let mut samples = repeated("vacuous-filter", 10, 0.5, 1.0);
640        samples.extend(repeated("other-gate", 10, 0.95, 1.0));
641        let first = score_distribution_report(&samples);
642        let second = score_distribution_report(&samples);
643        assert_eq!(first, second);
644        assert_eq!(
645            serde_json::to_string(&first).unwrap(),
646            serde_json::to_string(&second).unwrap(),
647        );
648        assert_eq!(first.min_samples, MIN_SAMPLE_COUNT);
649        assert_eq!(first.never_approaches_margin, NEVER_APPROACHES_MARGIN);
650        assert_eq!(
651            first.near_constant_variance_epsilon,
652            NEAR_CONSTANT_VARIANCE_EPSILON
653        );
654        // Gate order on the wire is the BTreeMap (identity) order.
655        let json = serde_json::to_value(&first).unwrap();
656        let kinds: Vec<&str> = json["flags"]
657            .as_array()
658            .unwrap()
659            .iter()
660            .map(|f| f["kind"].as_str().unwrap())
661            .collect();
662        assert!(
663            kinds.contains(&"never-approaches-threshold"),
664            "kebab-case wire kind: {kinds:?}"
665        );
666    }
667}