diff --git a/crates/site-api/src/handlers.rs b/crates/site-api/src/handlers.rs index 90cda0361..ea5498b27 100644 --- a/crates/site-api/src/handlers.rs +++ b/crates/site-api/src/handlers.rs @@ -22,8 +22,8 @@ use site_data::map::{ }; use site_prism::{ enrich_leaderboard_row_from_detail_with_zone, enrich_submission_from_detail_with_zone, - infer_recipe_era_with_live, map_benchmarks, payload_is_v21_contest, pin_id_from_payload, - prism_reference_baselines, prism_submission_detail_with_zone, + fill_v21_run_from_live, infer_recipe_era_with_live, map_benchmarks, payload_is_v21_contest, + pin_id_from_payload, prism_reference_baselines, prism_submission_detail_with_zone, }; use site_types::coding_arena; use site_types::page_slice; @@ -605,40 +605,46 @@ async fn get_submissions( .trim() .to_ascii_lowercase(); let champions_only = matches!(scope.as_str(), "champions" | "champion"); - let mut items: Vec<_> = rows + let mut paired: Vec<(crate::Submission, &Value)> = rows .iter() .filter(|r| !champions_only || is_prism_champion_submission(r)) - .filter_map(prism_submission) + .filter_map(|r| prism_submission(r).map(|s| (s, r))) .collect(); // Prefer in-flight + recent rows for detail fan-out (era / benches). - let mut ids: Vec = items.iter().map(|s| s.id.clone()).collect(); + let mut ids: Vec = paired.iter().map(|(s, _)| s.id.clone()).collect(); ids.truncate(PRISM_CHAMPION_DETAIL_FANOUT); let details = fetch_prism_details(&st, &ids).await; - let live_recipe = fetch_prism_recipe(&st) - .await + let recipe_json = fetch_prism_recipe(&st).await; + let live = recipe_json .as_ref() .and_then(|r| r.get("version")) - .and_then(Value::as_str) - .map(str::to_owned); - let live = live_recipe.as_deref(); - for item in &mut items { + .and_then(Value::as_str); + for (item, raw) in &mut paired { if let Some(fan) = details.get(&item.id) { enrich_submission_from_detail_with_zone(item, &fan.detail, fan.zone_a.as_ref()); - item.recipe_era = Some(infer_recipe_era_with_live(&fan.detail, live)); - if item.recipe_era == Some(RecipeEra::V21) { + let harvested = infer_recipe_era_with_live(&fan.detail, None); + let era = infer_recipe_era_with_live(&fan.detail, live); + item.recipe_era = Some(era); + // Live-recipe fallback only — keep harvest/gate eligibility intact. + if era == RecipeEra::V21 && harvested != RecipeEra::V21 { item.run.weight_eligible = Some(true); } - } else if item.recipe_era.is_none() { - // Pre-2.0 / unknown → legacy so era tabs are not empty. - item.recipe_era = Some(RecipeEra::Legacy); + fill_v21_run_from_live(&mut item.run, recipe_json.as_ref(), era); + } else { + let era = infer_recipe_era_with_live(raw, live); + item.recipe_era = Some(era); + item.run.weight_eligible = Some(era == RecipeEra::V21); + fill_v21_run_from_live(&mut item.run, recipe_json.as_ref(), era); } } + let mut items: Vec<_> = paired.into_iter().map(|(s, _)| s).collect(); decorate_submissions(&st, &mut items); if let Some(st_f) = status_filter { items.retain(|s| match st_f { "scored" => s.status == crate::SubmissionStatus::Scored, "pending" => s.status == crate::SubmissionStatus::Pending, "failed" => s.status == crate::SubmissionStatus::Failed, + "queued" | "running" | "terminated" => s.stage.eq_ignore_ascii_case(st_f), _ => true, }); } @@ -1447,6 +1453,7 @@ mod tests { assert_eq!(v["items"][0]["recipeEra"], "v21"); assert_eq!(v["items"][0]["pinId"], "automodel@v0.5.0"); assert_eq!(v["items"][0]["weightEligible"], true); + assert_eq!(v["items"][0]["gatesComplete"], true); assert_eq!(v["items"][0]["competitionId"], "prism-v2.1"); assert_eq!(v["items"][0]["tokens"], 2048.0); assert_eq!(v["items"][0]["tokensPerSec"], 1200.0); @@ -1458,7 +1465,24 @@ mod tests { assert_eq!(v["items"][1]["weightEligible"], false); assert_eq!(v["items"][2]["id"], "sub-running"); assert_eq!(v["items"][2]["status"], "pending"); - assert_eq!(v["items"][2]["recipeEra"], "legacy"); + assert_eq!(v["items"][2]["stage"], "running"); + assert_eq!( + v["items"][2]["recipeEra"], "v21", + "live recipe 2.1.x labels unlabeled in-flight as v2.1: {v}" + ); + assert_eq!(v["items"][2]["weightEligible"], true); + assert_eq!(v["items"][2]["competitionId"], "prism-v2.1"); + assert_eq!(v["items"][2]["scoringGeneration"], 21); + assert_eq!(v["items"][2]["recipeVersion"], "2.1.0"); + + let (s, v) = call( + app.clone(), + "/v1/site/arenas/prism/submissions?status=running", + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["total"], 1, "status=running filters by stage: {v}"); + assert_eq!(v["items"][0]["id"], "sub-running"); // scope=champions keeps Score>0 gallery; default scope=all includes in-flight. let (s, v) = call( diff --git a/crates/site-prism/src/lib.rs b/crates/site-prism/src/lib.rs index c95343d19..894f12bd2 100644 --- a/crates/site-prism/src/lib.rs +++ b/crates/site-prism/src/lib.rs @@ -113,7 +113,8 @@ pub fn infer_recipe_era(payload: &Value) -> RecipeEra { } /// Same as [`infer_recipe_era`], but a live recipe `2.1.x` lets pin-only -/// in-flight rows (no harvest metrics yet) count as v2.1. +/// or unlabeled in-flight rows (no harvest metrics yet) count as v2.1. +/// Explicit `1.x` / `2.0.x` stay archived even while 2.1 is live. #[must_use] pub fn infer_recipe_era_with_live(payload: &Value, live_recipe: Option<&str>) -> RecipeEra { if payload_is_v21_contest(payload) { @@ -123,6 +124,9 @@ pub fn infer_recipe_era_with_live(payload: &Value, live_recipe: Option<&str>) -> if recipe_is_major_minor(sub, 2, 0) { return RecipeEra::Automodel; } + if recipe_major(sub).is_some_and(|m| m < 2) { + return RecipeEra::Legacy; + } if pin_id_from_payload(payload).is_some() { if live_recipe.is_some_and(recipe_semver_is_v21) { return RecipeEra::V21; @@ -142,9 +146,37 @@ pub fn infer_recipe_era_with_live(payload: &Value, live_recipe: Option<&str>) -> } return RecipeEra::Automodel; } + if live_recipe.is_some_and(recipe_semver_is_v21) { + return RecipeEra::V21; + } RecipeEra::Legacy } +/// Copy live contest identity onto a v2.1 row that has no harvest metrics yet. +pub fn fill_v21_run_from_live(run: &mut PrismRunStats, live: Option<&Value>, era: RecipeEra) { + if era != RecipeEra::V21 { + return; + } + if run.competition_id.is_none() { + run.competition_id = live + .and_then(|r| r.get("competition_id")) + .and_then(Value::as_str) + .map(str::to_owned); + } + if run.scoring_generation.is_none() { + run.scoring_generation = live + .and_then(|r| r.get("scoring_generation")) + .and_then(Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + } + if run.recipe_version.is_none() { + run.recipe_version = live + .and_then(|r| r.get("version")) + .and_then(Value::as_str) + .map(str::to_owned); + } +} + /// True when the payload is the live Prism v2.1 contest (fail-closed). #[must_use] pub fn payload_is_v21_contest(payload: &Value) -> bool { @@ -463,27 +495,41 @@ fn map_run_stats(detail: &Value, era: RecipeEra) -> PrismRunStats { .and_then(Value::as_str) }) }) + .or_else(|| root.get("competition_id").and_then(Value::as_str)) .map(str::to_owned); - let scoring_generation = metrics.and_then(|m| { - m.get("scoring_generation") - .and_then(Value::as_u64) - .or_else(|| { - m.get("scoring_generation") - .and_then(Value::as_str) - .and_then(|s| s.trim().parse::().ok()) - }) - .or_else(|| { - m.pointer("/pod_manifest/scoring_generation") - .and_then(Value::as_u64) - }) - .and_then(|n| u16::try_from(n).ok()) - }); + let scoring_generation = metrics + .and_then(|m| { + m.get("scoring_generation") + .and_then(Value::as_u64) + .or_else(|| { + m.get("scoring_generation") + .and_then(Value::as_str) + .and_then(|s| s.trim().parse::().ok()) + }) + .or_else(|| { + m.pointer("/pod_manifest/scoring_generation") + .and_then(Value::as_u64) + }) + .and_then(|n| u16::try_from(n).ok()) + }) + .or_else(|| { + root.get("scoring_generation") + .and_then(Value::as_u64) + .and_then(|n| u16::try_from(n).ok()) + }); let eval = root.get("eval").filter(|e| !e.is_null()); + let eval_ineligible = eval + .and_then(|e| e.get("status")) + .and_then(Value::as_str) + .is_some_and(|s| s.eq_ignore_ascii_case("ineligible")); + let gates_complete = eval + .and_then(|e| e.pointer("/gates/complete")) + .and_then(Value::as_bool); PrismRunStats { competition_id, scoring_generation, recipe_version: recipe, - weight_eligible: Some(era == RecipeEra::V21), + weight_eligible: Some(era == RecipeEra::V21 && !eval_ineligible), bits_per_byte: metrics.and_then(|m| { metric_f64( m, @@ -521,9 +567,7 @@ fn map_run_stats(detail: &Value, era: RecipeEra) -> PrismRunStats { .and_then(|m| m.get("gpu_type")) .and_then(Value::as_str) .map(str::to_owned), - gates_complete: eval - .and_then(|e| e.pointer("/gates/complete")) - .and_then(Value::as_bool), + gates_complete, } } @@ -670,6 +714,33 @@ mod tests { infer_recipe_era_with_live(&pin, Some("2.1.0")), RecipeEra::V21 ); + let inflight = json!({"id": "run", "status": "running", "label": "inflight"}); + assert_eq!(infer_recipe_era(&inflight), RecipeEra::Legacy); + assert_eq!( + infer_recipe_era_with_live(&inflight, Some("2.1.0")), + RecipeEra::V21 + ); + assert_eq!( + infer_recipe_era_with_live(&auto, Some("2.1.0")), + RecipeEra::Automodel + ); + assert_eq!( + infer_recipe_era_with_live(&legacy, Some("2.1.0")), + RecipeEra::Legacy + ); + let mut run = PrismRunStats::default(); + fill_v21_run_from_live( + &mut run, + Some(&json!({ + "version": "2.1.0", + "competition_id": "prism-v2.1", + "scoring_generation": 21 + })), + RecipeEra::V21, + ); + assert_eq!(run.competition_id.as_deref(), Some("prism-v2.1")); + assert_eq!(run.scoring_generation, Some(21)); + assert_eq!(run.recipe_version.as_deref(), Some("2.1.0")); assert_eq!( pin_id_from_payload(&pin).as_deref(), Some("automodel@v0.5.0")