diff --git a/rust_hft/alpha-harness/app/src/mission.rs b/rust_hft/alpha-harness/app/src/mission.rs index 9a1e77da2..b35876027 100644 --- a/rust_hft/alpha-harness/app/src/mission.rs +++ b/rust_hft/alpha-harness/app/src/mission.rs @@ -2,7 +2,9 @@ use crate::{ cli::{print_json, EngineChoice, LearnMissionArgs, MissionStatusArgs, RunMissionArgs}, data_mission, }; -use alpha_domain::{canonical_json_hash, MissionStatus, ResearchMission}; +use alpha_domain::{ + canonical_json_hash, CexGpPolicyV1, CexResearchContentRefV1, MissionStatus, ResearchMission, +}; use alpha_engine::{ engines::{ CexMctsSearchIdentityV1, GeneticProgrammingEngine, MctsEngine, OfflineRlEngine, @@ -40,6 +42,8 @@ pub struct MissionRunReport { pub engine: EngineChoice, pub engine_authority: ResearchEngineAuthority, pub dataset_manifest_id: String, + pub research_dataset: CexResearchContentRefV1, + pub walk_forward_partition: CexResearchContentRefV1, } pub fn run_mission(args: RunMissionArgs, resume: bool) -> anyhow::Result<()> { @@ -47,6 +51,23 @@ pub fn run_mission(args: RunMissionArgs, resume: bool) -> anyhow::Result<()> { } pub fn execute_mission(args: &RunMissionArgs, resume: bool) -> anyhow::Result { + execute_mission_inner(args, resume, None) +} + +pub(crate) fn execute_governed_gp_mission( + args: &RunMissionArgs, + resume: bool, + policy: &CexGpPolicyV1, + candidate_namespace: &str, +) -> anyhow::Result { + execute_mission_inner(args, resume, Some((policy, candidate_namespace))) +} + +fn execute_mission_inner( + args: &RunMissionArgs, + resume: bool, + governed_gp: Option<(&CexGpPolicyV1, &str)>, +) -> anyhow::Result { validate_live_mission_args(args)?; let mut store = AlphaStore::open(&args.db)?; let mission = store.get_mission(&args.mission_id)?; @@ -70,6 +91,20 @@ pub fn execute_mission(args: &RunMissionArgs, resume: bool) -> anyhow::Result anyhow::Result anyhow::Result ResearchEngineAuthority::CandidateResearchOnly, }, dataset_manifest_id: manifest.manifest_id().to_string(), + research_dataset, + walk_forward_partition, }) } @@ -155,6 +193,7 @@ fn build_engine( mission: &ResearchMission, evaluation_protocol_hash: &str, evaluator_config_hash: &str, + governed_gp: Option<(&CexGpPolicyV1, &str)>, ) -> anyhow::Result> { validate_live_mission_args(args)?; let fields = args @@ -178,10 +217,27 @@ fn build_engine( let primary = fields[0].clone(); let secondary = fields.get(1).cloned().unwrap_or_else(|| primary.clone()); let engine: Box = match args.engine { - EngineChoice::Gp => Box::new( - GeneticProgrammingEngine::new(args.seed, fields.clone(), 32, 5) - .map_err(anyhow::Error::msg)?, - ), + EngineChoice::Gp => match governed_gp { + Some((policy, candidate_namespace)) => { + if fields != policy.admitted_fields + || args.seed != policy.seed + || mission.search_budget != policy.budget + { + bail!("mission GP execution drifted from its frozen policy"); + } + Box::new( + GeneticProgrammingEngine::new_governed( + policy.clone(), + candidate_namespace.to_string(), + ) + .map_err(anyhow::Error::msg)?, + ) + } + None => Box::new( + GeneticProgrammingEngine::new(args.seed, fields.clone(), 32, 5) + .map_err(anyhow::Error::msg)?, + ), + }, EngineChoice::Mcts => Box::new( MctsEngine::new_live_bound( args.seed, diff --git a/rust_hft/alpha-harness/app/src/mission_runner.rs b/rust_hft/alpha-harness/app/src/mission_runner.rs index 1d7c19946..4a10e897f 100644 --- a/rust_hft/alpha-harness/app/src/mission_runner.rs +++ b/rust_hft/alpha-harness/app/src/mission_runner.rs @@ -5,10 +5,13 @@ use crate::{ data_mission, governance, mission, }; use alpha_domain::{ - canonical_json_hash, CexResearchMissionArtifactV1, EvaluationCostsV1, MissionCompletionPolicy, - MissionStatus, ResearchMission, ValidatorMode, + canonical_json_hash, CandidateArtifact, CandidateEvaluation, CexFactorBankRevisionV2, + CexFactorEvaluationEvidenceV2, CexFactorRejectionCodeV1, CexFactorScreeningAttemptV2, + CexFactorScreeningVerdictV1, CexGpPolicyV1, CexResearchMissionArtifactV1, EvaluationCostsV1, + FormulaEvaluatorConfig, IterationVerdict, MissionCompletionPolicy, MissionStatus, + ResearchMission, ValidatorMode, }; -use alpha_store::{AlphaStore, RegistryRevision, StoreError}; +use alpha_store::{AlphaStore, MissionLineage, RegistryRevision, StoreError}; use anyhow::{bail, Context}; use chrono::Utc; use hft_research_manifest::{ @@ -155,9 +158,17 @@ pub fn execute(args: ExecuteMissionArgs) -> anyhow::Result<()> { .context("CEX Research Mission artifact is invalid JSON or schema")?; control_mission.validate()?; mission::validate_live_feature_fields(&control_mission.spec.feature_fields)?; + let gp_policy = CexGpPolicyV1::controlled_v1( + control_mission.spec.policies.gp.id.clone(), + control_mission.spec.feature_fields.clone(), + control_mission.spec.search.seed, + &control_mission.spec.search.budget, + )?; + gp_policy.validate_binding(&control_mission.spec.policies.gp)?; + data_mission::write_json_atomic(&results_dir.join("gp-policy.json"), &gp_policy)?; let mission_id = control_mission.semantic_id()?; let validation = ValidationArgs::from_protocol(&control_mission.spec.evaluation_protocol); - let engine = EngineChoice::Mcts; + let engine = EngineChoice::Gp; let (_, feature_sha256) = fetch_to_file(&client, &args.feature_url, &feature_path, MAX_FEATURE_BYTES)?; @@ -271,12 +282,17 @@ pub fn execute(args: ExecuteMissionArgs) -> anyhow::Result<()> { search_budget: control_mission.spec.search.budget.clone(), completion_policy: MissionCompletionPolicy::default(), prompt_snapshot_id: None, - search_policy_snapshot_id: control_mission.spec.policies.subset_search.id.clone(), + search_policy_snapshot_id: control_mission.spec.policies.gp.id.clone(), status: MissionStatus::Pending, terminal_reason: None, created_at: now, updated_at: now, }; + if canonical_json_hash(&FormulaEvaluatorConfig::for_mission(&research_mission)?)? + != control_mission.spec.policies.screening.content_sha256 + { + bail!("Mission screening policy does not match the evaluator configuration"); + } data_mission::write_json_atomic(&results_dir.join("mission.json"), &research_mission)?; store.create_mission(&research_mission)?; data_mission::write_json_atomic(&results_dir.join("mission-create.json"), &research_mission)?; @@ -296,20 +312,41 @@ pub fn execute(args: ExecuteMissionArgs) -> anyhow::Result<()> { max_new_iterations: Some(control_mission.spec.search.max_new_iterations), dataset: dataset.clone(), }; - let mut run_report = mission::execute_mission(&run_args, false)?; + let mut run_report = mission::execute_governed_gp_mission( + &run_args, + false, + &gp_policy, + &control_mission.spec.search_lineage_id, + )?; while run_report.status == MissionStatus::Paused { let previous_iterations = run_report.total_iterations; - run_report = mission::execute_mission(&run_args, true)?; + run_report = mission::execute_governed_gp_mission( + &run_args, + true, + &gp_policy, + &control_mission.spec.search_lineage_id, + )?; if run_report.total_iterations <= previous_iterations && run_report.status == MissionStatus::Paused { - bail!("resumed MCTS mission made no progress"); + bail!("resumed mission made no progress"); } } data_mission::write_json_atomic(&results_dir.join("mission-run.json"), &run_report)?; - let store = AlphaStore::open(&db)?; + let mut store = AlphaStore::open(&db)?; let lineage = store.mission_lineage(&mission_id)?; + let factor_bank = build_factor_bank(&control_mission, &gp_policy, &run_report, &lineage)?; + let factor_bank_payload = serde_json::to_value(&factor_bank)?; + store.put_registry_revision(&RegistryRevision { + revision_id: factor_bank.revision_id.clone(), + registry_kind: "cex_factor_bank".to_string(), + asset_id: control_mission.spec.instrument.symbol.clone(), + parent_revision_id: Some(run_report.dataset_manifest_id.clone()), + payload: factor_bank_payload, + created_at: Utc::now(), + })?; + data_mission::write_json_atomic(&results_dir.join("factor-bank.json"), &factor_bank)?; let checkpoint = match store.get_checkpoint(&mission_id) { Ok(checkpoint) => Some(checkpoint), Err(StoreError::NotFound) => None, @@ -354,6 +391,123 @@ pub fn execute(args: ExecuteMissionArgs) -> anyhow::Result<()> { }) } +fn build_factor_bank( + control_mission: &CexResearchMissionArtifactV1, + gp_policy: &CexGpPolicyV1, + run_report: &mission::MissionRunReport, + lineage: &MissionLineage, +) -> anyhow::Result { + let mut attempts = Vec::with_capacity(lineage.iterations.len()); + for iteration in &lineage.iterations { + let candidate_id = iteration + .candidate_artifact_id + .as_deref() + .with_context(|| { + format!( + "iteration {} has no auditable candidate artifact", + iteration.iteration_id + ) + })?; + let candidate = lineage + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + .with_context(|| format!("candidate {candidate_id} is missing from lineage"))?; + if candidate.mission_id != run_report.mission_id + || candidate.iteration_id != iteration.iteration_id + { + bail!("candidate {candidate_id} does not match its mission iteration"); + } + let canonical_ast = match &candidate.artifact { + CandidateArtifact::Formula(ast) => ast.clone(), + _ => bail!("governed GP lineage contains a non-formula candidate"), + }; + let ast_sha256 = canonical_json_hash(&canonical_ast)?; + let evaluation = iteration + .evaluation_artifact_id + .as_deref() + .map(|evaluation_id| { + let stored = lineage + .evaluations + .iter() + .find(|evaluation| evaluation.record.evaluation_id == evaluation_id) + .with_context(|| { + format!("evaluation {evaluation_id} is missing from lineage") + })?; + if stored.record.mission_id != run_report.mission_id + || stored.record.candidate_id != candidate_id + || stored.record.dataset_manifest_id != run_report.dataset_manifest_id + || stored.record.evaluation_protocol_hash + != control_mission.spec.policies.evaluation.content_sha256 + { + bail!("evaluation {evaluation_id} does not match its screening lineage"); + } + let evidence: CandidateEvaluation = + serde_json::from_value(stored.record.payload.clone())?; + evidence.validate()?; + Ok::<_, anyhow::Error>(CexFactorEvaluationEvidenceV2 { + candidate_id: candidate_id.to_string(), + candidate_ast_sha256: ast_sha256.clone(), + research_dataset: run_report.research_dataset.clone(), + walk_forward_partition: run_report.walk_forward_partition.clone(), + evidence, + }) + }) + .transpose()?; + let verdict = if iteration.verdict == IterationVerdict::Keep { + CexFactorScreeningVerdictV1::Accepted + } else { + CexFactorScreeningVerdictV1::Rejected + }; + let rejection_codes = match iteration.verdict { + IterationVerdict::Keep => vec![], + IterationVerdict::Discard => evaluation + .as_ref() + .context("discarded candidate is missing screening evidence")? + .rejection_codes()?, + IterationVerdict::Crash => vec![match iteration.failure_class.as_deref() { + Some("duplicate_candidate") => CexFactorRejectionCodeV1::DuplicateCandidate, + Some("evaluation_error") => CexFactorRejectionCodeV1::EvaluationFailed, + _ => CexFactorRejectionCodeV1::EngineFailure, + }], + }; + let rejection_details = evaluation + .as_ref() + .map(|evaluation| evaluation.evidence.failure_reasons.clone()) + .filter(|reasons| !reasons.is_empty()) + .or_else(|| { + iteration + .failure_explanation + .clone() + .map(|reason| vec![reason]) + }) + .unwrap_or_default(); + let post_warmup_coverage_rows = evaluation + .as_ref() + .map(|evaluation| evaluation.evidence.metrics.row_count) + .unwrap_or(0); + attempts.push(CexFactorScreeningAttemptV2 { + candidate_id: candidate_id.to_string(), + canonical_ast, + ast_sha256, + post_warmup_coverage_rows, + verdict, + rejection_codes, + rejection_details, + evaluation, + }); + } + Ok(CexFactorBankRevisionV2::new( + control_mission.spec.search_lineage_id.clone(), + gp_policy.clone(), + control_mission.spec.policies.screening.clone(), + control_mission.spec.policies.evaluation.clone(), + run_report.research_dataset.clone(), + run_report.walk_forward_partition.clone(), + attempts, + )?) +} + fn validate_args(args: &ExecuteMissionArgs) -> anyhow::Result<()> { if args.work_dir.as_os_str().is_empty() || [ @@ -1086,6 +1240,160 @@ mod tests { std::fs::remove_dir_all(fixture.root).unwrap(); } + #[test] + fn execute_screens_gp_candidates_into_an_immutable_factor_bank() { + let mut fixture = fixture("gp-factor-bank"); + fixture.mission.spec.feature_fields = vec!["book_imbalance".to_string()]; + rewrite_features(&mut fixture, |row| { + let direction = row.label.signum(); + row.features.insert("book_imbalance".to_string(), direction); + row.label = direction * 0.001; + }); + execute(fixture.args.clone()).unwrap(); + let results = fixture.args.work_dir.join("results"); + let run: serde_json::Value = + serde_json::from_slice(&std::fs::read(results.join("mission-run.json")).unwrap()) + .unwrap(); + assert_eq!(run["engine"], "Gp"); + + let factor_bank: serde_json::Value = + serde_json::from_slice(&std::fs::read(results.join("factor-bank.json")).unwrap()) + .unwrap(); + assert_eq!(factor_bank["schema_version"], "cex-factor-bank-v2"); + assert_eq!(factor_bank["research_dataset"], run["research_dataset"]); + let entries = factor_bank["entries"].as_array().unwrap(); + assert!(!entries.is_empty()); + assert!(entries + .iter() + .all(|entry| entry["orientation"] == "positive")); + assert!(results.join("gp-policy.json").exists()); + let typed: CexFactorBankRevisionV2 = serde_json::from_value(factor_bank.clone()).unwrap(); + typed.validate().unwrap(); + let mut forged = typed.clone(); + forged.screening_policy.content_sha256 = "0".repeat(64); + forged.revision_id.clear(); + forged.revision_id = format!("cex-factor-bank-{}", canonical_json_hash(&forged).unwrap()); + assert!(forged.validate().is_err()); + let revision_id = factor_bank["revision_id"].as_str().unwrap(); + let mut store = AlphaStore::open(results.join("alpha.duckdb")).unwrap(); + let revision = store.get_registry_revision(revision_id).unwrap(); + assert_eq!(revision.registry_kind, "cex_factor_bank"); + assert_eq!( + revision.parent_revision_id.as_deref(), + run["dataset_manifest_id"].as_str() + ); + assert_eq!(revision.payload, factor_bank); + let mut conflict = revision.clone(); + conflict.payload["schema_version"] = serde_json::json!("tampered"); + assert!(matches!( + store.put_registry_revision(&conflict), + Err(StoreError::DuplicateRecord) + )); + assert!(store + .mission_lineage(fixture.mission.semantic_id().unwrap().as_str()) + .unwrap() + .iterations + .iter() + .all(|iteration| iteration.engine == alpha_domain::EngineKind::GeneticProgramming)); + assert!(!results.join("mcts-research-receipt.json").exists()); + std::fs::remove_dir_all(fixture.root).unwrap(); + } + + #[test] + fn execute_rejects_gp_policy_drift_before_screening() { + let mut fixture = fixture("gp-policy-drift"); + fixture.mission.spec.policies.gp.content_sha256 = "f".repeat(64); + write_mission(&mut fixture); + let error = execute(fixture.args.clone()).unwrap_err(); + + assert!(error + .to_string() + .contains("GP policy identity or content hash")); + assert!(!fixture.args.work_dir.join("results/alpha.duckdb").exists()); + std::fs::remove_dir_all(fixture.root).unwrap(); + } + + #[test] + fn execute_records_duplicate_gp_attempts_with_a_typed_rejection() { + let mut fixture = fixture("gp-duplicate-audit"); + fixture.mission.spec.search.seed = 1; + fixture.mission.spec.search.budget.max_candidates = 6; + fixture.mission.spec.search.budget.max_expansions = 6; + rewrite_features(&mut fixture, |row| row.label = 0.0); + rebind_mission_inputs(&mut fixture); + + execute(fixture.args.clone()).unwrap(); + + let factor_bank = read_factor_bank(&fixture.args); + let attempts = factor_bank["attempts"].as_array().unwrap(); + let duplicate = attempts + .iter() + .find(|attempt| { + attempt["rejection_details"] + .as_array() + .is_some_and(|details| { + details.iter().any(|detail| { + detail + .as_str() + .is_some_and(|detail| detail.contains("duplicated an existing")) + }) + }) + }) + .expect("fixture must exercise a duplicate GP proposal"); + assert_eq!( + duplicate["rejection_codes"], + serde_json::json!(["duplicate_candidate"]) + ); + let screened = attempts + .iter() + .find(|attempt| attempt["verdict"] == "rejected" && attempt["evaluation"].is_object()) + .expect("fixture must exercise an evaluated rejection"); + let rejection_codes = screened["rejection_codes"].as_array().unwrap(); + assert!(rejection_codes.contains(&serde_json::json!("predictive_gate_failed"))); + assert!(!rejection_codes.contains(&serde_json::json!("screening_failed"))); + let entries = factor_bank["entries"].as_array().unwrap(); + assert!(attempts + .iter() + .filter(|attempt| attempt["verdict"] == "rejected") + .all(|attempt| entries + .iter() + .all(|entry| entry["candidate_id"] != attempt["candidate_id"]))); + std::fs::remove_dir_all(fixture.root).unwrap(); + } + + #[test] + fn execute_is_deterministic_and_ignores_holdout_only_values() { + let mut fixture = fixture("gp-holdout-isolation"); + fixture.mission.spec.search.budget.max_candidates = 4; + fixture.mission.spec.search.budget.max_expansions = 4; + fixture.mission.spec.search.max_new_iterations = 1; + resign_mission(&mut fixture); + execute(fixture.args.clone()).unwrap(); + let first = read_factor_bank(&fixture.args); + + fixture.mission.spec.search.max_new_iterations = 4; + resign_mission(&mut fixture); + fixture.args.work_dir = fixture.root.join("work-2"); + fixture.args.result_put_url = fixture.root.join("result-2.zip").to_string_lossy().into(); + execute(fixture.args.clone()).unwrap(); + assert_eq!(read_factor_bank(&fixture.args), first); + + let last: chrono::DateTime = + serde_json::from_value(fixture.materialization["last_event_time"].clone()).unwrap(); + let holdout_start = last - ChronoDuration::seconds(29); + rewrite_features(&mut fixture, |row| { + if row.event_time >= holdout_start { + row.label = -row.label; + } + }); + rebind_mission_inputs(&mut fixture); + fixture.args.work_dir = fixture.root.join("work-3"); + fixture.args.result_put_url = fixture.root.join("result-3.zip").to_string_lossy().into(); + execute(fixture.args.clone()).unwrap(); + assert_eq!(read_factor_bank(&fixture.args), first); + std::fs::remove_dir_all(fixture.root).unwrap(); + } + #[test] fn execute_preserves_prior_holdout_evidence_in_reused_work_dir() { let fixture = fixture("preserve-prior-holdout"); @@ -1387,10 +1695,18 @@ mod tests { max_candidates: 1, max_expansions: 1, max_tokens: 0, - max_seconds: 5, + max_seconds: 0, }, max_new_iterations: 1, }; + let feature_fields = vec!["book_imbalance".to_string(), "spread_bps".to_string()]; + let gp_policy = CexGpPolicyV1::controlled_v1( + "gp-policy-1", + feature_fields.clone(), + search.seed, + &search.budget, + ) + .unwrap(); let reference = |id: &str, byte: char| CexResearchContentRefV1 { id: id.to_string(), content_sha256: byte.to_string().repeat(64), @@ -1400,7 +1716,7 @@ mod tests { let dataset = CexReplayDatasetManifestV1::new(format!("dataset-{feature_sha256}"), snapshot.clone()) .unwrap(); - let mission = CexResearchMissionArtifactV1 { + let mut mission = CexResearchMissionArtifactV1 { schema_version: CEX_RESEARCH_MISSION_SCHEMA_V1.to_string(), spec: CexResearchMissionSpecV1 { objective: "test objective".to_string(), @@ -1454,7 +1770,10 @@ mod tests { }, }, policies: CexResearchPolicyBindingsV1 { - gp: reference("gp-policy-1", '1'), + gp: CexResearchContentRefV1 { + id: gp_policy.policy_id.clone(), + content_sha256: gp_policy.content_hash().unwrap(), + }, screening: reference("screening-policy-1", '2'), baseline: reference("baseline-policy-1", '3'), subset_search: CexResearchContentRefV1 { @@ -1478,7 +1797,7 @@ mod tests { signature: None, holdout_id: None, }], - feature_fields: vec!["book_imbalance".to_string(), "spread_bps".to_string()], + feature_fields, search, evaluation_protocol, holdout: CexResearchHoldoutV1 { @@ -1490,6 +1809,10 @@ mod tests { submitted_at: Some(Utc::now()), }, }; + mission.spec.policies.screening.content_sha256 = canonical_json_hash( + &FormulaEvaluatorConfig::for_trials(mission.spec.search.budget.max_candidates).unwrap(), + ) + .unwrap(); mission.validate().unwrap(); let mission_path = root.join("mission.json"); std::fs::write(&mission_path, serde_json::to_vec_pretty(&mission).unwrap()).unwrap(); @@ -1533,6 +1856,11 @@ mod tests { resign_materialization(fixture); } + fn read_factor_bank(args: &ExecuteMissionArgs) -> serde_json::Value { + let file = File::open(args.work_dir.join("results/factor-bank.json")).unwrap(); + serde_json::from_reader(file).unwrap() + } + fn write_mission(fixture: &mut Fixture) { std::fs::write( &fixture.mission_path, @@ -1543,8 +1871,22 @@ mod tests { } fn resign_mission(fixture: &mut Fixture) { + let gp_policy = CexGpPolicyV1::controlled_v1( + fixture.mission.spec.policies.gp.id.clone(), + fixture.mission.spec.feature_fields.clone(), + fixture.mission.spec.search.seed, + &fixture.mission.spec.search.budget, + ); + if let Ok(gp_policy) = gp_policy { + fixture.mission.spec.policies.gp.content_sha256 = gp_policy.content_hash().unwrap(); + } fixture.mission.spec.policies.subset_search.content_sha256 = canonical_json_hash(&fixture.mission.spec.search).unwrap(); + fixture.mission.spec.policies.screening.content_sha256 = canonical_json_hash( + &FormulaEvaluatorConfig::for_trials(fixture.mission.spec.search.budget.max_candidates) + .unwrap(), + ) + .unwrap(); fixture.mission.spec.policies.evaluation.content_sha256 = fixture .mission .spec diff --git a/rust_hft/alpha-harness/domain/src/lib.rs b/rust_hft/alpha-harness/domain/src/lib.rs index 91275d0d6..7878c95b3 100644 --- a/rust_hft/alpha-harness/domain/src/lib.rs +++ b/rust_hft/alpha-harness/domain/src/lib.rs @@ -1537,6 +1537,11 @@ pub enum CexFactorScreeningVerdictV1 { #[serde(rename_all = "snake_case")] pub enum CexFactorRejectionCodeV1 { ScreeningFailed, + CoverageGateFailed, + PredictiveGateFailed, + TradingGateFailed, + CapacityGateFailed, + MultipleTestingGateFailed, DuplicateCandidate, EvaluationFailed, EngineFailure, @@ -1559,6 +1564,7 @@ pub struct CexFactorScreeningAttemptV1 { #[serde(rename_all = "snake_case")] pub enum CexFactorOrientationV1 { Positive, + Negative, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1737,6 +1743,70 @@ pub struct CexFactorEvaluationEvidenceV2 { pub evidence: CandidateEvaluation, } +impl CexFactorEvaluationEvidenceV2 { + pub fn rejection_codes(&self) -> Result, DomainError> { + self.evidence.validate()?; + if self.evidence.passed { + return Ok(vec![]); + } + let config = self.evidence.formula_config()?; + let (protocol, _) = self.evidence.protocol_binding()?; + let metrics = &self.evidence.metrics; + let mut codes = Vec::new(); + if metrics + .folds + .iter() + .any(|fold| fold.row_count < config.min_validation_rows) + { + codes.push(CexFactorRejectionCodeV1::CoverageGateFailed); + } + if !metrics.predictive.passes(&config, true) { + codes.push(CexFactorRejectionCodeV1::PredictiveGateFailed); + } + if metrics.folds.iter().any(|fold| { + fold.trade_count < config.min_trades + || fold.mean_net_return <= config.min_fold_mean_return + || fold.max_drawdown > config.max_drawdown + }) { + codes.push(CexFactorRejectionCodeV1::TradingGateFailed); + } + if protocol.costs.capacity_enabled() + && metrics.folds.iter().any(|fold| { + fold.max_book_depth_fraction + .is_none_or(|fraction| fraction > protocol.costs.max_book_depth_fraction) + }) + { + codes.push(CexFactorRejectionCodeV1::CapacityGateFailed); + } + if metrics.adjusted_score < config.min_aggregate_score { + codes.push(CexFactorRejectionCodeV1::MultipleTestingGateFailed); + } + if codes.is_empty() { + return Err(DomainError::InvalidCexFactorBank( + "failed evaluation has no structured screening rejection", + )); + } + Ok(codes) + } + + fn orientation(&self) -> Result { + self.evidence.validate()?; + let predictive = &self.evidence.metrics.predictive; + let signed_ic = predictive + .time_series_rank_ic + .filter(|value| *value != 0.0) + .or_else(|| predictive.time_series_ic.filter(|value| *value != 0.0)) + .ok_or(DomainError::InvalidCexFactorBank( + "accepted factor orientation is not identified by predictive evidence", + ))?; + Ok(if signed_ic.is_sign_positive() { + CexFactorOrientationV1::Positive + } else { + CexFactorOrientationV1::Negative + }) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CexFactorScreeningAttemptV2 { @@ -1779,15 +1849,24 @@ impl CexFactorBankRevisionV2 { let entries = attempts .iter() .filter(|attempt| attempt.verdict == CexFactorScreeningVerdictV1::Accepted) - .map(|attempt| CexFactorBankEntryV1 { - factor_id: format!("cex-factor-{}", attempt.ast_sha256), - candidate_id: attempt.candidate_id.clone(), - canonical_ast: attempt.canonical_ast.clone(), - ast_sha256: attempt.ast_sha256.clone(), - orientation: CexFactorOrientationV1::Positive, - source_features: factor_ast_source_features(&attempt.canonical_ast), + .map(|attempt| { + let orientation = attempt + .evaluation + .as_ref() + .ok_or(DomainError::InvalidCexFactorBank( + "accepted factor is missing evaluation evidence", + ))? + .orientation()?; + Ok(CexFactorBankEntryV1 { + factor_id: format!("cex-factor-{}", attempt.ast_sha256), + candidate_id: attempt.candidate_id.clone(), + canonical_ast: attempt.canonical_ast.clone(), + ast_sha256: attempt.ast_sha256.clone(), + orientation, + source_features: factor_ast_source_features(&attempt.canonical_ast), + }) }) - .collect(); + .collect::, DomainError>>()?; let mut revision = Self { schema_version: CEX_FACTOR_BANK_SCHEMA_V2.to_string(), revision_id: String::new(), @@ -1823,6 +1902,7 @@ impl CexFactorBankRevisionV2 { let mut candidate_ids = BTreeSet::new(); let mut accepted_ids = BTreeSet::new(); let mut accepted_asts = BTreeSet::new(); + let mut accepted_orientations = BTreeMap::new(); for attempt in &self.attempts { if attempt.candidate_id.trim().is_empty() || self @@ -1857,13 +1937,33 @@ impl CexFactorBankRevisionV2 { )); } } + let derived_rejection_codes = attempt + .evaluation + .as_ref() + .map(CexFactorEvaluationEvidenceV2::rejection_codes) + .transpose()?; + let accepted_codes_are_bound = derived_rejection_codes + .as_ref() + .is_some_and(|codes| codes.is_empty() && attempt.rejection_codes == *codes); + let rejected_codes_are_bound = match &derived_rejection_codes { + Some(codes) => !codes.is_empty() && attempt.rejection_codes == *codes, + None => { + attempt.rejection_codes.len() == 1 + && matches!( + attempt.rejection_codes[0], + CexFactorRejectionCodeV1::DuplicateCandidate + | CexFactorRejectionCodeV1::EvaluationFailed + | CexFactorRejectionCodeV1::EngineFailure + ) + } + }; match attempt.verdict { CexFactorScreeningVerdictV1::Accepted if attempt .evaluation .as_ref() .is_some_and(|value| value.evidence.passed) - && attempt.rejection_codes.is_empty() + && accepted_codes_are_bound && attempt.rejection_details.is_empty() && attempt.post_warmup_coverage_rows > 0 => { @@ -1873,9 +1973,17 @@ impl CexFactorBankRevisionV2 { )); } accepted_ids.insert(attempt.candidate_id.as_str()); + accepted_orientations.insert( + attempt.candidate_id.as_str(), + attempt + .evaluation + .as_ref() + .expect("accepted evidence was checked by the match guard") + .orientation()?, + ); } CexFactorScreeningVerdictV1::Rejected - if !attempt.rejection_codes.is_empty() + if rejected_codes_are_bound && !attempt.rejection_details.is_empty() && attempt .rejection_details @@ -1908,6 +2016,10 @@ impl CexFactorBankRevisionV2 { || self.entries.iter().any(|entry| { !factor_ids.insert(entry.factor_id.as_str()) || !accepted_ids.contains(entry.candidate_id.as_str()) + || accepted_orientations + .get(entry.candidate_id.as_str()) + .copied() + != Some(entry.orientation) || entry.factor_id != format!("cex-factor-{}", entry.ast_sha256) || entry.source_features != factor_ast_source_features(&entry.canonical_ast) || self.attempts.iter().all(|attempt| { @@ -4259,6 +4371,34 @@ mod tests { #[test] fn factor_bank_entry_bindings_fail_closed_when_rehashed() { let original = factor_bank(); + assert_eq!( + original.entries[0].orientation, + CexFactorOrientationV1::Positive + ); + let mut wrong_orientation = original.clone(); + wrong_orientation.entries[0].orientation = CexFactorOrientationV1::Negative; + rebind_factor_bank(&mut wrong_orientation); + assert!(wrong_orientation.validate().is_err()); + + let mut rejected = original.clone(); + let evaluation = rejected.attempts[0].evaluation.as_mut().unwrap(); + let mut config = evaluation.evidence.formula_config().unwrap(); + config.min_aggregate_score = 10.0; + evaluation.evidence.evaluator_config = serde_json::to_value(&config).unwrap(); + evaluation.evidence.passed = false; + evaluation.evidence.failure_reasons = vec!["adjusted score rejected".to_string()]; + rejected.screening_policy.content_sha256 = canonical_json_hash(&config).unwrap(); + rejected.attempts[0].verdict = CexFactorScreeningVerdictV1::Rejected; + rejected.attempts[0].rejection_codes = + vec![CexFactorRejectionCodeV1::MultipleTestingGateFailed]; + rejected.attempts[0].rejection_details = vec!["adjusted score rejected".to_string()]; + rejected.entries.clear(); + rebind_factor_bank(&mut rejected); + rejected.validate().unwrap(); + rejected.attempts[0].rejection_codes = vec![CexFactorRejectionCodeV1::ScreeningFailed]; + rebind_factor_bank(&mut rejected); + assert!(rejected.validate().is_err()); + let mut forged_id = original.clone(); forged_id.entries[0].factor_id = "forged".to_string(); rebind_factor_bank(&mut forged_id);