Skip to main content

sharpebench_core/
forecast.rs

1//! Strict ingestion and independent analysis of prospective forecast evidence.
2//!
3//! SharpeArena owns the commit-time ledger. SharpeBench accepts only the closed
4//! `sharpe.forecast-evidence.v1` and `.v2` file contracts, reconstructs every score locally,
5//! and compares two agents only on the exact contracts both resolved, settled to the
6//! exact same outcome. A pair receives an interval, a p-value and a Holm verdict only
7//! when each agent resolved every contract the other did, so one agent's gaps can
8//! neither shrink another pair's comparison nor choose its own. Bench takes documents
9//! from any producer, so contract identity alone does not license differencing two
10//! losses; the realized outcome and its availability time are retained past scoring
11//! and must agree. This module does not import [`crate::composite`] and its report is
12//! not an input to trading-rank eligibility.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::error::Error;
16use std::fmt::{Display, Formatter};
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use sha2::{Digest, Sha256};
21use sharpebench_protocol::canonical::{versioned_preimage, CANONICAL_JSON_VERSION};
22
23use crate::stats::norm_cdf;
24
25pub const FORECAST_EVIDENCE_SCHEMA: &str = "sharpe.forecast-evidence.v1";
26/// The v1 envelope plus `contract_digest_encoding` on every revision. A v2
27/// revision is verified under the encoding it declares and under nothing else.
28pub const FORECAST_EVIDENCE_SCHEMA_V2: &str = "sharpe.forecast-evidence.v2";
29/// Version 2 replaces the field-wide support intersection with exact pairwise
30/// support: `common_support` names the digests any agent resolved and charges each
31/// agent's gaps to that agent, and a pair with unequal resolved support carries
32/// `support_gap` and a withheld inference.
33pub const FORECAST_QUALITY_SCHEMA: &str = "sharpebench.forecast-quality.v2";
34/// The report written against a declared [`ForecastContractPlan`]: v2 with the
35/// plan as the field support and `common_support.outside_plan_by_agent` listing
36/// the resolved digests the plan does not name, which are not scored. A report
37/// without a plan stays v2.
38pub const FORECAST_QUALITY_PLAN_SCHEMA: &str = "sharpebench.forecast-quality.v3";
39/// Schema of the declared contract universe file.
40pub const FORECAST_CONTRACT_PLAN_SCHEMA: &str = "sharpebench.forecast-contract-plan.v1";
41const SUPPORT_RULE: &str = "a pair is differenced on the contract digests both agents resolved \
42     and receives inference only when each agent resolved every digest the other did";
43const PLAN_SUPPORT_RULE: &str = "a pair is differenced on the declared plan's contract digests \
44     both agents resolved and receives inference only when each agent resolved every plan digest \
45     the other did; resolved digests outside the plan are listed and not scored";
46const CONTRACT_SCHEMA: &str = "sharpearena.forecast-contract.v1";
47
48/// The encoding a revision's `contract_sha256` was verified under.
49///
50/// A contract has two accepted digests. The current one hashes the
51/// [`versioned_preimage`] of `sharpebench/canonical-json/v1`, so it carries its
52/// version and cannot collide with a digest taken under another form. The legacy
53/// one hashes the unframed pre-migration text (`serde_json` number rendering with
54/// Python exponent padding) and is accepted only because published evidence pins
55/// it: `paper/evidence/prospective-forecast-field/` carries 24 such digests. A
56/// revision digest that recomputes under neither is refused, and the version each
57/// scored contract verified under is reported, so a legacy field is never mistaken
58/// for a current one.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
60pub enum ContractDigestVersion {
61    /// SHA-256 over `versioned_preimage` under `sharpebench/canonical-json/v1`.
62    CanonicalJsonV1,
63    /// SHA-256 over the unframed pre-migration canonical text.
64    Legacy,
65}
66
67impl ContractDigestVersion {
68    /// The label written into the report: the canonical-form version string for a
69    /// current digest, `legacy` for a pre-migration one.
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::CanonicalJsonV1 => CANONICAL_JSON_VERSION,
73            Self::Legacy => "legacy",
74        }
75    }
76
77    /// The inverse of [`Self::as_str`]: the encoding a v2 revision declares in
78    /// `contract_digest_encoding`. Any other label is not an encoding.
79    pub fn from_label(label: &str) -> Option<Self> {
80        [Self::CanonicalJsonV1, Self::Legacy]
81            .into_iter()
82            .find(|version| version.as_str() == label)
83    }
84}
85
86/// A v2 revision whose `contract_sha256` does not recompute under the encoding
87/// it declares. Carries the digest, the declared encoding and the encoding the
88/// digest does recompute under, or `None` when it matches no contract at all.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct ContractDigestEncodingMismatch {
91    pub revision_id: String,
92    pub contract_sha256: String,
93    pub declared: ContractDigestVersion,
94    pub recomputed: Option<ContractDigestVersion>,
95}
96
97impl Display for ContractDigestEncodingMismatch {
98    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
99        write!(
100            formatter,
101            "revision {} declares contract digest {} under {}, but it recomputes under ",
102            self.revision_id,
103            self.contract_sha256,
104            self.declared.as_str()
105        )?;
106        match self.recomputed {
107            Some(recomputed) => write!(formatter, "{}", recomputed.as_str()),
108            None => write!(
109                formatter,
110                "neither {CANONICAL_JSON_VERSION} nor the legacy encoding of any contract"
111            ),
112        }
113    }
114}
115
116impl Error for ContractDigestEncodingMismatch {}
117
118impl From<ContractDigestEncodingMismatch> for ForecastError {
119    fn from(mismatch: ContractDigestEncodingMismatch) -> Self {
120        ForecastError(mismatch.to_string())
121    }
122}
123
124impl Serialize for ContractDigestVersion {
125    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
126        serializer.serialize_str(self.as_str())
127    }
128}
129
130/// Both digests one contract answers to.
131struct ContractDigests {
132    canonical_json_v1: String,
133    legacy: String,
134}
135
136/// Contracts keyed by every digest they answer to, with the version each key is.
137type ContractIndex<'a> = BTreeMap<String, (&'a ForecastContract, ContractDigestVersion)>;
138
139fn index_contracts<'a>(
140    contracts: impl IntoIterator<Item = (ContractDigests, &'a ForecastContract)>,
141) -> ContractIndex<'a> {
142    let mut index = BTreeMap::new();
143    for (digests, contract) in contracts {
144        index.insert(
145            digests.canonical_json_v1,
146            (contract, ContractDigestVersion::CanonicalJsonV1),
147        );
148        // The v1 digest is over the versioned frame and the legacy digest over
149        // the bare text, so the two keys never coincide and this never
150        // displaces the entry above.
151        index
152            .entry(digests.legacy)
153            .or_insert((contract, ContractDigestVersion::Legacy));
154    }
155    index
156}
157
158fn unknown_contract_digest() -> ForecastError {
159    reject(format!(
160        "revision names an unknown contract digest: it recomputes under neither \
161         {CANONICAL_JSON_VERSION} nor the legacy encoding of any contract"
162    ))
163}
164
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct ForecastError(pub String);
167
168impl Display for ForecastError {
169    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
170        formatter.write_str(&self.0)
171    }
172}
173
174impl Error for ForecastError {}
175
176fn reject(message: impl Into<String>) -> ForecastError {
177    ForecastError(message.into())
178}
179
180#[derive(Clone, Debug, Deserialize, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct ForecastProducer {
183    pub name: String,
184    pub contract: String,
185}
186
187#[derive(Clone, Debug, Deserialize, Serialize)]
188#[serde(deny_unknown_fields)]
189pub struct ForecastRunIdentity {
190    pub agent_id: String,
191    pub model_id: String,
192    pub model_sha256: String,
193    pub scaffold_id: String,
194    pub scaffold_sha256: String,
195    pub prompt_sha256: String,
196    pub operator_id: String,
197    pub config_sha256: String,
198}
199
200#[derive(Clone, Debug, Deserialize, Serialize)]
201#[serde(deny_unknown_fields)]
202pub struct ForecastContract {
203    pub schema_version: String,
204    pub contract_id: String,
205    pub question: String,
206    pub instrument: String,
207    pub target: String,
208    pub kind: String,
209    pub opens_at: u64,
210    pub deadline: u64,
211    pub resolves_at: u64,
212    pub observation_source: String,
213    pub open_definition: String,
214    pub close_definition: String,
215    pub unit: String,
216    pub scoring_rule: String,
217    pub neutral_threshold: f64,
218    pub boundary_ownership: String,
219    pub missing_data_policy: String,
220    pub fallback_policy: String,
221    pub categories: Vec<String>,
222    pub interval_alpha: Option<f64>,
223}
224
225#[derive(Clone, Debug, Deserialize, Serialize)]
226#[serde(deny_unknown_fields)]
227pub struct InformationExposure {
228    pub observed_at: u64,
229    pub market_snapshot_sha256: Option<String>,
230    pub consensus_visible: bool,
231    pub consensus_snapshot_sha256: Option<String>,
232    pub source_ids: Vec<String>,
233}
234
235#[derive(Clone, Debug, Deserialize, Serialize)]
236#[serde(deny_unknown_fields)]
237pub struct ForecastRevision {
238    pub revision_id: String,
239    pub claim_id: String,
240    pub ordinal: u64,
241    pub supersedes: Option<String>,
242    pub contract_sha256: String,
243    /// Present on every revision of a `sharpe.forecast-evidence.v2` document and
244    /// on none of a v1 one; the parser refuses either envelope with the other
245    /// shape. Absent rather than `null` on the wire, so a v1 document round-trips
246    /// byte for byte.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub contract_digest_encoding: Option<String>,
249    pub prediction: Vec<f64>,
250    pub confidence: f64,
251    pub rationale: String,
252    pub submitted_at: u64,
253    pub status: String,
254    pub reason: Option<String>,
255    pub trigger_event_id: Option<String>,
256    pub revision_reason: Option<String>,
257    pub exposure: InformationExposure,
258    pub idempotency_key: String,
259}
260
261#[derive(Clone, Debug, Deserialize, Serialize)]
262#[serde(deny_unknown_fields)]
263pub struct ForecastResolution {
264    pub claim_id: String,
265    pub status: String,
266    pub outcome: Option<Value>,
267    pub available_at: Option<u64>,
268    pub recorded_at: u64,
269    pub reason: Option<String>,
270}
271
272#[derive(Clone, Debug, Deserialize, Serialize)]
273#[serde(deny_unknown_fields)]
274pub struct ForecastEvidence {
275    pub schema_version: String,
276    pub producer: ForecastProducer,
277    pub generated_at: u64,
278    pub identity: ForecastRunIdentity,
279    pub contracts: Vec<ForecastContract>,
280    pub revisions: Vec<ForecastRevision>,
281    pub resolutions: Vec<ForecastResolution>,
282}
283
284/// Parse the closed evidence envelope and validate all semantic links.
285pub fn parse_forecast_evidence(payload: &str) -> Result<ForecastEvidence, ForecastError> {
286    let evidence: ForecastEvidence = serde_json::from_str(payload)
287        .map_err(|error| reject(format!("invalid forecast evidence JSON: {error}")))?;
288    validate_evidence(&evidence)?;
289    Ok(evidence)
290}
291
292/// The contract digests every agent in a field is asked to answer, declared
293/// before resolution.
294///
295/// Without a plan the field support is the union of the digests the documents
296/// resolved, and a document chooses its part of that union: resolving one
297/// contract nobody else resolved withholds inference on every pair it is in and
298/// charges that contract to every other agent. A plan fixes the support
299/// instead. A resolved digest the plan does not name is listed and not scored,
300/// and an agent's gaps are the planned digests it did not resolve.
301#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
302#[serde(deny_unknown_fields)]
303pub struct ForecastContractPlan {
304    pub schema_version: String,
305    pub contract_sha256: Vec<String>,
306}
307
308/// Parse and validate a [`ForecastContractPlan`]: the exact schema, at least one
309/// digest, every digest a lowercase SHA-256, and no digest twice.
310pub fn parse_forecast_contract_plan(payload: &str) -> Result<ForecastContractPlan, ForecastError> {
311    let plan: ForecastContractPlan = serde_json::from_str(payload)
312        .map_err(|error| reject(format!("invalid forecast contract plan: {error}")))?;
313    if plan.schema_version != FORECAST_CONTRACT_PLAN_SCHEMA {
314        return Err(reject("unsupported forecast contract plan schema_version"));
315    }
316    if plan.contract_sha256.is_empty() {
317        return Err(reject(
318            "a forecast contract plan names at least one contract",
319        ));
320    }
321    let mut seen = BTreeSet::new();
322    for value in &plan.contract_sha256 {
323        digest(value, "plan contract_sha256")?;
324        if !seen.insert(value.as_str()) {
325            return Err(reject(format!(
326                "forecast contract plan names contract digest {value} twice"
327            )));
328        }
329    }
330    Ok(plan)
331}
332
333fn nonempty(value: &str, field: &str) -> Result<(), ForecastError> {
334    if value.trim().is_empty() {
335        Err(reject(format!("{field} must be non-empty")))
336    } else {
337        Ok(())
338    }
339}
340
341fn digest(value: &str, field: &str) -> Result<(), ForecastError> {
342    if value.len() != 64
343        || !value
344            .bytes()
345            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
346    {
347        return Err(reject(format!(
348            "{field} must be a lowercase SHA-256 digest"
349        )));
350    }
351    Ok(())
352}
353
354fn finite(value: f64, field: &str) -> Result<(), ForecastError> {
355    if value.is_finite() {
356        Ok(())
357    } else {
358        Err(reject(format!("{field} must be finite")))
359    }
360}
361
362fn validate_contract(contract: &ForecastContract) -> Result<ContractDigests, ForecastError> {
363    if contract.schema_version != CONTRACT_SCHEMA {
364        return Err(reject(format!(
365            "contract {} has unsupported schema_version",
366            contract.contract_id
367        )));
368    }
369    for (field, value) in [
370        ("contract_id", contract.contract_id.as_str()),
371        ("question", contract.question.as_str()),
372        ("instrument", contract.instrument.as_str()),
373        ("target", contract.target.as_str()),
374        ("observation_source", contract.observation_source.as_str()),
375        ("open_definition", contract.open_definition.as_str()),
376        ("close_definition", contract.close_definition.as_str()),
377        ("unit", contract.unit.as_str()),
378        ("boundary_ownership", contract.boundary_ownership.as_str()),
379        ("missing_data_policy", contract.missing_data_policy.as_str()),
380        ("fallback_policy", contract.fallback_policy.as_str()),
381    ] {
382        nonempty(value, field)?;
383    }
384    if contract.opens_at > contract.deadline || contract.deadline >= contract.resolves_at {
385        return Err(reject(format!(
386            "contract {} must satisfy opens_at <= deadline < resolves_at",
387            contract.contract_id
388        )));
389    }
390    finite(contract.neutral_threshold, "neutral_threshold")?;
391    if contract.neutral_threshold < 0.0 {
392        return Err(reject("neutral_threshold must be non-negative"));
393    }
394    let allowed = match contract.kind.as_str() {
395        "point" => &["point_errors"][..],
396        "probability" => &["binary_brier", "binary_log"][..],
397        "categorical" => &["categorical_brier", "categorical_log"][..],
398        "normal" => &["normal_crps"][..],
399        "direction" => &["direction_accuracy"][..],
400        "interval" => &["interval_score"][..],
401        other => return Err(reject(format!("unknown forecast kind {other:?}"))),
402    };
403    if !allowed.contains(&contract.scoring_rule.as_str()) {
404        return Err(reject(format!(
405            "scoring rule {:?} is invalid for kind {:?}",
406            contract.scoring_rule, contract.kind
407        )));
408    }
409    let category_set: BTreeSet<&str> = contract.categories.iter().map(String::as_str).collect();
410    if contract.kind == "categorical" {
411        if contract.categories.len() < 2 || category_set.len() != contract.categories.len() {
412            return Err(reject(
413                "categorical contracts need at least two unique categories",
414            ));
415        }
416        for category in &contract.categories {
417            nonempty(category, "categories[]")?;
418        }
419    } else if !contract.categories.is_empty() {
420        return Err(reject(
421            "categories are valid only for categorical contracts",
422        ));
423    }
424    if contract.kind == "interval" {
425        match contract.interval_alpha {
426            Some(alpha) if alpha.is_finite() && alpha > 0.0 && alpha < 1.0 => {}
427            _ => return Err(reject("interval contracts need interval_alpha in (0, 1)")),
428        }
429    } else if contract.interval_alpha.is_some() {
430        return Err(reject(
431            "interval_alpha is valid only for interval contracts",
432        ));
433    }
434    contract_digests(contract)
435}
436
437fn validate_prediction(
438    contract: &ForecastContract,
439    prediction: &[f64],
440) -> Result<(), ForecastError> {
441    if prediction.iter().any(|value| !value.is_finite()) {
442        return Err(reject("prediction values must be finite"));
443    }
444    match contract.kind.as_str() {
445        "point" => {
446            if prediction.len() != 1 {
447                return Err(reject("point prediction needs exactly one value"));
448            }
449        }
450        "probability" => {
451            if prediction.len() != 1 || !(0.0..=1.0).contains(&prediction[0]) {
452                return Err(reject("probability prediction must be one value in [0, 1]"));
453            }
454            if contract.scoring_rule == "binary_log"
455                && (prediction[0] <= 0.0 || prediction[0] >= 1.0)
456            {
457                return Err(reject("binary_log probability must lie inside (0, 1)"));
458            }
459        }
460        "categorical" => {
461            let sum: f64 = prediction.iter().sum();
462            if prediction.len() != contract.categories.len()
463                || prediction.iter().any(|value| !(0.0..=1.0).contains(value))
464                || (sum - 1.0).abs() > 1e-12
465            {
466                return Err(reject(
467                    "categorical prediction must be a simplex matching categories",
468                ));
469            }
470            if contract.scoring_rule == "categorical_log"
471                && prediction.iter().any(|value| *value <= 0.0)
472            {
473                return Err(reject("categorical_log probabilities must be positive"));
474            }
475        }
476        "normal" => {
477            if prediction.len() != 2 || prediction[1] <= 0.0 {
478                return Err(reject("normal prediction must be [mean, positive sigma]"));
479            }
480        }
481        "direction" => {
482            if prediction.len() != 1 || !matches!(prediction[0], -1.0 | 1.0) {
483                return Err(reject("direction prediction must be -1 or 1"));
484            }
485        }
486        "interval" => {
487            if prediction.len() != 2 || prediction[0] > prediction[1] {
488                return Err(reject("interval prediction must be [lo, hi] with lo <= hi"));
489            }
490        }
491        _ => return Err(reject("unknown forecast kind")),
492    }
493    Ok(())
494}
495
496/// Resolve one revision's contract under the rules of its envelope.
497///
498/// A v1 revision names a digest and Bench infers the encoding by recomputation
499/// under both. A v2 revision declares the encoding beside the digest and is
500/// verified under that encoding only: a digest that recomputes under the other
501/// encoding, or under neither, is refused with the declared and the recomputed
502/// encoding named, so a producer that mislabels its field learns which label
503/// its digests actually answer to.
504fn resolve_revision_contract<'a>(
505    declares_encoding: bool,
506    revision: &ForecastRevision,
507    contracts: &ContractIndex<'a>,
508) -> Result<&'a ForecastContract, ForecastError> {
509    let recomputed = contracts.get(&revision.contract_sha256).copied();
510    match (declares_encoding, &revision.contract_digest_encoding) {
511        (false, None) => recomputed
512            .map(|(contract, _)| contract)
513            .ok_or_else(unknown_contract_digest),
514        (false, Some(_)) => Err(reject(format!(
515            "contract_digest_encoding is not a field of {FORECAST_EVIDENCE_SCHEMA}; \
516             a document that declares it must be {FORECAST_EVIDENCE_SCHEMA_V2}"
517        ))),
518        (true, None) => Err(reject(format!(
519            "{FORECAST_EVIDENCE_SCHEMA_V2} requires contract_digest_encoding on every revision"
520        ))),
521        (true, Some(label)) => {
522            let declared = ContractDigestVersion::from_label(label).ok_or_else(|| {
523                reject(format!(
524                    "unknown contract_digest_encoding {label:?}; expected \
525                     {CANONICAL_JSON_VERSION} or legacy"
526                ))
527            })?;
528            match recomputed {
529                Some((contract, version)) if version == declared => Ok(contract),
530                other => Err(ContractDigestEncodingMismatch {
531                    revision_id: revision.revision_id.clone(),
532                    contract_sha256: revision.contract_sha256.clone(),
533                    declared,
534                    recomputed: other.map(|(_, version)| version),
535                }
536                .into()),
537            }
538        }
539    }
540}
541
542fn validate_evidence(evidence: &ForecastEvidence) -> Result<(), ForecastError> {
543    let declares_encoding = match evidence.schema_version.as_str() {
544        FORECAST_EVIDENCE_SCHEMA => false,
545        FORECAST_EVIDENCE_SCHEMA_V2 => true,
546        _ => return Err(reject("unsupported forecast evidence schema_version")),
547    };
548    if evidence.producer.name != "sharpearena" || evidence.producer.contract != "native" {
549        return Err(reject("producer must be the native SharpeArena contract"));
550    }
551    for (field, value) in [
552        ("agent_id", evidence.identity.agent_id.as_str()),
553        ("model_id", evidence.identity.model_id.as_str()),
554        ("scaffold_id", evidence.identity.scaffold_id.as_str()),
555        ("operator_id", evidence.identity.operator_id.as_str()),
556    ] {
557        nonempty(value, field)?;
558    }
559    for (field, value) in [
560        ("model_sha256", evidence.identity.model_sha256.as_str()),
561        (
562            "scaffold_sha256",
563            evidence.identity.scaffold_sha256.as_str(),
564        ),
565        ("prompt_sha256", evidence.identity.prompt_sha256.as_str()),
566        ("config_sha256", evidence.identity.config_sha256.as_str()),
567    ] {
568        digest(value, field)?;
569    }
570    if evidence.contracts.is_empty()
571        || evidence.revisions.is_empty()
572        || evidence.resolutions.is_empty()
573    {
574        return Err(reject(
575            "contracts, revisions, and resolutions must all be non-empty",
576        ));
577    }
578
579    let mut validated = Vec::with_capacity(evidence.contracts.len());
580    let mut contract_ids = BTreeSet::new();
581    for contract in &evidence.contracts {
582        if !contract_ids.insert(contract.contract_id.as_str()) {
583            return Err(reject(format!(
584                "duplicate contract_id {:?}",
585                contract.contract_id
586            )));
587        }
588        validated.push((validate_contract(contract)?, contract));
589    }
590    let contracts = index_contracts(validated);
591
592    let mut revision_ids = BTreeSet::new();
593    let mut idempotency_keys = BTreeSet::new();
594    let mut by_claim: BTreeMap<&str, Vec<&ForecastRevision>> = BTreeMap::new();
595    let mut claim_contracts: BTreeMap<&str, &str> = BTreeMap::new();
596    for revision in &evidence.revisions {
597        nonempty(&revision.revision_id, "revision_id")?;
598        nonempty(&revision.claim_id, "claim_id")?;
599        nonempty(&revision.idempotency_key, "idempotency_key")?;
600        if !revision_ids.insert(revision.revision_id.as_str()) {
601            return Err(reject(format!(
602                "duplicate revision_id {:?}",
603                revision.revision_id
604            )));
605        }
606        if !idempotency_keys.insert(revision.idempotency_key.as_str()) {
607            return Err(reject(format!(
608                "duplicate idempotency_key {:?}",
609                revision.idempotency_key
610            )));
611        }
612        digest(&revision.contract_sha256, "contract_sha256")?;
613        let contract = resolve_revision_contract(declares_encoding, revision, &contracts)?;
614        match claim_contracts.insert(&revision.claim_id, &revision.contract_sha256) {
615            Some(prior) if prior != revision.contract_sha256 => {
616                return Err(reject("a claim changes contract across revisions"));
617            }
618            _ => {}
619        }
620        validate_prediction(contract, &revision.prediction)?;
621        finite(revision.confidence, "confidence")?;
622        if !(0.0..=1.0).contains(&revision.confidence) {
623            return Err(reject("confidence must lie in [0, 1]"));
624        }
625        if revision.exposure.observed_at != revision.submitted_at {
626            return Err(reject("exposure.observed_at must equal submitted_at"));
627        }
628        if let Some(hash) = &revision.exposure.market_snapshot_sha256 {
629            digest(hash, "market_snapshot_sha256")?;
630        }
631        match (
632            revision.exposure.consensus_visible,
633            &revision.exposure.consensus_snapshot_sha256,
634        ) {
635            (true, Some(hash)) => digest(hash, "consensus_snapshot_sha256")?,
636            (true, None) => return Err(reject("visible consensus requires its snapshot digest")),
637            (false, Some(_)) => {
638                return Err(reject("hidden consensus cannot carry a snapshot digest"));
639            }
640            (false, None) => {}
641        }
642        let sources: BTreeSet<&str> = revision
643            .exposure
644            .source_ids
645            .iter()
646            .map(String::as_str)
647            .collect();
648        if sources.len() != revision.exposure.source_ids.len()
649            || sources.iter().any(|source| source.trim().is_empty())
650        {
651            return Err(reject("source_ids must be unique and non-empty"));
652        }
653        match revision.status.as_str() {
654            "eligible"
655                if revision.submitted_at >= contract.opens_at
656                    && revision.submitted_at <= contract.deadline
657                    && revision.reason.is_none() => {}
658            "late"
659                if revision.submitted_at > contract.deadline
660                    && revision
661                        .reason
662                        .as_deref()
663                        .is_some_and(|reason| !reason.trim().is_empty()) => {}
664            "rejected"
665                if revision.submitted_at < contract.opens_at
666                    && revision
667                        .reason
668                        .as_deref()
669                        .is_some_and(|reason| !reason.trim().is_empty()) => {}
670            _ => {
671                return Err(reject(
672                    "revision status, clock, and reason are inconsistent",
673                ))
674            }
675        }
676        if revision
677            .trigger_event_id
678            .as_deref()
679            .is_some_and(|value| value.trim().is_empty())
680        {
681            return Err(reject("trigger_event_id must be non-empty when present"));
682        }
683        let chain = by_claim.entry(&revision.claim_id).or_default();
684        if revision.ordinal as usize != chain.len() {
685            return Err(reject("claim revisions are not in append order"));
686        }
687        let expected = chain.last().map(|prior| prior.revision_id.as_str());
688        if revision.supersedes.as_deref() != expected {
689            return Err(reject("revision has the wrong supersedes link"));
690        }
691        if revision.ordinal == 0 && revision.revision_reason.is_some() {
692            return Err(reject("initial forecast cannot have revision_reason"));
693        }
694        if revision.ordinal > 0
695            && revision
696                .revision_reason
697                .as_deref()
698                .is_none_or(|reason| reason.trim().is_empty())
699        {
700            return Err(reject("a revision requires revision_reason"));
701        }
702        chain.push(revision);
703    }
704    // Count contracts, not digests: a contract answers to two digests, and every
705    // referenced digest was resolved to its contract above.
706    let referenced: BTreeSet<&str> = claim_contracts
707        .values()
708        .map(|digest| contracts[*digest].0.contract_id.as_str())
709        .collect();
710    if referenced.len() != evidence.contracts.len() {
711        return Err(reject(
712            "every exported contract must be referenced by a claim",
713        ));
714    }
715
716    let mut resolutions = BTreeMap::new();
717    for resolution in &evidence.resolutions {
718        nonempty(&resolution.claim_id, "resolution.claim_id")?;
719        let revisions = by_claim
720            .get(resolution.claim_id.as_str())
721            .ok_or_else(|| reject("resolution names an unknown claim"))?;
722        if resolutions
723            .insert(resolution.claim_id.as_str(), resolution)
724            .is_some()
725        {
726            return Err(reject("duplicate resolution for a claim"));
727        }
728        let eligible = revisions
729            .iter()
730            .rev()
731            .find(|revision| revision.status == "eligible")
732            .copied();
733        match resolution.status.as_str() {
734            "resolved" => {
735                let revision =
736                    eligible.ok_or_else(|| reject("resolved claim has no eligible revision"))?;
737                let available_at = resolution
738                    .available_at
739                    .ok_or_else(|| reject("resolved record needs available_at"))?;
740                let outcome = resolution
741                    .outcome
742                    .as_ref()
743                    .ok_or_else(|| reject("resolved record needs an outcome"))?;
744                if resolution.reason.is_some() {
745                    return Err(reject("resolved record cannot carry a reason"));
746                }
747                let (contract, _) = contracts
748                    .get(&revision.contract_sha256)
749                    .ok_or_else(|| reject("resolved revision lost its contract"))?;
750                if available_at <= revision.submitted_at || available_at < contract.resolves_at {
751                    return Err(reject(
752                        "resolution predates the frozen information boundary",
753                    ));
754                }
755                score_prediction(contract, &revision.prediction, outcome)?;
756            }
757            "pending" => {
758                if eligible.is_none()
759                    || resolution.outcome.is_some()
760                    || resolution.available_at.is_some()
761                    || resolution.reason.is_some()
762                {
763                    return Err(reject("pending resolution fields are inconsistent"));
764                }
765            }
766            "cancelled" => {
767                if resolution.outcome.is_some()
768                    || resolution.available_at.is_some()
769                    || resolution
770                        .reason
771                        .as_deref()
772                        .is_none_or(|reason| reason.trim().is_empty())
773                {
774                    return Err(reject("cancelled resolution fields are inconsistent"));
775                }
776            }
777            "rejected" => {
778                if eligible.is_some()
779                    || resolution.outcome.is_some()
780                    || resolution
781                        .reason
782                        .as_deref()
783                        .is_none_or(|reason| reason.trim().is_empty())
784                {
785                    return Err(reject("rejected resolution fields are inconsistent"));
786                }
787            }
788            _ => return Err(reject("unknown resolution status")),
789        }
790    }
791    if resolutions.len() != by_claim.len() {
792        return Err(reject("resolutions must cover every claim exactly once"));
793    }
794    Ok(())
795}
796
797/// The pre-migration canonical text: sorted keys, no whitespace, `serde_json`
798/// number rendering with the exponent padded to two digits the way Python's
799/// `repr` pads it. Kept verbatim because the digests it produced are pinned in
800/// published evidence; new digests come from [`versioned_preimage`].
801fn legacy_canonical_json(value: &Value, output: &mut String) -> Result<(), ForecastError> {
802    match value {
803        Value::Null => output.push_str("null"),
804        Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
805        Value::Number(number) => output.push_str(&python_number(number)),
806        Value::String(value) => output.push_str(
807            &serde_json::to_string(value)
808                .map_err(|error| reject(format!("cannot encode contract string: {error}")))?,
809        ),
810        Value::Array(values) => {
811            output.push('[');
812            for (index, value) in values.iter().enumerate() {
813                if index > 0 {
814                    output.push(',');
815                }
816                legacy_canonical_json(value, output)?;
817            }
818            output.push(']');
819        }
820        Value::Object(values) => {
821            output.push('{');
822            let mut fields: Vec<_> = values.iter().collect();
823            fields.sort_by(|left, right| left.0.cmp(right.0));
824            for (index, (key, value)) in fields.into_iter().enumerate() {
825                if index > 0 {
826                    output.push(',');
827                }
828                output.push_str(
829                    &serde_json::to_string(key)
830                        .map_err(|error| reject(format!("cannot encode contract key: {error}")))?,
831                );
832                output.push(':');
833                legacy_canonical_json(value, output)?;
834            }
835            output.push('}');
836        }
837    }
838    Ok(())
839}
840
841fn python_number(number: &serde_json::Number) -> String {
842    let rendered = number.to_string();
843    let Some((mantissa, exponent)) = rendered.split_once('e') else {
844        return rendered;
845    };
846    let (sign, digits) = if let Some(rest) = exponent.strip_prefix('-') {
847        ("-", rest)
848    } else if let Some(rest) = exponent.strip_prefix('+') {
849        ("+", rest)
850    } else {
851        ("+", exponent)
852    };
853    format!("{mantissa}e{sign}{digits:0>2}")
854}
855
856fn contract_digests(contract: &ForecastContract) -> Result<ContractDigests, ForecastError> {
857    let value = serde_json::to_value(contract)
858        .map_err(|error| reject(format!("cannot serialize forecast contract: {error}")))?;
859    let framed = versioned_preimage(&value)
860        .map_err(|error| reject(format!("cannot encode forecast contract: {error}")))?;
861    let mut legacy = String::new();
862    legacy_canonical_json(&value, &mut legacy)?;
863    Ok(ContractDigests {
864        canonical_json_v1: crate::lower_hex(&Sha256::digest(&framed)),
865        legacy: crate::lower_hex(&Sha256::digest(legacy.as_bytes())),
866    })
867}
868
869/// Content address of a realized outcome, over the same canonical encoding as the
870/// contract digest.
871///
872/// The contract digest fixes the question. It says nothing about how the question
873/// was settled, so two producers can present the identical contract with opposite
874/// realized outcomes. Comparing agents requires both, and the outcome must survive
875/// scoring rather than be discarded once a loss has been computed from it.
876fn outcome_sha256(outcome: &Value) -> Result<String, ForecastError> {
877    let preimage = versioned_preimage(outcome)
878        .map_err(|error| reject(format!("cannot encode forecast outcome: {error}")))?;
879    Ok(crate::lower_hex(&Sha256::digest(&preimage)))
880}
881
882fn number_outcome(outcome: &Value) -> Result<f64, ForecastError> {
883    let value = outcome
884        .as_f64()
885        .ok_or_else(|| reject("forecast outcome must be numeric"))?;
886    finite(value, "outcome")?;
887    Ok(value)
888}
889
890#[derive(Clone, Debug)]
891struct ScoredForecast {
892    contract_sha256: String,
893    contract_digest_version: ContractDigestVersion,
894    instrument: String,
895    resolves_at: u64,
896    scoring_rule: String,
897    /// Retained settlement identity: which outcome this loss was computed against.
898    outcome_sha256: String,
899    /// Retained settlement availability, when the resolution record carries one.
900    outcome_available_at: Option<u64>,
901    loss: f64,
902    probability: Option<(f64, bool)>,
903    categorical_confidence: Option<(f64, bool)>,
904    normal_pit: Option<f64>,
905    consensus_visible: bool,
906}
907
908type ScoreParts = (f64, Option<(f64, bool)>, Option<(f64, bool)>, Option<f64>);
909
910fn score_prediction(
911    contract: &ForecastContract,
912    prediction: &[f64],
913    outcome: &Value,
914) -> Result<ScoreParts, ForecastError> {
915    validate_prediction(contract, prediction)?;
916    match contract.kind.as_str() {
917        "point" => {
918            let error = number_outcome(outcome)? - prediction[0];
919            Ok((error * error, None, None, None))
920        }
921        "probability" => {
922            let realized = number_outcome(outcome)?;
923            if realized != 0.0 && realized != 1.0 {
924                return Err(reject("binary outcome must be 0 or 1"));
925            }
926            let event = realized == 1.0;
927            let probability = prediction[0];
928            let loss = if contract.scoring_rule == "binary_brier" {
929                (probability - realized).powi(2)
930            } else if event {
931                -probability.ln()
932            } else {
933                -(1.0 - probability).ln()
934            };
935            Ok((loss, Some((probability, event)), None, None))
936        }
937        "categorical" => {
938            let realized = outcome
939                .as_str()
940                .ok_or_else(|| reject("categorical outcome must be a string"))?;
941            let index = contract
942                .categories
943                .iter()
944                .position(|category| category == realized)
945                .ok_or_else(|| reject("categorical outcome is not in the frozen categories"))?;
946            let loss = if contract.scoring_rule == "categorical_brier" {
947                prediction
948                    .iter()
949                    .enumerate()
950                    .map(|(at, probability)| {
951                        let target = if at == index { 1.0 } else { 0.0 };
952                        (probability - target).powi(2)
953                    })
954                    .sum()
955            } else {
956                -prediction[index].ln()
957            };
958            let (chosen, confidence) = prediction
959                .iter()
960                .copied()
961                .enumerate()
962                .max_by(|left, right| left.1.total_cmp(&right.1))
963                .ok_or_else(|| reject("categorical prediction is empty"))?;
964            Ok((loss, None, Some((confidence, chosen == index)), None))
965        }
966        "normal" => {
967            let realized = number_outcome(outcome)?;
968            let mean = prediction[0];
969            let sigma = prediction[1];
970            let z = (realized - mean) / sigma;
971            let phi = (-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt();
972            let cdf = norm_cdf(z);
973            let crps =
974                sigma * (z * (2.0 * cdf - 1.0) + 2.0 * phi - 1.0 / std::f64::consts::PI.sqrt());
975            Ok((crps, None, None, Some(cdf)))
976        }
977        "direction" => {
978            let realized = number_outcome(outcome)?;
979            let direction = if realized.abs() <= contract.neutral_threshold {
980                0.0
981            } else {
982                realized.signum()
983            };
984            let correct = direction != 0.0 && direction == prediction[0];
985            Ok((if correct { 0.0 } else { 1.0 }, None, None, None))
986        }
987        "interval" => {
988            let realized = number_outcome(outcome)?;
989            let alpha = contract
990                .interval_alpha
991                .ok_or_else(|| reject("interval_alpha is missing"))?;
992            let (lower, upper) = (prediction[0], prediction[1]);
993            let penalty = if realized < lower {
994                2.0 * (lower - realized) / alpha
995            } else if realized > upper {
996                2.0 * (realized - upper) / alpha
997            } else {
998                0.0
999            };
1000            Ok((upper - lower + penalty, None, None, None))
1001        }
1002        _ => Err(reject("unknown forecast kind")),
1003    }
1004}
1005
1006fn scored_forecasts(evidence: &ForecastEvidence) -> Result<Vec<ScoredForecast>, ForecastError> {
1007    let mut digests = Vec::with_capacity(evidence.contracts.len());
1008    for contract in &evidence.contracts {
1009        digests.push((contract_digests(contract)?, contract));
1010    }
1011    let contracts = index_contracts(digests);
1012    let mut effective: BTreeMap<&str, &ForecastRevision> = BTreeMap::new();
1013    for revision in &evidence.revisions {
1014        if revision.status == "eligible" {
1015            effective.insert(&revision.claim_id, revision);
1016        }
1017    }
1018    let resolutions: BTreeMap<&str, &ForecastResolution> = evidence
1019        .resolutions
1020        .iter()
1021        .map(|resolution| (resolution.claim_id.as_str(), resolution))
1022        .collect();
1023    let mut used_contracts = BTreeSet::new();
1024    let mut scored = Vec::new();
1025    for (claim_id, revision) in effective {
1026        let Some(resolution) = resolutions.get(claim_id) else {
1027            continue;
1028        };
1029        if resolution.status != "resolved" {
1030            continue;
1031        }
1032        let (contract, contract_digest_version) = *contracts
1033            .get(&revision.contract_sha256)
1034            .ok_or_else(|| reject("effective revision names an unknown contract"))?;
1035        // By contract, not by digest: the same contract under its v1 and its
1036        // legacy digest is still one contract.
1037        if !used_contracts.insert(contract.contract_id.as_str()) {
1038            return Err(reject(
1039                "one agent has multiple effective claims for the same contract",
1040            ));
1041        }
1042        let outcome = resolution
1043            .outcome
1044            .as_ref()
1045            .ok_or_else(|| reject("resolved record has no outcome"))?;
1046        let (loss, probability, categorical_confidence, normal_pit) =
1047            score_prediction(contract, &revision.prediction, outcome)?;
1048        scored.push(ScoredForecast {
1049            contract_sha256: revision.contract_sha256.clone(),
1050            contract_digest_version,
1051            instrument: contract.instrument.clone(),
1052            resolves_at: contract.resolves_at,
1053            scoring_rule: contract.scoring_rule.clone(),
1054            outcome_sha256: outcome_sha256(outcome)?,
1055            outcome_available_at: resolution.available_at,
1056            loss,
1057            probability,
1058            categorical_confidence,
1059            normal_pit,
1060            consensus_visible: revision.exposure.consensus_visible,
1061        });
1062    }
1063    Ok(scored)
1064}
1065
1066#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1067#[serde(deny_unknown_fields)]
1068pub struct ForecastAnalysisConfig {
1069    pub bootstrap_seed: u64,
1070    pub bootstrap_samples: usize,
1071    pub confidence: f64,
1072    pub familywise_alpha: f64,
1073    pub calibration_bins: usize,
1074}
1075
1076impl Default for ForecastAnalysisConfig {
1077    fn default() -> Self {
1078        Self {
1079            bootstrap_seed: 0xF0EC_A57A_2026,
1080            bootstrap_samples: 2_000,
1081            confidence: 0.95,
1082            familywise_alpha: 0.05,
1083            calibration_bins: 10,
1084        }
1085    }
1086}
1087
1088#[derive(Clone, Debug, Serialize)]
1089pub struct MetricMean {
1090    pub scoring_rule: String,
1091    pub n: usize,
1092    pub mean_loss: f64,
1093}
1094
1095#[derive(Clone, Debug, Serialize)]
1096pub struct CalibrationBin {
1097    pub lower: f64,
1098    pub upper: f64,
1099    pub n: usize,
1100    pub mean_forecast: Option<f64>,
1101    pub event_rate: Option<f64>,
1102}
1103
1104#[derive(Clone, Debug, Serialize)]
1105pub struct BinaryCalibration {
1106    pub n: usize,
1107    pub brier: f64,
1108    pub base_rate: f64,
1109    pub reliability: f64,
1110    pub resolution: f64,
1111    pub uncertainty: f64,
1112    pub brier_skill: Option<f64>,
1113    pub bins: Vec<CalibrationBin>,
1114}
1115
1116#[derive(Clone, Debug, Serialize)]
1117pub struct ConfidenceCalibration {
1118    pub n: usize,
1119    pub mean_confidence: f64,
1120    pub accuracy: f64,
1121    pub bins: Vec<CalibrationBin>,
1122}
1123
1124#[derive(Clone, Debug, Serialize)]
1125pub struct DistributionCalibration {
1126    pub n: usize,
1127    pub pit_mean: f64,
1128    pub pit_variance: f64,
1129    pub uniform_reference_mean: f64,
1130    pub uniform_reference_variance: f64,
1131    pub bin_counts: Vec<usize>,
1132}
1133
1134#[derive(Clone, Debug, Serialize)]
1135pub struct AgentForecastSummary {
1136    pub agent_id: String,
1137    pub model_id: String,
1138    pub n_claims: usize,
1139    pub n_revisions: usize,
1140    pub n_eligible_claims: usize,
1141    pub n_resolved: usize,
1142    pub n_pending: usize,
1143    pub n_cancelled: usize,
1144    pub n_rejected: usize,
1145    pub resolution_rate: f64,
1146    pub blind_resolved: usize,
1147    pub consensus_exposed_resolved: usize,
1148    pub metrics: Vec<MetricMean>,
1149    pub binary_calibration: Option<BinaryCalibration>,
1150    pub categorical_confidence_calibration: Option<ConfidenceCalibration>,
1151    pub normal_distribution_calibration: Option<DistributionCalibration>,
1152}
1153
1154/// The field contracts one agent did not resolve, each listed once under that agent's
1155/// own status for it.
1156///
1157/// A digest named by a pending claim is listed as pending even if another claim on it
1158/// was cancelled or rejected, and a cancelled one outranks a rejected one: the list
1159/// records the most recoverable reason the agent gave.
1160#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
1161pub struct AgentUnresolvedSupport {
1162    pub n_unresolved: usize,
1163    /// Named by no claim in this agent's document. Support is exact by digest, so a
1164    /// contract the agent claimed under its other encoding is listed here.
1165    pub not_claimed: Vec<String>,
1166    pub pending: Vec<String>,
1167    pub cancelled: Vec<String>,
1168    /// Claimed, but the claim had no eligible revision to settle.
1169    pub rejected: Vec<String>,
1170}
1171
1172/// A contract some agent resolved while another agent's document leaves it pending
1173/// or cancelled. Recorded, never differenced: the non-resolving agent is charged the
1174/// gap and its pairs are withheld.
1175#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1176pub struct SettlementStatusDisagreement {
1177    pub contract_sha256: String,
1178    pub resolved_by: Vec<String>,
1179    pub pending_by: Vec<String>,
1180    pub cancelled_by: Vec<String>,
1181}
1182
1183#[derive(Clone, Debug, Serialize)]
1184pub struct CommonSupport {
1185    pub rule: &'static str,
1186    /// The field support each agent's gaps are charged against: every contract
1187    /// digest at least one agent resolved, or, against a plan, the planned
1188    /// digests. It equals every pair's support when the whole field resolved the
1189    /// same digests.
1190    pub n_contracts: usize,
1191    pub contract_sha256: Vec<String>,
1192    pub unresolved_by_agent: BTreeMap<String, AgentUnresolvedSupport>,
1193    pub settlement_status_disagreements: Vec<SettlementStatusDisagreement>,
1194    /// Against a plan only: per agent, the resolved digests the plan does not
1195    /// name, sorted. They enter no score, calibration or comparison.
1196    #[serde(skip_serializing_if = "Option::is_none")]
1197    pub outside_plan_by_agent: Option<BTreeMap<String, Vec<String>>>,
1198}
1199
1200/// How far apart two agents' resolved supports are. Each count is charged to the
1201/// agent that did not resolve those digests.
1202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
1203pub struct SupportGap {
1204    /// Digests agent B resolved and agent A did not.
1205    pub agent_a_unresolved: usize,
1206    /// Digests agent A resolved and agent B did not.
1207    pub agent_b_unresolved: usize,
1208}
1209
1210#[derive(Clone, Debug, Serialize)]
1211pub struct PairwiseForecastComparison {
1212    pub agent_a: String,
1213    pub agent_b: String,
1214    /// Contracts both agents resolved, which are the contracts differenced.
1215    pub n_contracts: usize,
1216    pub n_settlement_blocks: usize,
1217    /// Mean loss(A) minus mean loss(B); negative favors A. Descriptive only when
1218    /// inference is withheld: under a `support_gap` it covers the contracts the
1219    /// incomplete agent chose to resolve.
1220    pub mean_loss_difference: f64,
1221    /// Absent, together with the other three inference fields, when
1222    /// `inference_error` says the block resampling law cannot support the claim.
1223    #[serde(skip_serializing_if = "Option::is_none")]
1224    pub confidence_lower: Option<f64>,
1225    #[serde(skip_serializing_if = "Option::is_none")]
1226    pub confidence_upper: Option<f64>,
1227    #[serde(skip_serializing_if = "Option::is_none")]
1228    pub raw_p_value: Option<f64>,
1229    #[serde(skip_serializing_if = "Option::is_none")]
1230    pub holm_adjusted_p_value: Option<f64>,
1231    pub familywise_significant: bool,
1232    /// The measured rejection rate of this comparison's test under the null, at
1233    /// this comparison's block count, against the nominal 5%.
1234    ///
1235    /// It travels with the flag because the flag is not delivered at the block
1236    /// counts the protocol admits: at four blocks, the first count the
1237    /// pair-support gate passes, the test rejects a true null 18.8% of the
1238    /// time. Read `familywise_significant` against this, not against 0.05.
1239    /// `None` when the comparison carries no p-value.
1240    #[serde(skip_serializing_if = "Option::is_none")]
1241    pub measured_size_at_nominal_5pct: Option<f64>,
1242    /// Present only when the two agents did not resolve the same digests.
1243    #[serde(skip_serializing_if = "Option::is_none")]
1244    pub support_gap: Option<SupportGap>,
1245    /// Present only when inference was withheld. Omitted for a supported comparison.
1246    #[serde(skip_serializing_if = "Option::is_none")]
1247    pub inference_error: Option<String>,
1248}
1249
1250/// Why a paired comparison carries no interval, no p-value and no significance.
1251#[derive(Clone, Copy, Debug, PartialEq)]
1252enum ForecastInferenceError {
1253    UnequalResolvedSupport(SupportGap),
1254    NoSettlementBlock,
1255    LevelBelowResamplingResolution {
1256        blocks: usize,
1257        degenerate_mass: f64,
1258        familywise_alpha: f64,
1259    },
1260}
1261
1262impl Display for ForecastInferenceError {
1263    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1264        match self {
1265            Self::UnequalResolvedSupport(gap) => write!(
1266                formatter,
1267                "unequal resolved support: agent_a did not resolve {} contract(s) that agent_b \
1268                 resolved and agent_b did not resolve {} that agent_a resolved; a pair receives \
1269                 inference only when both agents resolved the same contracts",
1270                gap.agent_a_unresolved, gap.agent_b_unresolved
1271            ),
1272            Self::NoSettlementBlock => formatter.write_str(
1273                "no settlement block on exact common support: there is nothing to resample",
1274            ),
1275            Self::LevelBelowResamplingResolution {
1276                blocks,
1277                degenerate_mass,
1278                familywise_alpha,
1279            } => write!(
1280                formatter,
1281                "familywise level {familywise_alpha} is finer than a {blocks}-block resampling \
1282                 law can resolve: single-block resamples hold {degenerate_mass} of its mass, and \
1283                 more bootstrap replications estimate that same law without adding blocks"
1284            ),
1285        }
1286    }
1287}
1288
1289impl Error for ForecastInferenceError {}
1290
1291/// Mass the block resampling law places on resamples that are one block repeated.
1292///
1293/// The comparison resamples whole resolution-clock blocks with replacement. A draw
1294/// that takes the same block every time reproduces one block's mean and carries no
1295/// information about variation between blocks. Drawing `blocks` times from `blocks`
1296/// blocks lands on such a draw with probability `blocks * blocks^-blocks`, that is
1297/// `blocks^(1 - blocks)`: 1 at one block, 1/2 at two, 1/9 at three, 1/64 at four.
1298/// What `familywise_significant` actually delivers, as written into the report.
1299pub const FAMILYWISE_SIZE_RULE: &str =
1300    "the paired test resamples whole blocks and centres the raw \
1301     mean, not a studentised statistic, so it rejects a true null more often than its nominal 5%: \
1302     measured 0.188 at 4 blocks, 0.162 at 5, 0.138 at 6, 0.096 at 10, 0.072 at 20 and 0.060 at 40 \
1303     over 6000 i.i.d. normal draws of 5 contracts a block, and 0.237 at 4 blocks under lognormal \
1304     contract differences; read familywise_significant against measured_size_at_nominal_5pct";
1305
1306/// The measured size of the block bootstrap at a block count, against a nominal
1307/// 5%, under i.i.d. normal per-contract differences with five contracts a block
1308/// and the shipped 2000 replications. Entries are `(blocks, size)` ascending.
1309///
1310/// These are measurements of this implementation, not properties of a block
1311/// bootstrap in general, and the data-generating process is named because the
1312/// number moves with it: the same table under lognormal differences reads 0.237
1313/// at four blocks rather than 0.188. The table is reported rather than corrected
1314/// because no available replacement holds its size across the admitted counts.
1315/// Studentising the statistic swaps the error for the opposite one, measuring
1316/// 0.013 at four blocks and rejecting a half-sigma effect 12.1% of the time
1317/// against this test's 74.5%; a Student-t on the block means is exact under
1318/// normal blocks (0.050) but reads 0.094 under skewed ones, and it tests the
1319/// unweighted block mean rather than the contract mean this report publishes as
1320/// `mean_loss_difference`.
1321const MEASURED_SIZE: &[(usize, f64)] = &[
1322    (4, 0.188),
1323    (5, 0.162),
1324    (6, 0.138),
1325    (10, 0.096),
1326    (20, 0.072),
1327    (40, 0.060),
1328];
1329
1330/// The measured size at or below `blocks`, so a reader is never handed a
1331/// friendlier number than the block count earns. Above the largest measured
1332/// count the last entry is carried forward, which is the closest to nominal the
1333/// table states.
1334fn measured_size(blocks: usize) -> f64 {
1335    MEASURED_SIZE
1336        .iter()
1337        .rev()
1338        .find(|(at, _)| *at <= blocks)
1339        .map_or(MEASURED_SIZE[0].1, |(_, size)| *size)
1340}
1341
1342fn degenerate_resample_mass(blocks: usize) -> f64 {
1343    if blocks == 0 {
1344        return 1.0;
1345    }
1346    let blocks = blocks as f64;
1347    blocks.powf(1.0 - blocks)
1348}
1349
1350/// The independent-block requirement this protocol enforces before it reports
1351/// significance.
1352///
1353/// A claim at familywise level `familywise_alpha` is withheld unless the block
1354/// resampling law places at most that much mass on its own degenerate draws. At one
1355/// block every resample is the observed sample: the percentile interval has zero
1356/// width by construction and the exceedance count is zero, so the reported p-value
1357/// is the `(0 + 1) / (samples + 1)` smoothing floor and shrinks as replications are
1358/// added. At two blocks half the law's mass is degenerate, at three blocks a ninth.
1359/// The bar is passed at four blocks for the default alpha of 0.05.
1360///
1361/// This is a necessary condition on the reported level, not a certificate of
1362/// calibration. Clearing it does not establish that blocks are independent, that the
1363/// resolution clock is the right dependence unit, or that the percentile interval has
1364/// its nominal coverage in finite samples. Those remain assumptions of the protocol.
1365fn inference_support(blocks: usize, familywise_alpha: f64) -> Result<(), ForecastInferenceError> {
1366    if blocks == 0 {
1367        return Err(ForecastInferenceError::NoSettlementBlock);
1368    }
1369    let degenerate_mass = degenerate_resample_mass(blocks);
1370    if degenerate_mass > familywise_alpha {
1371        return Err(ForecastInferenceError::LevelBelowResamplingResolution {
1372            blocks,
1373            degenerate_mass,
1374            familywise_alpha,
1375        });
1376    }
1377    Ok(())
1378}
1379
1380#[derive(Clone, Debug, Serialize)]
1381pub struct ForecastQualityReport {
1382    pub schema_version: &'static str,
1383    pub rank_effect: &'static str,
1384    pub dependence_unit: &'static str,
1385    /// What `familywise_significant` delivers at the admitted block counts.
1386    pub familywise_size: &'static str,
1387    pub config: ForecastAnalysisConfig,
1388    pub common_support: CommonSupport,
1389    /// The encoding each scored contract digest verified under, for every digest
1390    /// an effective resolved revision in the field names. Support is exact by
1391    /// digest, so a contract presented under `legacy` by one agent and under
1392    /// `sharpebench/canonical-json/v1` by another is two digests and not common.
1393    pub contract_digest_versions: BTreeMap<String, ContractDigestVersion>,
1394    pub agents: Vec<AgentForecastSummary>,
1395    pub comparisons: Vec<PairwiseForecastComparison>,
1396}
1397
1398/// Recompute forecast quality without changing or consulting trading-rank eligibility.
1399///
1400/// The field support is the union of the digests the documents resolved, so a
1401/// document that resolves a contract nobody else did withholds inference on its
1402/// own pairs and charges that contract to every other agent. Use
1403/// [`analyze_forecast_quality_against_plan`] when the question set is declared.
1404pub fn analyze_forecast_quality(
1405    evidence: &[ForecastEvidence],
1406    config: ForecastAnalysisConfig,
1407) -> Result<ForecastQualityReport, ForecastError> {
1408    analyze(evidence, config, None)
1409}
1410
1411/// [`analyze_forecast_quality`] against a declared contract universe. The plan
1412/// is the field support: every agent's gaps are the planned digests it did not
1413/// resolve, and a resolved digest outside the plan is listed in
1414/// `outside_plan_by_agent` and enters no score, calibration or comparison. Its
1415/// settlement is still checked against every other document that resolved it.
1416/// The report is [`FORECAST_QUALITY_PLAN_SCHEMA`].
1417pub fn analyze_forecast_quality_against_plan(
1418    evidence: &[ForecastEvidence],
1419    config: ForecastAnalysisConfig,
1420    plan: &ForecastContractPlan,
1421) -> Result<ForecastQualityReport, ForecastError> {
1422    analyze(evidence, config, Some(plan))
1423}
1424
1425/// Every unordered pair of documents in a field, by index, in report order. One
1426/// statement of the pairing, so the settlement check and the comparisons cover
1427/// the same pairs and neither compares a document with itself.
1428fn document_pairs(documents: usize) -> impl Iterator<Item = (usize, usize)> {
1429    (0..documents).flat_map(move |left| ((left + 1)..documents).map(move |right| (left, right)))
1430}
1431
1432fn analyze(
1433    evidence: &[ForecastEvidence],
1434    config: ForecastAnalysisConfig,
1435    plan: Option<&ForecastContractPlan>,
1436) -> Result<ForecastQualityReport, ForecastError> {
1437    if evidence.is_empty() {
1438        return Err(reject(
1439            "at least one forecast evidence document is required",
1440        ));
1441    }
1442    if config.bootstrap_samples == 0
1443        || config.calibration_bins == 0
1444        || !config.confidence.is_finite()
1445        || !(0.0..1.0).contains(&config.confidence)
1446        || !config.familywise_alpha.is_finite()
1447        || !(0.0..1.0).contains(&config.familywise_alpha)
1448    {
1449        return Err(reject(
1450            "forecast analysis configuration is outside its valid range",
1451        ));
1452    }
1453    let mut agent_ids = BTreeSet::new();
1454    let mut rows = Vec::new();
1455    for document in evidence {
1456        validate_evidence(document)?;
1457        if !agent_ids.insert(document.identity.agent_id.as_str()) {
1458            return Err(reject("forecast field contains duplicate agent_id values"));
1459        }
1460        rows.push(scored_forecasts(document)?);
1461    }
1462    let mut outside_plan_by_agent = None;
1463    let mut planned = None;
1464    if let Some(plan) = plan {
1465        // Every settlement is checked before the plan drops anything, so an
1466        // unscored contract still has one outcome across the field. The pairs
1467        // are the ones compared below.
1468        for (left, right) in document_pairs(rows.len()) {
1469            check_shared_settlements(&rows[left], &rows[right])?;
1470        }
1471        let digests: BTreeSet<&str> = plan.contract_sha256.iter().map(String::as_str).collect();
1472        let mut outside = BTreeMap::new();
1473        for (document, rows) in evidence.iter().zip(&mut rows) {
1474            let unplanned: BTreeSet<String> = rows
1475                .iter()
1476                .filter(|row| !digests.contains(row.contract_sha256.as_str()))
1477                .map(|row| row.contract_sha256.clone())
1478                .collect();
1479            rows.retain(|row| digests.contains(row.contract_sha256.as_str()));
1480            outside.insert(
1481                document.identity.agent_id.clone(),
1482                unplanned.into_iter().collect::<Vec<_>>(),
1483            );
1484        }
1485        outside_plan_by_agent = Some(outside);
1486        planned = Some(digests);
1487    }
1488    let mut common_support = field_support(evidence, &rows, planned.as_ref());
1489    common_support.outside_plan_by_agent = outside_plan_by_agent;
1490    let agents = evidence
1491        .iter()
1492        .zip(&rows)
1493        .map(|(document, rows)| summarize_agent(document, rows, config.calibration_bins))
1494        .collect();
1495    let mut comparisons = Vec::new();
1496    for (left, right) in document_pairs(evidence.len()) {
1497        comparisons.push(compare_agents(
1498            &evidence[left].identity.agent_id,
1499            &rows[left],
1500            &evidence[right].identity.agent_id,
1501            &rows[right],
1502            config,
1503        )?);
1504    }
1505    holm_adjust(&mut comparisons, config.familywise_alpha);
1506    let contract_digest_versions = rows
1507        .iter()
1508        .flatten()
1509        .map(|row| (row.contract_sha256.clone(), row.contract_digest_version))
1510        .collect();
1511    Ok(ForecastQualityReport {
1512        schema_version: if plan.is_some() {
1513            FORECAST_QUALITY_PLAN_SCHEMA
1514        } else {
1515            FORECAST_QUALITY_SCHEMA
1516        },
1517        rank_effect: "reported_only_never_trading_rank",
1518        dependence_unit: "whole resolution-clock block across assets and questions",
1519        familywise_size: FAMILYWISE_SIZE_RULE,
1520        config,
1521        common_support,
1522        contract_digest_versions,
1523        agents,
1524        comparisons,
1525    })
1526}
1527
1528/// Charge every field contract an agent did not resolve to that agent, and record
1529/// each contract one agent resolved while another left it pending or cancelled.
1530///
1531/// Without a plan the field support is the union of resolved digests, so an agent's
1532/// gaps are measured against what the rest of the field settled. With one it is the
1533/// planned digests, and `rows` hold only those. It is disclosure only: no comparison
1534/// reads it, so a document with gaps or extra contracts cannot change the support of
1535/// a pair it is not part of.
1536fn field_support(
1537    evidence: &[ForecastEvidence],
1538    rows: &[Vec<ScoredForecast>],
1539    plan: Option<&BTreeSet<&str>>,
1540) -> CommonSupport {
1541    let resolved: Vec<BTreeSet<&str>> = rows
1542        .iter()
1543        .map(|rows| {
1544            rows.iter()
1545                .map(|row| row.contract_sha256.as_str())
1546                .collect()
1547        })
1548        .collect();
1549    let field: BTreeSet<&str> = match plan {
1550        Some(plan) => plan.clone(),
1551        None => resolved.iter().flatten().copied().collect(),
1552    };
1553    let mut unresolved_by_agent = BTreeMap::new();
1554    let mut disputed: BTreeMap<&str, SettlementStatusDisagreement> = BTreeMap::new();
1555    for (document, resolved) in evidence.iter().zip(&resolved) {
1556        let agent_id = &document.identity.agent_id;
1557        let statuses = claim_statuses_by_digest(document);
1558        let mut gap = AgentUnresolvedSupport::default();
1559        for digest in field.difference(resolved) {
1560            let record = || SettlementStatusDisagreement {
1561                contract_sha256: digest.to_string(),
1562                resolved_by: Vec::new(),
1563                pending_by: Vec::new(),
1564                cancelled_by: Vec::new(),
1565            };
1566            match statuses.get(digest) {
1567                None => gap.not_claimed.push(digest.to_string()),
1568                Some(status) if status.contains("pending") => {
1569                    gap.pending.push(digest.to_string());
1570                    disputed
1571                        .entry(*digest)
1572                        .or_insert_with(record)
1573                        .pending_by
1574                        .push(agent_id.clone());
1575                }
1576                Some(status) if status.contains("cancelled") => {
1577                    gap.cancelled.push(digest.to_string());
1578                    disputed
1579                        .entry(*digest)
1580                        .or_insert_with(record)
1581                        .cancelled_by
1582                        .push(agent_id.clone());
1583                }
1584                Some(_) => gap.rejected.push(digest.to_string()),
1585            }
1586        }
1587        gap.n_unresolved = field.len() - resolved.len();
1588        unresolved_by_agent.insert(agent_id.clone(), gap);
1589    }
1590    for (document, resolved) in evidence.iter().zip(&resolved) {
1591        for (digest, record) in &mut disputed {
1592            if resolved.contains(digest) {
1593                record.resolved_by.push(document.identity.agent_id.clone());
1594            }
1595        }
1596    }
1597    let settlement_status_disagreements = disputed
1598        .into_values()
1599        .map(|mut record| {
1600            record.resolved_by.sort();
1601            record.pending_by.sort();
1602            record.cancelled_by.sort();
1603            record
1604        })
1605        .collect();
1606    CommonSupport {
1607        rule: if plan.is_some() {
1608            PLAN_SUPPORT_RULE
1609        } else {
1610            SUPPORT_RULE
1611        },
1612        n_contracts: field.len(),
1613        contract_sha256: field.into_iter().map(str::to_string).collect(),
1614        unresolved_by_agent,
1615        settlement_status_disagreements,
1616        outside_plan_by_agent: None,
1617    }
1618}
1619
1620/// Every resolution status this document gives a contract digest, over all claims
1621/// naming it. Validation holds a claim to one digest across its revisions, so any
1622/// revision identifies it.
1623fn claim_statuses_by_digest(document: &ForecastEvidence) -> BTreeMap<&str, BTreeSet<&str>> {
1624    let digest_by_claim: BTreeMap<&str, &str> = document
1625        .revisions
1626        .iter()
1627        .map(|revision| {
1628            (
1629                revision.claim_id.as_str(),
1630                revision.contract_sha256.as_str(),
1631            )
1632        })
1633        .collect();
1634    let mut statuses: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
1635    for resolution in &document.resolutions {
1636        statuses
1637            .entry(digest_by_claim[resolution.claim_id.as_str()])
1638            .or_default()
1639            .insert(resolution.status.as_str());
1640    }
1641    statuses
1642}
1643
1644fn summarize_agent(
1645    document: &ForecastEvidence,
1646    rows: &[ScoredForecast],
1647    calibration_bins: usize,
1648) -> AgentForecastSummary {
1649    let claim_ids: BTreeSet<&str> = document
1650        .revisions
1651        .iter()
1652        .map(|revision| revision.claim_id.as_str())
1653        .collect();
1654    let eligible_ids: BTreeSet<&str> = document
1655        .revisions
1656        .iter()
1657        .filter(|revision| revision.status == "eligible")
1658        .map(|revision| revision.claim_id.as_str())
1659        .collect();
1660    let status_count = |status: &str| {
1661        document
1662            .resolutions
1663            .iter()
1664            .filter(|resolution| resolution.status == status)
1665            .count()
1666    };
1667    let mut metrics: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
1668    for row in rows {
1669        metrics
1670            .entry(row.scoring_rule.as_str())
1671            .or_default()
1672            .push(row.loss);
1673    }
1674    let metrics = metrics
1675        .into_iter()
1676        .map(|(rule, values)| MetricMean {
1677            scoring_rule: rule.to_string(),
1678            n: values.len(),
1679            mean_loss: mean(&values),
1680        })
1681        .collect();
1682    let binary: Vec<_> = rows.iter().filter_map(|row| row.probability).collect();
1683    let categorical: Vec<_> = rows
1684        .iter()
1685        .filter_map(|row| row.categorical_confidence)
1686        .collect();
1687    let pits: Vec<_> = rows.iter().filter_map(|row| row.normal_pit).collect();
1688    AgentForecastSummary {
1689        agent_id: document.identity.agent_id.clone(),
1690        model_id: document.identity.model_id.clone(),
1691        n_claims: claim_ids.len(),
1692        n_revisions: document.revisions.len(),
1693        n_eligible_claims: eligible_ids.len(),
1694        n_resolved: status_count("resolved"),
1695        n_pending: status_count("pending"),
1696        n_cancelled: status_count("cancelled"),
1697        n_rejected: status_count("rejected"),
1698        resolution_rate: status_count("resolved") as f64 / claim_ids.len() as f64,
1699        blind_resolved: rows.iter().filter(|row| !row.consensus_visible).count(),
1700        consensus_exposed_resolved: rows.iter().filter(|row| row.consensus_visible).count(),
1701        metrics,
1702        binary_calibration: binary_calibration(&binary, calibration_bins),
1703        categorical_confidence_calibration: confidence_calibration(&categorical, calibration_bins),
1704        normal_distribution_calibration: distribution_calibration(&pits, calibration_bins),
1705    }
1706}
1707
1708fn calibration_bins(values: &[(f64, bool)], bins: usize) -> Vec<CalibrationBin> {
1709    (0..bins)
1710        .map(|index| {
1711            let selected: Vec<_> = values
1712                .iter()
1713                .filter(|(probability, _)| {
1714                    ((*probability * bins as f64).floor() as usize).min(bins - 1) == index
1715                })
1716                .collect();
1717            CalibrationBin {
1718                lower: index as f64 / bins as f64,
1719                upper: (index + 1) as f64 / bins as f64,
1720                n: selected.len(),
1721                mean_forecast: (!selected.is_empty()).then(|| {
1722                    selected
1723                        .iter()
1724                        .map(|(probability, _)| probability)
1725                        .sum::<f64>()
1726                        / selected.len() as f64
1727                }),
1728                event_rate: (!selected.is_empty()).then(|| {
1729                    selected.iter().filter(|(_, event)| *event).count() as f64
1730                        / selected.len() as f64
1731                }),
1732            }
1733        })
1734        .collect()
1735}
1736
1737fn binary_calibration(values: &[(f64, bool)], bins: usize) -> Option<BinaryCalibration> {
1738    if values.is_empty() {
1739        return None;
1740    }
1741    let bins = calibration_bins(values, bins);
1742    let n = values.len() as f64;
1743    let base_rate = values.iter().filter(|(_, event)| *event).count() as f64 / n;
1744    let brier = values
1745        .iter()
1746        .map(|(probability, event)| (probability - if *event { 1.0 } else { 0.0 }).powi(2))
1747        .sum::<f64>()
1748        / n;
1749    let reliability = bins
1750        .iter()
1751        .filter_map(|bin| Some((bin.n as f64 / n, bin.mean_forecast?, bin.event_rate?)))
1752        .map(|(weight, forecast, observed)| weight * (forecast - observed).powi(2))
1753        .sum();
1754    let resolution = bins
1755        .iter()
1756        .filter_map(|bin| Some((bin.n as f64 / n, bin.event_rate?)))
1757        .map(|(weight, observed)| weight * (observed - base_rate).powi(2))
1758        .sum();
1759    let uncertainty = base_rate * (1.0 - base_rate);
1760    Some(BinaryCalibration {
1761        n: values.len(),
1762        brier,
1763        base_rate,
1764        reliability,
1765        resolution,
1766        uncertainty,
1767        brier_skill: (uncertainty > 0.0).then_some(1.0 - brier / uncertainty),
1768        bins,
1769    })
1770}
1771
1772fn confidence_calibration(values: &[(f64, bool)], bins: usize) -> Option<ConfidenceCalibration> {
1773    if values.is_empty() {
1774        return None;
1775    }
1776    Some(ConfidenceCalibration {
1777        n: values.len(),
1778        mean_confidence: values.iter().map(|(confidence, _)| confidence).sum::<f64>()
1779            / values.len() as f64,
1780        accuracy: values.iter().filter(|(_, correct)| *correct).count() as f64
1781            / values.len() as f64,
1782        bins: calibration_bins(values, bins),
1783    })
1784}
1785
1786fn distribution_calibration(values: &[f64], bins: usize) -> Option<DistributionCalibration> {
1787    if values.is_empty() {
1788        return None;
1789    }
1790    let average = mean(values);
1791    let variance = values
1792        .iter()
1793        .map(|value| (value - average).powi(2))
1794        .sum::<f64>()
1795        / values.len() as f64;
1796    let mut bin_counts = vec![0; bins];
1797    for value in values {
1798        let index = ((*value * bins as f64).floor() as usize).min(bins - 1);
1799        bin_counts[index] += 1;
1800    }
1801    Some(DistributionCalibration {
1802        n: values.len(),
1803        pit_mean: average,
1804        pit_variance: variance,
1805        uniform_reference_mean: 0.5,
1806        uniform_reference_variance: 1.0 / 12.0,
1807        bin_counts,
1808    })
1809}
1810
1811fn mean(values: &[f64]) -> f64 {
1812    values.iter().sum::<f64>() / values.len() as f64
1813}
1814
1815#[derive(Clone, Copy)]
1816struct SplitMix64(u64);
1817
1818impl SplitMix64 {
1819    fn next(&mut self) -> u64 {
1820        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1821        let mut value = self.0;
1822        value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1823        value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1824        value ^ (value >> 31)
1825    }
1826
1827    fn below(&mut self, upper: usize) -> usize {
1828        ((self.next() as u128 * upper as u128) >> 64) as usize
1829    }
1830}
1831
1832fn percentile(sorted: &[f64], probability: f64) -> f64 {
1833    let index = ((sorted.len() - 1) as f64 * probability).round() as usize;
1834    sorted[index]
1835}
1836
1837/// Refuse two documents that settle a contract digest they both resolved
1838/// differently: other contract semantics, another realized outcome, or another
1839/// outcome availability time.
1840fn check_shared_settlements(
1841    rows_a: &[ScoredForecast],
1842    rows_b: &[ScoredForecast],
1843) -> Result<(), ForecastError> {
1844    let by_hash_a: BTreeMap<_, _> = rows_a
1845        .iter()
1846        .map(|row| (row.contract_sha256.as_str(), row))
1847        .collect();
1848    let by_hash_b: BTreeMap<_, _> = rows_b
1849        .iter()
1850        .map(|row| (row.contract_sha256.as_str(), row))
1851        .collect();
1852    for (contract_hash, left) in &by_hash_a {
1853        let Some(right) = by_hash_b.get(contract_hash) else {
1854            continue;
1855        };
1856        if left.resolves_at != right.resolves_at
1857            || left.instrument != right.instrument
1858            || left.scoring_rule != right.scoring_rule
1859        {
1860            return Err(reject(
1861                "equal contract digest resolved to unequal contract semantics",
1862            ));
1863        }
1864        // Contract equality is not outcome equality. Bench takes documents from any
1865        // producer, so the settlement each loss was computed against has to agree
1866        // before the two losses may be differenced.
1867        if left.outcome_sha256 != right.outcome_sha256 {
1868            return Err(reject(
1869                "equal contract digest settled to unequal realized outcomes",
1870            ));
1871        }
1872        if left.outcome_available_at != right.outcome_available_at {
1873            return Err(reject(
1874                "equal contract digest settled at unequal outcome availability times",
1875            ));
1876        }
1877    }
1878    Ok(())
1879}
1880
1881fn compare_agents(
1882    agent_a: &str,
1883    rows_a: &[ScoredForecast],
1884    agent_b: &str,
1885    rows_b: &[ScoredForecast],
1886    config: ForecastAnalysisConfig,
1887) -> Result<PairwiseForecastComparison, ForecastError> {
1888    // Settlement agreement is checked on every contract both resolved, including a
1889    // pair whose inference is withheld below, so a disputed settlement is refused
1890    // whichever other documents the field holds.
1891    check_shared_settlements(rows_a, rows_b)?;
1892    let by_hash_a: BTreeMap<_, _> = rows_a
1893        .iter()
1894        .map(|row| (row.contract_sha256.as_str(), row))
1895        .collect();
1896    let by_hash_b: BTreeMap<_, _> = rows_b
1897        .iter()
1898        .map(|row| (row.contract_sha256.as_str(), row))
1899        .collect();
1900    let mut blocks: BTreeMap<u64, Vec<f64>> = BTreeMap::new();
1901    for (contract_hash, left) in &by_hash_a {
1902        let Some(right) = by_hash_b.get(contract_hash) else {
1903            continue;
1904        };
1905        blocks
1906            .entry(left.resolves_at)
1907            .or_default()
1908            .push(left.loss - right.loss);
1909    }
1910    let observed_values: Vec<f64> = blocks.values().flatten().copied().collect();
1911    let block_values: Vec<&Vec<f64>> = blocks.values().collect();
1912    let gap = SupportGap {
1913        agent_a_unresolved: by_hash_b.len() - observed_values.len(),
1914        agent_b_unresolved: by_hash_a.len() - observed_values.len(),
1915    };
1916    let supported = if gap.agent_a_unresolved > 0 || gap.agent_b_unresolved > 0 {
1917        Err(ForecastInferenceError::UnequalResolvedSupport(gap))
1918    } else {
1919        inference_support(block_values.len(), config.familywise_alpha)
1920    };
1921    if let Err(unsupported) = supported {
1922        return Ok(PairwiseForecastComparison {
1923            agent_a: agent_a.to_string(),
1924            agent_b: agent_b.to_string(),
1925            n_contracts: observed_values.len(),
1926            n_settlement_blocks: block_values.len(),
1927            mean_loss_difference: if observed_values.is_empty() {
1928                0.0
1929            } else {
1930                mean(&observed_values)
1931            },
1932            confidence_lower: None,
1933            confidence_upper: None,
1934            raw_p_value: None,
1935            holm_adjusted_p_value: None,
1936            familywise_significant: false,
1937            measured_size_at_nominal_5pct: None,
1938            support_gap: match unsupported {
1939                ForecastInferenceError::UnequalResolvedSupport(gap) => Some(gap),
1940                _ => None,
1941            },
1942            inference_error: Some(unsupported.to_string()),
1943        });
1944    }
1945    let observed = mean(&observed_values);
1946    let mut rng = SplitMix64(config.bootstrap_seed ^ stable_pair_seed(agent_a, agent_b));
1947    let mut samples = Vec::with_capacity(config.bootstrap_samples);
1948    let mut null_extreme = 0usize;
1949    for _ in 0..config.bootstrap_samples {
1950        let mut sample = Vec::new();
1951        for _ in 0..block_values.len() {
1952            sample.extend_from_slice(block_values[rng.below(block_values.len())]);
1953        }
1954        let sample_mean = mean(&sample);
1955        samples.push(sample_mean);
1956        let centered =
1957            sample.iter().map(|value| value - observed).sum::<f64>() / sample.len() as f64;
1958        if centered.abs() >= observed.abs() {
1959            null_extreme += 1;
1960        }
1961    }
1962    samples.sort_by(f64::total_cmp);
1963    let tail = (1.0 - config.confidence) / 2.0;
1964    Ok(PairwiseForecastComparison {
1965        agent_a: agent_a.to_string(),
1966        agent_b: agent_b.to_string(),
1967        n_contracts: observed_values.len(),
1968        n_settlement_blocks: block_values.len(),
1969        mean_loss_difference: observed,
1970        confidence_lower: Some(percentile(&samples, tail)),
1971        confidence_upper: Some(percentile(&samples, 1.0 - tail)),
1972        raw_p_value: Some((null_extreme as f64 + 1.0) / (config.bootstrap_samples as f64 + 1.0)),
1973        holm_adjusted_p_value: None,
1974        familywise_significant: false,
1975        measured_size_at_nominal_5pct: Some(measured_size(block_values.len())),
1976        support_gap: None,
1977        inference_error: None,
1978    })
1979}
1980
1981fn stable_pair_seed(left: &str, right: &str) -> u64 {
1982    let (first, second) = if left <= right {
1983        (left, right)
1984    } else {
1985        (right, left)
1986    };
1987    let digest = Sha256::digest(format!("{first}\0{second}").as_bytes());
1988    u64::from_be_bytes(digest[..8].try_into().unwrap_or([0; 8]))
1989}
1990
1991/// Holm over the comparisons that produced a p-value.
1992///
1993/// A withheld comparison is not a test and receives no adjusted p-value, but it stays
1994/// in the family size: dropping it would shrink the multiplier and make the surviving
1995/// comparisons look more significant because a neighbour could not be evaluated.
1996fn holm_adjust(comparisons: &mut [PairwiseForecastComparison], alpha: f64) {
1997    let mut order: Vec<usize> = (0..comparisons.len())
1998        .filter(|index| comparisons[*index].raw_p_value.is_some())
1999        .collect();
2000    order.sort_by(|left, right| {
2001        comparisons[*left]
2002            .raw_p_value
2003            .unwrap_or(1.0)
2004            .total_cmp(&comparisons[*right].raw_p_value.unwrap_or(1.0))
2005    });
2006    let mut prior = 0.0_f64;
2007    let family = comparisons.len();
2008    for (rank, index) in order.into_iter().enumerate() {
2009        let adjusted = ((family - rank) as f64 * comparisons[index].raw_p_value.unwrap_or(1.0))
2010            .max(prior)
2011            .min(1.0);
2012        comparisons[index].holm_adjusted_p_value = Some(adjusted);
2013        comparisons[index].familywise_significant = adjusted <= alpha;
2014        prior = adjusted;
2015    }
2016}
2017
2018#[cfg(test)]
2019mod tests {
2020    use super::*;
2021
2022    fn fixture(agent: &str, probabilities: &[f64], outcomes: &[f64]) -> String {
2023        assert_eq!(probabilities.len(), outcomes.len());
2024        let contracts: Vec<_> = probabilities
2025            .iter()
2026            .enumerate()
2027            .map(|(index, _)| ForecastContract {
2028                schema_version: CONTRACT_SCHEMA.to_string(),
2029                contract_id: format!("c{index}"),
2030                question: format!("question {index}"),
2031                instrument: if index % 2 == 0 { "ES" } else { "NQ" }.to_string(),
2032                target: "close_up".to_string(),
2033                kind: "probability".to_string(),
2034                opens_at: index as u64 * 2,
2035                deadline: index as u64 * 2 + 1,
2036                resolves_at: (index as u64 / 2 + 1) * 10,
2037                observation_source: "fixture:v1".to_string(),
2038                open_definition: "close at opens_at".to_string(),
2039                close_definition: "close at resolves_at".to_string(),
2040                unit: "binary".to_string(),
2041                scoring_rule: "binary_brier".to_string(),
2042                neutral_threshold: 0.001,
2043                boundary_ownership: "threshold is false".to_string(),
2044                missing_data_policy: "cancel".to_string(),
2045                fallback_policy: "cancel".to_string(),
2046                categories: vec![],
2047                interval_alpha: None,
2048            })
2049            .collect();
2050        let hashes: Vec<_> = contracts
2051            .iter()
2052            .map(|contract| contract_digests(contract).unwrap().canonical_json_v1)
2053            .collect();
2054        let evidence = ForecastEvidence {
2055            schema_version: FORECAST_EVIDENCE_SCHEMA.to_string(),
2056            producer: ForecastProducer {
2057                name: "sharpearena".to_string(),
2058                contract: "native".to_string(),
2059            },
2060            generated_at: 100,
2061            identity: ForecastRunIdentity {
2062                agent_id: agent.to_string(),
2063                model_id: format!("model-{agent}"),
2064                model_sha256: "a".repeat(64),
2065                scaffold_id: "scaffold".to_string(),
2066                scaffold_sha256: "b".repeat(64),
2067                prompt_sha256: "c".repeat(64),
2068                operator_id: "operator".to_string(),
2069                config_sha256: "d".repeat(64),
2070            },
2071            revisions: probabilities
2072                .iter()
2073                .enumerate()
2074                .map(|(index, probability)| ForecastRevision {
2075                    revision_id: format!("claim-{index}:r0"),
2076                    claim_id: format!("claim-{index}"),
2077                    ordinal: 0,
2078                    supersedes: None,
2079                    contract_sha256: hashes[index].clone(),
2080                    contract_digest_encoding: None,
2081                    prediction: vec![*probability],
2082                    confidence: *probability,
2083                    rationale: "evidence".to_string(),
2084                    submitted_at: index as u64 * 2,
2085                    status: "eligible".to_string(),
2086                    reason: None,
2087                    trigger_event_id: None,
2088                    revision_reason: None,
2089                    exposure: InformationExposure {
2090                        observed_at: index as u64 * 2,
2091                        market_snapshot_sha256: Some("e".repeat(64)),
2092                        consensus_visible: false,
2093                        consensus_snapshot_sha256: None,
2094                        source_ids: vec!["market".to_string()],
2095                    },
2096                    idempotency_key: format!("request-{index}"),
2097                })
2098                .collect(),
2099            resolutions: outcomes
2100                .iter()
2101                .enumerate()
2102                .map(|(index, outcome)| ForecastResolution {
2103                    claim_id: format!("claim-{index}"),
2104                    status: "resolved".to_string(),
2105                    outcome: Some(Value::from(*outcome)),
2106                    available_at: Some(contracts[index].resolves_at),
2107                    recorded_at: 100,
2108                    reason: None,
2109                })
2110                .collect(),
2111            contracts,
2112        };
2113        serde_json::to_string(&evidence).unwrap()
2114    }
2115
2116    #[test]
2117    fn strict_ingestion_rejects_unknown_fields_and_contract_drift() {
2118        let payload = fixture("a", &[0.8], &[1.0]);
2119        let mut value: Value = serde_json::from_str(&payload).unwrap();
2120        value["revisions"][0]["unknown"] = Value::Bool(true);
2121        assert!(parse_forecast_evidence(&value.to_string()).is_err());
2122
2123        let mut value: Value = serde_json::from_str(&payload).unwrap();
2124        value["contracts"][0]["question"] = Value::String("changed".to_string());
2125        let error = parse_forecast_evidence(&value.to_string()).unwrap_err();
2126        assert!(error.0.contains("unknown contract digest"));
2127    }
2128
2129    #[test]
2130    fn sharpearena_golden_file_crosses_the_artifact_boundary_exactly() {
2131        let payload = include_str!("../tests/fixtures/sharpearena-forecast-evidence-v1.json");
2132        let document = parse_forecast_evidence(payload).unwrap();
2133        let report =
2134            analyze_forecast_quality(&[document], ForecastAnalysisConfig::default()).unwrap();
2135        assert_eq!(report.agents[0].agent_id, "arena-reference");
2136        assert_eq!(report.agents[0].metrics[0].scoring_rule, "binary_brier");
2137        assert!((report.agents[0].metrics[0].mean_loss - 0.09).abs() < 1e-12);
2138    }
2139
2140    /// The v2 golden is the v1 golden with the envelope bumped and the encoding
2141    /// its digest recomputes under declared; the report is the same report plus the
2142    /// declared label under `contract_digest_versions`.
2143    #[test]
2144    fn sharpearena_v2_golden_file_crosses_the_artifact_boundary_exactly() {
2145        let v1 = include_str!("../tests/fixtures/sharpearena-forecast-evidence-v1.json");
2146        let v2 = include_str!("../tests/fixtures/sharpearena-forecast-evidence-v2.json");
2147        let config = ForecastAnalysisConfig::default();
2148        let baseline =
2149            analyze_forecast_quality(&[parse_forecast_evidence(v1).unwrap()], config).unwrap();
2150
2151        let document = parse_forecast_evidence(v2).unwrap();
2152        assert_eq!(document.schema_version, FORECAST_EVIDENCE_SCHEMA_V2);
2153        assert_eq!(
2154            document.revisions[0].contract_digest_encoding.as_deref(),
2155            Some("legacy")
2156        );
2157        let report = analyze_forecast_quality(&[document], config).unwrap();
2158        assert_eq!(report.agents[0].agent_id, "arena-reference");
2159        assert_eq!(report.agents[0].metrics[0].scoring_rule, "binary_brier");
2160        assert!((report.agents[0].metrics[0].mean_loss - 0.09).abs() < 1e-12);
2161        assert_eq!(
2162            report.contract_digest_versions,
2163            BTreeMap::from([(
2164                "0eb3250fcb87a5e225f476e8a6e18ccdf65ac458919ad0b150594eb5761ecd2d".to_string(),
2165                ContractDigestVersion::Legacy
2166            )])
2167        );
2168        assert_eq!(
2169            serde_json::to_string_pretty(&report).unwrap(),
2170            serde_json::to_string_pretty(&baseline).unwrap()
2171        );
2172    }
2173
2174    /// Verify one tutorial field against its manifest and recompute its frozen report
2175    /// byte for byte with the tutorial's analysis settings.
2176    fn recompute_tutorial_field(
2177        alpha: &str,
2178        beta: &str,
2179        manifest: &str,
2180        expected_report: &str,
2181    ) -> ForecastQualityReport {
2182        let manifest: Value = serde_json::from_str(manifest).unwrap();
2183        for (name, payload) in [("agent-alpha.json", alpha), ("agent-beta.json", beta)] {
2184            let digest = crate::lower_hex(&Sha256::digest(payload.as_bytes()));
2185            assert_eq!(manifest["files"][name].as_str(), Some(digest.as_str()));
2186        }
2187
2188        let documents = [
2189            parse_forecast_evidence(alpha).unwrap(),
2190            parse_forecast_evidence(beta).unwrap(),
2191        ];
2192        let report = analyze_forecast_quality(
2193            &documents,
2194            ForecastAnalysisConfig {
2195                bootstrap_seed: 23,
2196                bootstrap_samples: 400,
2197                confidence: 0.9,
2198                familywise_alpha: 0.05,
2199                calibration_bins: 5,
2200            },
2201        )
2202        .unwrap();
2203        let actual = serde_json::to_string_pretty(&report).unwrap() + "\n";
2204        assert_eq!(actual, expected_report);
2205        assert_eq!(report.rank_effect, "reported_only_never_trading_rank");
2206        report
2207    }
2208
2209    #[test]
2210    fn cross_product_tutorial_is_content_addressed_and_recomputes_exactly() {
2211        let report = recompute_tutorial_field(
2212            include_str!("../../../examples/forecast-quality/fixtures/agent-alpha.json"),
2213            include_str!("../../../examples/forecast-quality/fixtures/agent-beta.json"),
2214            include_str!("../../../examples/forecast-quality/fixtures/manifest.json"),
2215            include_str!("../../../examples/forecast-quality/fixtures/report.json"),
2216        );
2217        assert_eq!(report.common_support.n_contracts, 12);
2218        let comparison = &report.comparisons[0];
2219        assert_eq!(comparison.n_contracts, 12);
2220        assert_eq!(comparison.support_gap, None);
2221        assert_eq!(comparison.n_settlement_blocks, 6);
2222        assert_eq!(comparison.inference_error, None);
2223        assert!(comparison.confidence_lower.unwrap() < comparison.confidence_upper.unwrap());
2224        assert!(comparison.familywise_significant);
2225    }
2226
2227    #[test]
2228    fn cross_product_tutorial_withheld_field_records_the_reason_and_recomputes_exactly() {
2229        let report = recompute_tutorial_field(
2230            include_str!("../../../examples/forecast-quality/fixtures/withheld/agent-alpha.json"),
2231            include_str!("../../../examples/forecast-quality/fixtures/withheld/agent-beta.json"),
2232            include_str!("../../../examples/forecast-quality/fixtures/withheld/manifest.json"),
2233            include_str!("../../../examples/forecast-quality/fixtures/withheld/report.json"),
2234        );
2235        assert_eq!(report.common_support.n_contracts, 8);
2236        let comparison = &report.comparisons[0];
2237        assert_eq!(comparison.n_contracts, 8);
2238        assert_eq!(comparison.support_gap, None);
2239        assert_eq!(comparison.n_settlement_blocks, 2);
2240        assert!(comparison.inference_error.is_some());
2241        assert_eq!(comparison.raw_p_value, None);
2242        assert!(!comparison.familywise_significant);
2243    }
2244
2245    #[test]
2246    fn scoring_recomputes_brier_and_calibration_from_raw_evidence() {
2247        let document =
2248            parse_forecast_evidence(&fixture("a", &[0.8, 0.2, 0.7, 0.1], &[1.0, 0.0, 1.0, 0.0]))
2249                .unwrap();
2250        let report =
2251            analyze_forecast_quality(&[document], ForecastAnalysisConfig::default()).unwrap();
2252        let summary = &report.agents[0];
2253        assert!((summary.metrics[0].mean_loss - 0.045).abs() < 1e-12);
2254        let calibration = summary.binary_calibration.as_ref().unwrap();
2255        assert!((calibration.brier - 0.045).abs() < 1e-12);
2256        assert!((calibration.brier_skill.unwrap() - 0.82).abs() < 1e-12);
2257        assert_eq!(summary.blind_resolved, 4);
2258        assert_eq!(report.rank_effect, "reported_only_never_trading_rank");
2259    }
2260
2261    #[test]
2262    fn pairwise_bootstrap_is_invariant_to_input_file_order() {
2263        let left = parse_forecast_evidence(&fixture(
2264            "left",
2265            &[0.81, 0.22, 0.73, 0.34, 0.66, 0.15, 0.59, 0.27],
2266            &[1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0],
2267        ))
2268        .unwrap();
2269        let right = parse_forecast_evidence(&fixture(
2270            "right",
2271            &[0.61, 0.42, 0.53, 0.54, 0.46, 0.35, 0.39, 0.47],
2272            &[1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0],
2273        ))
2274        .unwrap();
2275        let config = ForecastAnalysisConfig {
2276            bootstrap_seed: 260_904,
2277            bootstrap_samples: 500,
2278            confidence: 0.95,
2279            familywise_alpha: 0.05,
2280            calibration_bins: 5,
2281        };
2282
2283        let forward = analyze_forecast_quality(&[left.clone(), right.clone()], config)
2284            .unwrap()
2285            .comparisons
2286            .remove(0);
2287        let reverse = analyze_forecast_quality(&[right, left], config)
2288            .unwrap()
2289            .comparisons
2290            .remove(0);
2291
2292        assert_eq!(forward.raw_p_value, reverse.raw_p_value);
2293        assert_eq!(forward.holm_adjusted_p_value, reverse.holm_adjusted_p_value);
2294        assert_eq!(
2295            forward.familywise_significant,
2296            reverse.familywise_significant
2297        );
2298        assert_eq!(forward.mean_loss_difference, -reverse.mean_loss_difference);
2299        let negated = |value: Option<f64>| value.map(|value| -value);
2300        assert_eq!(forward.confidence_lower, negated(reverse.confidence_upper));
2301        assert_eq!(forward.confidence_upper, negated(reverse.confidence_lower));
2302        assert!(forward.inference_error.is_none());
2303    }
2304
2305    #[test]
2306    fn fixed_point_brier_model_matches_the_executable_float_rule() {
2307        let base = serde_json::from_str::<ForecastEvidence>(&fixture("formal", &[0.5], &[1.0]))
2308            .unwrap()
2309            .contracts
2310            .remove(0);
2311        let scale = 1_000_i64;
2312        for probability in [0_i64, 100, 500, 900, 1_000] {
2313            for truth in [0_i64, 200, 500, 800, 1_000] {
2314                let p = probability as f64 / scale as f64;
2315                let q = truth as f64 / scale as f64;
2316                let loss_zero = score_prediction(&base, &[p], &Value::from(0.0)).unwrap().0;
2317                let loss_one = score_prediction(&base, &[p], &Value::from(1.0)).unwrap().0;
2318                let expected = q * loss_one + (1.0 - q) * loss_zero;
2319                let numerator =
2320                    truth * (scale - probability).pow(2) + (scale - truth) * probability.pow(2);
2321                let decomposed =
2322                    truth * (scale - truth) * scale + scale * (probability - truth).pow(2);
2323                assert_eq!(numerator, decomposed);
2324                assert!((expected - numerator as f64 / (scale.pow(3) as f64)).abs() < 1e-15);
2325            }
2326        }
2327    }
2328
2329    #[test]
2330    fn late_revision_is_audited_but_never_becomes_the_scored_forecast() {
2331        let mut document =
2332            serde_json::from_str::<ForecastEvidence>(&fixture("a", &[0.8], &[1.0])).unwrap();
2333        let mut late = document.revisions[0].clone();
2334        late.revision_id = "claim-0:r1".to_string();
2335        late.ordinal = 1;
2336        late.supersedes = Some("claim-0:r0".to_string());
2337        late.prediction = vec![0.01];
2338        late.submitted_at = document.contracts[0].deadline + 1;
2339        late.status = "late".to_string();
2340        late.reason = Some("submitted after the contract deadline".to_string());
2341        late.revision_reason = Some("answer became easier".to_string());
2342        late.exposure.observed_at = late.submitted_at;
2343        late.idempotency_key = "late-request".to_string();
2344        document.revisions.push(late);
2345        validate_evidence(&document).unwrap();
2346
2347        let report =
2348            analyze_forecast_quality(&[document], ForecastAnalysisConfig::default()).unwrap();
2349        assert_eq!(report.agents[0].n_revisions, 2);
2350        assert!((report.agents[0].metrics[0].mean_loss - 0.04).abs() < 1e-12);
2351    }
2352
2353    #[test]
2354    fn comparison_uses_exact_pair_support_and_whole_time_blocks() {
2355        let a =
2356            parse_forecast_evidence(&fixture("a", &[0.9, 0.1, 0.8, 0.2], &[1.0, 0.0, 1.0, 0.0]))
2357                .unwrap();
2358        let b = parse_forecast_evidence(&fixture(
2359            "b",
2360            &[0.6, 0.4, 0.55, 0.45],
2361            &[1.0, 0.0, 1.0, 0.0],
2362        ))
2363        .unwrap();
2364        let report = analyze_forecast_quality(&[a, b], ForecastAnalysisConfig::default()).unwrap();
2365        assert_eq!(report.common_support.n_contracts, 4);
2366        assert_eq!(report.comparisons[0].n_settlement_blocks, 2);
2367        assert!(report.comparisons[0].mean_loss_difference < 0.0);
2368    }
2369
2370    #[test]
2371    fn equivalent_numeric_settlements_compare_but_distinct_outcomes_refuse() {
2372        for (left, right) in [
2373            (serde_json::json!(1), serde_json::json!(1.0)),
2374            (serde_json::json!(0.0), serde_json::json!(-0.0)),
2375        ] {
2376            let a = parse_forecast_evidence(&fixture("a", &[0.8], &[1.0])).unwrap();
2377            let b = parse_forecast_evidence(&fixture("b", &[0.6], &[1.0])).unwrap();
2378            let mut docs = [a, b];
2379            docs[0].resolutions[0].outcome = Some(left);
2380            docs[1].resolutions[0].outcome = Some(right);
2381            let report =
2382                analyze_forecast_quality(&docs, ForecastAnalysisConfig::default()).unwrap();
2383            assert_eq!(report.comparisons[0].n_contracts, 1);
2384            docs[1].resolutions[0].outcome = Some(serde_json::json!(
2385                1.0 - number_outcome(docs[0].resolutions[0].outcome.as_ref().unwrap()).unwrap()
2386            ));
2387            assert!(
2388                analyze_forecast_quality(&docs, ForecastAnalysisConfig::default())
2389                    .unwrap_err()
2390                    .to_string()
2391                    .contains("unequal realized outcomes")
2392            );
2393        }
2394    }
2395
2396    /// Replaces the pre-v2 pin that charged `b`'s pending settlement to `a` as one
2397    /// excluded contract. The gap is `b`'s, and the pair receives no inference.
2398    #[test]
2399    fn unmatched_questions_are_charged_to_the_agent_missing_them_and_withhold_the_pair() {
2400        let a =
2401            parse_forecast_evidence(&fixture("a", &[0.9, 0.1, 0.8, 0.2], &[1.0, 0.0, 1.0, 0.0]))
2402                .unwrap();
2403        let mut b = parse_forecast_evidence(&fixture(
2404            "b",
2405            &[0.6, 0.4, 0.55, 0.45],
2406            &[1.0, 0.0, 1.0, 0.0],
2407        ))
2408        .unwrap();
2409        b.resolutions[3].status = "pending".to_string();
2410        b.resolutions[3].outcome = None;
2411        b.resolutions[3].available_at = None;
2412        let pending = b.revisions[3].contract_sha256.clone();
2413        let report = analyze_forecast_quality(&[a, b], ForecastAnalysisConfig::default()).unwrap();
2414        let support = &report.common_support;
2415        assert_eq!(report.schema_version, "sharpebench.forecast-quality.v2");
2416        assert_eq!(support.n_contracts, 4);
2417        assert_eq!(
2418            support.unresolved_by_agent["a"],
2419            AgentUnresolvedSupport::default()
2420        );
2421        assert_eq!(
2422            support.unresolved_by_agent["b"],
2423            AgentUnresolvedSupport {
2424                n_unresolved: 1,
2425                pending: vec![pending.clone()],
2426                ..AgentUnresolvedSupport::default()
2427            }
2428        );
2429        assert_eq!(
2430            support.settlement_status_disagreements,
2431            vec![SettlementStatusDisagreement {
2432                contract_sha256: pending,
2433                resolved_by: vec!["a".to_string()],
2434                pending_by: vec!["b".to_string()],
2435                cancelled_by: vec![],
2436            }]
2437        );
2438        let comparison = &report.comparisons[0];
2439        assert_eq!(comparison.n_contracts, 3);
2440        assert_eq!(
2441            comparison.support_gap,
2442            Some(SupportGap {
2443                agent_a_unresolved: 0,
2444                agent_b_unresolved: 1,
2445            })
2446        );
2447        assert_eq!(comparison.raw_p_value, None);
2448        assert_eq!(
2449            comparison.inference_error.as_deref(),
2450            Some(
2451                "unequal resolved support: agent_a did not resolve 0 contract(s) that agent_b \
2452                 resolved and agent_b did not resolve 1 that agent_a resolved; a pair receives \
2453                 inference only when both agents resolved the same contracts"
2454            )
2455        );
2456    }
2457
2458    #[test]
2459    fn holm_adjustment_is_monotone_and_controls_the_family() {
2460        let mut comparisons = vec![0.01, 0.03, 0.04]
2461            .into_iter()
2462            .enumerate()
2463            .map(|(index, p)| PairwiseForecastComparison {
2464                agent_a: format!("a{index}"),
2465                agent_b: "b".to_string(),
2466                n_contracts: 10,
2467                n_settlement_blocks: 5,
2468                mean_loss_difference: -0.1,
2469                confidence_lower: Some(-0.2),
2470                confidence_upper: Some(-0.01),
2471                raw_p_value: Some(p),
2472                holm_adjusted_p_value: None,
2473                familywise_significant: false,
2474                measured_size_at_nominal_5pct: Some(measured_size(5)),
2475                support_gap: None,
2476                inference_error: None,
2477            })
2478            .collect::<Vec<_>>();
2479        holm_adjust(&mut comparisons, 0.05);
2480        assert_eq!(comparisons[0].holm_adjusted_p_value, Some(0.03));
2481        assert_eq!(comparisons[1].holm_adjusted_p_value, Some(0.06));
2482        assert_eq!(comparisons[2].holm_adjusted_p_value, Some(0.06));
2483        assert!(comparisons[0].familywise_significant);
2484        assert!(!comparisons[1].familywise_significant);
2485    }
2486
2487    /// `familywise_significant` carries the size it is actually run at.
2488    ///
2489    /// The paired test resamples blocks and centres the raw mean rather than a
2490    /// studentised statistic, so its rejection rate under the null is well above
2491    /// its nominal 5% at every block count `inference_support` admits. The flag
2492    /// is reported with that number beside it rather than silently, and the
2493    /// table is monotone toward nominal so a larger block count never reads
2494    /// worse than a smaller one.
2495    #[test]
2496    fn the_measured_size_travels_with_the_familywise_flag() {
2497        // The table is the one the book prints and the report states.
2498        assert_eq!(measured_size(4), 0.188);
2499        assert_eq!(measured_size(5), 0.162);
2500        assert_eq!(measured_size(6), 0.138);
2501        assert_eq!(measured_size(10), 0.096);
2502        assert_eq!(measured_size(20), 0.072);
2503        assert_eq!(measured_size(40), 0.060);
2504        // Every entry overstates the error that a nominal 5% claims.
2505        for (blocks, size) in MEASURED_SIZE {
2506            assert!(*size > 0.05, "{blocks} blocks reads {size}");
2507        }
2508        // A count between entries reads the lower one, never the friendlier.
2509        assert_eq!(measured_size(7), measured_size(6));
2510        assert_eq!(measured_size(19), measured_size(10));
2511        assert_eq!(measured_size(1000), measured_size(40));
2512        // Monotone toward nominal.
2513        let sizes: Vec<f64> = (4..=60).map(measured_size).collect();
2514        assert!(sizes.windows(2).all(|w| w[1] <= w[0]), "{sizes:?}");
2515        // Four blocks is the first count the resampling gate admits, and it is
2516        // where the flag is furthest from what it claims.
2517        assert!(inference_support(4, 0.05).is_ok());
2518        assert!(inference_support(3, 0.05).is_err());
2519        assert!(measured_size(4) > 3.0 * 0.05);
2520
2521        // The statement the report carries names the same numbers.
2522        for number in [
2523            "0.188", "0.162", "0.138", "0.096", "0.072", "0.060", "0.237",
2524        ] {
2525            assert!(
2526                FAMILYWISE_SIZE_RULE.contains(number),
2527                "the report statement must name {number}"
2528            );
2529        }
2530    }
2531
2532    #[test]
2533    fn normal_crps_and_interval_score_match_closed_form_values() {
2534        let mut normal =
2535            serde_json::from_str::<ForecastEvidence>(&fixture("n", &[0.5], &[1.0])).unwrap();
2536        normal.contracts[0].kind = "normal".to_string();
2537        normal.contracts[0].scoring_rule = "normal_crps".to_string();
2538        normal.contracts[0].unit = "USD".to_string();
2539        normal.revisions[0].prediction = vec![1.0, 2.0];
2540        normal.revisions[0].contract_sha256 = contract_digests(&normal.contracts[0])
2541            .unwrap()
2542            .canonical_json_v1;
2543        let score = score_prediction(
2544            &normal.contracts[0],
2545            &normal.revisions[0].prediction,
2546            normal.resolutions[0].outcome.as_ref().unwrap(),
2547        )
2548        .unwrap()
2549        .0;
2550        let expected =
2551            2.0 * ((2.0 / std::f64::consts::PI).sqrt() - 1.0 / std::f64::consts::PI.sqrt());
2552        assert!((score - expected).abs() < 1e-7);
2553    }
2554
2555    #[test]
2556    fn every_declared_scoring_rule_is_recomputed_from_raw_values() {
2557        let base = serde_json::from_str::<ForecastEvidence>(&fixture("rules", &[0.5], &[1.0]))
2558            .unwrap()
2559            .contracts
2560            .remove(0);
2561
2562        let mut binary_log = base.clone();
2563        binary_log.scoring_rule = "binary_log".to_string();
2564        let score = score_prediction(&binary_log, &[0.8], &Value::from(1.0))
2565            .unwrap()
2566            .0;
2567        assert!((score - -0.8_f64.ln()).abs() < 1e-12);
2568
2569        let mut categorical = base.clone();
2570        categorical.kind = "categorical".to_string();
2571        categorical.scoring_rule = "categorical_brier".to_string();
2572        categorical.categories = vec!["up".into(), "flat".into(), "down".into()];
2573        let score = score_prediction(
2574            &categorical,
2575            &[0.2, 0.3, 0.5],
2576            &Value::String("down".to_string()),
2577        )
2578        .unwrap()
2579        .0;
2580        assert!((score - 0.38).abs() < 1e-12);
2581        categorical.scoring_rule = "categorical_log".to_string();
2582        let score = score_prediction(
2583            &categorical,
2584            &[0.2, 0.3, 0.5],
2585            &Value::String("flat".to_string()),
2586        )
2587        .unwrap()
2588        .0;
2589        assert!((score - -0.3_f64.ln()).abs() < 1e-12);
2590
2591        let mut direction = base.clone();
2592        direction.kind = "direction".to_string();
2593        direction.scoring_rule = "direction_accuracy".to_string();
2594        direction.neutral_threshold = 0.01;
2595        assert_eq!(
2596            score_prediction(&direction, &[1.0], &Value::from(0.009))
2597                .unwrap()
2598                .0,
2599            1.0
2600        );
2601
2602        let mut interval = base;
2603        interval.kind = "interval".to_string();
2604        interval.scoring_rule = "interval_score".to_string();
2605        interval.interval_alpha = Some(0.1);
2606        let score = score_prediction(&interval, &[8.0, 12.0], &Value::from(14.0))
2607            .unwrap()
2608            .0;
2609        assert!((score - 44.0).abs() < 1e-12);
2610    }
2611
2612    #[test]
2613    fn forecast_analysis_cannot_change_the_trading_board() {
2614        use crate::composite::{rank, AgentSubmission, Run, ScoreConfig};
2615
2616        let submission = AgentSubmission {
2617            agent_id: "trader".to_string(),
2618            runs: vec![Run {
2619                returns: (0..80)
2620                    .map(|index| 0.002 + 0.0005 * (index as f64 * 0.7).sin())
2621                    .collect(),
2622                ..Run::default()
2623            }],
2624            ..AgentSubmission::default()
2625        };
2626        let before = rank(std::slice::from_ref(&submission), &ScoreConfig::default());
2627        let evidence = parse_forecast_evidence(&fixture("forecast-only", &[0.9], &[0.0])).unwrap();
2628        let report =
2629            analyze_forecast_quality(&[evidence], ForecastAnalysisConfig::default()).unwrap();
2630        let after = rank(&[submission], &ScoreConfig::default());
2631        assert_eq!(before, after);
2632        assert_eq!(report.rank_effect, "reported_only_never_trading_rank");
2633    }
2634    /// Audit finding R08 (deferred, not repaired): the pairwise comparison pools
2635    /// every common contract into one `mean_loss_difference` regardless of
2636    /// scoring rule or target unit. A binary Brier loss is dimensionless and a
2637    /// point squared error carries the target unit squared, so rescaling the
2638    /// point contract from dollars to cents changes the sign of the pooled
2639    /// difference while the forecasts are unchanged. This test pins that the
2640    /// analysis averages silently today. Every committed field is one stratum,
2641    /// so no reported verdict depends on it; the deferral reopens when a
2642    /// committed comparison mixes strata and stratifying changes its verdict.
2643    #[test]
2644    fn mixed_scoring_rules_are_silently_pooled_into_one_mean_loss_difference_r08() {
2645        fn field(agent: &str, probability: f64, point: f64, unit: &str) -> ForecastEvidence {
2646            let mut evidence = serde_json::from_str::<ForecastEvidence>(&fixture(
2647                agent,
2648                &[probability, 0.5],
2649                &[1.0, 0.0],
2650            ))
2651            .unwrap();
2652            let contract = &mut evidence.contracts[1];
2653            contract.kind = "point".to_string();
2654            contract.scoring_rule = "point_errors".to_string();
2655            contract.target = "price".to_string();
2656            contract.unit = unit.to_string();
2657            let digest = contract_digests(contract).unwrap().canonical_json_v1;
2658            evidence.revisions[1].prediction = vec![point];
2659            evidence.revisions[1].contract_sha256 = digest;
2660            evidence
2661        }
2662        let mut pooled = Vec::new();
2663        for (scale, unit) in [(1.0, "USD"), (100.0, "US_cents")] {
2664            let a = field("a", 0.9, 0.2 * scale, unit);
2665            let b = field("b", 0.1, 0.1 * scale, unit);
2666            let report =
2667                analyze_forecast_quality(&[a, b], ForecastAnalysisConfig::default()).unwrap();
2668            // Per-agent means are grouped by rule; the pairwise comparison is not.
2669            assert_eq!(report.agents[0].metrics.len(), 2);
2670            let comparison = &report.comparisons[0];
2671            assert_eq!(comparison.n_contracts, 2);
2672            let brier_difference = (0.9f64 - 1.0).powi(2) - (0.1f64 - 1.0).powi(2);
2673            let point_difference = (0.2 * scale).powi(2) - (0.1 * scale).powi(2);
2674            let expected = (brier_difference + point_difference) / 2.0;
2675            assert!((comparison.mean_loss_difference - expected).abs() < 1e-9);
2676            pooled.push(comparison.mean_loss_difference);
2677        }
2678        assert!(
2679            pooled[0] < 0.0,
2680            "in USD agent a looks better: {}",
2681            pooled[0]
2682        );
2683        assert!(
2684            pooled[1] > 0.0,
2685            "in cents agent b looks better: {}",
2686            pooled[1]
2687        );
2688    }
2689}