"""Built-in physics scenarios + procedural sampling. Each scenario binds a hidden ``LatentParticle`` truth and a public ``TaskSpec`` (search window, available channels, resource budgets, expected findings, paper references). Curated scenarios are inspired by famous LHC discoveries; procedural ones randomise mass, channel, width and budgets to build a curriculum. """ from __future__ import annotations from dataclasses import dataclass from typing import List, Optional import numpy as np from models import ( DetectorChannel, ExpectedFinding, PaperReference, TOOL_REGISTRY, TaskSpec, ) from server.simulator.latent_state import ( DetectorState, FullLatentState, LatentParticle, ResourceState, ) @dataclass class Scenario: name: str difficulty: str task: TaskSpec latent: FullLatentState def fresh_latent(self) -> FullLatentState: # Pydantic deep-copy so the env can mutate freely return self.latent.model_copy(deep=True) # ── Curated, story-driven scenarios ────────────────────────────────────── def _higgs_like_scenario() -> Scenario: particle = LatentParticle( name="HiggsLike", mass_gev=125.0, width_gev=0.004, spin=0, parity="+", cross_section_fb=55.0, decay_branching={ "diphoton": 0.0023, "dilepton_ee": 0.00003, "dilepton_mumu": 0.00022, "four_lepton": 0.000125, "bb": 0.58, "dijet": 0.30, }, primary_channel="diphoton", ) detector = DetectorState( detector_resolution_gev=1.5, pileup_mu=30.0, trigger_efficiency=0.85, ) resources = ResourceState( budget_total_musd=120.0, luminosity_total_fb=300.0, time_limit_days=365.0, ) latent = FullLatentState( particle=particle, detector=detector, resources=resources, rng_seed=125, ) task = TaskSpec( problem_statement=( "An anomalous excess at ~125 GeV is rumoured in early 13 TeV runs. " "Plan a campaign to confirm or refute a Standard-Model Higgs-like scalar. " "Pick channels, allocate luminosity, fit, and submit a calibrated discovery claim." ), target_collider="LHC", mass_search_window_gev=[100.0, 200.0], budget_limit_musd=120.0, luminosity_budget_fb=300.0, time_limit_days=365.0, prior_observations=[ "Earlier Tevatron data shows a mild diphoton excess near 125 GeV.", "ATLAS/CMS rumour mills suggest a 4ℓ excess at low mass.", ], success_criteria=[ "Identify a resonance within 1 GeV of the truth.", "Reach ≥5σ local significance.", "Submit confidence consistent with calibration.", ], paper_references=[ PaperReference( title="Observation of a new particle in the search for the SM Higgs boson", arxiv_id="1207.7214", doi="10.1016/j.physletb.2012.08.020", ), ], expected_findings=[ ExpectedFinding(finding="Diphoton resonance at ~125 GeV", category="discovery"), ExpectedFinding(finding="Spin-0, even parity", category="property"), ], difficulty="medium", available_tools=list(TOOL_REGISTRY.keys()), ) return Scenario(name="higgs_like_125", difficulty="medium", task=task, latent=latent) def _hidden_zprime_scenario() -> Scenario: particle = LatentParticle( name="ZPrime", mass_gev=600.0, width_gev=18.0, spin=1, parity="-", cross_section_fb=12.0, decay_branching={ "diphoton": 0.0, "dilepton_ee": 0.04, "dilepton_mumu": 0.04, "four_lepton": 0.0, "bb": 0.20, "dijet": 0.70, }, primary_channel="dilepton_mumu", ) detector = DetectorState( detector_resolution_gev=8.0, pileup_mu=45.0, trigger_efficiency=0.78, qcd_background_strength=1.2, ) resources = ResourceState( budget_total_musd=140.0, luminosity_total_fb=200.0, time_limit_days=400.0, ) latent = FullLatentState( particle=particle, detector=detector, resources=resources, rng_seed=600, ) task = TaskSpec( problem_statement=( "Run-2 dilepton spectra hint at a high-mass excess. Hunt for a heavy " "Z'-like vector resonance and characterise spin-1, parity-odd hypothesis." ), mass_search_window_gev=[300.0, 1500.0], budget_limit_musd=140.0, luminosity_budget_fb=200.0, time_limit_days=400.0, prior_observations=[ "High-pT dilepton tail shows a 2.7σ shoulder near 600 GeV.", "Dijet smooth-fit residuals consistent with the same window.", ], success_criteria=[ "Identify a high-mass dilepton/dijet resonance.", "Constrain spin to be vector (1).", "Report calibrated mass within 5% and ≥4σ significance.", ], paper_references=[ PaperReference( title="Search for high-mass dilepton resonances at the LHC", arxiv_id="1903.06248", ), ], expected_findings=[ ExpectedFinding(finding="Heavy Z'-like dilepton resonance", category="discovery"), ExpectedFinding(finding="Spin-1, parity-odd", category="property"), ], difficulty="hard", available_tools=list(TOOL_REGISTRY.keys()), ) return Scenario(name="hidden_zprime_600", difficulty="hard", task=task, latent=latent) def _diboson_resonance_scenario() -> Scenario: particle = LatentParticle( name="Graviton", mass_gev=750.0, width_gev=45.0, spin=2, parity="+", cross_section_fb=6.0, decay_branching={ "diphoton": 0.06, "dilepton_ee": 0.005, "dilepton_mumu": 0.005, "four_lepton": 0.001, "bb": 0.15, "dijet": 0.70, }, primary_channel="diphoton", ) detector = DetectorState( detector_resolution_gev=12.0, pileup_mu=50.0, trigger_efficiency=0.80, ) resources = ResourceState( budget_total_musd=110.0, luminosity_total_fb=180.0, time_limit_days=350.0, ) latent = FullLatentState( particle=particle, detector=detector, resources=resources, rng_seed=750, ) task = TaskSpec( problem_statement=( "A faint γγ excess at 750 GeV stirred the field briefly in 2015-2016. " "Re-investigate with the modern luminosity budget and decide if it is " "real or a fluctuation." ), mass_search_window_gev=[400.0, 1200.0], budget_limit_musd=110.0, luminosity_budget_fb=180.0, time_limit_days=350.0, prior_observations=[ "Public CMS/ATLAS data show a 2-3σ diphoton bump near 750 GeV.", "Theory papers proposed graviton, scalar singlet, and SM-fluctuation explanations.", ], success_criteria=[ "Decide between discovery and fluctuation with calibrated confidence.", ], paper_references=[ PaperReference( title="Search for resonant production of high-mass diphoton pairs", arxiv_id="1606.04093", ), ], expected_findings=[ ExpectedFinding(finding="Possible diphoton resonance near 750 GeV", category="discovery"), ], difficulty="hard", available_tools=list(TOOL_REGISTRY.keys()), ) return Scenario(name="diphoton_750", difficulty="hard", task=task, latent=latent) def _easy_diphoton_scenario() -> Scenario: """Generous budgets, narrow scalar, single obvious channel.""" particle = LatentParticle( name="EasyScalar", mass_gev=160.0, width_gev=0.5, spin=0, parity="+", cross_section_fb=120.0, decay_branching={ "diphoton": 0.05, "dilepton_ee": 0.001, "dilepton_mumu": 0.005, "four_lepton": 0.0001, "bb": 0.50, "dijet": 0.30, }, primary_channel="diphoton", ) detector = DetectorState( detector_resolution_gev=2.0, pileup_mu=20.0, trigger_efficiency=0.9, ) resources = ResourceState( budget_total_musd=200.0, luminosity_total_fb=400.0, time_limit_days=500.0, ) latent = FullLatentState( particle=particle, detector=detector, resources=resources, rng_seed=160, ) task = TaskSpec( problem_statement=( "Tutorial scenario: discover a narrow scalar that decays cleanly to " "two photons. Resources are abundant; focus on running a clean pipeline." ), mass_search_window_gev=[80.0, 300.0], budget_limit_musd=200.0, luminosity_budget_fb=400.0, time_limit_days=500.0, success_criteria=[ "Identify the diphoton peak and submit a calibrated 5σ claim.", ], expected_findings=[ ExpectedFinding(finding="Diphoton scalar near 160 GeV", category="discovery"), ], difficulty="easy", available_tools=list(TOOL_REGISTRY.keys()), ) return Scenario(name="easy_diphoton_160", difficulty="easy", task=task, latent=latent) CURATED_SCENARIOS: List[Scenario] = [ _easy_diphoton_scenario(), _higgs_like_scenario(), _hidden_zprime_scenario(), _diboson_resonance_scenario(), ] # ── Procedural sampler ─────────────────────────────────────────────────── _DIFFICULTY_TIERS = { "easy": {"mass_lo": 90.0, "mass_hi": 250.0, "xsec_lo": 80.0, "xsec_hi": 150.0, "res": 1.5, "budget": 200.0, "lumi": 400.0}, "medium": {"mass_lo": 100.0, "mass_hi": 600.0, "xsec_lo": 25.0, "xsec_hi": 80.0, "res": 3.0, "budget": 150.0, "lumi": 300.0}, "hard": {"mass_lo": 250.0, "mass_hi": 1500.0, "xsec_lo": 5.0, "xsec_hi": 25.0, "res": 8.0, "budget": 110.0, "lumi": 200.0}, } def _procedural_scenario(difficulty: str, rng: np.random.Generator) -> Scenario: tier = _DIFFICULTY_TIERS.get(difficulty, _DIFFICULTY_TIERS["medium"]) mass = float(rng.uniform(tier["mass_lo"], tier["mass_hi"])) xsec = float(rng.uniform(tier["xsec_lo"], tier["xsec_hi"])) spin = int(rng.choice([0, 1, 2])) parity = str(rng.choice(["+", "-"])) primary = str(rng.choice([c.value for c in DetectorChannel])) branching = {c.value: 0.001 for c in DetectorChannel} branching[primary] = float(rng.uniform(0.02, 0.6)) # normalise so it sums to ~1 total = sum(branching.values()) branching = {k: v / total for k, v in branching.items()} particle = LatentParticle( name=f"Mystery_{int(mass)}GeV", mass_gev=mass, width_gev=float(rng.uniform(0.5, 30.0) if difficulty != "easy" else rng.uniform(0.05, 2.0)), spin=spin, parity=parity, cross_section_fb=xsec, decay_branching=branching, primary_channel=primary, ) detector = DetectorState( detector_resolution_gev=tier["res"], pileup_mu=float(rng.uniform(20.0, 60.0)), trigger_efficiency=float(rng.uniform(0.7, 0.92)), qcd_background_strength=float(rng.uniform(0.8, 1.3)), ) resources = ResourceState( budget_total_musd=tier["budget"], luminosity_total_fb=tier["lumi"], time_limit_days=float(rng.uniform(300.0, 500.0)), ) latent = FullLatentState( particle=particle, detector=detector, resources=resources, rng_seed=int(rng.integers(1, 1_000_000)), ) window_lo = max(50.0, mass - 200.0) window_hi = mass + 300.0 task = TaskSpec( problem_statement=( f"Procedural ({difficulty}): a hidden resonance lives somewhere in " f"[{window_lo:.0f}, {window_hi:.0f}] GeV. Discover and characterise it." ), mass_search_window_gev=[window_lo, window_hi], budget_limit_musd=tier["budget"], luminosity_budget_fb=tier["lumi"], time_limit_days=resources.time_limit_days, difficulty=difficulty, available_tools=list(TOOL_REGISTRY.keys()), success_criteria=[ "Discover the hidden resonance with a calibrated mass and channel.", ], ) return Scenario( name=f"procedural_{difficulty}_{int(mass)}", difficulty=difficulty, task=task, latent=latent, ) def sample_scenario( *, difficulty: Optional[str] = None, name: Optional[str] = None, seed: Optional[int] = None, ) -> Scenario: rng = np.random.default_rng(seed) if name: for s in CURATED_SCENARIOS: if s.name == name: fresh = Scenario( name=s.name, difficulty=s.difficulty, task=s.task, latent=s.fresh_latent(), ) if seed is not None: fresh.latent.rng_seed = int(seed) return fresh if difficulty in {"easy", "medium", "hard"}: # mix curated + procedural curated_pool = [s for s in CURATED_SCENARIOS if s.difficulty == difficulty] if curated_pool and rng.random() < 0.4: picked = curated_pool[int(rng.integers(0, len(curated_pool)))] return Scenario( name=picked.name, difficulty=picked.difficulty, task=picked.task, latent=picked.fresh_latent(), ) return _procedural_scenario(difficulty, rng) # default: random difficulty diff = str(rng.choice(["easy", "medium", "hard"])) return _procedural_scenario(diff, rng) __all__ = ["CURATED_SCENARIOS", "Scenario", "sample_scenario"]