Skip to main content

sharpebench_core/
decision_stability.rs

1//! Decision stability: how often an agent decides differently when it saw the
2//! same observations.
3//!
4//! pass^k across execution seeds certifies that an outcome repeats. It cannot
5//! tell an agent whose decisions are a fixed function of what it saw from one
6//! that samples a different choice each time it is asked, because execution
7//! noise moves the fills, and with them every later observation, before the two
8//! can be compared. This module measures the second property where it is
9//! measurable: over replicate runs of one window (execution seeds of the window,
10//! or repeated captures of the same seed), at the steps where the replicates
11//! share their history.
12//!
13//! # Grouping
14//!
15//! Every step carries the SHA-256 of the observation it answered, taken over the
16//! `sharpebench/canonical-json/v1` framed pre-image ([`observation_sha256`]).
17//! Replicate runs of one window start in one group. At each step a group is split
18//! by that step's observation digest, the members are compared, and each part is
19//! then split again by the decision its members made. A group at step `t`
20//! therefore holds the replicates that were shown equal observations at `t` and
21//! at every earlier step, and made the same decisions at every earlier step.
22//!
23//! The observation history is required because the determinism contract lets a
24//! decision depend on the run's earlier observations: two replicates that meet on
25//! one observation after their histories differed may decide differently without
26//! any non-determinism. The decision history is required because the contract
27//! also lets a decision depend on the agent's own earlier decisions, and because
28//! a difference the engine does not act on (a confidence, an action label, a
29//! restated target) leaves the observations equal. Without it one such
30//! difference would be counted again at every later step. Under this rule an
31//! agent that honours the contract reports exactly zero.
32//!
33//! A replicate whose observation history no other member shares leaves the
34//! comparison for the rest of the window, and its remaining steps are counted in
35//! `steps_excluded_diverged_observation`. A replicate whose decision no other
36//! member shared stays compared at that step and leaves afterwards, counted in
37//! `steps_excluded_diverged_decision`. For every window with at least two
38//! replicates, `steps_compared` plus the two excluded counts equals
39//! `steps_total`. A window with a single replicate has nothing to compare
40//! against: its steps are counted in `steps_unreplicated` and its rate is typed
41//! unavailable.
42//!
43//! # What "differ" means
44//!
45//! Two decisions differ when their `orders` arrays have different lengths, or
46//! when the orders at any one position differ in `symbol`, `action`,
47//! `target_weight` or `confidence` ([`same_decision`]). A stated confidence
48//! differs from an unstated one. The fields are the score-bearing order fields of
49//! the re-execution contract, and they are compared exactly: a tolerance on
50//! `target_weight` would be a free parameter the contract does not define, and
51//! the engine executes the exact weight it is given. The one numeric relaxation is
52//! that `0` and `-0` are equal, as they are in the canonical form the observation
53//! digest uses. `reasoning` and each order's `rationale` are audit text, and the
54//! `cost` report is spend rather than choice (token counts can vary between
55//! samples that choose the same orders), so none of the three is compared.
56//!
57//! # The rate
58//!
59//! The headline rate is pairwise disagreement: over every compared group, the
60//! number of replicate pairs whose decisions differ, divided by the number of
61//! replicate pairs, `n (n - 1) / 2` for a group of `n`. If replicates choose
62//! independently with probabilities `p_j`, a pair differs with probability
63//! `1 - sum p_j^2` whatever the group size, so the rate does not move with how
64//! many captures were supplied or how long histories stay shared. The share of
65//! groups that differ does move with group size (`1 - (1 - q)^n - q^n` for a
66//! binary choice with flip probability `q`), so the group counts and the
67//! group-size distribution are reported as context only.
68//!
69//! The independent unit is the replicate run. Groups and pairs at later steps
70//! reuse the same runs, so their counts are not sample sizes, and the rate
71//! carries no interval.
72//!
73//! # Copies
74//!
75//! A replicate carries an identity of its recorded content. Two replicates of
76//! one window with equal identities agree by construction if one is a copy of
77//! the other, so the report counts them and refuses them unless the caller
78//! declares that they are separate executions (a deterministic agent captured
79//! twice produces equal content).
80//!
81//! # Rank neutrality
82//!
83//! The report is a separate document with `rank_input: false`. Nothing in
84//! [`crate::composite`] or any gate reads it.
85
86use std::collections::{BTreeMap, BTreeSet};
87use std::fmt;
88
89use serde::Serialize;
90use serde_json::Value;
91use sha2::{Digest, Sha256};
92use sharpebench_protocol::canonical::{versioned_preimage, CanonicalError, CANONICAL_JSON_VERSION};
93use sharpebench_protocol::{Decision, MarketObservation, Order};
94
95/// Schema identifier of [`DecisionStabilityReport`].
96pub const DECISION_STABILITY_SCHEMA: &str = "sharpebench.decision-stability.v1";
97
98/// How [`observation_sha256`] identifies an observation, as written into the report.
99pub const OBSERVATION_DIGEST_RULE: &str =
100    "sha256 over the sharpebench/canonical-json/v1 framed preimage of the MarketObservation";
101
102/// Which steps are compared, as written into the report.
103pub const GROUPING_RULE: &str = "a group is two or more replicate runs of one window that were shown equal observations at this step and at every earlier step, and made the same decisions at every earlier step";
104
105/// When two decisions differ, as written into the report.
106pub const DECISION_DIFFERENCE_RULE: &str = "decisions differ when their orders arrays differ in length or when any position differs in symbol, action, target_weight or confidence (a stated confidence differs from an unstated one), numbers compared exactly with 0 equal to -0; reasoning, rationale and cost are not compared";
107
108/// How the headline rate is computed, as written into the report.
109pub const RATE_RULE: &str = "pairwise disagreement: replicate pairs with differing decisions over replicate pairs, summed over compared groups, where a group of n replicates holds n(n-1)/2 pairs";
110
111/// What the counts are counts of, as written into the report.
112pub const SAMPLING_UNIT: &str = "the replicate run: groups and pairs at later steps reuse the same runs, so their counts are not sample sizes, and the rate carries no interval";
113
114/// SHA-256 of an observation's `sharpebench/canonical-json/v1` framed pre-image,
115/// as lowercase hex.
116///
117/// `serde_json` writes a non-finite `f64` as `null`, which would give NaN and
118/// both infinities one digest. [`MarketObservation`] has no optional field, so a
119/// `null` in its JSON value can only be such a number, and the observation is
120/// refused as having no canonical form instead.
121pub fn observation_sha256(observation: &MarketObservation) -> Result<String, CanonicalError> {
122    let value = serde_json::to_value(observation).expect("market observations serialize");
123    if contains_null(&value) {
124        return Err(CanonicalError::NonFinite(format!(
125            "number in the observation dated {}",
126            observation.date
127        )));
128    }
129    let preimage = versioned_preimage(&value)?;
130    Ok(crate::lower_hex(&Sha256::digest(&preimage)))
131}
132
133fn contains_null(value: &Value) -> bool {
134    match value {
135        Value::Null => true,
136        Value::Array(items) => items.iter().any(contains_null),
137        Value::Object(members) => members.values().any(contains_null),
138        _ => false,
139    }
140}
141
142/// Whether two decisions are the same choice under [`DECISION_DIFFERENCE_RULE`].
143pub fn same_decision(left: &Decision, right: &Decision) -> bool {
144    left.orders.len() == right.orders.len()
145        && left
146            .orders
147            .iter()
148            .zip(&right.orders)
149            .all(|(left, right)| same_order(left, right))
150}
151
152fn same_order(left: &Order, right: &Order) -> bool {
153    left.symbol == right.symbol
154        && left.action == right.action
155        && same_number(left.target_weight, right.target_weight)
156        && same_confidence(left.confidence, right.confidence)
157}
158
159fn same_confidence(left: Option<f64>, right: Option<f64>) -> bool {
160    match (left, right) {
161        (Some(left), Some(right)) => same_number(left, right),
162        (None, None) => true,
163        _ => false,
164    }
165}
166
167/// Numeric equality, so `0 == -0`, or identical bits, so a NaN a deterministic
168/// agent repeats is not counted as a change of mind.
169fn same_number(left: f64, right: f64) -> bool {
170    left == right || left.to_bits() == right.to_bits()
171}
172
173/// One recorded decision and the digest of the observation it answered.
174#[derive(Clone, Debug)]
175pub struct ObservedDecision<'a> {
176    pub observation_sha256: String,
177    pub decision: &'a Decision,
178}
179
180/// One replicate run of a window: a decision for every step, in step order.
181#[derive(Clone, Debug)]
182pub struct ReplicateRun<'a> {
183    /// Inclusive window start.
184    pub window_start: usize,
185    /// Exclusive window end.
186    pub window_end: usize,
187    /// Identity of the run's recorded content, such as a digest of its bytes.
188    /// Two runs of one window with equal identities are counted as identical
189    /// replicates.
190    pub content_sha256: String,
191    pub steps: Vec<ObservedDecision<'a>>,
192}
193
194/// Whether replicate runs with identical content may be measured.
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub enum IdenticalReplicates {
197    /// Refuse them: a copy of a capture agrees with it by construction.
198    Refused,
199    /// The caller declares them separate executions, as two captures of a
200    /// deterministic agent are.
201    Declared,
202}
203
204/// Input the report refuses rather than measuring.
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum DecisionStabilityError {
207    /// A run does not carry one decision for every step of its window, so its
208    /// later steps could not be lined up with the other replicates.
209    IncompleteRun {
210        run: usize,
211        recorded: usize,
212        required: usize,
213    },
214    /// A window holds replicate runs with identical content and the caller did
215    /// not declare them separate executions.
216    UndeclaredIdenticalReplicates {
217        window_start: usize,
218        window_end: usize,
219        identical: usize,
220    },
221}
222
223impl fmt::Display for DecisionStabilityError {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        match self {
226            Self::IncompleteRun {
227                run,
228                recorded,
229                required,
230            } => write!(
231                f,
232                "replicate run {run} records {recorded} of the {required} decisions its window requires"
233            ),
234            Self::UndeclaredIdenticalReplicates {
235                window_start,
236                window_end,
237                identical,
238            } => write!(
239                f,
240                "window [{window_start}, {window_end}) holds {identical} replicate runs identical to another replicate; a copied capture agrees with itself, so declare them separate executions or remove the copies"
241            ),
242        }
243    }
244}
245
246impl std::error::Error for DecisionStabilityError {}
247
248/// Why a stability rate has no value.
249#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
250#[serde(rename_all = "snake_case")]
251pub enum StabilityUnavailable {
252    /// No window has two or more replicate runs.
253    SingleReplicate,
254    /// Replicates exist, but no step's history was shared by two of them.
255    NoMatchedObservation,
256}
257
258/// The pairwise disagreement rate, or why there is none.
259#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
260#[serde(tag = "status", rename_all = "snake_case")]
261pub enum StabilityRate {
262    /// `differing_pairs / pairs_compared`, in `[0, 1]`.
263    Available {
264        value: f64,
265    },
266    Unavailable {
267        reason: StabilityUnavailable,
268    },
269}
270
271/// Step, group and pair counts, for one window or summed over all windows.
272#[derive(Clone, Debug, PartialEq, Serialize)]
273pub struct StabilityCounts {
274    /// Every recorded step of every replicate run counted here.
275    pub steps_total: usize,
276    /// Steps that belong to a compared group.
277    pub steps_compared: usize,
278    /// Steps of a replicate whose observation history no other replicate
279    /// shared.
280    pub steps_excluded_diverged_observation: usize,
281    /// Steps after a replicate made a decision no other member of its group
282    /// made.
283    pub steps_excluded_diverged_decision: usize,
284    /// Steps of a window that has a single replicate run.
285    pub steps_unreplicated: usize,
286    /// Replicate runs whose content identity equals an earlier run's in the
287    /// same window.
288    pub identical_replicate_runs: usize,
289    /// Context only: the share of differing groups depends on group size.
290    pub groups_compared: usize,
291    /// Context only.
292    pub groups_with_differing_decisions: usize,
293    /// Compared groups by their number of replicates.
294    pub group_sizes: BTreeMap<usize, usize>,
295    pub pairs_compared: usize,
296    pub differing_pairs: usize,
297    /// The headline rate.
298    pub pairwise_disagreement: StabilityRate,
299}
300
301/// A compared group whose members did not all decide the same.
302#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
303pub struct DifferingGroup {
304    /// 0-based step within the window.
305    pub step: usize,
306    pub observation_sha256: String,
307    /// Replicate runs in the group.
308    pub replicates: usize,
309    /// Distinct decisions among them, at least two.
310    pub distinct_decisions: usize,
311    /// Replicate pairs in the group whose decisions differ.
312    pub differing_pairs: usize,
313}
314
315/// Decision stability over the replicate runs of one window.
316#[derive(Clone, Debug, PartialEq, Serialize)]
317pub struct WindowStability {
318    pub window_start: usize,
319    pub window_end: usize,
320    /// Replicate runs of the window: the independent unit.
321    pub replicates: usize,
322    #[serde(flatten)]
323    pub counts: StabilityCounts,
324    /// Every differing group, in step order.
325    pub differing_groups: Vec<DifferingGroup>,
326}
327
328/// Rank-neutral decision-stability report for one entrant.
329#[derive(Clone, Debug, PartialEq, Serialize)]
330pub struct DecisionStabilityReport {
331    pub schema: String,
332    pub agent_id: String,
333    /// Always `false`: no gate or rank reads this report.
334    pub rank_input: bool,
335    pub observation_digest: String,
336    pub grouping: String,
337    pub decision_difference: String,
338    pub rate: String,
339    pub sampling_unit: String,
340    /// Whether the caller declared identical replicate runs separate executions.
341    pub identical_replicates_declared: bool,
342    pub replicate_runs: usize,
343    /// Sums over `windows`. The rate is unavailable only when every window's is.
344    #[serde(flatten)]
345    pub totals: StabilityCounts,
346    /// One entry per distinct `(window_start, window_end)`, in ascending order.
347    pub windows: Vec<WindowStability>,
348}
349
350/// Build the report over `runs`, grouping them into windows by their
351/// `(window_start, window_end)`.
352///
353/// Every run must carry exactly `window_end - window_start` decisions. The
354/// digests are compared as given, so every run must have been digested the same
355/// way; [`observation_sha256`] is the rule this crate's callers use. A window
356/// holding runs with equal `content_sha256` is refused unless `identical` is
357/// [`IdenticalReplicates::Declared`].
358pub fn decision_stability(
359    agent_id: &str,
360    runs: &[ReplicateRun<'_>],
361    identical: IdenticalReplicates,
362) -> Result<DecisionStabilityReport, DecisionStabilityError> {
363    for (index, run) in runs.iter().enumerate() {
364        let required = run.window_end.saturating_sub(run.window_start);
365        if run.steps.len() != required {
366            return Err(DecisionStabilityError::IncompleteRun {
367                run: index,
368                recorded: run.steps.len(),
369                required,
370            });
371        }
372    }
373
374    let mut by_window: BTreeMap<(usize, usize), Vec<&ReplicateRun<'_>>> = BTreeMap::new();
375    for run in runs {
376        by_window
377            .entry((run.window_start, run.window_end))
378            .or_default()
379            .push(run);
380    }
381    let windows: Vec<WindowStability> = by_window
382        .into_iter()
383        .map(|((start, end), replicates)| window_stability(start, end, &replicates))
384        .collect();
385
386    if identical == IdenticalReplicates::Refused {
387        if let Some(window) = windows
388            .iter()
389            .find(|window| window.counts.identical_replicate_runs > 0)
390        {
391            return Err(DecisionStabilityError::UndeclaredIdenticalReplicates {
392                window_start: window.window_start,
393                window_end: window.window_end,
394                identical: window.counts.identical_replicate_runs,
395            });
396        }
397    }
398
399    let mut totals = empty_counts(0);
400    for window in &windows {
401        let counts = &window.counts;
402        totals.steps_total += counts.steps_total;
403        totals.steps_compared += counts.steps_compared;
404        totals.steps_excluded_diverged_observation += counts.steps_excluded_diverged_observation;
405        totals.steps_excluded_diverged_decision += counts.steps_excluded_diverged_decision;
406        totals.steps_unreplicated += counts.steps_unreplicated;
407        totals.identical_replicate_runs += counts.identical_replicate_runs;
408        totals.groups_compared += counts.groups_compared;
409        totals.groups_with_differing_decisions += counts.groups_with_differing_decisions;
410        for (&size, &groups) in &counts.group_sizes {
411            *totals.group_sizes.entry(size).or_insert(0) += groups;
412        }
413        totals.pairs_compared += counts.pairs_compared;
414        totals.differing_pairs += counts.differing_pairs;
415    }
416    let reason = if windows.iter().any(|window| window.replicates >= 2) {
417        StabilityUnavailable::NoMatchedObservation
418    } else {
419        StabilityUnavailable::SingleReplicate
420    };
421    totals.pairwise_disagreement = rate(totals.pairs_compared, totals.differing_pairs, reason);
422
423    Ok(DecisionStabilityReport {
424        schema: DECISION_STABILITY_SCHEMA.to_string(),
425        agent_id: agent_id.to_string(),
426        rank_input: false,
427        observation_digest: format!("{OBSERVATION_DIGEST_RULE} ({CANONICAL_JSON_VERSION})"),
428        grouping: GROUPING_RULE.to_string(),
429        decision_difference: DECISION_DIFFERENCE_RULE.to_string(),
430        rate: RATE_RULE.to_string(),
431        sampling_unit: SAMPLING_UNIT.to_string(),
432        identical_replicates_declared: identical == IdenticalReplicates::Declared,
433        replicate_runs: runs.len(),
434        totals,
435        windows,
436    })
437}
438
439fn empty_counts(steps_total: usize) -> StabilityCounts {
440    StabilityCounts {
441        steps_total,
442        steps_compared: 0,
443        steps_excluded_diverged_observation: 0,
444        steps_excluded_diverged_decision: 0,
445        steps_unreplicated: 0,
446        identical_replicate_runs: 0,
447        groups_compared: 0,
448        groups_with_differing_decisions: 0,
449        group_sizes: BTreeMap::new(),
450        pairs_compared: 0,
451        differing_pairs: 0,
452        pairwise_disagreement: StabilityRate::Unavailable {
453            reason: StabilityUnavailable::SingleReplicate,
454        },
455    }
456}
457
458fn rate(pairs: usize, differing: usize, reason: StabilityUnavailable) -> StabilityRate {
459    if pairs == 0 {
460        StabilityRate::Unavailable { reason }
461    } else {
462        StabilityRate::Available {
463            value: differing as f64 / pairs as f64,
464        }
465    }
466}
467
468/// Unordered pairs among `n` replicates.
469fn pairs_among(n: usize) -> usize {
470    n * (n - 1) / 2
471}
472
473fn window_stability(start: usize, end: usize, runs: &[&ReplicateRun<'_>]) -> WindowStability {
474    let steps = end.saturating_sub(start);
475    let mut counts = empty_counts(runs.len() * steps);
476    let distinct_contents: BTreeSet<&str> =
477        runs.iter().map(|run| run.content_sha256.as_str()).collect();
478    counts.identical_replicate_runs = runs.len() - distinct_contents.len();
479    let mut differing_groups = Vec::new();
480    if runs.len() < 2 {
481        counts.steps_unreplicated = counts.steps_total;
482        return WindowStability {
483            window_start: start,
484            window_end: end,
485            replicates: runs.len(),
486            counts,
487            differing_groups,
488        };
489    }
490
491    let mut groups: Vec<Vec<usize>> = vec![(0..runs.len()).collect()];
492    for step in 0..steps {
493        let mut refined = Vec::with_capacity(groups.len());
494        for group in &groups {
495            let mut by_digest: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
496            for &member in group {
497                by_digest
498                    .entry(runs[member].steps[step].observation_sha256.as_str())
499                    .or_default()
500                    .push(member);
501            }
502            for (digest, members) in by_digest {
503                if members.len() < 2 {
504                    // No other replicate shares this history, now or later.
505                    counts.steps_excluded_diverged_observation += steps - step;
506                    continue;
507                }
508                let classes =
509                    decision_classes(&members, |member| runs[member].steps[step].decision);
510                let pairs = pairs_among(members.len());
511                let agreeing: usize = classes.iter().map(|class| pairs_among(class.len())).sum();
512                counts.steps_compared += members.len();
513                counts.groups_compared += 1;
514                *counts.group_sizes.entry(members.len()).or_insert(0) += 1;
515                counts.pairs_compared += pairs;
516                counts.differing_pairs += pairs - agreeing;
517                if classes.len() > 1 {
518                    counts.groups_with_differing_decisions += 1;
519                    differing_groups.push(DifferingGroup {
520                        step,
521                        observation_sha256: digest.to_string(),
522                        replicates: members.len(),
523                        distinct_decisions: classes.len(),
524                        differing_pairs: pairs - agreeing,
525                    });
526                }
527                for class in classes {
528                    if class.len() < 2 {
529                        // Compared at this step; its decision history is now
530                        // its own for the steps that follow.
531                        counts.steps_excluded_diverged_decision += steps - step - 1;
532                    } else {
533                        refined.push(class);
534                    }
535                }
536            }
537        }
538        groups = refined;
539    }
540    counts.pairwise_disagreement = rate(
541        counts.pairs_compared,
542        counts.differing_pairs,
543        StabilityUnavailable::NoMatchedObservation,
544    );
545    WindowStability {
546        window_start: start,
547        window_end: end,
548        replicates: runs.len(),
549        counts,
550        differing_groups,
551    }
552}
553
554/// `members` partitioned by [`same_decision`], each class in first-appearance order.
555fn decision_classes<'a>(
556    members: &[usize],
557    decision_of: impl Fn(usize) -> &'a Decision,
558) -> Vec<Vec<usize>> {
559    let mut classes: Vec<Vec<usize>> = Vec::new();
560    for &member in members {
561        let decision = decision_of(member);
562        match classes
563            .iter_mut()
564            .find(|class| same_decision(decision_of(class[0]), decision))
565        {
566            Some(class) => class.push(member),
567            None => classes.push(vec![member]),
568        }
569    }
570    classes
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use sharpebench_protocol::{Action, DecisionCost, PositionState, SymbolSnapshot};
577
578    fn order(symbol: &str, action: Action, target_weight: f64, confidence: f64) -> Order {
579        Order {
580            symbol: symbol.to_string(),
581            action,
582            target_weight,
583            confidence: Some(confidence),
584            rationale: String::new(),
585        }
586    }
587
588    fn decision(orders: Vec<Order>) -> Decision {
589        Decision {
590            orders,
591            reasoning: String::new(),
592            cost: None,
593        }
594    }
595
596    fn buy(weight: f64) -> Decision {
597        decision(vec![order("A", Action::Buy, weight, 0.5)])
598    }
599
600    fn hold() -> Decision {
601        decision(Vec::new())
602    }
603
604    /// A replicate run of window `[start, start + steps.len())` whose content
605    /// identity is `content`.
606    fn run_as<'a>(content: &str, start: usize, steps: &[(&str, &'a Decision)]) -> ReplicateRun<'a> {
607        ReplicateRun {
608            window_start: start,
609            window_end: start + steps.len(),
610            content_sha256: content.to_string(),
611            steps: steps
612                .iter()
613                .map(|&(digest, decision)| ObservedDecision {
614                    observation_sha256: digest.to_string(),
615                    decision,
616                })
617                .collect(),
618        }
619    }
620
621    /// Replicates with distinct content identities.
622    fn runs<'a>(start: usize, replicates: &[&[(&str, &'a Decision)]]) -> Vec<ReplicateRun<'a>> {
623        replicates
624            .iter()
625            .enumerate()
626            .map(|(index, steps)| run_as(&format!("run-{start}-{index}"), start, steps))
627            .collect()
628    }
629
630    fn measure(runs: &[ReplicateRun<'_>]) -> DecisionStabilityReport {
631        decision_stability("entrant", runs, IdenticalReplicates::Refused).unwrap()
632    }
633
634    fn available(value: f64) -> StabilityRate {
635        StabilityRate::Available { value }
636    }
637
638    fn observation() -> MarketObservation {
639        MarketObservation {
640            date: "2026-01-02".to_string(),
641            cash: 1.0,
642            symbols: vec![SymbolSnapshot {
643                symbol: "A".to_string(),
644                close_history: vec![10.0, 10.5],
645                fundamentals: [("pe".to_string(), 12.0)].into_iter().collect(),
646                news: vec!["headline".to_string()],
647            }],
648            portfolio: vec![PositionState {
649                symbol: "A".to_string(),
650                shares: 0.0,
651                avg_price: 0.0,
652            }],
653        }
654    }
655
656    #[test]
657    fn the_observation_digest_is_sha256_of_the_canonical_framed_preimage() {
658        let observation = observation();
659        let value = serde_json::to_value(&observation).unwrap();
660        let expected = crate::lower_hex(&Sha256::digest(
661            versioned_preimage(&value).unwrap().as_slice(),
662        ));
663        assert_eq!(observation_sha256(&observation).unwrap(), expected);
664        assert_eq!(expected.len(), 64);
665    }
666
667    #[test]
668    fn the_observation_digest_moves_with_the_observation_and_not_with_the_sign_of_zero() {
669        let base = observation_sha256(&observation()).unwrap();
670        let mut moved = observation();
671        moved.cash = 0.999;
672        assert_ne!(observation_sha256(&moved).unwrap(), base);
673
674        let mut negative_zero = observation();
675        negative_zero.portfolio[0].shares = -0.0;
676        assert_eq!(observation_sha256(&negative_zero).unwrap(), base);
677    }
678
679    #[test]
680    fn a_finite_observation_serializes_without_a_null() {
681        // The non-finite check reads a null as a non-finite number. This fails
682        // the day MarketObservation gains a field that serializes as null.
683        let empty = MarketObservation {
684            date: String::new(),
685            cash: 0.0,
686            symbols: vec![SymbolSnapshot {
687                symbol: String::new(),
688                close_history: Vec::new(),
689                fundamentals: BTreeMap::new(),
690                news: Vec::new(),
691            }],
692            portfolio: Vec::new(),
693        };
694        assert!(!contains_null(&serde_json::to_value(&empty).unwrap()));
695        assert!(observation_sha256(&empty).is_ok());
696    }
697
698    #[test]
699    fn a_non_finite_observation_has_no_digest() {
700        let mut cash = observation();
701        cash.cash = f64::NAN;
702        let mut history = observation();
703        history.symbols[0].close_history[1] = f64::INFINITY;
704        let mut fundamentals = observation();
705        fundamentals.symbols[0]
706            .fundamentals
707            .insert("pe".to_string(), f64::NEG_INFINITY);
708        for refused in [cash, history, fundamentals] {
709            assert!(
710                matches!(
711                    observation_sha256(&refused),
712                    Err(CanonicalError::NonFinite(ref message)) if message.contains("2026-01-02")
713                ),
714                "a non-finite number must not collapse into a null digest"
715            );
716        }
717    }
718
719    #[test]
720    fn decisions_differ_on_each_score_bearing_order_field() {
721        let base = decision(vec![order("A", Action::Buy, 0.5, 0.7)]);
722        assert!(same_decision(&base, &base.clone()));
723        let mut unstated = base.clone();
724        unstated.orders[0].confidence = None;
725        for changed in [
726            decision(vec![order("B", Action::Buy, 0.5, 0.7)]),
727            decision(vec![order("A", Action::Sell, 0.5, 0.7)]),
728            decision(vec![order("A", Action::Buy, 0.5000001, 0.7)]),
729            decision(vec![order("A", Action::Buy, 0.5, 0.71)]),
730            decision(vec![
731                order("A", Action::Buy, 0.5, 0.7),
732                order("B", Action::Buy, 0.0, 0.7),
733            ]),
734            unstated.clone(),
735            hold(),
736        ] {
737            assert!(!same_decision(&base, &changed), "{changed:?}");
738            assert!(!same_decision(&changed, &base), "{changed:?}");
739        }
740        assert!(
741            same_decision(&unstated, &unstated.clone()),
742            "two unstated confidences are the same statement"
743        );
744    }
745
746    #[test]
747    fn order_position_matters_but_audit_text_and_cost_do_not() {
748        let first = decision(vec![
749            order("A", Action::Buy, 0.5, 0.7),
750            order("B", Action::Sell, -0.2, 0.6),
751        ]);
752        let swapped = decision(vec![first.orders[1].clone(), first.orders[0].clone()]);
753        assert!(!same_decision(&first, &swapped));
754
755        let mut annotated = first.clone();
756        annotated.reasoning = "a different explanation".to_string();
757        annotated.orders[0].rationale = "another rationale".to_string();
758        annotated.cost = Some(DecisionCost {
759            cost_usd: 0.25,
760            tokens_in: 900,
761            tokens_out: 120,
762            reasoning_tokens: 40,
763        });
764        assert!(same_decision(&first, &annotated));
765    }
766
767    #[test]
768    fn numbers_compare_exactly_with_zero_equal_to_negative_zero() {
769        assert!(same_number(0.0, -0.0));
770        assert!(same_number(f64::NAN, f64::NAN));
771        assert!(!same_number(f64::NAN, -f64::NAN));
772        assert!(!same_number(0.3, 0.30000000000000004));
773        assert!(same_decision(&buy(0.0), &buy(-0.0)));
774        assert!(same_confidence(Some(0.0), Some(-0.0)));
775        assert!(!same_confidence(Some(0.5), Some(0.6)));
776        assert!(!same_confidence(None, Some(0.5)));
777    }
778
779    #[test]
780    fn a_deterministic_field_reports_exactly_zero_over_matched_groups() {
781        let (a, b) = (buy(0.5), hold());
782        let replicate: &[(&str, &Decision)] = &[("o0", &a), ("o1", &b), ("o2", &b)];
783        let report = measure(&runs(10, &[replicate, replicate, replicate]));
784        let totals = &report.totals;
785        assert_eq!(totals.groups_compared, 3);
786        assert_eq!(totals.pairs_compared, 9);
787        assert_eq!(totals.differing_pairs, 0);
788        assert_eq!(totals.pairwise_disagreement, available(0.0));
789        assert_eq!(totals.steps_compared, 9);
790        assert_eq!(totals.steps_excluded_diverged_observation, 0);
791        assert_eq!(totals.steps_excluded_diverged_decision, 0);
792        assert_eq!(totals.group_sizes, BTreeMap::from([(3, 3)]));
793        assert!(report.windows[0].differing_groups.is_empty());
794    }
795
796    /// A field whose groups are exactly the outcome distribution of `size`
797    /// independent replicates choosing among `choices`, where choice `c` has
798    /// probability `weights[c] / weights.sum()`. Every arrangement of choices is
799    /// one window of one step, repeated `prod weights[c]` times, so empirical
800    /// frequencies equal the probabilities and the rate equals its expectation.
801    fn exact_outcome_field(size: usize, weights: &[usize]) -> (Vec<Decision>, Vec<Vec<usize>>) {
802        let decisions: Vec<Decision> = (0..weights.len())
803            .map(|choice| buy(0.1 * (choice + 1) as f64))
804            .collect();
805        let mut arrangements = Vec::new();
806        let mut current = vec![0usize; size];
807        loop {
808            let copies: usize = current.iter().map(|&choice| weights[choice]).product();
809            for _ in 0..copies {
810                arrangements.push(current.clone());
811            }
812            let Some(position) = current
813                .iter()
814                .rposition(|&choice| choice + 1 < weights.len())
815            else {
816                break;
817            };
818            current[position] += 1;
819            for later in &mut current[position + 1..] {
820                *later = 0;
821            }
822        }
823        (decisions, arrangements)
824    }
825
826    fn measure_outcome_field(size: usize, weights: &[usize]) -> DecisionStabilityReport {
827        let (decisions, arrangements) = exact_outcome_field(size, weights);
828        let field: Vec<ReplicateRun<'_>> = arrangements
829            .iter()
830            .enumerate()
831            .flat_map(|(window, choices)| {
832                let decisions = &decisions;
833                choices.iter().enumerate().map(move |(replicate, &choice)| {
834                    run_as(
835                        &format!("{window}-{replicate}"),
836                        window,
837                        &[("same", &decisions[choice])],
838                    )
839                })
840            })
841            .collect();
842        measure(&field)
843    }
844
845    #[test]
846    fn the_pairwise_rate_is_invariant_to_group_size_under_independent_flips() {
847        // Binary choice, flip probability q = 1/4: every pair differs with
848        // probability 2q(1-q) = 3/8 whatever the group size, while the share
849        // of differing groups is 1 - (3/4)^n - (1/4)^n. Both values were
850        // checked with sympy in exact rationals.
851        let group_share = [
852            (2, 3.0 / 8.0),
853            (3, 9.0 / 16.0),
854            (4, 87.0 / 128.0),
855            (5, 195.0 / 256.0),
856            (6, 1683.0 / 2048.0),
857        ];
858        for (size, share) in group_share {
859            let report = measure_outcome_field(size, &[3, 1]);
860            let totals = &report.totals;
861            assert_eq!(
862                totals.groups_compared,
863                4usize.pow(size as u32),
864                "n = {size}"
865            );
866            assert_eq!(
867                totals.group_sizes,
868                BTreeMap::from([(size, 4usize.pow(size as u32))])
869            );
870            assert_eq!(
871                totals.pairs_compared,
872                totals.groups_compared * size * (size - 1) / 2
873            );
874            assert_eq!(
875                totals.pairwise_disagreement,
876                available(3.0 / 8.0),
877                "n = {size}"
878            );
879            let observed_share =
880                totals.groups_with_differing_decisions as f64 / totals.groups_compared as f64;
881            assert_eq!(observed_share, share, "n = {size}");
882        }
883
884        // Three choices with probabilities 1/2, 1/4, 1/4: 1 - sum p^2 = 5/8.
885        for size in 2..=5 {
886            let report = measure_outcome_field(size, &[2, 1, 1]);
887            assert_eq!(
888                report.totals.pairwise_disagreement,
889                available(5.0 / 8.0),
890                "n = {size}"
891            );
892        }
893    }
894
895    #[test]
896    fn a_group_counts_every_distinct_decision_and_every_differing_pair() {
897        // Classes of sizes 3, 2 and 1: 15 pairs, 3 + 1 agreeing, 11 differing.
898        let (a, b, c) = (buy(0.1), buy(0.2), buy(0.3));
899        let field = runs(
900            0,
901            &[
902                &[("o", &a)],
903                &[("o", &b)],
904                &[("o", &a)],
905                &[("o", &c)],
906                &[("o", &a)],
907                &[("o", &b)],
908            ],
909        );
910        let report = measure(&field);
911        assert_eq!(
912            report.windows[0].differing_groups,
913            vec![DifferingGroup {
914                step: 0,
915                observation_sha256: "o".to_string(),
916                replicates: 6,
917                distinct_decisions: 3,
918                differing_pairs: 11,
919            }]
920        );
921        assert_eq!(report.totals.pairs_compared, 15);
922        assert_eq!(report.totals.differing_pairs, 11);
923        assert_eq!(report.totals.pairwise_disagreement, available(11.0 / 15.0));
924    }
925
926    fn parity_split<'a>(
927        steady: &'a Decision,
928        flipped: &'a Decision,
929        flip_at: impl Fn(usize) -> bool,
930    ) -> Vec<Vec<(String, &'a Decision)>> {
931        (0..4)
932            .map(|replicate| {
933                (0..20)
934                    .map(|step| {
935                        let chosen = if replicate % 2 == 1 && flip_at(step) {
936                            flipped
937                        } else {
938                            steady
939                        };
940                        (format!("s{step}"), chosen)
941                    })
942                    .collect()
943            })
944            .collect()
945    }
946
947    fn measure_owned(replicates: &[Vec<(String, &Decision)>]) -> DecisionStabilityReport {
948        let borrowed: Vec<Vec<(&str, &Decision)>> = replicates
949            .iter()
950            .map(|steps| {
951                steps
952                    .iter()
953                    .map(|(digest, d)| (digest.as_str(), *d))
954                    .collect()
955            })
956            .collect();
957        let slices: Vec<&[(&str, &Decision)]> = borrowed.iter().map(Vec::as_slice).collect();
958        measure(&runs(30, &slices))
959    }
960
961    #[test]
962    fn a_planted_split_on_the_first_step_reports_its_pairwise_rate() {
963        // Four replicates, twenty identical observations; the odd replicates
964        // choose differently at step 0. Step 0 holds 4 of 6 differing pairs;
965        // the two agreeing pairs then carry 19 more steps: 4 / 44 = 1 / 11.
966        let (steady, flipped) = (buy(0.5), buy(0.25));
967        let report = measure_owned(&parity_split(&steady, &flipped, |step| step == 0));
968        let window = &report.windows[0];
969        assert_eq!(window.counts.pairs_compared, 6 + 19 * 2);
970        assert_eq!(window.counts.differing_pairs, 4);
971        assert_eq!(window.counts.pairwise_disagreement, available(1.0 / 11.0));
972        assert_eq!(window.counts.groups_compared, 1 + 19 * 2);
973        assert_eq!(window.counts.group_sizes, BTreeMap::from([(2, 38), (4, 1)]));
974        assert_eq!(window.counts.steps_compared, 80);
975        assert_eq!(
976            window.differing_groups,
977            vec![DifferingGroup {
978                step: 0,
979                observation_sha256: "s0".to_string(),
980                replicates: 4,
981                distinct_decisions: 2,
982                differing_pairs: 4,
983            }]
984        );
985    }
986
987    #[test]
988    fn a_difference_the_engine_does_not_act_on_is_counted_once() {
989        // The same odd replicates choose differently at every fourth step, and
990        // the observations never move. After step 0 the two sides are
991        // separate histories, so the later choices are not counted again.
992        let (steady, flipped) = (buy(0.5), buy(0.25));
993        let once = measure_owned(&parity_split(&steady, &flipped, |step| step == 0));
994        let repeated = measure_owned(&parity_split(&steady, &flipped, |step| {
995            step.is_multiple_of(4)
996        }));
997        assert_eq!(repeated.windows[0].counts, once.windows[0].counts);
998        assert_eq!(repeated.windows[0].differing_groups.len(), 1);
999    }
1000
1001    #[test]
1002    fn diverged_observations_are_excluded_for_the_rest_of_the_window_and_counted() {
1003        // Replicate c is shown a different observation at step 1 and never
1004        // rejoins; its differing step-2 decision is not a comparison.
1005        let (x, y) = (buy(0.5), buy(0.9));
1006        let a: &[(&str, &Decision)] = &[("o0", &x), ("o1", &x), ("o2", &x), ("o3", &x)];
1007        let c: &[(&str, &Decision)] = &[("o0", &x), ("fill", &x), ("o2", &y), ("o3", &y)];
1008        let report = measure(&runs(5, &[a, a, c]));
1009        let counts = &report.windows[0].counts;
1010        assert_eq!(counts.steps_total, 12);
1011        assert_eq!(counts.steps_compared, 3 + 2 + 2 + 2);
1012        assert_eq!(counts.steps_excluded_diverged_observation, 3);
1013        assert_eq!(counts.steps_excluded_diverged_decision, 0);
1014        assert_eq!(counts.steps_unreplicated, 0);
1015        assert_eq!(counts.groups_compared, 4);
1016        assert_eq!(counts.differing_pairs, 0);
1017        assert_eq!(counts.pairs_compared, 3 + 1 + 1 + 1);
1018    }
1019
1020    #[test]
1021    fn diverged_decisions_are_compared_once_then_excluded_and_counted() {
1022        // Replicate c decides differently at step 1 while its observations stay
1023        // equal: it is compared at step 1 and excluded for steps 2 and 3.
1024        let (x, y) = (buy(0.5), buy(0.9));
1025        let a: &[(&str, &Decision)] = &[("o0", &x), ("o1", &x), ("o2", &x), ("o3", &x)];
1026        let c: &[(&str, &Decision)] = &[("o0", &x), ("o1", &y), ("o2", &y), ("o3", &y)];
1027        let report = measure(&runs(5, &[a, a, c]));
1028        let counts = &report.windows[0].counts;
1029        assert_eq!(counts.steps_total, 12);
1030        assert_eq!(counts.steps_compared, 3 + 3 + 2 + 2);
1031        assert_eq!(counts.steps_excluded_diverged_decision, 2);
1032        assert_eq!(counts.steps_excluded_diverged_observation, 0);
1033        assert_eq!(counts.pairs_compared, 3 + 3 + 1 + 1);
1034        assert_eq!(counts.differing_pairs, 2);
1035        assert_eq!(counts.pairwise_disagreement, available(0.25));
1036
1037        // At the last step a differing replicate has no later steps to lose.
1038        let d: &[(&str, &Decision)] = &[("o0", &x), ("o1", &x), ("o2", &x), ("o3", &y)];
1039        let last = measure(&runs(5, &[a, a, d]));
1040        assert_eq!(last.windows[0].counts.steps_excluded_diverged_decision, 0);
1041        assert_eq!(last.windows[0].counts.steps_compared, 12);
1042    }
1043
1044    #[test]
1045    fn a_coincident_observation_after_divergence_is_not_compared() {
1046        // Both replicates are shown "o2" at step 2, but their step-1
1047        // observations differed, so a deterministic agent may answer "o2"
1048        // differently. Only step 0 is a group.
1049        let (x, y) = (buy(0.5), buy(0.9));
1050        let left: &[(&str, &Decision)] = &[("o0", &x), ("left", &x), ("o2", &x)];
1051        let right: &[(&str, &Decision)] = &[("o0", &x), ("right", &x), ("o2", &y)];
1052        let report = measure(&runs(0, &[left, right]));
1053        let counts = &report.windows[0].counts;
1054        assert_eq!(counts.groups_compared, 1);
1055        assert_eq!(counts.differing_pairs, 0);
1056        assert_eq!(counts.steps_compared, 2);
1057        assert_eq!(counts.steps_excluded_diverged_observation, 4);
1058        assert_eq!(counts.pairwise_disagreement, available(0.0));
1059    }
1060
1061    #[test]
1062    fn a_split_group_keeps_comparing_each_part() {
1063        let (x, y) = (buy(0.5), buy(0.9));
1064        let a: &[(&str, &Decision)] = &[("o0", &x), ("p", &x), ("p1", &x)];
1065        let b: &[(&str, &Decision)] = &[("o0", &x), ("p", &x), ("p1", &y)];
1066        let c: &[(&str, &Decision)] = &[("o0", &x), ("q", &x), ("q1", &x)];
1067        let report = measure(&runs(0, &[a, b, c, c]));
1068        let window = &report.windows[0];
1069        assert_eq!(window.counts.groups_compared, 5);
1070        assert_eq!(window.counts.group_sizes, BTreeMap::from([(2, 4), (4, 1)]));
1071        assert_eq!(window.counts.groups_with_differing_decisions, 1);
1072        assert_eq!(window.counts.steps_compared, 12);
1073        assert_eq!(window.counts.pairs_compared, 6 + 1 + 1 + 1 + 1);
1074        assert_eq!(window.counts.differing_pairs, 1);
1075        assert_eq!(window.counts.pairwise_disagreement, available(0.1));
1076        assert_eq!(window.differing_groups[0].step, 2);
1077        assert_eq!(window.differing_groups[0].observation_sha256, "p1");
1078        assert_eq!(window.differing_groups[0].replicates, 2);
1079    }
1080
1081    #[test]
1082    fn a_single_replicate_is_typed_unavailable() {
1083        let x = buy(0.5);
1084        let report = measure(&runs(0, &[&[("o0", &x), ("o1", &x)]]));
1085        let single = StabilityRate::Unavailable {
1086            reason: StabilityUnavailable::SingleReplicate,
1087        };
1088        assert_eq!(report.totals.pairwise_disagreement, single);
1089        let window = &report.windows[0];
1090        assert_eq!(window.replicates, 1);
1091        assert_eq!(window.counts.steps_total, 2);
1092        assert_eq!(window.counts.steps_unreplicated, 2);
1093        assert_eq!(window.counts.steps_compared, 0);
1094        assert_eq!(window.counts.steps_excluded_diverged_observation, 0);
1095        assert_eq!(window.counts.groups_compared, 0);
1096        assert_eq!(window.counts.pairs_compared, 0);
1097        assert_eq!(window.counts.pairwise_disagreement, single);
1098
1099        let empty = measure(&[]);
1100        assert_eq!(empty.replicate_runs, 0);
1101        assert!(empty.windows.is_empty());
1102        assert_eq!(empty.totals.pairwise_disagreement, single);
1103    }
1104
1105    #[test]
1106    fn two_replicates_that_never_share_an_observation_are_typed_unavailable() {
1107        let x = buy(0.5);
1108        let report = measure(&runs(0, &[&[("a", &x)], &[("b", &x)]]));
1109        let unmatched = StabilityRate::Unavailable {
1110            reason: StabilityUnavailable::NoMatchedObservation,
1111        };
1112        assert_eq!(report.totals.pairwise_disagreement, unmatched);
1113        assert_eq!(report.windows[0].counts.pairwise_disagreement, unmatched);
1114        assert_eq!(report.totals.steps_excluded_diverged_observation, 2);
1115    }
1116
1117    #[test]
1118    fn windows_are_reported_separately_and_summed() {
1119        let (x, y) = (buy(0.5), buy(0.9));
1120        let mut field = runs(
1121            50,
1122            &[
1123                &[("w", &x), ("w1", &x), ("w2", &x)],
1124                &[("w", &x), ("w1", &y), ("w2", &x)],
1125                &[("w", &x), ("w1", &x), ("other", &x)],
1126            ],
1127        );
1128        field.extend(runs(
1129            0,
1130            &[&[("v", &x), ("v1", &x)], &[("v", &y), ("v1", &x)]],
1131        ));
1132        field.extend(runs(90, &[&[("u", &x)]]));
1133        let report = measure(&field);
1134        assert_eq!(report.replicate_runs, 6);
1135        let spans: Vec<_> = report
1136            .windows
1137            .iter()
1138            .map(|window| (window.window_start, window.window_end, window.replicates))
1139            .collect();
1140        assert_eq!(spans, vec![(0, 2, 2), (50, 53, 3), (90, 91, 1)]);
1141        // Window 0: one pair differs at step 0, then both sides are excluded.
1142        assert_eq!(
1143            report.windows[0].counts.pairwise_disagreement,
1144            available(1.0)
1145        );
1146        assert_eq!(report.windows[0].counts.steps_excluded_diverged_decision, 2);
1147        // Window 50: step 0 has 3 agreeing pairs; step 1 has 2 of 3 differing;
1148        // step 2 has the two agreeing replicates shown different observations.
1149        let window = &report.windows[1].counts;
1150        assert_eq!(window.pairs_compared, 3 + 3);
1151        assert_eq!(window.differing_pairs, 2);
1152        assert_eq!(window.steps_excluded_diverged_decision, 1);
1153        assert_eq!(window.steps_excluded_diverged_observation, 2);
1154
1155        let totals = &report.totals;
1156        assert_eq!(totals.steps_total, 4 + 9 + 1);
1157        assert_eq!(totals.steps_compared, 2 + 6);
1158        assert_eq!(totals.steps_excluded_diverged_observation, 2);
1159        assert_eq!(totals.steps_excluded_diverged_decision, 2 + 1);
1160        assert_eq!(totals.steps_unreplicated, 1);
1161        assert_eq!(totals.groups_compared, 1 + 2);
1162        assert_eq!(totals.groups_with_differing_decisions, 1 + 1);
1163        assert_eq!(totals.group_sizes, BTreeMap::from([(2, 1), (3, 2)]));
1164        assert_eq!(totals.pairs_compared, 1 + 6);
1165        assert_eq!(totals.differing_pairs, 1 + 2);
1166        assert_eq!(totals.pairwise_disagreement, available(3.0 / 7.0));
1167    }
1168
1169    #[test]
1170    fn an_incomplete_run_is_refused() {
1171        let x = buy(0.5);
1172        let mut field = runs(
1173            0,
1174            &[
1175                &[("o0", &x), ("o1", &x), ("o2", &x)],
1176                &[("o0", &x), ("o1", &x)],
1177            ],
1178        );
1179        field[1].window_end = 3;
1180        let error = decision_stability("short", &field, IdenticalReplicates::Declared).unwrap_err();
1181        assert_eq!(
1182            error,
1183            DecisionStabilityError::IncompleteRun {
1184                run: 1,
1185                recorded: 2,
1186                required: 3,
1187            }
1188        );
1189        assert_eq!(
1190            error.to_string(),
1191            "replicate run 1 records 2 of the 3 decisions its window requires"
1192        );
1193    }
1194
1195    #[test]
1196    fn identical_replicates_are_counted_and_refused_unless_declared() {
1197        let (x, y) = (buy(0.5), buy(0.9));
1198        let steps: &[(&str, &Decision)] = &[("o", &x)];
1199        let other: &[(&str, &Decision)] = &[("o", &y)];
1200        let field = vec![
1201            run_as("copy", 0, steps),
1202            run_as("copy", 0, steps),
1203            run_as("copy", 0, steps),
1204            run_as("fresh", 0, other),
1205            run_as("copy", 7, steps),
1206            run_as("elsewhere", 7, steps),
1207        ];
1208
1209        let refused =
1210            decision_stability("copied", &field, IdenticalReplicates::Refused).unwrap_err();
1211        assert_eq!(
1212            refused,
1213            DecisionStabilityError::UndeclaredIdenticalReplicates {
1214                window_start: 0,
1215                window_end: 1,
1216                identical: 2,
1217            }
1218        );
1219        assert!(refused
1220            .to_string()
1221            .starts_with("window [0, 1) holds 2 replicate runs identical to another replicate"));
1222
1223        let declared = decision_stability("copied", &field, IdenticalReplicates::Declared).unwrap();
1224        assert!(declared.identical_replicates_declared);
1225        assert_eq!(declared.windows[0].counts.identical_replicate_runs, 2);
1226        assert_eq!(declared.windows[1].counts.identical_replicate_runs, 0);
1227        assert_eq!(declared.totals.identical_replicate_runs, 2);
1228        // The copies stay in the rate once declared: 3 of 6 pairs differ in
1229        // window 0 and none in window 7.
1230        assert_eq!(declared.totals.pairs_compared, 6 + 1);
1231        assert_eq!(declared.totals.differing_pairs, 3);
1232
1233        let distinct = measure(&runs(0, &[steps, steps]));
1234        assert!(!distinct.identical_replicates_declared);
1235        assert_eq!(distinct.totals.identical_replicate_runs, 0);
1236    }
1237
1238    #[test]
1239    fn the_report_is_rank_neutral_and_names_its_rules() {
1240        let x = buy(0.5);
1241        let report = measure(&runs(0, &[&[("o", &x)], &[("o", &x)]]));
1242        let json = serde_json::to_value(&report).unwrap();
1243        assert_eq!(json["schema"], "sharpebench.decision-stability.v1");
1244        assert_eq!(json["agent_id"], "entrant");
1245        assert_eq!(json["rank_input"], false);
1246        assert_eq!(
1247            json["observation_digest"],
1248            format!("{OBSERVATION_DIGEST_RULE} (sharpebench/canonical-json/v1)")
1249        );
1250        assert_eq!(json["grouping"], GROUPING_RULE);
1251        assert_eq!(json["decision_difference"], DECISION_DIFFERENCE_RULE);
1252        assert_eq!(json["rate"], RATE_RULE);
1253        assert_eq!(json["sampling_unit"], SAMPLING_UNIT);
1254        assert_eq!(json["identical_replicates_declared"], false);
1255        assert_eq!(json["groups_compared"], 1);
1256        assert_eq!(json["pairs_compared"], 1);
1257        assert_eq!(json["group_sizes"], serde_json::json!({"2": 1}));
1258        assert_eq!(
1259            json["pairwise_disagreement"],
1260            serde_json::json!({"status": "available", "value": 0.0})
1261        );
1262        assert_eq!(json["windows"][0]["replicates"], 2);
1263        assert_eq!(json["windows"][0]["steps_compared"], 2);
1264        assert_eq!(
1265            serde_json::to_value(StabilityRate::Unavailable {
1266                reason: StabilityUnavailable::NoMatchedObservation
1267            })
1268            .unwrap(),
1269            serde_json::json!({"status": "unavailable", "reason": "no_matched_observation"})
1270        );
1271    }
1272}