diff --git a/crates/ournotes-search/src/handler.rs b/crates/ournotes-search/src/handler.rs index fafff6f..79ff593 100644 --- a/crates/ournotes-search/src/handler.rs +++ b/crates/ournotes-search/src/handler.rs @@ -164,7 +164,15 @@ pub(crate) fn compile_execution( Err(reason) => (None, Some(reason.to_string())), } } else if matches!(strategy, Strategy::BranchAndBound) { - match crate::search::joint::JointBounds::compile(pool, request, &domain, metric, event_input, simulation) { + match crate::search::joint::JointBounds::compile( + pool, + request, + &domain, + metric, + event_input, + simulation, + crate::search::budget::SearchBudget::unlimited(), + ) { Ok(bound) => { // A played Live whose payoff steps with the local score is first ranked by deck under its score cap: // a combined metric when every term does. diff --git a/crates/ournotes-search/src/search/budget.rs b/crates/ournotes-search/src/search/budget.rs index b9ab42f..0cd23d2 100644 --- a/crates/ournotes-search/src/search/budget.rs +++ b/crates/ournotes-search/src/search/budget.rs @@ -20,6 +20,11 @@ impl SearchBudget { Ok(Self { deadline }) } + /// No deadline: preparation without a request time limit (diagnostics, fixed evaluation). + pub(crate) fn unlimited() -> Self { + Self { deadline: None } + } + pub(crate) fn deadline(self) -> Option { self.deadline } diff --git a/crates/ournotes-search/src/search/certified_engine.rs b/crates/ournotes-search/src/search/certified_engine.rs index 4f20c4f..5e9dd67 100644 --- a/crates/ournotes-search/src/search/certified_engine.rs +++ b/crates/ournotes-search/src/search/certified_engine.rs @@ -197,8 +197,29 @@ pub(super) struct CertifiedState<'m> { /// The lowest point tier no performance order of a team of the domain reaches in expected score, when proved: /// the bounds compiled from then on drop the tiers from it on (`JointBounds::cap_scores`). pub(super) tier_cap: Option, + /// The longest unit of each kind of exact refinement this request has run, in [`RefinementUnit`] kinds. A unit + /// that cannot be interrupted is not started with less time left than its kind's longest one, so a refinement the + /// deadline cuts short runs past it by at most one such unit. + unit_ms: [f64; RefinementUnit::COUNT], } +/// The exact refinement work that runs to its end once started. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::search) enum RefinementUnit { + /// The residue law of every order's rank bonuses ([`ournotes_sim::live::full::luck_exact_score_refined`]). + Residues, + /// The power-twin comparison of two candidates ([`ournotes_sim::live::full::luck_exact_orders_power_twin`]). + Twin, +} + +impl RefinementUnit { + const COUNT: usize = 2; +} + +/// Milliseconds a unit of exact refinement is assumed to take before this request has run one of its kind: a +/// conservative prior for a chart of ordinary length. A unit observed to take longer raises its kind's estimate. +const REFINEMENT_UNIT_PRIOR_MS: f64 = 100.0; + /// Key bytes the request's lottery-curve cache may hold. pub(super) const LUCK_CURVE_CACHE_BYTES: usize = 32 * 1024 * 1024; @@ -227,6 +248,7 @@ impl CertifiedState<'_> { floor: None, order_score_seen: f64::NEG_INFINITY, tier_cap: None, + unit_ms: [0.0; RefinementUnit::COUNT], }) } /// The denominator of the payoff numerators of the frontier's grid: a mean over the performance orders is a sum @@ -464,11 +486,37 @@ impl Engine<'_, '_> { self.refine_joint_orders() } + /// The longest unit of this kind so far, or the conservative prior before the first one. + fn unit_estimate_ms(&self, unit: RefinementUnit) -> f64 { + let observed = self.certified.as_ref().map_or(0.0, |state| state.unit_ms[unit as usize]); + if observed > 0.0 { observed } else { REFINEMENT_UNIT_PRIOR_MS } + } + + /// Whether a unit of `unit` that runs to its end cannot pass the deadline: false once the request has stopped, + /// and false when less time is left than the longest such unit took so far. A stopped request starts nothing. + fn unit_fits(&self, unit: RefinementUnit) -> bool { + if self.stop.is_some() { + return false; + } + let Some(deadline) = self.budget.deadline() else { return true }; + let left = deadline.saturating_duration_since(crate::search::budget::now()).as_secs_f64() * 1000.0; + left >= self.unit_estimate_ms(unit) + } + + /// Records how long a unit of `unit` took, for [`Self::unit_fits`]. + fn observe_unit(&mut self, unit: RefinementUnit, ms: f64) { + if let Some(state) = self.certified.as_mut() { + state.unit_ms[unit as usize] = state.unit_ms[unit as usize].max(ms); + } + } + /// Every installed order is a complete nominal law; declined/partial trees keep their old bounds. fn refine_certified_orders(&mut self) -> Result<(), Error> { use ournotes_sim::live::full::{LuckExactBudget, LuckExactDecline, LuckExactSession}; let state = self.certified.as_mut().expect("certified request"); state.domain_exhausted = true; + // The provider stops an order's law at the deadline; a declined attempt is no certificate, so the frontier + // keeps its enclosures. let mut work = LuckExactBudget::default(); let mut attempted = std::collections::BTreeSet::<(u64, usize)>::new(); let mut materialized = std::collections::BTreeSet::new(); @@ -778,6 +826,10 @@ impl Engine<'_, '_> { if self.expired() { return Ok(()); } + // The comparison of two candidates is not interrupted once begun. + if !self.unit_fits(RefinementUnit::Twin) { + return Ok(()); + } let Some(setup) = a.4.gekisou.as_ref() else { return Ok(()) }; self.tel.lottery_refinement.power_pairs += 1; let mut curves = std::mem::take(&mut self.certified.as_mut().expect("certified request").luck_curves); @@ -800,6 +852,7 @@ impl Engine<'_, '_> { b.1, ); self.tel.lottery_refinement.twin_ms += elapsed_ms(started); + self.observe_unit(RefinementUnit::Twin, elapsed_ms(started)); self.rec.clock.lap(resume); self.tel.caches.luck_curves.record(curves.stats()); let state = self.certified.as_mut().expect("certified request"); @@ -836,6 +889,10 @@ impl Engine<'_, '_> { if self.expired() { return Ok(()); } + // One order's residue law runs to its end once begun. + if !self.unit_fits(RefinementUnit::Residues) { + return Ok(()); + } let performers = order.map(|slot| input.performers[slot].clone()); let (_, resume) = self.rec.clock.lap(slot::SIMULATION); let started = crate::search::budget::now(); @@ -852,6 +909,7 @@ impl Engine<'_, '_> { input.rank_confirmations.as_deref(), ); self.tel.lottery_refinement.residue_ms += elapsed_ms(started); + self.observe_unit(RefinementUnit::Residues, elapsed_ms(started)); self.rec.clock.lap(resume); let score = match score { Ok(score) if score.wide_notes == 0 => score, diff --git a/crates/ournotes-search/src/search/diagnostics.rs b/crates/ournotes-search/src/search/diagnostics.rs index 8bdae37..8483482 100644 --- a/crates/ournotes-search/src/search/diagnostics.rs +++ b/crates/ournotes-search/src/search/diagnostics.rs @@ -463,6 +463,7 @@ pub fn luck_fine_order_sums( &spec.simulation, &laws, None, + crate::search::budget::SearchBudget::unlimited(), )?; let positions: Vec<_> = uniform::all_orders().iter().map(uniform::positions_of).collect(); let mut scratch = super::snaps::JointScratch::default(); diff --git a/crates/ournotes-search/src/search/domain_laws.rs b/crates/ournotes-search/src/search/domain_laws.rs index f3aabc2..7ada6f9 100644 --- a/crates/ournotes-search/src/search/domain_laws.rs +++ b/crates/ournotes-search/src/search/domain_laws.rs @@ -608,6 +608,7 @@ impl Engine<'_, '_> { || !self.metric.weighs_laws() || self.simulation.music_length_ms.is_some() || self.simulation.score_music_length_ms.is_some() + || self.expired() { return None; } @@ -652,7 +653,11 @@ impl Engine<'_, '_> { domain: &CandidateDomain, clusters: bool, ) -> Option { + if self.expired() { + return None; + } let pool = self.pool; + let budget = self.budget; let mut kinds = self.take_luck_kinds(); let mut curves = std::mem::take(&mut self.certified.as_mut().expect("certified request").luck_curves); let mut stats = self.tel.environment.bounds.domain_laws.take().unwrap_or_default(); @@ -672,7 +677,7 @@ impl Engine<'_, '_> { let mut bounds = laws.as_ref().map_err(Clone::clone).and_then(|laws| { let (request, metric, event_input, simulation) = (self.request, self.metric, self.event_input, self.simulation); - JointBounds::compile_with_laws(pool, request, domain, metric, event_input, simulation, laws, memo) + JointBounds::compile_with_laws(pool, request, domain, metric, event_input, simulation, laws, memo, budget) .map_err(|error| format!("the weighed bounds do not compile: {error}")) }); stats.compile_ms += started.elapsed().as_secs_f64() * 1000.0; @@ -708,6 +713,7 @@ impl Engine<'_, '_> { let (prefixes, teams) = multiset_places(multisets, &cluster)?; let (pool, request, metric, event_input, simulation) = (self.pool, self.request, self.metric, self.event_input, self.simulation); + let budget = self.budget; let mut bounds = Vec::with_capacity(sets.len()); for set in &sets { // each cluster's bounds compile as one step of preparation; none begins after the deadline @@ -715,8 +721,18 @@ impl Engine<'_, '_> { return None; } bounds.push( - JointBounds::compile_with_laws(pool, request, domain, metric, event_input, simulation, set, None) - .ok()?, + JointBounds::compile_with_laws( + pool, + request, + domain, + metric, + event_input, + simulation, + set, + None, + budget, + ) + .ok()?, ); } let mut kinds = vec![Box::<[u32]>::default(); pool.members.len()]; diff --git a/crates/ournotes-search/src/search/joint.rs b/crates/ournotes-search/src/search/joint.rs index de93632..51bcb9f 100644 --- a/crates/ournotes-search/src/search/joint.rs +++ b/crates/ournotes-search/src/search/joint.rs @@ -14,7 +14,6 @@ use super::{ tables::Tables, }; use crate::{ - clock::Instant, domain::CandidateDomain, types::{Metric, SimulationInput}, }; diff --git a/crates/ournotes-search/src/search/joint/compile.rs b/crates/ournotes-search/src/search/joint/compile.rs index b521319..7cd15cc 100644 --- a/crates/ournotes-search/src/search/joint/compile.rs +++ b/crates/ournotes-search/src/search/joint/compile.rs @@ -55,8 +55,9 @@ impl JointBounds { metric: &Metric, input: Option<&EventPayoffInput>, simulation: &SimulationInput, + budget: SearchBudget, ) -> Result { - Self::compile_with_laws(pool, request, domain, metric, input, simulation, &[], None) + Self::compile_with_laws(pool, request, domain, metric, input, simulation, &[], None, budget) } /// [`JointBounds::compile`] with the certified lottery laws of every team of the domain in every order: the @@ -65,6 +66,8 @@ impl JointBounds { /// /// A weighted bound caps the expected score of an order, which a certified LUCK live ranks its orders by /// ([`PointBound::ranked`]). The luck weights are read from `memo` when it holds those of the same laws and notes. + /// `budget` is the request's: preparation that would run past its deadline fails as unavailable instead of + /// starting another step, and the caller then searches without these bounds. #[allow(clippy::too_many_arguments)] pub(crate) fn compile_with_laws( pool: &Pool, @@ -75,7 +78,11 @@ impl JointBounds { simulation: &SimulationInput, laws: &[std::sync::Arc], memo: Option<&crate::search::snaps::WeightsMemo>, + budget: SearchBudget, ) -> Result { + if budget.expired() { + return Err(unavailable("the request deadline was reached before the bounds compiled")); + } if !domain.is_feasible() { return Err(unavailable("empty legal domain")); } @@ -108,8 +115,8 @@ impl JointBounds { return Err(unavailable("joint preparation exceeds bounded table capacity")); } let (song, event, _) = crate::search::objective_song(pool, &request.objective)?; - let budget = SearchBudget::new(Instant::now(), None)?; - let t = Tables::new(pool, song, event, domain.snaps(), budget)?.expect("unlimited preparation"); + let t = Tables::new(pool, song, event, domain.snaps(), budget)? + .ok_or_else(|| unavailable("the request deadline was reached while the power tables built"))?; let mut allowed = vec![false; n]; for &m in domain.members() { allowed[m] = true; @@ -133,6 +140,9 @@ impl JointBounds { } let setup = crate::search::full_setup(pool, &request.objective)?.ok_or_else(|| unavailable("missing Live setup"))?; + if budget.expired() { + return Err(unavailable("the request deadline was reached while the bounds compiled")); + } let envelope = SnapLive::new(pool, &t, &allowed, &setup, laws, memo)?; let nonnegative_scores = envelope.nonnegative_scores(); let (a0, global, eps, order_gains) = envelope.joint_envelope(); @@ -302,12 +312,18 @@ impl JointBounds { root_keys: None, }; compiled.compile_tables(pool, domain); + if budget.expired() { + return Err(unavailable("the request deadline was reached while the bounds compiled")); + } if !compiled.gekisou { compiled.composition = composition::CompositionTables::compile(&compiled, pool, domain); if let Some(tables) = lambda::LambdaTables::compile(&compiled, pool, domain) { compiled.modules.push(Box::new(tables)); } } + if budget.expired() { + return Err(unavailable("the request deadline was reached while the bounds compiled")); + } if let Some((levels, carrier)) = levels { let at = levels .into_iter() diff --git a/crates/ournotes-search/src/search/leaf.rs b/crates/ournotes-search/src/search/leaf.rs index bb279b0..c78d439 100644 --- a/crates/ournotes-search/src/search/leaf.rs +++ b/crates/ournotes-search/src/search/leaf.rs @@ -115,11 +115,21 @@ impl Engine<'_, '_> { let (_, resume) = self.rec.clock.lap(slot::FINE); let state = self.certified.as_mut().expect("certified request"); let tier_cap = state.tier_cap; + let bounded = self.budget; let bounds = state.law_bounds.get(domain, laws, |laws| { *compiled += 1; - let mut bounds = - JointBounds::compile_with_laws(pool, request, domain, metric, event_input, simulation, laws, None) - .ok()?; + let mut bounds = JointBounds::compile_with_laws( + pool, + request, + domain, + metric, + event_input, + simulation, + laws, + None, + bounded, + ) + .ok()?; if let Some(threshold) = tier_cap { bounds.drop_tiers(pool, domain, threshold); } diff --git a/crates/ournotes-search/src/search/mod.rs b/crates/ournotes-search/src/search/mod.rs index 38241ff..2764f81 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -21,7 +21,7 @@ pub use physical::{ StepBudget, evaluate_declared_context, score_summary, solve_physical, solve_physical_with_aggregation, }; -mod budget; +pub(crate) mod budget; pub(crate) mod deck_payoff; #[cfg(feature = "search-diagnostics")] pub mod diagnostics; diff --git a/crates/ournotes-search/src/search/physical/engine.rs b/crates/ournotes-search/src/search/physical/engine.rs index 0278822..69310fb 100644 --- a/crates/ournotes-search/src/search/physical/engine.rs +++ b/crates/ournotes-search/src/search/physical/engine.rs @@ -63,6 +63,10 @@ pub(super) struct Offered { pub(super) score: Option, } +/// What `bound_start` interrupted: the phase to reopen once the `bound` phase ends, if any, and the activity the +/// clock continues at. +pub(super) type BoundMark = crate::search::telemetry::PhaseMark; + impl Engine<'_, '_> { /// Formal deterministic objectives also retain the leader and four unordered member/Snap pairs. /// The explicit v1 session cursor and fixed deterministic evaluation keep their physical-deck contracts. @@ -366,10 +370,13 @@ impl Engine<'_, '_> { Ok(()) } - /// Start bounding what the stop leaves unexplored. Its time goes to `proof.boundMs` and `otherMs`, not to the - /// search activity the stop interrupted; pass the returned mark to `fold_unexplored`. - pub(super) fn bound_start(&mut self) -> (Instant, usize) { - self.rec.clock.lap(slot::OTHER) + /// Start bounding what the stop leaves unexplored. Its time goes to `proof.boundMs`, `otherMs` and its own `bound` + /// phase; the interrupted phase is reopened by `fold_unexplored`. Pass the returned mark to it. + pub(super) fn bound_start(&mut self) -> (Instant, BoundMark) { + // `pause` charges the interrupted work to its own activity and continues the clock at `otherMs`. + let paused = self.rec.pause(&mut self.tel); + self.rec.begin(&mut self.tel, "bound", None); + (crate::search::budget::now(), paused) } /// The branches still open are bounded by `open` (None: no branch open): offer the larger of it and the best @@ -385,11 +392,19 @@ impl Engine<'_, '_> { } } - pub(super) fn fold_unexplored(&mut self, upper: Option, (started, resume): (Instant, usize)) { + pub(super) fn fold_unexplored(&mut self, upper: Option, (started, paused): (Instant, BoundMark)) { if let Some(upper) = upper { self.rec.unexplored(upper); } - let (now, _) = self.rec.clock.lap(resume); + let (now, _) = self.rec.clock.lap(slot::OTHER); + if self.rec.is_open() { + self.rec.end(&mut self.tel); + } + let (phase, activity) = paused; + if let Some(phase) = phase { + self.rec.resume(&mut self.tel, phase); + } + self.rec.clock.switch(activity); self.tel.proof.bound_ms += now.saturating_duration_since(started).as_secs_f64() * 1000.0; } diff --git a/crates/ournotes-search/src/search/physical/joint_search.rs b/crates/ournotes-search/src/search/physical/joint_search.rs index 819825a..a412258 100644 --- a/crates/ournotes-search/src/search/physical/joint_search.rs +++ b/crates/ournotes-search/src/search/physical/joint_search.rs @@ -69,6 +69,7 @@ pub(super) fn joint_regimes( e: &mut Engine<'_, '_>, ) -> Result<(), Error> { use crate::search::joint::{JointBounds, SLOTS, SlotRules}; + e.rec.begin(&mut e.tel, "regimeCompile", Some("census".into())); let mut census = e.lottery_census(&plan.domain); let mut converting = bounds.conversion_snaps(&plan.domain); let conversion = converting.len(); @@ -89,12 +90,22 @@ pub(super) fn joint_regimes( // Under the maximum aggregation one traversal of the whole domain's bounds, whose teams' leaves read the // bounds of their own laws, outruns the traversals of law clusters, which repeat the shared prefixes. let clusters = !certified_maximum(e); + e.rec.end(&mut e.tel); + e.rec.begin(&mut e.tel, "regimeCompile", Some("laws".into())); let mut weighed = census.as_mut().and_then(|census| e.weighed_bounds(census, &plan.domain, clusters)); if certified_maximum(e) { // bounds of each order's payoff, weighing no law when the census reads none if weighed.is_none() { - weighed = - JointBounds::compile(e.pool, e.request, &plan.domain, e.metric, e.event_input, e.simulation).ok(); + weighed = JointBounds::compile( + e.pool, + e.request, + &plan.domain, + e.metric, + e.event_input, + e.simulation, + e.budget, + ) + .ok(); } if let Some(weighed) = &mut weighed { weighed.order_maximum(e.pool, &plan.domain); @@ -108,11 +119,16 @@ pub(super) fn joint_regimes( && bounds.resource_worthwhile(e.pool, &plan.domain, orders, e.correlated)?; } lend(e, &mut census); + e.rec.end(&mut e.tel); e.rec.begin(&mut e.tel, "search", None); traverse(p, &plan.domain, bounds, orders, e)?; e.rec.end(&mut e.tel); return Ok(()); } + // The converted parts prepare their own bounds in the `conversionCompile` phase below, one by one, each under the + // request deadline. + e.rec.end(&mut e.tel); + e.rec.begin(&mut e.tel, "regimeCompile", Some("parts".into())); let is_converting = |s: usize| converting.contains(&s); let mask = |domain: &crate::domain::CandidateDomain, keep: &dyn Fn(usize) -> bool| -> Vec { std::iter::once(false).chain(domain.snaps().iter().map(|&s| keep(s))).collect() @@ -173,6 +189,8 @@ pub(super) fn joint_regimes( schedule.sort_by_key(|(i, (_, rules))| { std::cmp::Reverse((0..rules.len()).filter_map(|j| priority.get(&(*i, j)).copied()).max()) }); + // The part list is complete: the compilations below time their own phases. + e.rec.end(&mut e.tel); let bound_pending = |e: &mut Engine<'_, '_>| -> Result<(), Error> { // The pool-wide bound covers the active group and every group still awaiting compilation. let started = e.bound_start(); @@ -198,7 +216,7 @@ pub(super) fn joint_regimes( let weighed = census.as_mut().and_then(|census| e.weighed_bounds(census, &domain, false)); let result = match weighed { Some(weighed) => Ok(weighed), - None => JointBounds::compile(e.pool, e.request, &domain, e.metric, e.event_input, e.simulation), + None => JointBounds::compile(e.pool, e.request, &domain, e.metric, e.event_input, e.simulation, e.budget), }; let result = result.map(|mut part| { if certified_maximum(e) { @@ -215,6 +233,12 @@ pub(super) fn joint_regimes( e.tel.environment.bounds.conversion.as_mut().expect("conversion context").prepared_domains += 1; part } + // The deadline ended this part's bounds: its teams stay unexplored under the pool-wide envelope, and no + // later part starts. + Err(_) if e.budget.expired() => { + bound_pending(e)?; + return Ok(()); + } Err(error) => { e.tel.environment.bounds.conversion.as_mut().expect("conversion context").fallback = Some(error.to_string()); diff --git a/crates/ournotes-search/src/search/physical/solve.rs b/crates/ournotes-search/src/search/physical/solve.rs index 20f7e7a..9c8eb7b 100644 --- a/crates/ournotes-search/src/search/physical/solve.rs +++ b/crates/ournotes-search/src/search/physical/solve.rs @@ -423,6 +423,7 @@ pub(crate) fn solve_physical_impl( metric, event_input, simulation, + engine.budget, ); regime.compile_ms = prepare.elapsed().as_secs_f64() * 1000.0; match result { @@ -445,6 +446,7 @@ pub(crate) fn solve_physical_impl( metric, event_input, simulation, + engine.budget, ) .ok(); } @@ -607,7 +609,9 @@ pub(crate) fn solve_physical_impl( engine.tel.incumbents.warm_start.final_top_k = engine.seeded_in_top(); // A search for a floor only asks whether a team reaches it: the ranking below it needs no refinement. if engine.certified.as_ref().is_some_and(|state| state.floor.is_none()) && engine.stop.is_none() { + engine.rec.begin(&mut engine.tel, "refine", None); engine.refine_certified_frontier()?; + engine.rec.end(&mut engine.tel); } let standing = engine.standing(); engine.rec.begin(&mut engine.tel, "finish", None); diff --git a/crates/ournotes-search/src/search/telemetry.rs b/crates/ournotes-search/src/search/telemetry.rs index 76c9f6b..438f9b8 100644 --- a/crates/ournotes-search/src/search/telemetry.rs +++ b/crates/ournotes-search/src/search/telemetry.rs @@ -997,6 +997,11 @@ impl Clock { self.mark = now; (now, std::mem::replace(&mut self.slot, next)) } + /// Continue `slot` as the current activity after work that [`Recorder::pause`] timed apart. + #[inline] + pub(crate) fn switch(&mut self, slot: usize) { + self.slot = slot; + } /// Like `lap`, charging the elapsed time to `charged` instead of the current activity. #[inline] pub(crate) fn lap_as(&mut self, charged: usize, next: usize) { @@ -1086,6 +1091,10 @@ pub(crate) struct Recorder { pub(crate) upper: Option, } +/// What [`Recorder::pause`] set aside: the phase to reopen with [`Recorder::resume`], if any, and the activity the +/// clock was in. +pub(crate) type PhaseMark = (Option<(&'static str, Option)>, usize); + impl Recorder { pub(crate) fn new(origin: Instant) -> Self { Self { @@ -1145,6 +1154,27 @@ impl Recorder { } } + /// Whether a phase is open. + pub(crate) fn is_open(&self) -> bool { + self.open.is_some() + } + + /// Closes the open phase, if any, and leaves the activity clock at [`slot::OTHER`]. Returns that phase, to reopen + /// with [`Recorder::resume`], and the activity the closing lap interrupted: work the wire document times apart from + /// the phase (a bound the stop needs) runs before both resume. + pub(crate) fn pause(&mut self, tel: &mut Telemetry) -> PhaseMark { + let (_, activity) = self.clock.lap(slot::OTHER); + let Some(open) = self.open.take() else { return (None, activity) }; + close_phase(tel, open, super::budget::now()); + let phase = &tel.phases[open.0]; + (Some((phase.name, phase.label.clone())), activity) + } + + /// Opens the phase [`Recorder::pause`] closed, as a new entry in time order. + pub(crate) fn resume(&mut self, tel: &mut Telemetry, phase: (&'static str, Option)) { + self.begin(tel, phase.0, phase.1); + } + /// Close the open phase, if any, at `now` in a copy of the document, leaving it open here. pub(crate) fn peek_open(&self, tel: &mut Telemetry, now: Instant) { if let Some(open) = self.open { diff --git a/crates/ournotes-search/tests/adapter_fixture_export.rs b/crates/ournotes-search/tests/adapter_fixture_export.rs index 351f406..93a4958 100644 --- a/crates/ournotes-search/tests/adapter_fixture_export.rs +++ b/crates/ournotes-search/tests/adapter_fixture_export.rs @@ -28,6 +28,8 @@ mod conversion_partitions; mod cp_priorities; #[path = "fixtures/effect_identity.rs"] mod effect_identity; +#[path = "fixtures/luck_deadline.rs"] +mod luck_deadline; #[path = "fixtures/luck_refinement.rs"] mod luck_refinement; #[path = "fixtures/network_snapshots.rs"] diff --git a/crates/ournotes-search/tests/fixtures/luck_deadline.rs b/crates/ournotes-search/tests/fixtures/luck_deadline.rs new file mode 100644 index 0000000..0f7cae2 --- /dev/null +++ b/crates/ournotes-search/tests/fixtures/luck_deadline.rs @@ -0,0 +1,66 @@ +//! The request deadline over a certified LUCK search on synthetic inputs. +use super::luck_refinement::inputs; +use ournotes_search::{ + engine, + search::{Completion, telemetry::Phase}, + types::Optimality, +}; + +/// Every phase of the request work lies in time order inside the request's elapsed time and, together, accounts for +/// all of it. +fn assert_phases_time_the_request(elapsed_ms: f64, phases: &[Phase]) { + let timed: f64 = phases.iter().map(|phase| phase.wall_ms).sum(); + assert!(timed <= elapsed_ms + 1.0, "phases time {timed} ms of {elapsed_ms} ms"); + assert!(timed >= elapsed_ms - (elapsed_ms * 0.1 + 50.0), "phases time {timed} ms of {elapsed_ms} ms",); + assert!(phases.iter().map(|phase| phase.start_ms).is_sorted(), "phases start in time order"); +} + +#[test] +fn an_expired_deadline_reports_no_proof_and_starts_no_refinement() { + let (data, roster, mut request) = inputs(611_236, 12, 0); + request.limits.time_limit_ms = Some(0); + let out = engine::recommend(&data, &roster, &request).unwrap(); + assert_eq!(out.completion, Completion::TimedOut); + assert_eq!(out.optimality, Optimality::Unproven); + assert_eq!(out.telemetry.lottery_refinement.installed_orders, 0); + assert!(!out.telemetry.proof.complete); + assert_phases_time_the_request(out.elapsed_ms, &out.telemetry.phases); +} + +#[test] +fn a_tiny_deadline_returns_within_a_bounded_multiple_and_never_claims_a_proof() { + const LIMIT_MS: u64 = 1; + let (data, roster, mut request) = inputs(611_236, 12, 0); + request.limits.time_limit_ms = Some(LIMIT_MS); + let out = engine::recommend(&data, &roster, &request).unwrap(); + let bound = LIMIT_MS as f64 * 2000.0 + 2_500.0; + assert!(out.elapsed_ms <= bound, "elapsed {} ms exceeds {bound} ms", out.elapsed_ms); + assert_phases_time_the_request(out.elapsed_ms, &out.telemetry.phases); + match out.completion { + // A request that would have completed in time keeps its proof. + Completion::Complete => { + assert_eq!(out.optimality, Optimality::Proven); + assert!(out.telemetry.proof.complete); + } + _ => { + assert_eq!(out.optimality, Optimality::Unproven); + assert!(!out.telemetry.proof.complete); + } + } +} + +#[test] +fn a_generous_deadline_changes_no_result() { + let (data, roster, request) = inputs(611_236, 12, 0); + let reference = engine::recommend(&data, &roster, &request).unwrap(); + assert_eq!(reference.completion, Completion::Complete); + assert_eq!(reference.optimality, Optimality::Proven); + let mut limited = request; + limited.limits.time_limit_ms = Some(600_000); + let actual = engine::recommend(&data, &roster, &limited).unwrap(); + assert_eq!(actual.completion, Completion::Complete); + assert_eq!(actual.results, reference.results); + assert!(actual.telemetry.phases.iter().any(|phase| phase.name == "refine")); + assert!(actual.telemetry.lottery_refinement.installed_orders > 0); + assert_phases_time_the_request(actual.elapsed_ms, &actual.telemetry.phases); +} diff --git a/crates/ournotes-search/tests/fixtures/luck_refinement.rs b/crates/ournotes-search/tests/fixtures/luck_refinement.rs index d227f42..8c0b957 100644 --- a/crates/ournotes-search/tests/fixtures/luck_refinement.rs +++ b/crates/ournotes-search/tests/fixtures/luck_refinement.rs @@ -10,7 +10,7 @@ use ournotes_search::{ use ournotes_sim::{cards::Roster, data::DeckData}; use serde_json::json; -fn inputs(threshold: i32, k: usize, cache_entries: usize) -> (DeckData, Roster, RecommendationRequest) { +pub(super) fn inputs(threshold: i32, k: usize, cache_entries: usize) -> (DeckData, Roster, RecommendationRequest) { let mut synth = synthetic_master(5, 2, 5); set_column(&mut synth, "MasterLiveMusic", &mut |row| { row["_gekisouMission1"] = json!(2); diff --git a/docs/telemetry.en.md b/docs/telemetry.en.md index 38644cb..56ec697 100644 --- a/docs/telemetry.en.md +++ b/docs/telemetry.en.md @@ -73,7 +73,7 @@ reports the work so far, a recommendation result reports the whole request. | `upperBound` | Payoff bound (numerator) of **the part not yet explored** at the stop | | `globalUpperBound` | Bound of the best payoff numerator over the whole domain, covering retained decks and every open branch; nonincreasing once known. A completed deterministic physical search sets it to `best`, including exhaustive and multipart conversion searches. Null for a completed empty domain, the LUCK interval path, and traversals that do not record this field | | `bestGap`, `kthGap` | `(upperBound − x) / x`; 0 when the bound does not exceed x; null when x is not positive | -| `boundMs` | Time spent computing `upperBound` after the stop (outside the search deadline) | +| `boundMs` | Time spent computing `upperBound` after the stop (in the `bound` phase, outside the search deadline) | For deterministic searches, `upperBound` is a true upper bound: the best deck of the whole domain pays at most `max(best, upperBound)`, and every deck of the true Top-K that was not kept pays at most `max(upperBound, kth)`. A stopped search can therefore show @@ -124,12 +124,15 @@ In time order, without overlap; small gaps between phases belong to none. Each e | `boundCompile` | Compile the joint bounds | | `initial` | Evaluate the request's `initialDecks` | | `setup` | Decide the correlated and resource bounds, prepare the warm start | +| `regimeCompile` | Gekisou score: read the lottery census (`label: census`) and compile the bounds weighing its laws (`label: laws`) | | `ptWarmStart` | PT: find a Top-K in the maximum-bonus regime first | | `tierProbe` | Certified LUCK PT: ask, tier by tier from the lowest point tier above the evaluated scores, whether any order reaches it, until one is not reached (see `bounds.tierProbes`) | | `ptRegimeCompile` | PT: drop members that cannot reach the current K-th and recompile the bounds | | `conversionCompile` | Gekisou score: compile the bounds of each conversion part | | `seed` | Warm start before the joint traversal (see `incumbents.warmStart`) | | `search` | Main search; with conversion parts one entry per part, `label` being `free`, `snap slot ` or `pair slots ,` | +| `refine` | Certified LUCK: refine the frontier of the exhaustive domain with exact laws and residues (see `lotteryRefinement`), within the request deadline | +| `bound` | Bound the regions a stop leaves unexplored (see `proof.boundMs`) | | `evaluate` | Evaluate the requested deck | | `warmStart`, `proposals` | The two stages of the heuristic candidate strategy | | `verify` | Result check of the canonical Power/Skip search |