1use 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";
26pub const FORECAST_EVIDENCE_SCHEMA_V2: &str = "sharpe.forecast-evidence.v2";
29pub const FORECAST_QUALITY_SCHEMA: &str = "sharpebench.forecast-quality.v2";
34pub const FORECAST_QUALITY_PLAN_SCHEMA: &str = "sharpebench.forecast-quality.v3";
39pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
60pub enum ContractDigestVersion {
61 CanonicalJsonV1,
63 Legacy,
65}
66
67impl ContractDigestVersion {
68 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 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#[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
130struct ContractDigests {
132 canonical_json_v1: String,
133 legacy: String,
134}
135
136type 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 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 #[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
284pub 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#[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
308pub 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
496fn 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 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
797fn 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
869fn 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 outcome_sha256: String,
899 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 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
1161pub struct AgentUnresolvedSupport {
1162 pub n_unresolved: usize,
1163 pub not_claimed: Vec<String>,
1166 pub pending: Vec<String>,
1167 pub cancelled: Vec<String>,
1168 pub rejected: Vec<String>,
1170}
1171
1172#[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 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 #[serde(skip_serializing_if = "Option::is_none")]
1197 pub outside_plan_by_agent: Option<BTreeMap<String, Vec<String>>>,
1198}
1199
1200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
1203pub struct SupportGap {
1204 pub agent_a_unresolved: usize,
1206 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 pub n_contracts: usize,
1216 pub n_settlement_blocks: usize,
1217 pub mean_loss_difference: f64,
1221 #[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 #[serde(skip_serializing_if = "Option::is_none")]
1241 pub measured_size_at_nominal_5pct: Option<f64>,
1242 #[serde(skip_serializing_if = "Option::is_none")]
1244 pub support_gap: Option<SupportGap>,
1245 #[serde(skip_serializing_if = "Option::is_none")]
1247 pub inference_error: Option<String>,
1248}
1249
1250#[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
1291pub 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
1306const 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
1330fn 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
1350fn 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 pub familywise_size: &'static str,
1387 pub config: ForecastAnalysisConfig,
1388 pub common_support: CommonSupport,
1389 pub contract_digest_versions: BTreeMap<String, ContractDigestVersion>,
1394 pub agents: Vec<AgentForecastSummary>,
1395 pub comparisons: Vec<PairwiseForecastComparison>,
1396}
1397
1398pub fn analyze_forecast_quality(
1405 evidence: &[ForecastEvidence],
1406 config: ForecastAnalysisConfig,
1407) -> Result<ForecastQualityReport, ForecastError> {
1408 analyze(evidence, config, None)
1409}
1410
1411pub 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
1425fn 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 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
1528fn 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
1620fn 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
1837fn 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 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 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
1991fn 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 #[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 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 #[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 #[test]
2496 fn the_measured_size_travels_with_the_familywise_flag() {
2497 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 for (blocks, size) in MEASURED_SIZE {
2506 assert!(*size > 0.05, "{blocks} blocks reads {size}");
2507 }
2508 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 let sizes: Vec<f64> = (4..=60).map(measured_size).collect();
2514 assert!(sizes.windows(2).all(|w| w[1] <= w[0]), "{sizes:?}");
2515 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 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 #[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 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}