From 39628a2ba0a5683eb6e310e9c716c19d3ad01b10 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 18:05:27 +0100 Subject: [PATCH] #3299: aep: one pard decoder, bounded precomp loop search inside a loop The container's `Pard` is now the only decoder of parameter definitions: `EffectParam` delegates to it and the export's test reference reads its encoded table back through it (no big-endian readers of its own). The export stores a checkbox default as on/off, as the importer reads it. Inside a strongly connected component of candidate precomp links (a file with a precomp loop), the per-composition searches share one budget of link visits; past it, a layer that would need a search is refused and reported, which never lets a loop in. Below the budget the decisions are the old ones exactly. --- crates/aep/src/container.rs | 3 + crates/aep/src/container/effects.rs | 128 +++++++++++++++++------- crates/aep/src/export/effects.rs | 93 ++++++++--------- crates/aep/src/import/build.rs | 150 +++++++++++++++++++++++----- 4 files changed, 262 insertions(+), 112 deletions(-) diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 5f2e3b2bc..43b1cf627 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -44,6 +44,9 @@ pub use arbitrary::{ pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM}; pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk}; pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue}; +// The export's tests read their encoded definitions back through the importer's decoder. +#[cfg(test)] +pub(crate) use effects::Pard; pub use error::AepError; pub use footage::{Footage, SequenceInfo}; pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer}; diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs index 6ccfa3af6..1ec0933cc 100644 --- a/crates/aep/src/container/effects.rs +++ b/crates/aep/src/container/effects.rs @@ -271,8 +271,8 @@ impl<'f, 'a> Effect<'f, 'a> { out.push(EffectParam { index: out.len(), match_name, - kind: ParamKind::from_u32(be_u32(pard, 12).unwrap_or(u32::MAX)), - name: cstr(pard, 16, 32).unwrap_or_default(), + kind: Pard(pard).kind(), + name: Pard(pard).name().unwrap_or_default(), items: None, borrowed_definition: borrowed, pard, @@ -485,26 +485,7 @@ impl<'f, 'a> EffectParam<'f, 'a> { pub fn pard_default(&self) -> Option { match self.kind { ParamKind::Popup if self.borrowed_definition => None, - ParamKind::FloatSlider => be_f64(self.pard, 56).map(ParamValue::Number), - ParamKind::FixedSlider | ParamKind::Angle => be_i32(self.pard, 56).map(|v| ParamValue::Number(f64::from(v) / 65536.0)), - ParamKind::IntSlider => be_i32(self.pard, 56).map(|v| ParamValue::Number(f64::from(v))), - ParamKind::Checkbox => be_u32(self.pard, 56).map(|v| ParamValue::Bool(v != 0)), - ParamKind::Popup => be_u32(self.pard, 56).map(ParamValue::Index), - ParamKind::Point => { - let p = [f64::from(be_i32(self.pard, 56)?) / 65536.0, f64::from(be_i32(self.pard, 60)?) / 65536.0]; - // A fresh instance stores fractions of the layer (0.5, 0.5); After Effects' save of - // a project it opened stores percent (50, 50: the re-saves of read-back tests e1 and - // e2, where Gradient Ramp's Start and End and every other point default came back - // as 50 / 0 and 50 / 100). Point - // defaults lie inside the layer, so a component beyond 1 tells percent. - let percent = p.iter().any(|c| c.abs() > 1.0 + 1e-9); - Some(ParamValue::Point(if percent { p.map(|c| c / 100.0) } else { p })) - } - ParamKind::Color => { - let [a, r, g, b] = array::<4>(self.pard, 56)?.map(|c| f64::from(c) / 255.0); - Some(ParamValue::Argb([a, r, g, b])) - } - _ => None, + kind => Pard(self.pard).default(kind), } } @@ -558,22 +539,14 @@ impl<'f, 'a> EffectParam<'f, 'a> { /// slider differs per parameter (Amount 37.5 % is stored 37.5, Blend 25 % is stored 0.25), so /// this is what tells a reader the scale. `None` for other types. pub fn valid_max(&self) -> Option { - match self.kind { - ParamKind::FixedSlider => be_i32(self.pard, 128).map(|v| f64::from(v) / 65536.0), - ParamKind::FloatSlider => be_f32(self.pard, 108).map(f64::from), - ParamKind::IntSlider => be_i32(self.pard, 128).map(f64::from), - _ => None, - } + Pard(self.pard).valid_max(self.kind) } /// The smallest valid value of a float slider, the `f32` at byte 104 of `pard` (Exposure /// -100, Offset -2, Gamma Correction 0.1: 389). `None` for other types (a fixed slider's /// minimum is not decoded). pub fn valid_min(&self) -> Option { - match self.kind { - ParamKind::FloatSlider => be_f32(self.pard, 104).map(f64::from), - _ => None, - } + Pard(self.pard).valid_min(self.kind) } /// The range of a float slider's slider (what the control's drag covers, inside the valid @@ -582,7 +555,7 @@ impl<'f, 'a> EffectParam<'f, 'a> { /// for other types. pub fn slider_range(&self) -> Option<(f64, f64)> { match self.kind { - ParamKind::FloatSlider => Some((f64::from(be_f32(self.pard, 112)?), f64::from(be_f32(self.pard, 116)?))), + ParamKind::FloatSlider => Pard(self.pard).slider_range(self.kind), _ => None, } } @@ -592,7 +565,7 @@ impl<'f, 'a> EffectParam<'f, 'a> { if self.kind != ParamKind::Popup { return None; } - be_u16(self.pard, 60) + Pard(self.pard).popup_item_count() } /// For a popup: the selected item index (1-based): the stored selection, else the default. @@ -620,6 +593,93 @@ impl<'f, 'a> EffectParam<'f, 'a> { } } +/// A parameter definition (`pard`, 148 bytes; aep-format.md 3.7): the one decoder of its fields. +/// The import reads them through [`EffectParam`]; the export writes them from its effect table and +/// its tests read them back through this. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Pard<'f>(pub(crate) &'f [u8]); + +impl Pard<'_> { + /// The type code, the `u32` at byte 12. + pub(crate) fn code(self) -> Option { + be_u32(self.0, 12) + } + + /// The parameter type (an unreadable code is [`ParamKind::Other`]). + pub(crate) fn kind(self) -> ParamKind { + ParamKind::from_u32(self.code().unwrap_or(u32::MAX)) + } + + /// The parameter name at byte 16 (32 bytes). + pub(crate) fn name(self) -> Option { + cstr(self.0, 16, 32) + } + + /// The default at byte 56 as a parameter of type `kind` reads it (see + /// [`EffectParam::pard_default`]). + pub(crate) fn default(self, kind: ParamKind) -> Option { + let p = self.0; + match kind { + ParamKind::FloatSlider => be_f64(p, 56).map(ParamValue::Number), + ParamKind::FixedSlider | ParamKind::Angle => be_i32(p, 56).map(|v| ParamValue::Number(f64::from(v) / 65536.0)), + ParamKind::IntSlider => be_i32(p, 56).map(|v| ParamValue::Number(f64::from(v))), + ParamKind::Checkbox => be_u32(p, 56).map(|v| ParamValue::Bool(v != 0)), + ParamKind::Popup => be_u32(p, 56).map(ParamValue::Index), + ParamKind::Point => { + let pt = [f64::from(be_i32(p, 56)?) / 65536.0, f64::from(be_i32(p, 60)?) / 65536.0]; + // A fresh instance stores fractions of the layer (0.5, 0.5); After Effects' save of + // a project it opened stores percent (50, 50: the re-saves of read-back tests e1 and + // e2, where Gradient Ramp's Start and End and every other point default came back + // as 50 / 0 and 50 / 100). Point + // defaults lie inside the layer, so a component beyond 1 tells percent. + let percent = pt.iter().any(|c| c.abs() > 1.0 + 1e-9); + Some(ParamValue::Point(if percent { pt.map(|c| c / 100.0) } else { pt })) + } + ParamKind::Color => { + let [a, r, g, b] = array::<4>(p, 56)?.map(|c| f64::from(c) / 255.0); + Some(ParamValue::Argb([a, r, g, b])) + } + _ => None, + } + } + + /// The valid maximum of a slider (see [`EffectParam::valid_max`]). + pub(crate) fn valid_max(self, kind: ParamKind) -> Option { + match kind { + ParamKind::FixedSlider => be_i32(self.0, 128).map(|v| f64::from(v) / 65536.0), + ParamKind::FloatSlider => be_f32(self.0, 108).map(f64::from), + ParamKind::IntSlider => be_i32(self.0, 128).map(f64::from), + _ => None, + } + } + + /// The valid minimum of a float slider (see [`EffectParam::valid_min`]). + pub(crate) fn valid_min(self, kind: ParamKind) -> Option { + match kind { + ParamKind::FloatSlider => be_f32(self.0, 104).map(f64::from), + _ => None, + } + } + + /// A slider's range, the one its value records repeat as `tdum` / `tduM`: a float slider's + /// `f32` pair at 112 / 116, a fixed slider's 16.16 pair and an integer slider's `i32` pair at + /// 132 / 136. + pub(crate) fn slider_range(self, kind: ParamKind) -> Option<(f64, f64)> { + let p = self.0; + match kind { + ParamKind::FloatSlider => Some((f64::from(be_f32(p, 112)?), f64::from(be_f32(p, 116)?))), + ParamKind::FixedSlider => Some((f64::from(be_i32(p, 132)?) / 65536.0, f64::from(be_i32(p, 136)?) / 65536.0)), + ParamKind::IntSlider => Some((f64::from(be_i32(p, 132)?), f64::from(be_i32(p, 136)?))), + _ => None, + } + } + + /// A popup's item count, the `u16` at byte 60. + pub(crate) fn popup_item_count(self) -> Option { + be_u16(self.0, 60) + } +} + impl<'f, 'a> Layer<'f, 'a> { /// The effects on the layer, in stack order (`ADBE Effect Parade`). pub fn effects(&self) -> Vec> { diff --git a/crates/aep/src/export/effects.rs b/crates/aep/src/export/effects.rs index 1090f59f7..c3a82be52 100644 --- a/crates/aep/src/export/effects.rs +++ b/crates/aep/src/export/effects.rs @@ -186,7 +186,8 @@ impl ParDef { 10 => vec![d(0)], 2 | 3 => vec![fixed(d(0))], 1 => vec![int(d(0))], - 4 => vec![f64::from(d(0).round().max(0.0) as u32)], + // On when the stored word is not 0 (as the importer reads it). + 4 => vec![if d(0).round().max(0.0) as u32 != 0 { 1.0 } else { 0.0 }], 7 => vec![f64::from(d(0).round().clamp(0.0, f64::from(u16::MAX)) as u32)], // Fractions of the layer, or percent after an After Effects re-save (see the reader). 6 => { @@ -1113,28 +1114,11 @@ pub(crate) fn put_project_lists(protos: &FxProtos, file: &mut AepFile<'static>, mod tests { use super::*; - /// The definitions as they were found before: the prototype's `parT` encoded from the table - /// and parsed back (the reference for [`ParDef::from_table`]). + /// The definitions as the importer's decoder reads them back from the prototype's `parT` + /// encoded from the table (the reference for [`ParDef::from_table`]; one `pard` decoder). mod reference { use super::super::*; - fn be_i32(d: &[u8], at: usize) -> Option { - Some(i32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) - } - fn be_u32(d: &[u8], at: usize) -> Option { - Some(u32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) - } - fn be_f32(d: &[u8], at: usize) -> Option { - Some(f64::from(f32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))) - } - fn be_f64(d: &[u8], at: usize) -> Option { - Some(f64::from_be_bytes(d.get(at..at.checked_add(8)?)?.try_into().ok()?)) - } - - fn cname(d: &[u8]) -> String { - d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect() - } - /// A definition as the decoder read it, with the name and popup items it also read. pub(super) struct Old { pub def: ParDef, @@ -1142,42 +1126,38 @@ mod tests { pub items: Vec, } - /// The decoder of an encoded `pard` that `ParDef::from_table` replaces. + /// An encoded `pard` as the importer's decoder (`container::Pard`) reads it, in the + /// export's stored units: what `ParDef::from_table` must agree with. pub(super) fn from_pard(pard: &[u8], pdnm: Option<&str>, data: Option<&[u8]>) -> Old { - let code = be_u32(pard, 12).unwrap_or(u32::MAX); - let fx = |at: usize| be_i32(pard, at).map(|v| f64::from(v) / 65536.0); - let default: Vec = match code { - 10 => be_f64(pard, 56).into_iter().collect(), - 2 | 3 => fx(56).into_iter().collect(), - 1 => be_i32(pard, 56).map(f64::from).into_iter().collect(), - 4 | 7 => be_u32(pard, 56).map(f64::from).into_iter().collect(), - // Fractions of the layer, or percent after an After Effects re-save (see the reader). - 6 => { - let p: Vec = [fx(56), fx(60)].into_iter().flatten().collect(); - if p.iter().any(|c| c.abs() > 1.0 + 1e-9) { p.iter().map(|c| c / 100.0).collect() } else { p } - } - 5 => pard.get(56..60).map(|c| c.iter().map(|b| f64::from(*b)).collect()).unwrap_or_default(), + use crate::ParamValue as Pv; + let d = crate::container::Pard(pard); + let kind = d.kind(); + let default: Vec = match d.default(kind) { + Some(Pv::Number(x)) => vec![x], + Some(Pv::Bool(on)) => vec![if on { 1.0 } else { 0.0 }], + Some(Pv::Index(i)) => vec![f64::from(i)], + Some(Pv::Point(pt)) => pt.to_vec(), + // Bytes 0..255, read as fractions. + Some(Pv::Argb(c)) => c.iter().map(|x| (x * 255.0).round()).collect(), + Some(Pv::Layer(_)) | None => Vec::new(), + }; + let items = match pdnm { + Some(text) if kind == crate::ParamKind::Popup && !text.is_empty() => text.split('|').map(str::to_string).collect(), _ => Vec::new(), }; - let range = match code { - 10 => be_f32(pard, 112).zip(be_f32(pard, 116)), - 2 => fx(132).zip(fx(136)), - 1 => be_i32(pard, 132).map(f64::from).zip(be_i32(pard, 136).map(f64::from)), - _ => None, + let name = match (kind, pdnm) { + (crate::ParamKind::Checkbox, Some(text)) if d.name().unwrap_or_default().trim().is_empty() => text.to_string(), + _ => d.name().unwrap_or_default(), }; - let valid_max = match code { - 10 => be_f32(pard, 108), - 2 => fx(128), - 1 => be_i32(pard, 128).map(f64::from), - _ => None, + let def = ParDef { + record: String::new(), + code: d.code().unwrap_or(u32::MAX), + default, + range: d.slider_range(kind), + valid_max: d.valid_max(kind), + data: data.map(<[u8]>::to_vec), }; - let pname = pard.get(16..48).map(cname).unwrap_or_default(); - let (name, items) = match (code, pdnm) { - (7, Some(p)) if !p.is_empty() => (pname, p.split('|').map(str::to_string).collect()), - (4, Some(p)) if pname.trim().is_empty() => (p.to_string(), Vec::new()), - _ => (pname, Vec::new()), - }; - Old { def: ParDef { record: String::new(), code, default, range, valid_max, data: data.map(<[u8]>::to_vec) }, name, items } + Old { def, name, items } } /// The parameter definitions of an instance's `parT`, by position (the table's index). @@ -1247,7 +1227,16 @@ mod tests { #[test] fn the_table_definitions_equal_the_encoded_and_parsed_ones() { for fx in FX_DEFS { - let parsed = reference::par_defs(&crate::export::fxsynth::default_sspc(fx)); + let sspc = crate::export::fxsynth::default_sspc(fx); + // The decoder only tells a checkbox on from off: the table's words are 0 or 1. + let part = sspc.child_list(b"parT").map(|p| p.children()).unwrap_or_default(); + for pard in part.iter().filter(|c| c.is(b"pard")).filter_map(|c| c.bytes()) { + if crate::container::Pard(pard).kind() == crate::ParamKind::Checkbox { + let word = pard.get(56..60).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); + assert!(matches!(word, Some(0 | 1)), "{}: checkbox word {word:?}", fx.match_name); + } + } + let parsed = reference::par_defs(&sspc); assert_same(&parsed, &ParDef::of_effect(fx), fx.match_name); } } diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 8d1f640c0..21bfc70d8 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -480,7 +480,7 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition let mut above: Option = None; // The comps that use this one, directly or through others: a precomp layer of one of them // would close a loop. Found once, at the first precomp layer (see `Precomps`). - let mut users: Option> = None; + let mut users = Users::NotSearched; for layer in comp_view.layers() { let Some(h) = layer.header() else { @@ -548,9 +548,16 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition SourceKind::Solid => (LayerSource::Solid { item: s.item }, (s.width.max(1), s.height.max(1))), SourceKind::Footage => (LayerSource::Footage { item: s.item }, (s.width.max(1), s.height.max(1))), SourceKind::Comp => { - if !cx.precomps.accept(p.id, s.item, &mut users) { - skip(cx, &mut skipped, LossCategory::UnsupportedLayers, "precomposition loop, layer"); - continue; + match cx.precomps.accept(p.id, s.item, &mut users) { + Link::Accepted => {} + Link::Loop => { + skip(cx, &mut skipped, LossCategory::UnsupportedLayers, "precomposition loop, layer"); + continue; + } + Link::Unchecked => { + skip(cx, &mut skipped, LossCategory::UnsupportedLayers, "precomposition in a loop of compositions too large to check, layer"); + continue; + } } (LayerSource::Comp { item: s.item }, (s.width.max(1), s.height.max(1))) } @@ -777,26 +784,75 @@ pub(crate) fn components(edges: &HashMap>) -> HashMap>, component: HashMap, + /// Links the searches may still visit. + steps_left: usize, +} + +impl Default for Precomps { + fn default() -> Self { + Precomps { used_by: HashMap::new(), component: HashMap::new(), steps_left: MAX_LOOP_SEARCH_STEPS } + } +} + +/// The most links the precomp loop searches of one import visit together (see [`Precomps`]). +const MAX_LOOP_SEARCH_STEPS: usize = 4_000_000; + +/// The comps that use the composition being built, as far as they were needed. +enum Users { + /// Not looked for yet (no layer needed them). + NotSearched, + Found(HashSet), + /// The search budget ran out. + OutOfBudget, +} + +/// What becomes of a precomp layer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Link { + Accepted, + /// It would close a loop. + Loop, + /// Inside a loop's component after the search budget is spent: refused without a check. + Unchecked, } impl Precomps { /// `candidates`: comp -> the comps its layers could show. fn new(candidates: &HashMap>) -> Self { - Precomps { used_by: HashMap::new(), component: components(candidates) } + Precomps { component: components(candidates), ..Precomps::default() } } - /// Decides a precomp layer of `to` in `from`: recorded and `true`, or `false` when it closes a - /// loop. `users` holds the comps that use `from` once they are needed (one per composition). - fn accept(&mut self, from: ItemId, to: ItemId, users: &mut Option>) -> bool { - let looped = closes_loop(from, to, |to| self.same_component(from, to) && users.get_or_insert_with(|| self.users_of(from)).contains(&to)); - if !looped { - self.used_by.entry(to).or_default().insert(from); + /// Decides a precomp layer of `to` in `from`, recording it when it is accepted. `users` holds + /// the comps that use `from` once they are needed (one search per composition). + fn accept(&mut self, from: ItemId, to: ItemId, users: &mut Users) -> Link { + let inside = to != from && self.same_component(from, to); + if inside && matches!(users, Users::NotSearched) { + *users = self.users_of(from).map_or(Users::OutOfBudget, Users::Found); } - !looped + let found = match users { + Users::Found(u) => Some(&*u), + Users::NotSearched | Users::OutOfBudget => None, + }; + if inside && found.is_none() { + return Link::Unchecked; + } + if closes_loop(from, to, |to| inside && found.is_some_and(|u| u.contains(&to))) { + return Link::Loop; + } + self.used_by.entry(to).or_default().insert(from); + Link::Accepted } /// Whether `a` and `b` are in one component of the candidate graph. @@ -805,18 +861,19 @@ impl Precomps { } /// The comps of `comp`'s component that use it over accepted links, directly or through - /// others (each visited once). - fn users_of(&self, comp: ItemId) -> HashSet { + /// others (each visited once); `None` when the search budget runs out. + fn users_of(&mut self, comp: ItemId) -> Option> { let mut found = HashSet::new(); let mut stack = vec![comp]; while let Some(c) = stack.pop() { for u in self.used_by.get(&c).into_iter().flatten() { + self.steps_left = self.steps_left.checked_sub(1)?; if self.same_component(*u, comp) && found.insert(*u) { stack.push(*u); } } } - found + Some(found) } } @@ -1124,11 +1181,12 @@ mod tests { let (mut uses, mut new) = (HashMap::>::new(), Precomps::new(&candidates)); let mut refused = 0; for (from, tos) in &plan { - let mut users: Option> = None; + let mut users = Users::NotSearched; for to in tos { - let accepted = new.accept(*from, *to, &mut users); - assert_eq!(!accepted, old_would_loop(&uses, *from, *to), "{from:?} -> {to:?}"); - if accepted { + let link = new.accept(*from, *to, &mut users); + assert_ne!(link, Link::Unchecked, "the budget is not reached here"); + assert_eq!(link == Link::Loop, old_would_loop(&uses, *from, *to), "{from:?} -> {to:?}"); + if link == Link::Accepted { uses.entry(*from).or_default().push(*to); } else { refused += 1; @@ -1156,11 +1214,11 @@ mod tests { let candidates = HashMap::from([(b, HashSet::from([a])), (c, HashSet::from([b]))]); let mut p = Precomps::new(&candidates); for (from, to) in [(b, a), (c, b)] { - let mut users = None; + let mut users = Users::NotSearched; for _ in 0..200_000 { - assert!(p.accept(from, to, &mut users)); + assert_eq!(p.accept(from, to, &mut users), Link::Accepted); } - assert!(users.is_none(), "no search"); + assert!(matches!(users, Users::NotSearched), "no search"); } } @@ -1178,14 +1236,54 @@ mod tests { } let mut p = Precomps::new(&candidates); for from in (0..k).map(top).chain((0..k).map(mid)) { - let mut users = None; + let mut users = Users::NotSearched; for to in candidates.get(&from).into_iter().flatten() { - assert!(p.accept(from, *to, &mut users)); + assert_eq!(p.accept(from, *to, &mut users), Link::Accepted); } - assert!(users.is_none(), "no search"); + assert!(matches!(users, Users::NotSearched), "no search"); } } + #[test] + fn a_large_loop_of_compositions_is_checked_within_the_budget() { + // 100 000 compositions in one loop (each shows the next, the last the first), built in + // order: the users of each one are all the ones before it, so checking every layer + // exactly is 5e9 steps. The searches stop at the budget; past it the layers inside the + // loop that need a search are refused unchecked (one whose composition has no accepted + // user needs none and is still decided exactly). The loop never closes. + let n = 100_000u64; + let c = |i: u64| ItemId(i); + let candidates: HashMap> = (0..n).map(|i| (c(i), HashSet::from([c((i + 1) % n)]))).collect(); + let mut p = Precomps::new(&candidates); + let mut seen = HashMap::new(); + for i in 0..n { + let link = p.accept(c(i), c((i + 1) % n), &mut Users::NotSearched); + *seen.entry(link).or_insert(0usize) += 1; + } + // Before the budget runs out every link is accepted (none closes the loop yet); after + // it, the ones that need a search are refused unchecked; the closing one never gets in. + let accepted = seen.get(&Link::Accepted).copied().unwrap_or(0); + assert!(accepted > 1000 && accepted < n as usize, "{seen:?}"); + assert!(seen.get(&Link::Unchecked).copied().unwrap_or(0) > 0, "{seen:?}"); + assert!(!seen.contains_key(&Link::Loop), "{seen:?}"); + assert_eq!(accepted + seen.get(&Link::Unchecked).copied().unwrap_or(0), n as usize, "{seen:?}"); + // The accepted links hold no loop: every component of them is a single composition. + let mut forward: HashMap> = HashMap::new(); + for (to, froms) in &p.used_by { + for from in froms { + forward.entry(*from).or_default().insert(*to); + } + } + let parts = components(&forward); + assert_eq!(parts.values().collect::>().len(), parts.len()); + // Below the budget the same loop is decided exactly: the closing link is refused. + let small: HashMap> = (0..100).map(|i| (c(i), HashSet::from([c((i + 1) % 100)]))).collect(); + let mut p = Precomps::new(&small); + let links: Vec = (0..100).map(|i| p.accept(c(i), c((i + 1) % 100), &mut Users::NotSearched)).collect(); + assert!(links[..99].iter().all(|l| *l == Link::Accepted)); + assert_eq!(links[99], Link::Loop); + } + #[test] fn components_are_the_loops() { let n = |i: u64| ItemId(i);