Skip to main content

sharpebench_protocol/
lib.rs

1//! The language-agnostic agent ⇄ harness protocol.
2//!
3//! Agents are **external** — a container or HTTP endpoint, in any language — not
4//! Rust code. Each decision step the harness sends a [`MarketObservation`] (JSON)
5//! and the agent replies with a [`Decision`] (JSON). Keeping this surface tiny and
6//! stable is what lets any vendor compete (and is the whole adoption story).
7//!
8//! All observations are **point-in-time**: `close_history`, `fundamentals` and
9//! `news` only ever contain information available at or before `date`.
10//!
11//! # The wire contract is closed (breaking for entrants as of 0.11.0)
12//!
13//! Every wire type carries `#[serde(deny_unknown_fields)]`. An agent that emits
14//! a key the contract does not define is rejected at the transport boundary and
15//! scored as an agent protocol fault, not silently accepted. This is a
16//! deliberate departure from the additive-only discipline the rest of the
17//! artifact formats follow: an attested benchmark cannot let an unread field
18//! carry meaning the scorer never saw.
19//!
20//! The authoritative machine-readable definition of the closed contract is
21//! published as JSON Schema (draft 2020-12) alongside this crate:
22//! `schema/decision.schema.json` and `schema/observation.schema.json`. Both set
23//! `additionalProperties: false` to mirror `deny_unknown_fields`, and a
24//! bidirectional drift guard (`tests/schema_drift.rs`) fails the build if the
25//! schema and the Rust types disagree in either direction.
26//!
27//! Entrants migrating from 0.10.x: drop any extra keys, or move them under
28//! `reasoning` (free text) or `cost` (structured spend). [`decision_from_wire`]
29//! produces the diagnostic that names the offending field.
30#![forbid(unsafe_code)]
31
32use std::collections::BTreeMap;
33
34use serde::{Deserialize, Serialize};
35
36/// What the agent sees at one decision point.
37#[derive(Clone, Debug, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct MarketObservation {
40    /// ISO-8601 date of the decision point.
41    pub date: String,
42    pub cash: f64,
43    pub symbols: Vec<SymbolSnapshot>,
44    pub portfolio: Vec<PositionState>,
45}
46
47/// Point-in-time data for one instrument.
48#[derive(Clone, Debug, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct SymbolSnapshot {
51    pub symbol: String,
52    /// Trailing closes up to and including `date` (oldest first).
53    pub close_history: Vec<f64>,
54    /// Named fundamental fields (e.g. `pe`, `revenue_yoy`). Empty if unavailable.
55    #[serde(default)]
56    pub fundamentals: BTreeMap<String, f64>,
57    /// Headlines published on or before `date`.
58    #[serde(default)]
59    pub news: Vec<String>,
60}
61
62/// The agent's current holding in one instrument.
63#[derive(Clone, Debug, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct PositionState {
66    pub symbol: String,
67    pub shares: f64,
68    pub avg_price: f64,
69}
70
71/// What the agent returns.
72#[derive(Clone, Debug, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct Decision {
75    pub orders: Vec<Order>,
76    /// Free-text rationale, captured into the trajectory for auditability.
77    #[serde(default)]
78    pub reasoning: String,
79    /// Optional self-reported compute/token spend for producing *this* decision.
80    /// The engine accumulates it into the run's `cost`, which drives the
81    /// cost-normalized leaderboard columns (`return_per_cost` / `dsr_per_cost` =
82    /// skill-per-dollar-of-compute). `None` = not reported, so existing agents
83    /// need no change and the cost columns stay `None` (back-compat).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub cost: Option<DecisionCost>,
86}
87
88/// An agent's self-reported spend to produce one decision. Every field defaults to
89/// zero so a partial report (e.g. tokens only, no dollar figure) still deserializes.
90/// The engine reduces this to a single scalar via [`DecisionCost::billable_units`].
91#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct DecisionCost {
94    /// Dollar cost of the compute/tokens spent on this decision. The preferred
95    /// unit for skill-per-dollar reporting.
96    #[serde(default)]
97    pub cost_usd: f64,
98    /// Prompt/input tokens consumed.
99    #[serde(default)]
100    pub tokens_in: u64,
101    /// Completion/output tokens produced.
102    #[serde(default)]
103    pub tokens_out: u64,
104    /// Reasoning/thinking tokens, reported as a legibility breakdown. Providers
105    /// typically already bill these inside `tokens_out`, so they are *not* re-added
106    /// into the token total; they are surfaced separately, not double-counted.
107    #[serde(default)]
108    pub reasoning_tokens: u64,
109}
110
111impl DecisionCost {
112    /// The single scalar the engine folds into `Run.cost` (any consistent unit,
113    /// matching the leaderboard's cost column). Prefers the reported dollar figure;
114    /// with no dollars reported it falls back to total billable tokens
115    /// (`tokens_in + tokens_out`). Reasoning tokens are a sub-breakdown of the
116    /// output and are not added again.
117    pub fn billable_units(&self) -> f64 {
118        if self.cost_usd > 0.0 {
119            self.cost_usd
120        } else {
121            (self.tokens_in + self.tokens_out) as f64
122        }
123    }
124}
125
126/// A single per-instrument instruction.
127#[derive(Clone, Debug, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct Order {
130    pub symbol: String,
131    pub action: Action,
132    /// Target portfolio weight for this symbol in [-1, 1]; negative values are shorts.
133    pub target_weight: f64,
134    /// Stated conviction in [0, 1]; scored for calibration.
135    #[serde(default = "default_confidence")]
136    pub confidence: f64,
137    /// Optional one-line rationale for *this* order, captured into the run trace
138    /// (audit trail). Defaults to empty so existing agents need no change.
139    #[serde(default)]
140    pub rationale: String,
141}
142
143/// Discrete action label (sizing is carried by `target_weight`).
144#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "snake_case")]
146pub enum Action {
147    Buy,
148    Sell,
149    Hold,
150    Close,
151}
152
153fn default_confidence() -> f64 {
154    0.5
155}
156
157/// Where an entrant finds the authoritative, machine-readable contract. Quoted
158/// into every wire-shape diagnostic so a failing agent is one link from the fix.
159pub const DECISION_SCHEMA_PATH: &str = "crates/sharpebench-protocol/schema/decision.schema.json";
160
161/// Deserialize a [`Decision`] from the wire, turning a contract violation into a
162/// diagnostic an entrant can act on.
163///
164/// The contract is closed ([`deny_unknown_fields`]), so the most common
165/// migration failure is an extra key. `serde` already knows which key that is
166/// and which keys were expected; the transports used to throw that away and
167/// report only an opaque protocol fault. This function keeps it, and points at
168/// the published schema.
169///
170/// This checks the object *shape* only. Semantic validity against the
171/// observation being answered is [`Decision::validate_for`].
172///
173/// [`deny_unknown_fields`]: https://serde.rs/container-attrs.html#deny_unknown_fields
174pub fn decision_from_wire(json: &str) -> Result<Decision, String> {
175    serde_json::from_str(json).map_err(|error| {
176        format!(
177            "decision rejected by the closed wire contract: {error}. \
178             Unknown fields are rejected rather than ignored; validate against {DECISION_SCHEMA_PATH} \
179             (additionalProperties: false) and move any extra payload into `reasoning` or `cost`."
180        )
181    })
182}
183
184impl Decision {
185    /// Validate the semantic part of the closed wire contract against the
186    /// observation this decision answers.  Deserialization enforces the object
187    /// shape; this method closes the gaps JSON Schema cannot express cheaply at
188    /// the transport boundary: point-in-time symbol membership, one target per
189    /// symbol, finite bounded weights/confidence, and nonnegative finite spend.
190    ///
191    /// `action` is deliberately not inferred from the target sign.  It is an
192    /// audit label: selling a long can leave a positive target, and buying to
193    /// cover can leave a negative one.  The signed target remains authoritative.
194    pub fn validate_for(&self, observation: &MarketObservation) -> Result<(), String> {
195        let offered = observation
196            .symbols
197            .iter()
198            .map(|snapshot| snapshot.symbol.as_str())
199            .collect::<std::collections::BTreeSet<_>>();
200        let mut seen = std::collections::BTreeSet::new();
201        for (index, order) in self.orders.iter().enumerate() {
202            if !offered.contains(order.symbol.as_str()) {
203                return Err(format!(
204                    "orders[{index}].symbol {:?} was not observed",
205                    order.symbol
206                ));
207            }
208            if !seen.insert(order.symbol.as_str()) {
209                return Err(format!("duplicate order for symbol {:?}", order.symbol));
210            }
211            if !order.target_weight.is_finite() || order.target_weight.abs() > 1.0 {
212                return Err(format!(
213                    "orders[{index}].target_weight must be finite and in [-1, 1]"
214                ));
215            }
216            if !order.confidence.is_finite() || !(0.0..=1.0).contains(&order.confidence) {
217                return Err(format!(
218                    "orders[{index}].confidence must be finite and in [0, 1]"
219                ));
220            }
221        }
222        if let Some(cost) = self.cost {
223            if !cost.cost_usd.is_finite() || cost.cost_usd < 0.0 {
224                return Err("cost.cost_usd must be finite and nonnegative".to_string());
225            }
226        }
227        Ok(())
228    }
229}
230
231/// One captured decision step of a single backtest run: the agent's *raw* output
232/// at one point-in-time observation. This is the persisted artifact — it holds the
233/// agent's [`Decision`] (orders, sizing, conviction, reasoning) tagged with the
234/// observation it was made against, and deliberately stores **no** returns, NAV, or
235/// any self-reported metric. The score is recomputed by replaying these decisions
236/// through the engine, never read from the agent's word.
237#[derive(Clone, Debug, Serialize, Deserialize)]
238pub struct DecisionStep {
239    /// 0-based step index within the run's window (`window.start + step` is the
240    /// dataset index the observation was drawn from).
241    pub step: usize,
242    /// Stable id of the point-in-time observation this decision answered — the
243    /// observation's ISO date. Lets a verifier confirm the decision lines up with
244    /// the frozen dataset's bar at the replayed step.
245    pub observation_id: String,
246    /// The agent's raw decision at this step (orders + reasoning).
247    pub decision: Decision,
248}
249
250/// One captured backtest run (a single window × seed): the ordered sequence of the
251/// agent's raw decision steps, plus the (window, seed) coordinates needed to replay
252/// it through the identical point-in-time engine path.
253#[derive(Clone, Debug, Serialize, Deserialize)]
254pub struct RunTrajectory {
255    /// Inclusive window start (dataset index of the first decision step).
256    pub window_start: usize,
257    /// Exclusive window end.
258    pub window_end: usize,
259    /// Execution seed the run was driven with (governs slippage noise on replay).
260    pub seed: u64,
261    /// The raw decisions, in step order.
262    pub steps: Vec<DecisionStep>,
263}
264
265/// Identity of the execution environment that produced a raw-decision
266/// trajectory. The score configuration is intentionally absent: a trajectory
267/// may be regraded under a newer scorer, but it must not be replayed against
268/// different market data, costs, or engine semantics while being described as
269/// the original run.
270#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
271pub struct TrajectoryWindow {
272    pub start: usize,
273    pub end: usize,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277pub struct TrajectoryContract {
278    pub schema_version: u32,
279    pub dataset_sha256: String,
280    pub cost_model_sha256: String,
281    pub engine_version: String,
282    /// Exact market windows the capture planned, in execution order.
283    #[serde(default)]
284    pub windows: Vec<TrajectoryWindow>,
285    /// Exact execution seeds applied to every window, in execution order.
286    #[serde(default)]
287    pub seeds: Vec<u64>,
288    /// Exact CLI executable when capture came through the command line. Library
289    /// callers may leave this absent and still bind the semantic inputs above.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub runner_artifact_sha256: Option<String>,
292}
293
294impl TrajectoryContract {
295    pub const SCHEMA_VERSION: u32 = 2;
296}
297
298/// The mandate an agent declares at submission: which **reliability verdict** it
299/// asks to be judged under. Opt-in and additive: a submission with no
300/// declaration is scored exactly as before.
301///
302/// A declaration selects the per-run series and aggregation of the pass^k gate
303/// and, for [`DeclaredMandate::DrawdownCapped`], adds a per-run drawdown bound.
304/// It never relaxes anything: the deflated-Sharpe bar, the block bootstrap, the
305/// process audit and the host's drawdown mandate are computed on the agent's raw
306/// returns under every declaration, and the host board's own verdict is still
307/// applied and still decides rank. The declared verdict is reported beside it,
308/// labeled, so a reader sees both "meets its declared mandate" and "is not
309/// all-weather" on one row. Serialized internally tagged in snake case, e.g.
310/// `{"kind":"relative_to","benchmark_id":"buy-and-hold"}`.
311#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
312#[serde(tag = "kind", rename_all = "snake_case")]
313pub enum DeclaredMandate {
314    /// Profitable in every regime: per-run PSR on raw returns, every run must
315    /// pass. The verdict the benchmark applies when nothing is declared.
316    AbsoluteReturn,
317    /// Beats the named benchmark agent in every regime: per-run PSR on the
318    /// excess return over `benchmark_id`'s run in the same (window, seed) cell,
319    /// every run must pass. The benchmark must be in the field being ranked; a
320    /// missing or misaligned benchmark fails every run rather than falling
321    /// back to the absolute test.
322    RelativeTo { benchmark_id: String },
323    /// Never catastrophic in any regime: at least one run clears the per-run
324    /// PSR bar, and no single run draws down more than `max_per_run_drawdown`
325    /// (in `(0, 1]`; a bound outside that range is a misdeclaration and fails).
326    DrawdownCapped { max_per_run_drawdown: f64 },
327    /// Beats the field's same-cell buy-and-hold reference in every regime.
328    /// This is an excess-return mandate, not a statement that the agent itself
329    /// is long-only or beta-tracking. The former `long_only_beta` wire spelling
330    /// remains accepted only for backward-compatible reads.
331    #[serde(rename = "outperform_buy_and_hold", alias = "long_only_beta")]
332    OutperformBuyAndHold,
333}
334
335/// An agent's full captured trajectory: every (window × seed) run's raw decisions.
336/// Serde-(de)serializable to JSON; this is the on-disk artifact a separate verifier
337/// ingests to recompute the score from raw decisions alone.
338#[derive(Clone, Debug, Serialize, Deserialize)]
339pub struct AgentTrajectory {
340    pub agent_id: String,
341    /// The data, costs, and engine that produced the decisions. Absent only on
342    /// legacy or deliberately unbound artifacts.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub contract: Option<TrajectoryContract>,
345    /// In-sample search budget the agent declared (mirrors `AgentSubmission`), so a
346    /// recomputed submission carries the same deflation footprint.
347    #[serde(default)]
348    pub in_sample_trials: u32,
349    /// The mandate the agent declared at submission (see [`DeclaredMandate`]).
350    /// `None` = undeclared, the default; the artifact's bytes are unchanged for
351    /// every existing trajectory.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub declared_mandate: Option<DeclaredMandate>,
354    /// One captured run per (window, seed), in the same order the harness produced
355    /// them (window-major: all seeds of window 0, then window 1, …).
356    pub runs: Vec<RunTrajectory>,
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn observation_and_decision_roundtrip() {
365        let obs = MarketObservation {
366            date: "2025-01-01".to_string(),
367            cash: 1.0,
368            symbols: vec![SymbolSnapshot {
369                symbol: "A".to_string(),
370                close_history: vec![1.0, 2.0],
371                fundamentals: Default::default(),
372                news: vec!["headline".to_string()],
373            }],
374            portfolio: vec![PositionState {
375                symbol: "A".to_string(),
376                shares: 1.0,
377                avg_price: 2.0,
378            }],
379        };
380        let back: MarketObservation =
381            serde_json::from_str(&serde_json::to_string(&obs).unwrap()).unwrap();
382        assert_eq!(back.symbols[0].symbol, "A");
383
384        let d = Decision {
385            orders: vec![Order {
386                symbol: "A".to_string(),
387                action: Action::Buy,
388                target_weight: 0.5,
389                confidence: 0.9,
390                rationale: "trailing breakout".to_string(),
391            }],
392            reasoning: "r".to_string(),
393            cost: None,
394        };
395        let db: Decision = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
396        assert_eq!(db.orders[0].action, Action::Buy);
397        // The per-order rationale survives the JSON round-trip into the trajectory.
398        assert_eq!(db.orders[0].rationale, "trailing breakout");
399
400        // Older agents that omit `rationale` still deserialize (default empty).
401        let legacy = r#"{"orders":[{"symbol":"A","action":"buy","target_weight":0.5}]}"#;
402        let parsed: Decision = serde_json::from_str(legacy).unwrap();
403        assert_eq!(parsed.orders[0].rationale, "");
404        assert!((parsed.orders[0].confidence - 0.5).abs() < 1e-12);
405        // A legacy decision omits `cost` entirely (back-compat → None).
406        assert!(parsed.cost.is_none());
407    }
408
409    #[test]
410    fn decision_cost_channel_parses_and_reduces() {
411        // An agent self-reporting spend: dollars present → billable = dollars.
412        let with_cost = r#"{"orders":[],"reasoning":"","cost":{"cost_usd":0.42,
413            "tokens_in":1200,"tokens_out":300,"reasoning_tokens":180}}"#;
414        let d: Decision = serde_json::from_str(with_cost).unwrap();
415        let c = d.cost.expect("cost channel present");
416        assert!((c.cost_usd - 0.42).abs() < 1e-12);
417        assert_eq!(c.tokens_in, 1200);
418        assert!((c.billable_units() - 0.42).abs() < 1e-12);
419
420        // Tokens-only report (no dollars) → billable = tokens_in + tokens_out;
421        // reasoning tokens are a sub-breakdown of the output, not re-added.
422        let tokens_only = DecisionCost {
423            cost_usd: 0.0,
424            tokens_in: 1000,
425            tokens_out: 250,
426            reasoning_tokens: 200,
427        };
428        assert!((tokens_only.billable_units() - 1250.0).abs() < 1e-12);
429
430        // `cost` round-trips through JSON.
431        let d2 = Decision {
432            orders: Vec::new(),
433            reasoning: String::new(),
434            cost: Some(tokens_only),
435        };
436        let back: Decision = serde_json::from_str(&serde_json::to_string(&d2).unwrap()).unwrap();
437        assert_eq!(back.cost, Some(tokens_only));
438    }
439
440    #[test]
441    fn closed_decision_contract_rejects_drift_and_semantic_faults() {
442        let obs = MarketObservation {
443            date: "2026-01-01".to_string(),
444            cash: 1.0,
445            symbols: vec![SymbolSnapshot {
446                symbol: "A".to_string(),
447                close_history: vec![1.0],
448                fundamentals: Default::default(),
449                news: Vec::new(),
450            }],
451            portfolio: Vec::new(),
452        };
453        assert!(serde_json::from_str::<Decision>(r#"{"orders":[],"typo":true}"#).is_err());
454
455        let order = |symbol: &str, weight: f64| Order {
456            symbol: symbol.to_string(),
457            action: Action::Sell,
458            target_weight: weight,
459            confidence: 0.5,
460            rationale: String::new(),
461        };
462        let valid = Decision {
463            orders: vec![order("A", -0.5)],
464            reasoning: String::new(),
465            cost: None,
466        };
467        assert!(valid.validate_for(&obs).is_ok());
468
469        for invalid in [
470            Decision {
471                orders: vec![order("UNKNOWN", 0.0)],
472                reasoning: String::new(),
473                cost: None,
474            },
475            Decision {
476                orders: vec![order("A", 0.1), order("A", 0.2)],
477                reasoning: String::new(),
478                cost: None,
479            },
480            Decision {
481                orders: vec![order("A", 1.01)],
482                reasoning: String::new(),
483                cost: None,
484            },
485        ] {
486            assert!(invalid.validate_for(&obs).is_err());
487        }
488    }
489
490    #[test]
491    fn unknown_field_diagnostic_names_the_offending_field() {
492        let error = decision_from_wire(r#"{"orders":[],"latency_ms":12}"#)
493            .expect_err("the closed contract rejects an undefined key");
494        assert!(
495            error.contains("latency_ms"),
496            "the diagnostic must name the offending field, got: {error}"
497        );
498        assert!(
499            error.contains("orders") && error.contains("reasoning") && error.contains("cost"),
500            "the diagnostic must list the accepted fields, got: {error}"
501        );
502        assert!(
503            error.contains(DECISION_SCHEMA_PATH),
504            "the diagnostic must point at the published schema, got: {error}"
505        );
506
507        // A shape fault that is not an unknown field still gets a diagnostic
508        // rather than an opaque failure.
509        let malformed = decision_from_wire("not json").expect_err("malformed input is rejected");
510        assert!(malformed.contains("closed wire contract"));
511
512        // The happy path is unchanged: a conforming decision parses.
513        let ok =
514            decision_from_wire(r#"{"orders":[{"symbol":"A","action":"buy","target_weight":0.5}]}"#)
515                .expect("a conforming decision parses");
516        assert_eq!(ok.orders[0].symbol, "A");
517    }
518
519    #[test]
520    fn trajectory_roundtrips_through_json() {
521        let traj = AgentTrajectory {
522            agent_id: "a".to_string(),
523            contract: None,
524            in_sample_trials: 7,
525            declared_mandate: None,
526            runs: vec![RunTrajectory {
527                window_start: 20,
528                window_end: 30,
529                seed: 3,
530                steps: vec![DecisionStep {
531                    step: 0,
532                    observation_id: "2025-001".to_string(),
533                    decision: Decision {
534                        orders: vec![Order {
535                            symbol: "A".to_string(),
536                            action: Action::Buy,
537                            target_weight: 0.25,
538                            confidence: 0.8,
539                            rationale: String::new(),
540                        }],
541                        reasoning: "r".to_string(),
542                        cost: None,
543                    },
544                }],
545            }],
546        };
547        let back: AgentTrajectory =
548            serde_json::from_str(&serde_json::to_string(&traj).unwrap()).unwrap();
549        assert_eq!(back.agent_id, "a");
550        assert_eq!(back.in_sample_trials, 7);
551        assert_eq!(back.runs[0].seed, 3);
552        assert_eq!(back.runs[0].steps[0].observation_id, "2025-001");
553        assert_eq!(back.runs[0].steps[0].decision.orders[0].target_weight, 0.25);
554        // An undeclared mandate is absent from the bytes, not serialized as null.
555        assert!(!serde_json::to_string(&traj)
556            .unwrap()
557            .contains("declared_mandate"));
558        assert!(back.declared_mandate.is_none());
559    }
560
561    #[test]
562    fn declared_mandate_is_additive_and_round_trips() {
563        // Every trajectory written before the field existed still parses.
564        let legacy = r#"{"agent_id":"a","runs":[]}"#;
565        let t: AgentTrajectory = serde_json::from_str(legacy).unwrap();
566        assert!(t.declared_mandate.is_none());
567
568        for (m, json) in [
569            (
570                DeclaredMandate::AbsoluteReturn,
571                r#"{"kind":"absolute_return"}"#,
572            ),
573            (
574                DeclaredMandate::RelativeTo {
575                    benchmark_id: "buy-and-hold".to_string(),
576                },
577                r#"{"kind":"relative_to","benchmark_id":"buy-and-hold"}"#,
578            ),
579            (
580                DeclaredMandate::DrawdownCapped {
581                    max_per_run_drawdown: 0.2,
582                },
583                r#"{"kind":"drawdown_capped","max_per_run_drawdown":0.2}"#,
584            ),
585            (
586                DeclaredMandate::OutperformBuyAndHold,
587                r#"{"kind":"outperform_buy_and_hold"}"#,
588            ),
589        ] {
590            assert_eq!(serde_json::to_string(&m).unwrap(), json);
591            assert_eq!(serde_json::from_str::<DeclaredMandate>(json).unwrap(), m);
592        }
593
594        let declared = AgentTrajectory {
595            agent_id: "a".to_string(),
596            contract: None,
597            in_sample_trials: 0,
598            declared_mandate: Some(DeclaredMandate::OutperformBuyAndHold),
599            runs: Vec::new(),
600        };
601        let back: AgentTrajectory =
602            serde_json::from_str(&serde_json::to_string(&declared).unwrap()).unwrap();
603        assert_eq!(
604            back.declared_mandate,
605            Some(DeclaredMandate::OutperformBuyAndHold)
606        );
607        assert_eq!(
608            serde_json::from_str::<DeclaredMandate>(r#"{"kind":"long_only_beta"}"#).unwrap(),
609            DeclaredMandate::OutperformBuyAndHold,
610            "old artifacts remain readable but are re-emitted under the honest name"
611        );
612    }
613}