Skip to main content

sharpebench_core/
run_identity.rs

1//! Typed run identity for a submitted field.
2//!
3//! A submission is a `Vec<Run>`. Nothing in that array says which window, seed
4//! or period each run belongs to, so every cross-agent comparison in
5//! [`composite`](crate::composite) reaches for `runs[i]` and trusts that agent
6//! A's index `i` and agent B's index `i` name the same cell. That trust is not
7//! established anywhere: a field assembled from two producers, or one where a
8//! single cell failed and was dropped, silently compares different windows.
9//!
10//! This module makes the cell an explicit, typed key instead. A keyed field
11//! declares, per agent, one [`RunKey`] per run; parsing validates that
12//!
13//! 1. every submission is keyed (an unkeyed legacy array is **refused**, never
14//!    aligned by position),
15//! 2. no agent repeats a key,
16//! 3. every agent carries exactly the same key set,
17//! 4. that key set is the complete Cartesian product of the observed windows
18//!    and seeds, with no missing and no unexpected cell, and
19//! 5. declared period identities match the run's return length, name no
20//!    period twice within a run, and agree across agents for the same cell,
21//!    and
22//! 6. no period is declared in two different windows. Execution seeds of one
23//!    window replicate the same market periods and may share them; windows are
24//!    the successive segments the pooled track concatenates, so a period in two
25//!    of them would enter that track twice, and
26//! 7. every agent lists the windows in the same order. That order is the one
27//!    the pooled track concatenates them in, so it is the field's time axis and
28//!    the submission has to declare it. A window identity is opaque, so its
29//!    spelling says nothing about when it ran: ordering the segments by label
30//!    would make `max_drawdown` and `return_drift_half_life` change when
31//!    `w1 .. w12` is renamed `w01 .. w12`. Agents that disagree declare no
32//!    order, and the field is refused rather than settled by the labels.
33//!
34//! A repeated period is refused per run, before any cross-agent comparison, so
35//! a field in which every agent repeats the same period is refused too: the
36//! agents agreeing does not make one period two observations.
37//!
38//! Only then are the runs reordered into one canonical key order, so the
39//! positional reads downstream are keyed reads by construction.
40//!
41//! Completeness is a safety property, not a convenience. Restriction to shared
42//! support (see [`comparison_sets`](crate::comparison_sets)) means a peer that
43//! submits a partial field shrinks the support every other entrant is judged
44//! on, which can drop the very cell that carried another entrant's process
45//! violation. Scoring a partial grid must therefore fail loudly rather than
46//! quietly rescope the comparison.
47//!
48//! Pure and deterministic: no I/O, no clock, no ambient randomness. Structural
49//! validation only; nothing here rescores or revalidates an outcome.
50
51use std::collections::{BTreeMap, BTreeSet};
52use std::fmt;
53
54use serde::{Deserialize, Serialize};
55
56use crate::composite::{parse_declared_field, AgentSubmission, MandateDeclarations};
57
58/// The cell one run was produced on: a window identity and an execution seed.
59///
60/// `window` is an opaque exact-match identity (e.g. `"2025-Q4"`), not an index,
61/// so a reordered or partially assembled field cannot be reinterpreted. Order
62/// is `(window, seed)` so a canonical key order is stable across producers.
63#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64pub struct RunKey {
65    pub window: String,
66    pub seed: u64,
67}
68
69impl fmt::Display for RunKey {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "(window `{}`, seed {})", self.window, self.seed)
72    }
73}
74
75/// One declared run identity: its cell, plus optionally the period identities
76/// the run's returns are indexed by.
77///
78/// `periods` is empty when the submitter declares no period axis. When it is
79/// present it must have one entry per return, no entry twice, and two agents
80/// on the same cell must declare the same periods.
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct RunIdentity {
83    #[serde(flatten)]
84    pub key: RunKey,
85    #[serde(default)]
86    pub periods: Vec<String>,
87}
88
89/// The `run_keys` sidecar as it arrives on the wire, beside the ordinary
90/// submission keys. Unknown fields are ignored on both sides, so a keyed field
91/// still deserializes as a plain `Vec<AgentSubmission>` for readers that do not
92/// ask for identity, and an unkeyed field parses here with `run_keys` absent
93/// (which [`parse_keyed_field`] then refuses).
94#[derive(Clone, Debug, Deserialize)]
95struct RunKeysDoc {
96    agent_id: String,
97    run_keys: Option<Vec<RunIdentity>>,
98}
99
100/// Why a field could not be accepted as keyed. Every variant names the missing
101/// or conflicting identity; none of them is recoverable by guessing an
102/// alignment.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum RunIdentityError {
105    /// The submissions JSON did not parse, or agent identities were invalid.
106    InvalidField(String),
107    /// The field has no submissions, so there is no grid to validate.
108    EmptyField,
109    /// A legacy unkeyed `Run` array. Refused rather than aligned by position.
110    UnkeyedSubmission { agent_id: String },
111    /// `run_keys` and `runs` disagree in length.
112    KeyCountMismatch {
113        agent_id: String,
114        runs: usize,
115        keys: usize,
116    },
117    /// A window identity that cannot be compared exactly.
118    InvalidKey {
119        agent_id: String,
120        index: usize,
121        reason: String,
122    },
123    /// One agent declared the same cell twice.
124    DuplicateKey { agent_id: String, key: RunKey },
125    /// An agent is missing a cell another agent submitted.
126    MissingCell { agent_id: String, key: RunKey },
127    /// The submitted cells are not the complete window times seed product.
128    IncompleteGrid {
129        windows: usize,
130        seeds: usize,
131        submitted: usize,
132        first_missing: RunKey,
133    },
134    /// Declared period identities do not index the run's returns.
135    PeriodCountMismatch {
136        agent_id: String,
137        key: RunKey,
138        periods: usize,
139        returns: usize,
140    },
141    /// One run declared the same period identity at two indices, so that
142    /// period's return would count as two observations in every statistic
143    /// computed from the run.
144    DuplicatePeriod {
145        agent_id: String,
146        key: RunKey,
147        period: String,
148        first: usize,
149        second: usize,
150    },
151    /// Two different windows declare the same period identity, so the pooled
152    /// track, which concatenates the windows, would count that period's return
153    /// twice. Seeds of one window may share periods.
154    PeriodInTwoWindows(Box<PeriodOverlap>),
155    /// Two agents declared different period identities for the same cell.
156    PeriodMismatch {
157        key: RunKey,
158        agent_id: String,
159        other_agent_id: String,
160        index: usize,
161    },
162    /// Two agents listed the same windows in different orders, so the field
163    /// does not say which order the pooled track concatenates them in.
164    WindowOrderMismatch {
165        agent_id: String,
166        other_agent_id: String,
167        windows: Vec<String>,
168        other_windows: Vec<String>,
169    },
170}
171
172/// The two cells of [`RunIdentityError::PeriodInTwoWindows`]. `first` is the
173/// earlier cell in canonical order; each cell names the agent whose declaration
174/// recorded it.
175#[derive(Clone, Debug, PartialEq, Eq)]
176pub struct PeriodOverlap {
177    pub period: String,
178    pub first: RunKey,
179    pub first_agent_id: String,
180    pub second: RunKey,
181    pub second_agent_id: String,
182}
183
184impl fmt::Display for RunIdentityError {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        match self {
187            Self::InvalidField(error) => write!(f, "{error}"),
188            Self::EmptyField => write!(f, "run identity: the field contains no submissions"),
189            Self::UnkeyedSubmission { agent_id } => write!(
190                f,
191                "run identity: agent `{agent_id}` submitted an unkeyed run array; \
192                 add a `run_keys` entry per run (window and seed). Runs are not \
193                 aligned across agents by position"
194            ),
195            Self::KeyCountMismatch {
196                agent_id,
197                runs,
198                keys,
199            } => write!(
200                f,
201                "run identity: agent `{agent_id}` declared {keys} run key(s) for {runs} run(s)"
202            ),
203            Self::InvalidKey {
204                agent_id,
205                index,
206                reason,
207            } => write!(
208                f,
209                "run identity: agent `{agent_id}` run key {index} is invalid: {reason}"
210            ),
211            Self::DuplicateKey { agent_id, key } => write!(
212                f,
213                "run identity: agent `{agent_id}` declared {key} more than once"
214            ),
215            Self::MissingCell { agent_id, key } => write!(
216                f,
217                "run identity: agent `{agent_id}` is missing {key}, which another \
218                 agent submitted. A partial field cannot be scored: restricting to \
219                 shared support would rescope every other entrant's evidence"
220            ),
221            Self::IncompleteGrid {
222                windows,
223                seeds,
224                submitted,
225                first_missing,
226            } => write!(
227                f,
228                "run identity: incomplete grid, {submitted} cell(s) submitted for \
229                 {windows} window(s) times {seeds} seed(s); first missing {first_missing}"
230            ),
231            Self::PeriodCountMismatch {
232                agent_id,
233                key,
234                periods,
235                returns,
236            } => write!(
237                f,
238                "run identity: agent `{agent_id}` {key} declared {periods} period \
239                 identity/identities for {returns} return(s)"
240            ),
241            Self::DuplicatePeriod {
242                agent_id,
243                key,
244                period,
245                first,
246                second,
247            } => write!(
248                f,
249                "run identity: agent `{agent_id}` {key} declares period `{period}` at \
250                 index {first} and again at index {second}; one period cannot contribute \
251                 two returns to a run"
252            ),
253            Self::PeriodInTwoWindows(overlap) => {
254                let PeriodOverlap {
255                    period,
256                    first,
257                    first_agent_id,
258                    second,
259                    second_agent_id,
260                } = overlap.as_ref();
261                write!(
262                    f,
263                    "run identity: period `{period}` is declared in {first} by agent \
264                 `{first_agent_id}` and in {second} by agent `{second_agent_id}`; a \
265                 period belongs to one window, and the pooled track would count its \
266                 return twice. Seeds of one window may share periods, different \
267                 windows may not"
268                )
269            }
270            Self::PeriodMismatch {
271                key,
272                agent_id,
273                other_agent_id,
274                index,
275            } => write!(
276                f,
277                "run identity: {key} has different period identities at index {index} \
278                 for agents `{other_agent_id}` and `{agent_id}`"
279            ),
280            Self::WindowOrderMismatch {
281                agent_id,
282                other_agent_id,
283                windows,
284                other_windows,
285            } => write!(
286                f,
287                "run identity: agent `{other_agent_id}` lists windows {other_windows:?} \
288                 and agent `{agent_id}` lists {windows:?}. The declared order is the \
289                 order the pooled track concatenates them in, so the field must not \
290                 leave it to the labels' spelling"
291            ),
292        }
293    }
294}
295
296impl std::error::Error for RunIdentityError {}
297
298/// A field whose runs are identified rather than positioned: every submission
299/// carries the same complete set of cells, and its runs are reordered into the
300/// shared canonical `keys` order.
301#[derive(Clone, Debug)]
302pub struct KeyedField {
303    /// The canonical cell order: window-major in `windows` order, seeds
304    /// ascending. Index `i` of every submission's `runs` is `keys[i]` for every
305    /// agent.
306    pub keys: Vec<RunKey>,
307    /// The distinct window identities, in the order every agent declared them.
308    /// This is the field's time axis, not a sort of the labels.
309    pub windows: Vec<String>,
310    /// The distinct execution seeds, sorted.
311    pub seeds: Vec<u64>,
312    /// Submissions in input order, runs reordered into `keys` order.
313    pub submissions: Vec<AgentSubmission>,
314    /// Mandate declarations, unchanged from [`parse_declared_field`].
315    pub declarations: MandateDeclarations,
316}
317
318/// The windows in the order `identities` first names each one. This is the
319/// field's declared time axis: the order the pooled track concatenates its
320/// segments in, taken from the submission rather than from the labels.
321fn declared_window_order(identities: &[RunIdentity]) -> Vec<String> {
322    let mut seen: BTreeSet<&str> = BTreeSet::new();
323    let mut order = Vec::new();
324    for identity in identities {
325        if seen.insert(identity.key.window.as_str()) {
326            order.push(identity.key.window.clone());
327        }
328    }
329    order
330}
331
332fn check_window(window: &str) -> Result<(), String> {
333    if window.is_empty() {
334        return Err("window identity is empty".to_string());
335    }
336    if window.trim() != window {
337        return Err(format!(
338            "window identity `{window}` has leading or trailing whitespace, so exact \
339             comparison across producers is unsafe"
340        ));
341    }
342    if window.chars().any(char::is_control) {
343        return Err(format!(
344            "window identity `{window}` contains a control character"
345        ));
346    }
347    Ok(())
348}
349
350/// The indices of the first period identity that appears twice, as
351/// `(first, second)`, where `second` is the earliest index that repeats an
352/// earlier entry.
353fn first_repeated_period(periods: &[String]) -> Option<(usize, usize)> {
354    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
355    for (second, period) in periods.iter().enumerate() {
356        if let Some(first) = seen.insert(period.as_str(), second) {
357            return Some((first, second));
358        }
359    }
360    None
361}
362
363/// Parse a submissions field and require typed run identity throughout.
364///
365/// Refuses a legacy unkeyed array, a partial grid, a duplicated cell, a period
366/// repeated within a run, disagreeing period identities, and a period declared
367/// in two windows. On success the
368/// returned submissions are reordered so positional access downstream is
369/// keyed access.
370pub fn parse_keyed_field(json: &str) -> Result<KeyedField, RunIdentityError> {
371    let (subs, declarations) =
372        parse_declared_field(json).map_err(RunIdentityError::InvalidField)?;
373    let docs: Vec<RunKeysDoc> = serde_json::from_str(json)
374        .map_err(|error| RunIdentityError::InvalidField(format!("invalid run keys: {error}")))?;
375    if subs.is_empty() {
376        return Err(RunIdentityError::EmptyField);
377    }
378
379    // Per-agent identity: keyed, well formed, no repeats, one key per run.
380    let mut per_agent: Vec<(usize, Vec<RunIdentity>)> = Vec::with_capacity(subs.len());
381    for (position, sub) in subs.iter().enumerate() {
382        let doc = docs
383            .iter()
384            .find(|d| d.agent_id == sub.agent_id)
385            .ok_or_else(|| RunIdentityError::UnkeyedSubmission {
386                agent_id: sub.agent_id.clone(),
387            })?;
388        let identities =
389            doc.run_keys
390                .clone()
391                .ok_or_else(|| RunIdentityError::UnkeyedSubmission {
392                    agent_id: sub.agent_id.clone(),
393                })?;
394        if identities.len() != sub.runs.len() {
395            return Err(RunIdentityError::KeyCountMismatch {
396                agent_id: sub.agent_id.clone(),
397                runs: sub.runs.len(),
398                keys: identities.len(),
399            });
400        }
401        let mut seen = BTreeSet::new();
402        for (index, identity) in identities.iter().enumerate() {
403            check_window(&identity.key.window).map_err(|reason| RunIdentityError::InvalidKey {
404                agent_id: sub.agent_id.clone(),
405                index,
406                reason,
407            })?;
408            if !seen.insert(identity.key.clone()) {
409                return Err(RunIdentityError::DuplicateKey {
410                    agent_id: sub.agent_id.clone(),
411                    key: identity.key.clone(),
412                });
413            }
414            if !identity.periods.is_empty()
415                && identity.periods.len() != sub.runs[index].returns.len()
416            {
417                return Err(RunIdentityError::PeriodCountMismatch {
418                    agent_id: sub.agent_id.clone(),
419                    key: identity.key.clone(),
420                    periods: identity.periods.len(),
421                    returns: sub.runs[index].returns.len(),
422                });
423            }
424            if let Some((first, second)) = first_repeated_period(&identity.periods) {
425                return Err(RunIdentityError::DuplicatePeriod {
426                    agent_id: sub.agent_id.clone(),
427                    key: identity.key.clone(),
428                    period: identity.periods[second].clone(),
429                    first,
430                    second,
431                });
432            }
433        }
434        per_agent.push((position, identities));
435    }
436
437    // The union of submitted cells is the grid every agent must cover.
438    let mut union: BTreeSet<RunKey> = BTreeSet::new();
439    for (_, identities) in &per_agent {
440        union.extend(identities.iter().map(|i| i.key.clone()));
441    }
442    for (position, identities) in &per_agent {
443        let held: BTreeSet<&RunKey> = identities.iter().map(|i| &i.key).collect();
444        if let Some(missing) = union.iter().find(|key| !held.contains(key)) {
445            return Err(RunIdentityError::MissingCell {
446                agent_id: subs[*position].agent_id.clone(),
447                key: missing.clone(),
448            });
449        }
450    }
451
452    // The window axis is ordered by declaration, not by spelling. The pooled
453    // track concatenates the windows as successive segments (rule 6), so their
454    // order sets the field's time axis, and `max_drawdown` and
455    // `return_drift_half_life` are read off that track. Taking the order from a
456    // `BTreeSet<RunKey>` would take it from the labels: `w1 .. w12` sorts as
457    // `w1, w10, w11, w12, w2, ..`, and respelling them `w01 .. w12` would move
458    // the drawdown without moving a single return. Each agent declares the
459    // order by the order it first names each window, and a field whose agents
460    // disagree names no order at all, so it is refused rather than settled by
461    // the labels.
462    let (first_position, first_identities) = &per_agent[0];
463    let windows = declared_window_order(first_identities);
464    for (position, identities) in &per_agent {
465        let declared = declared_window_order(identities);
466        if declared != windows {
467            return Err(RunIdentityError::WindowOrderMismatch {
468                agent_id: subs[*position].agent_id.clone(),
469                other_agent_id: subs[*first_position].agent_id.clone(),
470                windows: declared,
471                other_windows: windows,
472            });
473        }
474    }
475
476    // Completeness: the union must be the whole window times seed product. No
477    // axis is inferred beyond the identities actually submitted.
478    let seeds: Vec<u64> = union
479        .iter()
480        .map(|key| key.seed)
481        .collect::<BTreeSet<_>>()
482        .into_iter()
483        .collect();
484    if union.len() != windows.len() * seeds.len() {
485        let first_missing = windows
486            .iter()
487            .flat_map(|window| {
488                seeds.iter().map(move |seed| RunKey {
489                    window: window.clone(),
490                    seed: *seed,
491                })
492            })
493            .find(|key| !union.contains(key))
494            .expect("a short product has at least one missing cell");
495        return Err(RunIdentityError::IncompleteGrid {
496            windows: windows.len(),
497            seeds: seeds.len(),
498            submitted: union.len(),
499            first_missing,
500        });
501    }
502
503    // Period identities must describe the cell, not the submitter.
504    let mut declared_periods: BTreeMap<RunKey, (String, Vec<String>)> = BTreeMap::new();
505    for (position, identities) in &per_agent {
506        let agent_id = &subs[*position].agent_id;
507        for identity in identities {
508            if identity.periods.is_empty() {
509                continue;
510            }
511            match declared_periods.get(&identity.key) {
512                None => {
513                    declared_periods.insert(
514                        identity.key.clone(),
515                        (agent_id.clone(), identity.periods.clone()),
516                    );
517                }
518                Some((other_agent_id, periods)) => {
519                    if let Some(index) = periods
520                        .iter()
521                        .zip(&identity.periods)
522                        .position(|(a, b)| a != b)
523                        .or_else(|| {
524                            (periods.len() != identity.periods.len())
525                                .then(|| periods.len().min(identity.periods.len()))
526                        })
527                    {
528                        return Err(RunIdentityError::PeriodMismatch {
529                            key: identity.key.clone(),
530                            agent_id: agent_id.clone(),
531                            other_agent_id: other_agent_id.clone(),
532                            index,
533                        });
534                    }
535                }
536            }
537        }
538    }
539
540    // A period belongs to one window. `declared_periods` holds every cell that
541    // any agent gave periods for, and the check above made those declarations
542    // agree, so this covers one agent repeating a period across its windows and
543    // two agents whose separate declarations do.
544    let mut period_window: BTreeMap<&str, (&RunKey, &str)> = BTreeMap::new();
545    for (key, (agent_id, periods)) in &declared_periods {
546        for period in periods {
547            let (first, first_agent_id) = *period_window
548                .entry(period.as_str())
549                .or_insert((key, agent_id.as_str()));
550            if first.window != key.window {
551                return Err(RunIdentityError::PeriodInTwoWindows(Box::new(
552                    PeriodOverlap {
553                        period: period.clone(),
554                        first: first.clone(),
555                        first_agent_id: first_agent_id.to_string(),
556                        second: key.clone(),
557                        second_agent_id: agent_id.clone(),
558                    },
559                )));
560            }
561        }
562    }
563
564    // Canonical order, applied to every submission: window-major in the
565    // declared window order, seeds ascending. Completeness above makes this the
566    // same set as `union`, ordered by the field's time axis rather than by the
567    // labels.
568    let keys: Vec<RunKey> = windows
569        .iter()
570        .flat_map(|window| {
571            seeds.iter().map(move |seed| RunKey {
572                window: window.clone(),
573                seed: *seed,
574            })
575        })
576        .collect();
577    let mut submissions = subs;
578    for (position, identities) in per_agent {
579        let mut by_key: BTreeMap<&RunKey, usize> = BTreeMap::new();
580        for (index, identity) in identities.iter().enumerate() {
581            by_key.insert(&identity.key, index);
582        }
583        let order: Vec<usize> = keys
584            .iter()
585            .map(|key| *by_key.get(key).expect("every cell checked present"))
586            .collect();
587        let runs = std::mem::take(&mut submissions[position].runs);
588        let mut slots: Vec<Option<_>> = runs.into_iter().map(Some).collect();
589        submissions[position].runs = order
590            .into_iter()
591            .map(|index| {
592                slots[index]
593                    .take()
594                    .expect("permutation visits each run once")
595            })
596            .collect();
597    }
598
599    Ok(KeyedField {
600        keys,
601        windows,
602        seeds,
603        submissions,
604        declarations,
605    })
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::composite::{score_agent, ScoreConfig};
612
613    /// One agent, one seed, one return per window, windows listed in the given
614    /// order. Only the labels differ between two calls with the same returns.
615    fn spelled_field(windows: &[String], returns: &[f64]) -> String {
616        let docs = serde_json::json!([{
617            "agent_id": "a",
618            "runs": returns
619                .iter()
620                .map(|r| serde_json::json!({ "returns": [r] }))
621                .collect::<Vec<_>>(),
622            "run_keys": windows
623                .iter()
624                .map(|w| serde_json::json!({ "window": w, "seed": 0 }))
625                .collect::<Vec<_>>(),
626        }]);
627        serde_json::to_string(&docs).expect("serialize field")
628    }
629
630    /// S2: the pooled track concatenates the windows in the order they are
631    /// declared, so a statistic read off it cannot change when the labels are
632    /// respelled. `w1 .. w12` sorts lexicographically as `w1, w10, w11, w12,
633    /// w2, ..`, and `w01 .. w12` sorts into its declared order, so ordering the
634    /// canonical keys by label made `max_drawdown` a function of the spelling.
635    #[test]
636    fn a_pooled_statistic_does_not_change_when_the_window_labels_are_respelled() {
637        // Two drops first, then a long recovery. Read in declared order the
638        // drops compound; read in label order the recovery is spliced between
639        // them and the deepest drawdown is shallower.
640        let mut returns = vec![-0.3, -0.3];
641        returns.extend(std::iter::repeat_n(0.4, 10));
642        let short: Vec<String> = (1..=12).map(|i| format!("w{i}")).collect();
643        let padded: Vec<String> = (1..=12).map(|i| format!("w{i:02}")).collect();
644
645        let cfg = ScoreConfig {
646            execution_seeds_per_window: 1,
647            ..ScoreConfig::default()
648        };
649        let drawdown = |windows: &[String]| {
650            let keyed = parse_keyed_field(&spelled_field(windows, &returns)).expect("keyed field");
651            score_agent(&keyed.submissions[0], &cfg).max_drawdown
652        };
653
654        assert_eq!(
655            drawdown(&short),
656            drawdown(&padded),
657            "renaming w1..w12 to w01..w12 changed no return, so it must change no statistic"
658        );
659    }
660
661    /// The order the windows are declared in is the field's time axis, so two
662    /// agents that disagree about it describe two different pooled tracks.
663    /// Nothing else in a keyed field can settle which one is right.
664    #[test]
665    fn agents_that_declare_different_window_orders_are_refused() {
666        let json = field(&[
667            ("a", &[("w0", 0), ("w1", 0)]),
668            ("b", &[("w1", 0), ("w0", 0)]),
669        ]);
670        let error = parse_keyed_field(&json).expect_err("contradictory window order");
671        assert!(
672            matches!(error, RunIdentityError::WindowOrderMismatch { .. }),
673            "{error}"
674        );
675    }
676
677    fn field(entries: &[(&str, &[(&str, u64)])]) -> String {
678        let docs: Vec<serde_json::Value> = entries
679            .iter()
680            .map(|(agent_id, cells)| {
681                serde_json::json!({
682                    "agent_id": agent_id,
683                    "runs": cells
684                        .iter()
685                        .map(|(window, seed)| serde_json::json!({
686                            "returns": [0.01, 0.02, *seed as f64 * 0.001, window.len() as f64 * 0.001],
687                        }))
688                        .collect::<Vec<_>>(),
689                    "run_keys": cells
690                        .iter()
691                        .map(|(window, seed)| serde_json::json!({
692                            "window": window,
693                            "seed": seed,
694                        }))
695                        .collect::<Vec<_>>(),
696                })
697            })
698            .collect();
699        serde_json::to_string(&docs).expect("serialize field")
700    }
701
702    #[test]
703    fn a_complete_keyed_grid_is_reordered_into_one_canonical_cell_order() {
704        // The two agents interleave the same four cells differently. Position
705        // alignment would compare different cells; keyed alignment must not.
706        // They agree on which window comes first, which is the one part of the
707        // listing order that carries meaning.
708        let json = field(&[
709            ("a", &[("w0", 0), ("w0", 1), ("w1", 0), ("w1", 1)]),
710            ("b", &[("w0", 1), ("w1", 0), ("w0", 0), ("w1", 1)]),
711        ]);
712        let keyed = parse_keyed_field(&json).expect("complete grid");
713        assert_eq!(
714            keyed.keys,
715            vec![
716                RunKey {
717                    window: "w0".into(),
718                    seed: 0
719                },
720                RunKey {
721                    window: "w0".into(),
722                    seed: 1
723                },
724                RunKey {
725                    window: "w1".into(),
726                    seed: 0
727                },
728                RunKey {
729                    window: "w1".into(),
730                    seed: 1
731                },
732            ]
733        );
734        assert_eq!(keyed.windows, vec!["w0".to_string(), "w1".to_string()]);
735        assert_eq!(keyed.seeds, vec![0, 1]);
736        for index in 0..keyed.keys.len() {
737            assert_eq!(
738                keyed.submissions[0].runs[index].returns, keyed.submissions[1].runs[index].returns,
739                "cell {index} must hold the same window and seed for both agents"
740            );
741        }
742    }
743
744    #[test]
745    fn a_legacy_unkeyed_run_array_is_refused_not_positionally_aligned() {
746        let json = r#"[{"agent_id":"a","runs":[{"returns":[0.01,0.02]}]}]"#;
747        assert_eq!(
748            parse_keyed_field(json).unwrap_err(),
749            RunIdentityError::UnkeyedSubmission {
750                agent_id: "a".to_string()
751            }
752        );
753        assert!(parse_keyed_field(json)
754            .unwrap_err()
755            .to_string()
756            .contains("run_keys"));
757    }
758
759    #[test]
760    fn a_missing_cell_fails_instead_of_scoring_a_partial_field() {
761        let json = field(&[
762            ("a", &[("w0", 0), ("w0", 1), ("w1", 0), ("w1", 1)]),
763            ("b", &[("w0", 0), ("w0", 1), ("w1", 0)]),
764        ]);
765        assert_eq!(
766            parse_keyed_field(&json).unwrap_err(),
767            RunIdentityError::MissingCell {
768                agent_id: "b".to_string(),
769                key: RunKey {
770                    window: "w1".into(),
771                    seed: 1
772                },
773            }
774        );
775    }
776
777    #[test]
778    fn an_incomplete_cartesian_grid_fails_even_when_every_agent_agrees() {
779        let json = field(&[
780            ("a", &[("w0", 0), ("w0", 1), ("w1", 0)]),
781            ("b", &[("w0", 0), ("w0", 1), ("w1", 0)]),
782        ]);
783        assert_eq!(
784            parse_keyed_field(&json).unwrap_err(),
785            RunIdentityError::IncompleteGrid {
786                windows: 2,
787                seeds: 2,
788                submitted: 3,
789                first_missing: RunKey {
790                    window: "w1".into(),
791                    seed: 1
792                },
793            }
794        );
795    }
796
797    #[test]
798    fn a_duplicated_cell_fails_instead_of_inflating_the_grid() {
799        let json = field(&[("a", &[("w0", 0), ("w0", 0)])]);
800        assert_eq!(
801            parse_keyed_field(&json).unwrap_err(),
802            RunIdentityError::DuplicateKey {
803                agent_id: "a".to_string(),
804                key: RunKey {
805                    window: "w0".into(),
806                    seed: 0
807                },
808            }
809        );
810    }
811
812    #[test]
813    fn key_count_must_match_run_count() {
814        let json = serde_json::json!([{
815            "agent_id": "a",
816            "runs": [{"returns": [0.01, 0.02]}, {"returns": [0.03, 0.04]}],
817            "run_keys": [{"window": "w0", "seed": 0}],
818        }])
819        .to_string();
820        assert_eq!(
821            parse_keyed_field(&json).unwrap_err(),
822            RunIdentityError::KeyCountMismatch {
823                agent_id: "a".to_string(),
824                runs: 2,
825                keys: 1,
826            }
827        );
828    }
829
830    #[test]
831    fn a_whitespace_padded_window_identity_is_refused() {
832        let json = field(&[("a", &[(" w0", 0)])]);
833        assert!(matches!(
834            parse_keyed_field(&json),
835            Err(RunIdentityError::InvalidKey { .. })
836        ));
837    }
838
839    #[test]
840    fn declared_period_identities_must_index_the_returns_and_agree_across_agents() {
841        let one = serde_json::json!([{
842            "agent_id": "a",
843            "runs": [{"returns": [0.01, 0.02]}],
844            "run_keys": [{"window": "w0", "seed": 0, "periods": ["d1"]}],
845        }])
846        .to_string();
847        assert_eq!(
848            parse_keyed_field(&one).unwrap_err(),
849            RunIdentityError::PeriodCountMismatch {
850                agent_id: "a".to_string(),
851                key: RunKey {
852                    window: "w0".into(),
853                    seed: 0
854                },
855                periods: 1,
856                returns: 2,
857            }
858        );
859
860        let two = serde_json::json!([
861            {
862                "agent_id": "a",
863                "runs": [{"returns": [0.01, 0.02]}],
864                "run_keys": [{"window": "w0", "seed": 0, "periods": ["d1", "d2"]}],
865            },
866            {
867                "agent_id": "b",
868                "runs": [{"returns": [0.03, 0.04]}],
869                "run_keys": [{"window": "w0", "seed": 0, "periods": ["d1", "d3"]}],
870            },
871        ])
872        .to_string();
873        assert_eq!(
874            parse_keyed_field(&two).unwrap_err(),
875            RunIdentityError::PeriodMismatch {
876                key: RunKey {
877                    window: "w0".into(),
878                    seed: 0
879                },
880                agent_id: "b".to_string(),
881                other_agent_id: "a".to_string(),
882                index: 1,
883            }
884        );
885    }
886
887    fn periods_field(entries: &[(&str, &[&str])]) -> String {
888        let docs: Vec<serde_json::Value> = entries
889            .iter()
890            .map(|(agent_id, periods)| {
891                let returns: Vec<f64> = (0..periods.len().max(2))
892                    .map(|i| 0.001 * (i as f64 + 1.0))
893                    .collect();
894                let mut key = serde_json::json!({"window": "w0", "seed": 0});
895                if !periods.is_empty() {
896                    key["periods"] = serde_json::json!(periods);
897                }
898                serde_json::json!({
899                    "agent_id": agent_id,
900                    "runs": [{"returns": returns}],
901                    "run_keys": [key],
902                })
903            })
904            .collect();
905        serde_json::to_string(&docs).expect("serialize field")
906    }
907
908    fn w0() -> RunKey {
909        RunKey {
910            window: "w0".into(),
911            seed: 0,
912        }
913    }
914
915    #[test]
916    fn a_run_repeating_a_period_is_refused_naming_the_period_and_both_indices() {
917        // Distinct periods are the control: the same shape parses.
918        let distinct = periods_field(&[("a", &["d1", "d2", "d3", "d4"])]);
919        assert!(parse_keyed_field(&distinct).is_ok());
920
921        let repeated = periods_field(&[("a", &["d1", "d2", "d3", "d2"])]);
922        let error = parse_keyed_field(&repeated).unwrap_err();
923        assert_eq!(
924            error,
925            RunIdentityError::DuplicatePeriod {
926                agent_id: "a".to_string(),
927                key: w0(),
928                period: "d2".to_string(),
929                first: 1,
930                second: 3,
931            }
932        );
933        assert_eq!(
934            error.to_string(),
935            "run identity: agent `a` (window `w0`, seed 0) declares period `d2` at \
936             index 1 and again at index 3; one period cannot contribute two returns \
937             to a run"
938        );
939    }
940
941    #[test]
942    fn the_first_repeat_is_the_one_reported() {
943        assert_eq!(first_repeated_period(&[]), None);
944        let periods: Vec<String> = ["d1", "d2", "d3", "d3", "d1"]
945            .iter()
946            .map(|p| p.to_string())
947            .collect();
948        assert_eq!(first_repeated_period(&periods), Some((2, 3)));
949    }
950
951    #[test]
952    fn a_repeated_period_is_refused_even_when_every_agent_repeats_it() {
953        // Agreement across agents is the cross-agent check, and it passes
954        // here. The repeat is still one period counted twice.
955        let shared = periods_field(&[("a", &["d1", "d1"]), ("b", &["d1", "d1"])]);
956        assert_eq!(
957            parse_keyed_field(&shared).unwrap_err(),
958            RunIdentityError::DuplicatePeriod {
959                agent_id: "a".to_string(),
960                key: w0(),
961                period: "d1".to_string(),
962                first: 0,
963                second: 1,
964            }
965        );
966
967        // With only one agent declaring periods there is nothing to compare
968        // against, and the repeat is still refused.
969        let one_declares = periods_field(&[("a", &[]), ("b", &["d1", "d1"])]);
970        assert_eq!(
971            parse_keyed_field(&one_declares).unwrap_err(),
972            RunIdentityError::DuplicatePeriod {
973                agent_id: "b".to_string(),
974                key: w0(),
975                period: "d1".to_string(),
976                first: 0,
977                second: 1,
978            }
979        );
980    }
981
982    /// One run's cell as `(window, seed, periods)`.
983    type Cell<'a> = (&'a str, u64, &'a [&'a str]);
984
985    /// A field given as cells per agent. A cell with periods has one return per
986    /// period; a cell without has two returns and declares no period axis.
987    fn cells_field(entries: &[(&str, &[Cell<'_>])]) -> String {
988        let docs: Vec<serde_json::Value> = entries
989            .iter()
990            .map(|(agent_id, cells)| {
991                serde_json::json!({
992                    "agent_id": agent_id,
993                    "runs": cells
994                        .iter()
995                        .map(|(_, seed, periods)| serde_json::json!({
996                            "returns": (0..periods.len().max(2))
997                                .map(|i| 0.001 * (i as f64 + 1.0) + *seed as f64 * 0.0001)
998                                .collect::<Vec<_>>(),
999                        }))
1000                        .collect::<Vec<_>>(),
1001                    "run_keys": cells
1002                        .iter()
1003                        .map(|(window, seed, periods)| {
1004                            let mut key = serde_json::json!({"window": window, "seed": seed});
1005                            if !periods.is_empty() {
1006                                key["periods"] = serde_json::json!(periods);
1007                            }
1008                            key
1009                        })
1010                        .collect::<Vec<_>>(),
1011                })
1012            })
1013            .collect();
1014        serde_json::to_string(&docs).expect("serialize field")
1015    }
1016
1017    fn cell(window: &str, seed: u64) -> RunKey {
1018        RunKey {
1019            window: window.into(),
1020            seed,
1021        }
1022    }
1023
1024    fn overlap(
1025        period: &str,
1026        first: RunKey,
1027        first_agent_id: &str,
1028        second: RunKey,
1029        second_agent_id: &str,
1030    ) -> RunIdentityError {
1031        RunIdentityError::PeriodInTwoWindows(Box::new(PeriodOverlap {
1032            period: period.to_string(),
1033            first,
1034            first_agent_id: first_agent_id.to_string(),
1035            second,
1036            second_agent_id: second_agent_id.to_string(),
1037        }))
1038    }
1039
1040    #[test]
1041    fn seeds_of_one_window_share_its_periods() {
1042        let both: &[Cell<'_>] = &[
1043            ("w0", 0, &["a", "b"]),
1044            ("w0", 1, &["a", "b"]),
1045            ("w1", 0, &["c", "d"]),
1046            ("w1", 1, &["c", "d"]),
1047        ];
1048        let keyed = parse_keyed_field(&cells_field(&[("x", both), ("y", both)]))
1049            .expect("replicate seeds of one window are the grid, not a repeat");
1050        assert_eq!(keyed.seeds, vec![0, 1]);
1051        assert_eq!(keyed.windows, vec!["w0".to_string(), "w1".to_string()]);
1052    }
1053
1054    #[test]
1055    fn a_period_declared_in_two_windows_is_refused() {
1056        // w1 restates b, which w0 already covers: the pooled track would hold
1057        // b's return twice. Both agents declare it, so the cells agree.
1058        let overlapping: &[Cell<'_>] = &[("w0", 0, &["a", "b"]), ("w1", 0, &["b", "c"])];
1059        let error =
1060            parse_keyed_field(&cells_field(&[("x", overlapping), ("y", overlapping)])).unwrap_err();
1061        assert_eq!(error, overlap("b", cell("w0", 0), "x", cell("w1", 0), "x"));
1062        assert_eq!(
1063            error.to_string(),
1064            "run identity: period `b` is declared in (window `w0`, seed 0) by agent `x` \
1065             and in (window `w1`, seed 0) by agent `x`; a period belongs to one window, \
1066             and the pooled track would count its return twice. Seeds of one window may \
1067             share periods, different windows may not"
1068        );
1069
1070        // The same windows over the same periods, the shape of a field that
1071        // imported one window twice under two labels.
1072        let copied: &[Cell<'_>] = &[("w0", 0, &["a", "b"]), ("w1", 0, &["a", "b"])];
1073        assert_eq!(
1074            parse_keyed_field(&cells_field(&[("x", copied)])).unwrap_err(),
1075            overlap("a", cell("w0", 0), "x", cell("w1", 0), "x")
1076        );
1077
1078        // Disjoint windows are the control.
1079        let disjoint: &[Cell<'_>] = &[("w0", 0, &["a", "b"]), ("w1", 0, &["c", "d"])];
1080        assert!(parse_keyed_field(&cells_field(&[("x", disjoint)])).is_ok());
1081    }
1082
1083    #[test]
1084    fn a_period_in_two_windows_is_refused_whatever_the_seeds() {
1085        // At each seed the two windows are disjoint, but period c sits in w0
1086        // at seed 1 and in w1 at seed 0.
1087        let crossed: &[Cell<'_>] = &[
1088            ("w0", 0, &["a", "b"]),
1089            ("w0", 1, &["c", "d"]),
1090            ("w1", 0, &["c", "d"]),
1091            ("w1", 1, &["a", "b"]),
1092        ];
1093        assert_eq!(
1094            parse_keyed_field(&cells_field(&[("x", crossed)])).unwrap_err(),
1095            overlap("c", cell("w0", 1), "x", cell("w1", 0), "x")
1096        );
1097    }
1098
1099    #[test]
1100    fn a_period_in_two_windows_is_refused_when_different_agents_declare_them() {
1101        // No agent declares both windows, so no single submission repeats a
1102        // period. The cells still describe the same field: w0 (from x) and w1
1103        // (from y) both hold period b.
1104        let x: &[Cell<'_>] = &[("w0", 0, &["a", "b"]), ("w1", 0, &[])];
1105        let y: &[Cell<'_>] = &[("w0", 0, &[]), ("w1", 0, &["b", "c"])];
1106        assert_eq!(
1107            parse_keyed_field(&cells_field(&[("x", x), ("y", y)])).unwrap_err(),
1108            overlap("b", cell("w0", 0), "x", cell("w1", 0), "y")
1109        );
1110    }
1111
1112    #[test]
1113    fn a_keyed_field_still_parses_as_a_plain_submission_array() {
1114        // `run_keys` is a sidecar: existing readers ignore it.
1115        let json = field(&[("a", &[("w0", 0)])]);
1116        let subs: Vec<AgentSubmission> = serde_json::from_str(&json).expect("plain parse");
1117        assert_eq!(subs.len(), 1);
1118        assert_eq!(subs[0].runs.len(), 1);
1119    }
1120
1121    #[test]
1122    fn an_empty_field_has_no_grid() {
1123        assert_eq!(
1124            parse_keyed_field("[]").unwrap_err(),
1125            RunIdentityError::EmptyField
1126        );
1127    }
1128}