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}