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//!
31//! # Decisions must be deterministic under re-execution
32//!
33//! A run may be executed more than once: a runtime failure is retried by
34//! restarting the run from its first step with a fresh agent, an interrupted
35//! sweep resumes by running its unfinished runs again, and a verifier may
36//! re-execute a captured trajectory to check it. Each decision must therefore
37//! be a deterministic function of the observations of the same run up to and
38//! including the one being answered, and of the agent's own earlier decisions
39//! in that run. Behaviour that depends on anything else (wall-clock time,
40//! ambient randomness, or state carried in from another run or from outside
41//! the run) may diverge on re-execution, and re-execution verification refuses
42//! a run that diverges.
43//!
44//! What must repeat is the score-bearing decision: every order's `symbol`,
45//! `action`, `target_weight` and `confidence`, and the `cost` report.
46//! `reasoning` and each order's `rationale` are audit text that the scorer
47//! never reads, so they may differ. An agent that wants randomness must derive
48//! it from the observation stream it was given.
49//!
50//! The harness checks this in `sharpebench_harness::verify_trajectory_reexecuted`:
51//! after the strict artifact checks pass, every captured run is re-executed
52//! with a fresh agent on the same frozen data, window and seed, and the first
53//! decision that differs is refused as a typed `ReexecutionDivergence` naming
54//! the run, the step and the observation, never accepted as a silently
55//! different run. Replaying recorded decisions alone
56//! (`sharpebench_harness::verify_trajectory_strict`) is exact by construction
57//! and cannot see a non-deterministic agent; re-execution is the check that
58//! can. A sweep's own retries and resumes do not compare a rerun against the
59//! attempt it replaced, so a non-deterministic agent is caught when its
60//! trajectory is re-executed, not while the sweep runs. The command line
61//! exposes the check as `sharpebench verify-trajectory --reexecute`.
62//!
63//! # Consistency relaxations a fault plan may declare
64//!
65//! An operator may run an entrant under a seeded fault plan (`sharpebench run
66//! --fault-plan`, `sharpebench_harness::fault_plan`). A plan must declare
67//! exactly the relaxations its faults use, or it is refused, and the operator
68//! publishes that declaration to the entrant before the sweep. A faulted
69//! observation may violate only a declared relaxation, only for a bounded
70//! number of decision steps or presentations (at most 64), and never in the
71//! book: every fault changes what the entrant is shown or which of its
72//! submissions is accepted, never the executed book or the returns the scorer
73//! reads. The wire shape is the same under every relaxation, and without a
74//! plan none of them applies. Each relaxation is named here by its wire name
75//! in the plan's `declared_relaxations`:
76//!
77//! - `read_your_writes`: after an order executes, `cash` and `portfolio` on
78//!   later observations may show their pre-execution values for a bounded
79//!   number of decision steps. The order has executed and the book is
80//!   authoritative; `date` and `symbols` stay current. The harness records
81//!   convergence when an observation is seen to carry the canonical holdings
82//!   again, not when a timer expires. Restating the last target is the
83//!   idempotent response; pushing the target further in the direction of the
84//!   hidden write is graded as escalation.
85//! - `position_sign_convention`: a nonzero `portfolio[].shares` may be shown
86//!   with the opposite sign for a bounded number of decision steps. Zero has
87//!   no sign and is left alone, and the book is unchanged. The response is
88//!   graded against the entrant's own last stated target for the symbol.
89//! - `submission_acceptance`: a decision that carries orders may be rejected
90//!   under a rate limit. Rejection is signalled by presenting the identical
91//!   observation again, a bounded number of times; only the decision
92//!   answering the first presentation after that deadline executes. A hold is
93//!   never rejected. A deterministic entrant restates its decision and loses
94//!   nothing.
95//! - `complete_results`: declared for completeness only. No plan can arm it,
96//!   because the observation contract has no paged read.
97//!
98//! The grades of the entrant's responses are rank-neutral evidence on the
99//! attempt ledger. They never enter a return, a score or a rank.
100//!
101//! The declared metadata of each wire operation (does it change state, is it
102//! safe to repeat, may the harness retry it) is published beside the schema;
103//! see [`operations`].
104#![forbid(unsafe_code)]
105
106pub mod canonical;
107pub mod operations;
108
109pub use operations::{
110    operation_contract_preimage, AutomaticRetries, Idempotency, Operation, OperationMetadata,
111    OPERATIONS,
112};
113
114use std::collections::BTreeMap;
115
116use serde::{Deserialize, Serialize};
117
118/// What the agent sees at one decision point.
119#[derive(Clone, Debug, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct MarketObservation {
122    /// ISO-8601 date of the decision point.
123    pub date: String,
124    pub cash: f64,
125    pub symbols: Vec<SymbolSnapshot>,
126    pub portfolio: Vec<PositionState>,
127}
128
129/// Point-in-time data for one instrument.
130#[derive(Clone, Debug, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct SymbolSnapshot {
133    pub symbol: String,
134    /// Trailing closes up to and including `date` (oldest first).
135    pub close_history: Vec<f64>,
136    /// Named fundamental fields (e.g. `pe`, `revenue_yoy`). Empty if unavailable.
137    #[serde(default)]
138    pub fundamentals: BTreeMap<String, f64>,
139    /// Headlines published on or before `date`.
140    #[serde(default)]
141    pub news: Vec<String>,
142}
143
144/// The agent's current holding in one instrument.
145#[derive(Clone, Debug, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct PositionState {
148    pub symbol: String,
149    pub shares: f64,
150    pub avg_price: f64,
151}
152
153/// What the agent returns.
154#[derive(Clone, Debug, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct Decision {
157    pub orders: Vec<Order>,
158    /// Free-text rationale, captured into the trajectory for auditability.
159    #[serde(default)]
160    pub reasoning: String,
161    /// Optional self-reported compute/token spend for producing *this* decision.
162    /// The engine accumulates it into the run's `cost`, which drives the
163    /// cost-normalized leaderboard columns (`return_per_cost` / `dsr_per_cost` =
164    /// skill-per-dollar-of-compute). `None` = not reported, so existing agents
165    /// need no change and the cost columns stay `None` (back-compat).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub cost: Option<DecisionCost>,
168}
169
170/// An agent's self-reported spend to produce one decision. Every field defaults to
171/// zero so a partial report (e.g. tokens only, no dollar figure) still deserializes.
172/// The engine reduces this to a single scalar via [`DecisionCost::billable_units`].
173#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct DecisionCost {
176    /// Dollar cost of the compute/tokens spent on this decision. The preferred
177    /// unit for skill-per-dollar reporting.
178    #[serde(default)]
179    pub cost_usd: f64,
180    /// Prompt/input tokens consumed.
181    #[serde(default)]
182    pub tokens_in: u64,
183    /// Completion/output tokens produced.
184    #[serde(default)]
185    pub tokens_out: u64,
186    /// Reasoning/thinking tokens, reported as a legibility breakdown. Providers
187    /// typically already bill these inside `tokens_out`, so they are *not* re-added
188    /// into the token total; they are surfaced separately, not double-counted.
189    #[serde(default)]
190    pub reasoning_tokens: u64,
191}
192
193impl DecisionCost {
194    /// The single scalar the engine folds into `Run.cost` (any consistent unit,
195    /// matching the leaderboard's cost column). Prefers the reported dollar figure;
196    /// with no dollars reported it falls back to total billable tokens
197    /// (`tokens_in + tokens_out`). Reasoning tokens are a sub-breakdown of the
198    /// output and are not added again.
199    ///
200    /// The counts arrive from the entrant over an untrusted transport and no
201    /// semantic rule bounds them, so the total saturates rather than adding
202    /// unchecked: an unchecked sum panics the sweep where overflow checks are on
203    /// and wraps toward a near-zero cost where they are off, and a wrapped cost
204    /// flatters the cost-normalized columns of the agent that reported it.
205    pub fn billable_units(&self) -> f64 {
206        if self.cost_usd > 0.0 {
207            self.cost_usd
208        } else {
209            self.tokens_in.saturating_add(self.tokens_out) as f64
210        }
211    }
212}
213
214/// A single per-instrument instruction.
215#[derive(Clone, Debug, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct Order {
218    pub symbol: String,
219    pub action: Action,
220    /// Target portfolio weight for this symbol in [-1, 1]; negative values are shorts.
221    pub target_weight: f64,
222    /// Stated conviction in [0, 1]; scored for calibration. `None` when the
223    /// agent states none: an omitted confidence stays absent on the wire and in
224    /// a captured trajectory, no default value replaces it, and it adds no
225    /// calibration pair. A present key must carry a number; deserialization
226    /// refuses `null`.
227    #[serde(
228        default,
229        deserialize_with = "stated_confidence",
230        skip_serializing_if = "Option::is_none"
231    )]
232    pub confidence: Option<f64>,
233    /// Optional one-line rationale for *this* order, captured into the run trace
234    /// (audit trail). Defaults to empty so existing agents need no change.
235    #[serde(default)]
236    pub rationale: String,
237}
238
239/// Discrete action label (sizing is carried by `target_weight`).
240#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
241#[serde(rename_all = "snake_case")]
242pub enum Action {
243    Buy,
244    Sell,
245    Hold,
246    Close,
247}
248
249/// A present `confidence` key is a statement, so it must be a number. Only an
250/// omitted key means "not stated".
251fn stated_confidence<'de, D: serde::Deserializer<'de>>(
252    deserializer: D,
253) -> Result<Option<f64>, D::Error> {
254    f64::deserialize(deserializer).map(Some)
255}
256
257/// Where an entrant finds the authoritative, machine-readable contract. Quoted
258/// into every wire-shape diagnostic so a failing agent is one link from the fix.
259pub const DECISION_SCHEMA_PATH: &str = "crates/sharpebench-protocol/schema/decision.schema.json";
260
261/// Deserialize a [`Decision`] from the wire, turning a contract violation into a
262/// diagnostic an entrant can act on.
263///
264/// The contract is closed ([`deny_unknown_fields`]), so the most common
265/// migration failure is an extra key. `serde` already knows which key that is
266/// and which keys were expected; the transports used to throw that away and
267/// report only an opaque protocol fault. This function keeps it, and points at
268/// the published schema.
269///
270/// This checks the object *shape* only. Semantic validity against the
271/// observation being answered is [`Decision::validate_for`].
272///
273/// [`deny_unknown_fields`]: https://serde.rs/container-attrs.html#deny_unknown_fields
274pub fn decision_from_wire(json: &str) -> Result<Decision, String> {
275    serde_json::from_str(json).map_err(|error| {
276        format!(
277            "decision rejected by the closed wire contract: {error}. \
278             Unknown fields are rejected rather than ignored; validate against {DECISION_SCHEMA_PATH} \
279             (additionalProperties: false) and move any extra payload into `reasoning` or `cost`."
280        )
281    })
282}
283
284impl Decision {
285    /// Validate the semantic part of the closed wire contract against the
286    /// observation this decision answers.  Deserialization enforces the object
287    /// shape; this method closes the gaps JSON Schema cannot express cheaply at
288    /// the transport boundary: point-in-time symbol membership, one target per
289    /// symbol, finite bounded weights/confidence, and nonnegative finite spend.
290    ///
291    /// `action` is deliberately not inferred from the target sign.  It is an
292    /// audit label: selling a long can leave a positive target, and buying to
293    /// cover can leave a negative one.  The signed target remains authoritative.
294    pub fn validate_for(&self, observation: &MarketObservation) -> Result<(), String> {
295        let offered = observation
296            .symbols
297            .iter()
298            .map(|snapshot| snapshot.symbol.as_str())
299            .collect::<std::collections::BTreeSet<_>>();
300        let mut seen = std::collections::BTreeSet::new();
301        for (index, order) in self.orders.iter().enumerate() {
302            if !offered.contains(order.symbol.as_str()) {
303                return Err(format!(
304                    "orders[{index}].symbol {:?} was not observed",
305                    order.symbol
306                ));
307            }
308            if !seen.insert(order.symbol.as_str()) {
309                return Err(format!("duplicate order for symbol {:?}", order.symbol));
310            }
311            if !order.target_weight.is_finite() || order.target_weight.abs() > 1.0 {
312                return Err(format!(
313                    "orders[{index}].target_weight must be finite and in [-1, 1]"
314                ));
315            }
316            if let Some(confidence) = order.confidence {
317                if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
318                    return Err(format!(
319                        "orders[{index}].confidence must be finite and in [0, 1]"
320                    ));
321                }
322            }
323        }
324        if let Some(cost) = self.cost {
325            if !cost.cost_usd.is_finite() || cost.cost_usd < 0.0 {
326                return Err("cost.cost_usd must be finite and nonnegative".to_string());
327            }
328        }
329        Ok(())
330    }
331}
332
333/// One captured decision step of a single backtest run: the agent's *raw* output
334/// at one point-in-time observation. This is the persisted artifact — it holds the
335/// agent's [`Decision`] (orders, sizing, conviction, reasoning) tagged with the
336/// observation it was made against, and deliberately stores **no** returns, NAV, or
337/// any self-reported metric. The score is recomputed by replaying these decisions
338/// through the engine, never read from the agent's word.
339#[derive(Clone, Debug, Serialize, Deserialize)]
340pub struct DecisionStep {
341    /// 0-based step index within the run's window (`window.start + step` is the
342    /// dataset index the observation was drawn from).
343    pub step: usize,
344    /// Stable id of the point-in-time observation this decision answered — the
345    /// observation's ISO date. Lets a verifier confirm the decision lines up with
346    /// the frozen dataset's bar at the replayed step.
347    pub observation_id: String,
348    /// The agent's raw decision at this step (orders + reasoning).
349    pub decision: Decision,
350}
351
352/// One captured backtest run (a single window × seed): the ordered sequence of the
353/// agent's raw decision steps, plus the (window, seed) coordinates needed to replay
354/// it through the identical point-in-time engine path.
355#[derive(Clone, Debug, Serialize, Deserialize)]
356pub struct RunTrajectory {
357    /// Inclusive window start (dataset index of the first decision step).
358    pub window_start: usize,
359    /// Exclusive window end.
360    pub window_end: usize,
361    /// Execution seed the run was driven with (governs slippage noise on replay).
362    pub seed: u64,
363    /// The raw decisions, in step order.
364    pub steps: Vec<DecisionStep>,
365}
366
367/// Identity of the execution environment that produced a raw-decision
368/// trajectory. The score configuration is intentionally absent: a trajectory
369/// may be regraded under a newer scorer, but it must not be replayed against
370/// different market data, costs, or engine semantics while being described as
371/// the original run.
372#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
373pub struct TrajectoryWindow {
374    pub start: usize,
375    pub end: usize,
376}
377
378#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
379pub struct TrajectoryContract {
380    pub schema_version: u32,
381    pub dataset_sha256: String,
382    pub cost_model_sha256: String,
383    pub engine_version: String,
384    /// Exact market windows the capture planned, in execution order.
385    #[serde(default)]
386    pub windows: Vec<TrajectoryWindow>,
387    /// Exact execution seeds applied to every window, in execution order.
388    #[serde(default)]
389    pub seeds: Vec<u64>,
390    /// Exact CLI executable when capture came through the command line. Library
391    /// callers may leave this absent and still bind the semantic inputs above.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub runner_artifact_sha256: Option<String>,
394}
395
396impl TrajectoryContract {
397    /// Bumped to 3 when [`Order::confidence`] became optional. A schema-2
398    /// capture serialized a confidence on every order, including the 0.5 the
399    /// wire default filled in for an entrant that stated none, so its recorded
400    /// decisions cannot tell a stated 0.5 from an unstated one. Replayed by the
401    /// current engine, every such value would count as a stated calibration
402    /// pair. Strict verification refuses the older schema instead of reading
403    /// it that way.
404    pub const SCHEMA_VERSION: u32 = 3;
405}
406
407/// The mandate an agent declares at submission: which **reliability verdict** it
408/// asks to be judged under. Opt-in and additive: a submission with no
409/// declaration is scored exactly as before.
410///
411/// A declaration selects the per-run series and aggregation of the pass^k gate
412/// and, for [`DeclaredMandate::DrawdownCapped`], adds a per-run drawdown bound.
413/// It never relaxes anything: the deflated-Sharpe bar, the block bootstrap, the
414/// process audit and the host's drawdown mandate are computed on the agent's raw
415/// returns under every declaration, and the host board's own verdict is still
416/// applied and still decides rank. The declared verdict is reported beside it,
417/// labeled, so a reader sees both "meets its declared mandate" and "is not
418/// all-weather" on one row. Serialized internally tagged in snake case, e.g.
419/// `{"kind":"relative_to","benchmark_id":"buy-and-hold"}`.
420#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
421#[serde(tag = "kind", rename_all = "snake_case")]
422pub enum DeclaredMandate {
423    /// Profitable in every regime: per-run PSR on raw returns, every run must
424    /// pass. The verdict the benchmark applies when nothing is declared.
425    AbsoluteReturn,
426    /// Beats the named benchmark agent in every regime: per-run PSR on the
427    /// excess return over `benchmark_id`'s run in the same (window, seed) cell,
428    /// every run must pass. The benchmark must be in the field being ranked; a
429    /// missing or misaligned benchmark fails every run rather than falling
430    /// back to the absolute test.
431    RelativeTo { benchmark_id: String },
432    /// Never catastrophic in any regime: at least one run clears the per-run
433    /// PSR bar, and no single run draws down more than `max_per_run_drawdown`
434    /// (in `(0, 1]`; a bound outside that range is a misdeclaration and fails).
435    DrawdownCapped { max_per_run_drawdown: f64 },
436    /// Beats the field's same-cell buy-and-hold reference in every regime.
437    /// This is an excess-return mandate, not a statement that the agent itself
438    /// is long-only or beta-tracking. The former `long_only_beta` wire spelling
439    /// remains accepted only for backward-compatible reads.
440    #[serde(rename = "outperform_buy_and_hold", alias = "long_only_beta")]
441    OutperformBuyAndHold,
442}
443
444/// An agent's full captured trajectory: every (window × seed) run's raw decisions.
445/// Serde-(de)serializable to JSON; this is the on-disk artifact a separate verifier
446/// ingests to recompute the score from raw decisions alone.
447#[derive(Clone, Debug, Serialize, Deserialize)]
448pub struct AgentTrajectory {
449    pub agent_id: String,
450    /// The data, costs, and engine that produced the decisions. Absent only on
451    /// legacy or deliberately unbound artifacts.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub contract: Option<TrajectoryContract>,
454    /// In-sample search budget the agent declared (mirrors `AgentSubmission`), so a
455    /// recomputed submission carries the same deflation footprint.
456    #[serde(default)]
457    pub in_sample_trials: u32,
458    /// The mandate the agent declared at submission (see [`DeclaredMandate`]).
459    /// `None` = undeclared, the default; the artifact's bytes are unchanged for
460    /// every existing trajectory.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub declared_mandate: Option<DeclaredMandate>,
463    /// One captured run per (window, seed), in the same order the harness produced
464    /// them (window-major: all seeds of window 0, then window 1, …).
465    pub runs: Vec<RunTrajectory>,
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn observation_and_decision_roundtrip() {
474        let obs = MarketObservation {
475            date: "2025-01-01".to_string(),
476            cash: 1.0,
477            symbols: vec![SymbolSnapshot {
478                symbol: "A".to_string(),
479                close_history: vec![1.0, 2.0],
480                fundamentals: Default::default(),
481                news: vec!["headline".to_string()],
482            }],
483            portfolio: vec![PositionState {
484                symbol: "A".to_string(),
485                shares: 1.0,
486                avg_price: 2.0,
487            }],
488        };
489        let back: MarketObservation =
490            serde_json::from_str(&serde_json::to_string(&obs).unwrap()).unwrap();
491        assert_eq!(back.symbols[0].symbol, "A");
492
493        let d = Decision {
494            orders: vec![Order {
495                symbol: "A".to_string(),
496                action: Action::Buy,
497                target_weight: 0.5,
498                confidence: Some(0.9),
499                rationale: "trailing breakout".to_string(),
500            }],
501            reasoning: "r".to_string(),
502            cost: None,
503        };
504        let db: Decision = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
505        assert_eq!(db.orders[0].action, Action::Buy);
506        // The per-order rationale survives the JSON round-trip into the trajectory.
507        assert_eq!(db.orders[0].rationale, "trailing breakout");
508
509        // Older agents that omit `rationale` still deserialize (default empty).
510        let legacy = r#"{"orders":[{"symbol":"A","action":"buy","target_weight":0.5}]}"#;
511        let parsed: Decision = serde_json::from_str(legacy).unwrap();
512        assert_eq!(parsed.orders[0].rationale, "");
513        assert_eq!(
514            parsed.orders[0].confidence, None,
515            "an omitted confidence is unstated, not a default value"
516        );
517        // A legacy decision omits `cost` entirely (back-compat → None).
518        assert!(parsed.cost.is_none());
519    }
520
521    /// An entrant supplies its own token counts over an untrusted transport, so
522    /// the reduction has to survive the extremes of the declared `u64` type. An
523    /// unchecked `tokens_in + tokens_out` panics the sweep where overflow checks
524    /// are on and wraps to a near-zero cost where they are off, which is the
525    /// favorable direction for the cost-normalized columns.
526    #[test]
527    fn extreme_token_counts_saturate_instead_of_wrapping() {
528        let wire = format!(
529            r#"{{"orders":[],"reasoning":"","cost":{{"cost_usd":0.0,
530            "tokens_in":{},"tokens_out":1,"reasoning_tokens":0}}}}"#,
531            u64::MAX
532        );
533        let decision: Decision = serde_json::from_str(&wire).expect("extremes are valid u64 wire");
534        let observation = MarketObservation {
535            date: "2026-01-01".to_string(),
536            cash: 0.0,
537            symbols: Vec::new(),
538            portfolio: Vec::new(),
539        };
540        assert!(
541            decision.validate_for(&observation).is_ok(),
542            "no semantic rule rejects extreme token counts, so the reduction must hold"
543        );
544        let cost = decision.cost.expect("cost channel present");
545        assert_eq!(
546            cost.billable_units(),
547            u64::MAX as f64,
548            "the token total saturates at the type maximum, never wraps toward cheap"
549        );
550    }
551
552    #[test]
553    fn decision_cost_channel_parses_and_reduces() {
554        // An agent self-reporting spend: dollars present → billable = dollars.
555        let with_cost = r#"{"orders":[],"reasoning":"","cost":{"cost_usd":0.42,
556            "tokens_in":1200,"tokens_out":300,"reasoning_tokens":180}}"#;
557        let d: Decision = serde_json::from_str(with_cost).unwrap();
558        let c = d.cost.expect("cost channel present");
559        assert!((c.cost_usd - 0.42).abs() < 1e-12);
560        assert_eq!(c.tokens_in, 1200);
561        assert!((c.billable_units() - 0.42).abs() < 1e-12);
562
563        // Tokens-only report (no dollars) → billable = tokens_in + tokens_out;
564        // reasoning tokens are a sub-breakdown of the output, not re-added.
565        let tokens_only = DecisionCost {
566            cost_usd: 0.0,
567            tokens_in: 1000,
568            tokens_out: 250,
569            reasoning_tokens: 200,
570        };
571        assert!((tokens_only.billable_units() - 1250.0).abs() < 1e-12);
572
573        // `cost` round-trips through JSON.
574        let d2 = Decision {
575            orders: Vec::new(),
576            reasoning: String::new(),
577            cost: Some(tokens_only),
578        };
579        let back: Decision = serde_json::from_str(&serde_json::to_string(&d2).unwrap()).unwrap();
580        assert_eq!(back.cost, Some(tokens_only));
581    }
582
583    #[test]
584    fn closed_decision_contract_rejects_drift_and_semantic_faults() {
585        let obs = MarketObservation {
586            date: "2026-01-01".to_string(),
587            cash: 1.0,
588            symbols: vec![SymbolSnapshot {
589                symbol: "A".to_string(),
590                close_history: vec![1.0],
591                fundamentals: Default::default(),
592                news: Vec::new(),
593            }],
594            portfolio: Vec::new(),
595        };
596        assert!(serde_json::from_str::<Decision>(r#"{"orders":[],"typo":true}"#).is_err());
597
598        let order = |symbol: &str, weight: f64| Order {
599            symbol: symbol.to_string(),
600            action: Action::Sell,
601            target_weight: weight,
602            confidence: Some(0.5),
603            rationale: String::new(),
604        };
605        let valid = Decision {
606            orders: vec![order("A", -0.5)],
607            reasoning: String::new(),
608            cost: None,
609        };
610        assert!(valid.validate_for(&obs).is_ok());
611        let stated = |confidence: Option<f64>| Decision {
612            orders: vec![Order {
613                confidence,
614                ..order("A", 0.1)
615            }],
616            reasoning: String::new(),
617            cost: None,
618        };
619        for accepted in [None, Some(0.0), Some(1.0)] {
620            assert!(
621                stated(accepted).validate_for(&obs).is_ok(),
622                "{accepted:?} is a valid statement or no statement"
623            );
624        }
625        for refused in [Some(-0.01), Some(1.01), Some(f64::NAN), Some(f64::INFINITY)] {
626            let error = stated(refused)
627                .validate_for(&obs)
628                .expect_err("a stated confidence outside [0, 1] is refused");
629            assert_eq!(
630                error, "orders[0].confidence must be finite and in [0, 1]",
631                "{refused:?}"
632            );
633        }
634
635        for invalid in [
636            Decision {
637                orders: vec![order("UNKNOWN", 0.0)],
638                reasoning: String::new(),
639                cost: None,
640            },
641            Decision {
642                orders: vec![order("A", 0.1), order("A", 0.2)],
643                reasoning: String::new(),
644                cost: None,
645            },
646            Decision {
647                orders: vec![order("A", 1.01)],
648                reasoning: String::new(),
649                cost: None,
650            },
651        ] {
652            assert!(invalid.validate_for(&obs).is_err());
653        }
654    }
655
656    #[test]
657    fn unknown_field_diagnostic_names_the_offending_field() {
658        let error = decision_from_wire(r#"{"orders":[],"latency_ms":12}"#)
659            .expect_err("the closed contract rejects an undefined key");
660        assert!(
661            error.contains("latency_ms"),
662            "the diagnostic must name the offending field, got: {error}"
663        );
664        assert!(
665            error.contains("orders") && error.contains("reasoning") && error.contains("cost"),
666            "the diagnostic must list the accepted fields, got: {error}"
667        );
668        assert!(
669            error.contains(DECISION_SCHEMA_PATH),
670            "the diagnostic must point at the published schema, got: {error}"
671        );
672
673        // A shape fault that is not an unknown field still gets a diagnostic
674        // rather than an opaque failure.
675        let malformed = decision_from_wire("not json").expect_err("malformed input is rejected");
676        assert!(malformed.contains("closed wire contract"));
677
678        // The happy path is unchanged: a conforming decision parses.
679        let ok =
680            decision_from_wire(r#"{"orders":[{"symbol":"A","action":"buy","target_weight":0.5}]}"#)
681                .expect("a conforming decision parses");
682        assert_eq!(ok.orders[0].symbol, "A");
683    }
684
685    #[test]
686    fn trajectory_roundtrips_through_json() {
687        let traj = AgentTrajectory {
688            agent_id: "a".to_string(),
689            contract: None,
690            in_sample_trials: 7,
691            declared_mandate: None,
692            runs: vec![RunTrajectory {
693                window_start: 20,
694                window_end: 30,
695                seed: 3,
696                steps: vec![DecisionStep {
697                    step: 0,
698                    observation_id: "2025-001".to_string(),
699                    decision: Decision {
700                        orders: vec![Order {
701                            symbol: "A".to_string(),
702                            action: Action::Buy,
703                            target_weight: 0.25,
704                            confidence: Some(0.8),
705                            rationale: String::new(),
706                        }],
707                        reasoning: "r".to_string(),
708                        cost: None,
709                    },
710                }],
711            }],
712        };
713        let back: AgentTrajectory =
714            serde_json::from_str(&serde_json::to_string(&traj).unwrap()).unwrap();
715        assert_eq!(back.agent_id, "a");
716        assert_eq!(back.in_sample_trials, 7);
717        assert_eq!(back.runs[0].seed, 3);
718        assert_eq!(back.runs[0].steps[0].observation_id, "2025-001");
719        assert_eq!(back.runs[0].steps[0].decision.orders[0].target_weight, 0.25);
720        // An undeclared mandate is absent from the bytes, not serialized as null.
721        assert!(!serde_json::to_string(&traj)
722            .unwrap()
723            .contains("declared_mandate"));
724        assert!(back.declared_mandate.is_none());
725    }
726
727    #[test]
728    fn declared_mandate_is_additive_and_round_trips() {
729        // Every trajectory written before the field existed still parses.
730        let legacy = r#"{"agent_id":"a","runs":[]}"#;
731        let t: AgentTrajectory = serde_json::from_str(legacy).unwrap();
732        assert!(t.declared_mandate.is_none());
733
734        for (m, json) in [
735            (
736                DeclaredMandate::AbsoluteReturn,
737                r#"{"kind":"absolute_return"}"#,
738            ),
739            (
740                DeclaredMandate::RelativeTo {
741                    benchmark_id: "buy-and-hold".to_string(),
742                },
743                r#"{"kind":"relative_to","benchmark_id":"buy-and-hold"}"#,
744            ),
745            (
746                DeclaredMandate::DrawdownCapped {
747                    max_per_run_drawdown: 0.2,
748                },
749                r#"{"kind":"drawdown_capped","max_per_run_drawdown":0.2}"#,
750            ),
751            (
752                DeclaredMandate::OutperformBuyAndHold,
753                r#"{"kind":"outperform_buy_and_hold"}"#,
754            ),
755        ] {
756            assert_eq!(serde_json::to_string(&m).unwrap(), json);
757            assert_eq!(serde_json::from_str::<DeclaredMandate>(json).unwrap(), m);
758        }
759
760        let declared = AgentTrajectory {
761            agent_id: "a".to_string(),
762            contract: None,
763            in_sample_trials: 0,
764            declared_mandate: Some(DeclaredMandate::OutperformBuyAndHold),
765            runs: Vec::new(),
766        };
767        let back: AgentTrajectory =
768            serde_json::from_str(&serde_json::to_string(&declared).unwrap()).unwrap();
769        assert_eq!(
770            back.declared_mandate,
771            Some(DeclaredMandate::OutperformBuyAndHold)
772        );
773        assert_eq!(
774            serde_json::from_str::<DeclaredMandate>(r#"{"kind":"long_only_beta"}"#).unwrap(),
775            DeclaredMandate::OutperformBuyAndHold,
776            "old artifacts remain readable but are re-emitted under the honest name"
777        );
778    }
779}