#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.
This commit is contained in:
vangel76 committed 2026-11-08 18:05:27 +01:00
1 parent 2c6fe5f108
commit 39628a2ba0
4 files changed
+262 -112

No files matched your search

+3
View File
@@ -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};
+94 -34
View File
@@ -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<ParamValue> {
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<f64> {
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<f64> {
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<u32> {
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<String> {
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<ParamValue> {
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<f64> {
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<f64> {
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<u16> {
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<Effect<'f, 'a>> {
+41 -52
View File
@@ -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<i32> {
Some(i32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
fn be_u32(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
fn be_f32(d: &[u8], at: usize) -> Option<f64> {
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<f64> {
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<String>,
}
/// 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<f64> = 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<f64> = [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<f64> = 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);
}
}
+124 -26
View File
@@ -480,7 +480,7 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
let mut above: Option<u32> = 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<HashSet<ItemId>> = 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<ItemId, HashSet<ItemId>>) -> HashMap<It
/// `from`'s layers are built the only links added start at `from`, and a path that reaches `from`
/// never needs one of them. (Before, every precomp layer walked the whole graph, which held one
/// link per layer.)
#[derive(Default)]
///
/// Which links of a loop are refused depends on the order they come in (the first link that
/// closes it), so inside a component the answer cannot be computed once for all compositions:
/// each composition's search covers the links accepted so far, and a component of K compositions
/// and L links costs up to K x L steps. Those searches share one budget
/// ([`MAX_LOOP_SEARCH_STEPS`] links visited per import); once it is spent, a precomp layer inside a
/// loop's component whose search would have to visit a link is refused unchecked (reported),
/// which never closes a loop; a search that needs no step still decides exactly. Every file of
/// the corpus has no precomp loop and never searches.
struct Precomps {
used_by: HashMap<ItemId, HashSet<ItemId>>,
component: HashMap<ItemId, usize>,
/// 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<ItemId>),
/// 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<ItemId, HashSet<ItemId>>) -> 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<HashSet<ItemId>>) -> 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<ItemId> {
/// others (each visited once); `None` when the search budget runs out.
fn users_of(&mut self, comp: ItemId) -> Option<HashSet<ItemId>> {
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::<ItemId, Vec<ItemId>>::new(), Precomps::new(&candidates));
let mut refused = 0;
for (from, tos) in &plan {
let mut users: Option<HashSet<ItemId>> = 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<ItemId, HashSet<ItemId>> = (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<ItemId, HashSet<ItemId>> = 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::<HashSet<_>>().len(), parts.len());
// Below the budget the same loop is decided exactly: the closing link is refused.
let small: HashMap<ItemId, HashSet<ItemId>> = (0..100).map(|i| (c(i), HashSet::from([c((i + 1) % 100)]))).collect();
let mut p = Precomps::new(&small);
let links: Vec<Link> = (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);