perf: round 4 — one-pass row paths, drive-selector cache, CalDAV single-parse, streamed Azure, batched hydration

Nine benchmark-gated changes (benches/ROUND4.md; every one ships with a
BEFORE/AFTER bench + equivalence gate, rollback rule as ROUND2/3):

- Row→entity path build: one-pass StoragePath::from_folder_and_name /
  from_joined + normalize_storage_name_owned + alloc-free Display —
  743→417 ns/file-row (1.78x), −5 allocs/row on every listing surface.
- WebDAV drive-selector: per-user readable_cache (single-flight, 30 s
  TTL, explicit invalidation incl. membership + group changes) replaces
  the grants join per request — 441 µs → 0.8 µs (~550x), 0 queries warm.
- CalDAV from_ical/update_ical_data: 8 full IcalParser runs per VEVENT
  → 1 (7.1x per PUT, 4.4x on 50-event imports); alloc-free split_vevents,
  chunk scan without the whole-body uppercase copy (1.4x), borrowed-key
  UID grouping (1.3x), REPORT props no longer cloned.
- PROPFIND emit: partition Vecs dropped (single-pass 404 list) + stack
  rendered RFC 3339/2822 dates, sizes, quoted etags (common::fmt,
  chrono-byte-identical, sweep-tested) on both DAV surfaces — 1.22x
  per page, 17.9→12.0 allocs/row.
- Grant-listing hydration: calendars/address books/playlists batch
  hydrate via = ANY($1) — 15 serial queries → 1 (~13x per sync poll).
- user-flags cache: get→insert → try_get_with single-flight (32→1
  queries per cold herd).
- Azure downloads: whole-blob Vec buffering → streamed SDK pages —
  TTFB 349→4 ms (87x), peak heap 480→1.9 MiB (254x) on 256 MiB blobs;
  new OXICLOUD_AZURE_ENDPOINT_URL override (Azurite/bench hook).
- Face indexing: unbounded per-image tokio::spawn → core-count
  semaphore, permit before blob read — peak heap 1175→176 MiB (6.7x).

Checks: cargo fmt, clippy --all-features --all-targets -D warnings,
cargo test --workspace (523 passed) + --features test_utils. hurl API
suite and dockerized integration DB not runnable in this environment —
left to CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
Claude
2026-07-17 13:48:37 +00:00
parent 8a73607229
commit 12dc648cff
44 changed files with 5092 additions and 402 deletions
+572
View File
@@ -0,0 +1,572 @@
//! CalDAV parse-path benchmark — the write-side 8×-reparse and the
//! read-side per-event copies (ROUND4).
//!
//! What changed:
//!
//! • `CalendarEvent::from_ical` funnelled each of its 8 property
//! lookups through an extractor that re-ran the full `IcalParser`
//! (line unfolding + component tree) over the whole body — 8
//! complete parses per VEVENT on every CalDAV PUT, `8·(M+1)` on a
//! master+M-exceptions PUT, `8·N` on an N-event import. Now: one
//! parse, all lookups on the parsed component (value-only lookups
//! also skip the parameter-map build).
//! • `split_vevents` uppercased EVERY line into a fresh String.
//! Now: allocation-free case-insensitive prefix tests.
//! • `extract_vevent_chunk` (read side: every REPORT/GET, per event)
//! allocated a full uppercase copy of the stored body just to find
//! two tags. Now: memchr fast path + alloc-free CI scan fallback.
//! • `group_events_by_uid` (read side, per REPORT) cloned every
//! event's UID String. Now: borrowed keys.
//!
//! The OLD logic is copied verbatim into `mod before`; equivalence
//! gates assert byte-identical parsed fields / chunk slices / grouping
//! across a corpus incl. folded lines, params, VALARM, all-day,
//! exceptions and mixed-case tags (exit 1 on any diff).
//!
//! Run (no Postgres needed):
//! cargo run --release --features bench --example bench_caldav_parse
//! Tunables (env):
//! BENCH_EVENTS (200) BENCH_PASSES (30) BENCH_GROUP_N (5000)
use std::env;
use std::hint::black_box;
use std::time::Instant;
use chrono::{DateTime, TimeZone, Utc};
use oxicloud::application::adapters::caldav_adapter::bench as caldav_bench;
use oxicloud::application::dtos::calendar_dto::CalendarEventDto;
use oxicloud::domain::entities::calendar_event::CalendarEvent;
use uuid::Uuid;
// ─── BEFORE: verbatim copies of the pre-optimization logic ──────────────────
#[allow(clippy::all)]
mod before {
use std::collections::HashMap;
/// Old `parse_first_vevent` — fresh parser per call.
pub fn parse_first_vevent(ical_data: &str) -> Option<ical::parser::ical::component::IcalEvent> {
use std::io::BufReader;
let reader = BufReader::new(ical_data.as_bytes());
let parser = ical::IcalParser::new(reader);
for cal in parser {
let Ok(cal) = cal else { continue };
if let Some(event) = cal.events.into_iter().next() {
return Some(event);
}
}
None
}
/// Old params-aware extractor — one FULL parse per property lookup.
pub fn extract_ical_property_with_params(
ical_data: &str,
property_name: &str,
) -> Option<(String, HashMap<String, Vec<String>>)> {
let event = parse_first_vevent(ical_data)?;
let prop = event
.properties
.into_iter()
.find(|p| p.name.eq_ignore_ascii_case(property_name))?;
let value = prop.value?;
if value.trim().is_empty() {
return None;
}
let mut params: HashMap<String, Vec<String>> = HashMap::new();
if let Some(param_list) = prop.params {
for (name, values) in param_list {
params.insert(name.to_ascii_uppercase(), values);
}
}
Some((value.trim().to_string(), params))
}
pub fn extract_ical_property(ical_data: &str, property_name: &str) -> Option<String> {
extract_ical_property_with_params(ical_data, property_name).map(|(v, _p)| v)
}
/// Comparable subset of the entity fields `from_ical` derives.
#[derive(Debug, PartialEq)]
pub struct BeforeEvent {
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
pub start_time: chrono::DateTime<chrono::Utc>,
pub end_time: chrono::DateTime<chrono::Utc>,
pub all_day: bool,
pub rrule: Option<String>,
pub ical_uid: Option<String>,
pub recurrence_id: Option<chrono::DateTime<chrono::Utc>>,
}
/// Old `from_ical` body (8 extractor calls = 8 full parses), minus
/// the entity envelope (ids/timestamps — identical on both sides).
pub fn from_ical(ical_data: &str) -> Result<BeforeEvent, String> {
let summary = extract_ical_property(ical_data, "SUMMARY").ok_or("Missing SUMMARY")?;
let (dtstart_value, dtstart_params) =
extract_ical_property_with_params(ical_data, "DTSTART").ok_or("Missing DTSTART")?;
let (dtend_value, _dtend_params) =
extract_ical_property_with_params(ical_data, "DTEND").ok_or("Missing DTEND")?;
let all_day = dtstart_params
.get("VALUE")
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
.unwrap_or(false);
let start_time = parse_ical_datetime(&dtstart_value, all_day)?;
let end_time = parse_ical_datetime(&dtend_value, all_day)?;
let description = extract_ical_property(ical_data, "DESCRIPTION");
let location = extract_ical_property(ical_data, "LOCATION");
let rrule = extract_ical_property(ical_data, "RRULE");
let ical_uid = extract_ical_property(ical_data, "UID");
let recurrence_id = match extract_ical_property_with_params(ical_data, "RECURRENCE-ID") {
Some((value, params)) => {
let is_date = params
.get("VALUE")
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
.unwrap_or(false);
parse_ical_datetime(&value, is_date).ok()
}
None => None,
};
Ok(BeforeEvent {
summary,
description,
location,
start_time,
end_time,
all_day,
rrule,
ical_uid,
recurrence_id,
})
}
/// Old datetime parser (verbatim semantics for the two supported forms).
pub fn parse_ical_datetime(
value: &str,
is_date_only: bool,
) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::TimeZone;
if is_date_only {
if value.len() != 8 {
return Err("bad all-day".into());
}
let year: i32 = value[0..4].parse().map_err(|_| "year")?;
let month: u32 = value[4..6].parse().map_err(|_| "month")?;
let day: u32 = value[6..8].parse().map_err(|_| "day")?;
return chrono::NaiveDate::from_ymd_opt(year, month, day)
.map(|d| chrono::Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap()))
.ok_or_else(|| "date".into());
}
if value.len() < 15 || !value.ends_with('Z') {
return Err(format!("bad datetime {value:?}"));
}
let year: i32 = value[0..4].parse().map_err(|_| "year")?;
let month: u32 = value[4..6].parse().map_err(|_| "month")?;
let day: u32 = value[6..8].parse().map_err(|_| "day")?;
let hour: u32 = value[9..11].parse().map_err(|_| "hour")?;
let minute: u32 = value[11..13].parse().map_err(|_| "minute")?;
let second: u32 = value[13..15].parse().map_err(|_| "second")?;
match chrono::NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => match date.and_hms_opt(hour, minute, second) {
Some(datetime) => Ok(chrono::Utc.from_utc_datetime(&datetime)),
None => Err("time".into()),
},
None => Err("date".into()),
}
}
/// Old `split_vevents` — per-line uppercase String.
pub fn split_vevents(ical_data: &str) -> Vec<String> {
let mut blocks = Vec::new();
let mut in_event = false;
let mut current = String::new();
for raw_line in ical_data.split('\n') {
let line = raw_line.trim_end_matches('\r');
let upper = line.trim_start().to_ascii_uppercase();
if upper.starts_with("BEGIN:VEVENT") {
in_event = true;
current.clear();
}
if in_event {
current.push_str(line);
current.push_str("\r\n");
}
if in_event && upper.starts_with("END:VEVENT") {
blocks.push(std::mem::take(&mut current));
in_event = false;
}
}
blocks
}
/// Old `extract_vevent_chunk` — full uppercase copy of the body.
pub fn extract_vevent_chunk(ical_data: &str) -> Option<&str> {
let upper = ical_data.to_ascii_uppercase();
let begin = upper.find("BEGIN:VEVENT")?;
let after_begin = &upper[begin..];
let rel_end = after_begin.find("END:VEVENT")?;
let end_tag_end = begin + rel_end + "END:VEVENT".len();
let mut end = end_tag_end;
if ical_data[end..].starts_with('\r') {
end += 1;
}
if ical_data[end..].starts_with('\n') {
end += 1;
}
Some(&ical_data[begin..end])
}
/// Old `group_events_by_uid` — String-keyed map, UID cloned per event.
pub fn group_events_by_uid<'a>(
events: &'a [oxicloud::application::dtos::calendar_dto::CalendarEventDto],
) -> Vec<Vec<&'a oxicloud::application::dtos::calendar_dto::CalendarEventDto>> {
let mut order: Vec<String> = Vec::new();
let mut buckets: HashMap<
String,
Vec<&'a oxicloud::application::dtos::calendar_dto::CalendarEventDto>,
> = HashMap::new();
for event in events {
let key = event.ical_uid.clone();
if !buckets.contains_key(&key) {
order.push(key.clone());
}
buckets.entry(key).or_default().push(event);
}
let mut out = Vec::with_capacity(order.len());
for uid in order {
let mut bucket = buckets.remove(&uid).unwrap_or_default();
bucket.sort_by_key(|e| e.recurrence_id.is_some());
out.push(bucket);
}
out
}
}
// ─── Corpus ─────────────────────────────────────────────────────────────────
/// A realistic ~1.3 KiB VEVENT: params on DTSTART, folded DESCRIPTION,
/// three ATTENDEEs with CN/PARTSTAT, ORGANIZER, VALARM, CATEGORIES,
/// STATUS and X-props. `variant` 0 = timed master with RRULE, 1 = all-day,
/// 2 = exception override (RECURRENCE-ID).
fn build_vevent_body(i: usize, variant: usize) -> String {
let uid = format!("evt-{i:05}@oxicloud.bench");
let mut v = String::with_capacity(1400);
v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n");
v.push_str("BEGIN:VEVENT\r\n");
v.push_str(&format!("UID:{uid}\r\n"));
v.push_str("DTSTAMP:20260701T120000Z\r\n");
match variant {
1 => {
v.push_str("DTSTART;VALUE=DATE:20260810\r\n");
v.push_str("DTEND;VALUE=DATE:20260811\r\n");
}
2 => {
v.push_str("DTSTART:20260812T090000Z\r\n");
v.push_str("DTEND:20260812T100000Z\r\n");
v.push_str("RECURRENCE-ID:20260812T090000Z\r\n");
}
_ => {
v.push_str("DTSTART:20260805T090000Z\r\n");
v.push_str("DTEND:20260805T103000Z\r\n");
v.push_str("RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20261231T000000Z\r\n");
}
}
v.push_str(&format!(
"SUMMARY:Sprint review #{i} — métricas y datos\r\n"
));
v.push_str(
"DESCRIPTION:Repaso de los objetivos del sprint con el equipo completo\\, in\r\n cluyendo demo de la nueva vista de fotos y el plan de la ronda de rendimien\r\n to número cuatro.\r\n",
);
v.push_str("LOCATION:Sala Turing — 3ª planta\r\n");
v.push_str("ORGANIZER;CN=Ana García:mailto:ana@example.com\r\n");
v.push_str(
"ATTENDEE;CN=Luis Pérez;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:luis@example.com\r\n",
);
v.push_str("ATTENDEE;CN=Sam Chen;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:sam@example.com\r\n");
v.push_str("ATTENDEE;CN=Río Núñez;PARTSTAT=TENTATIVE:mailto:rio@example.com\r\n");
v.push_str("CATEGORIES:TRABAJO,EQUIPO\r\n");
v.push_str("STATUS:CONFIRMED\r\n");
v.push_str("SEQUENCE:2\r\n");
v.push_str("TRANSP:OPAQUE\r\n");
v.push_str("X-OXICLOUD-ROUND:4\r\n");
v.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n");
v.push_str("END:VEVENT\r\n");
v.push_str("END:VCALENDAR\r\n");
v
}
/// N-event import body (master + exception pairs inside one VCALENDAR).
fn build_import_body(n_events: usize) -> String {
let mut v = String::with_capacity(n_events * 1400);
v.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Foreign//Client//EN\r\n");
for i in 0..n_events {
let single = build_vevent_body(i, i % 3);
// Extract just the VEVENT block from the standalone body.
let begin = single.find("BEGIN:VEVENT").unwrap();
let end = single.find("END:VEVENT").unwrap() + "END:VEVENT\r\n".len();
v.push_str(&single[begin..end]);
}
v.push_str("END:VCALENDAR\r\n");
v
}
fn make_dto(i: usize, uid: &str, recurrence: Option<DateTime<Utc>>) -> CalendarEventDto {
CalendarEventDto {
id: Uuid::from_u128(i as u128).to_string(),
calendar_id: Uuid::nil().to_string(),
summary: format!("Evento {i}"),
description: None,
location: None,
start_time: Utc.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap(),
end_time: Utc.with_ymd_and_hms(2026, 8, 5, 10, 0, 0).unwrap(),
all_day: false,
rrule: None,
ical_uid: uid.to_string(),
recurrence_id: recurrence,
ical_data: build_vevent_body(i, if recurrence.is_some() { 2 } else { 0 }),
created_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
updated_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
}
}
fn p50(mut xs: Vec<f64>) -> f64 {
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
xs[xs.len() / 2]
}
fn time_passes<T>(passes: usize, mut f: impl FnMut() -> T) -> f64 {
let mut per_pass = Vec::with_capacity(passes);
for _ in 0..passes {
let t0 = Instant::now();
black_box(f());
per_pass.push(t0.elapsed().as_secs_f64() * 1e6);
}
p50(per_pass)
}
fn main() {
let n_events: usize = env::var("BENCH_EVENTS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(200);
let passes: usize = env::var("BENCH_PASSES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
let group_n: usize = env::var("BENCH_GROUP_N")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
let calendar_id = Uuid::nil();
let bodies: Vec<String> = (0..n_events).map(|i| build_vevent_body(i, i % 3)).collect();
let import_body = build_import_body(50);
println!("bench_caldav_parse — {n_events} bodies, {passes} passes\n");
// ── [1] from_ical: single-event PUT path ────────────────────────────────
let t_before = time_passes(passes, || {
for b in &bodies {
black_box(before::from_ical(b).expect("before parse"));
}
}) / n_events as f64;
let t_after = time_passes(passes, || {
for b in &bodies {
black_box(CalendarEvent::from_ical(calendar_id, b.clone()).expect("after parse"));
}
}) / n_events as f64;
// The AFTER side clones the body (the real API takes it by value) —
// measure that clone alone so the comparison can subtract it.
let t_clone = time_passes(passes, || {
for b in &bodies {
black_box(b.clone());
}
}) / n_events as f64;
println!("[1] from_ical µs/event (8-parse chain vs single parse)");
println!(" BEFORE {t_before:8.2}");
println!(
" AFTER {t_after:8.2} (incl. {t_clone:.2} body clone) {:.1}x",
t_before / (t_after - t_clone)
);
// ── [2] parse_all_events: 50-event import PUT ───────────────────────────
let t_before_imp = time_passes(passes, || {
let blocks = before::split_vevents(&import_body);
let mut out = Vec::with_capacity(blocks.len());
for block in blocks {
let wrapped = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n",
block,
);
out.push(before::from_ical(&wrapped).expect("before import"));
}
out
});
let t_after_imp = time_passes(passes, || {
CalendarEvent::parse_all_events(calendar_id, &import_body).expect("after import")
});
println!("[2] parse_all_events µs/50-event import body");
println!(" BEFORE {t_before_imp:8.1}");
println!(
" AFTER {t_after_imp:8.1} {:.1}x",
t_before_imp / t_after_imp
);
// ── [3] extract_vevent_chunk: REPORT/GET read path ──────────────────────
let t_chunk_before = time_passes(passes, || {
for b in &bodies {
black_box(before::extract_vevent_chunk(b));
}
}) / n_events as f64
* 1000.0;
let t_chunk_after = time_passes(passes, || {
for b in &bodies {
black_box(caldav_bench::extract_vevent_chunk(b));
}
}) / n_events as f64
* 1000.0;
println!("[3] extract_vevent_chunk ns/event (uppercase copy vs direct scan)");
println!(" BEFORE {t_chunk_before:8.0}");
println!(
" AFTER {t_chunk_after:8.0} {:.1}x",
t_chunk_before / t_chunk_after
);
// ── [4] group_events_by_uid: REPORT fold ────────────────────────────────
// 80% masters, 20% exception overrides sharing a master's UID.
let dtos: Vec<CalendarEventDto> = (0..group_n)
.map(|i| {
if i % 5 == 4 {
let master = i - 1;
make_dto(
i,
&format!("evt-{master:05}@oxicloud.bench"),
Some(Utc.with_ymd_and_hms(2026, 8, 12, 9, 0, 0).unwrap()),
)
} else {
make_dto(i, &format!("evt-{i:05}@oxicloud.bench"), None)
}
})
.collect();
let t_grp_before = time_passes(passes, || black_box(before::group_events_by_uid(&dtos)));
let t_grp_after = time_passes(passes, || {
black_box(caldav_bench::group_events_by_uid(&dtos))
});
println!("[4] group_events_by_uid µs/{group_n} events (String keys vs borrowed)");
println!(" BEFORE {t_grp_before:8.1}");
println!(
" AFTER {t_grp_after:8.1} {:.1}x",
t_grp_before / t_grp_after
);
// ── [5] Equivalence gates ───────────────────────────────────────────────
let mut ok = true;
// Gate A: from_ical field identity across the corpus + edge bodies.
let mut gate_bodies: Vec<String> = bodies.clone();
gate_bodies.push(build_vevent_body(9990, 1));
gate_bodies.push(build_vevent_body(9991, 2));
// Mixed-case tags + LF-only line endings (foreign client shapes).
gate_bodies.push(
"begin:vcalendar\nversion:2.0\nbegin:vevent\nuid:mixed-case@x\nsummary:Mixed Case\ndtstart:20260801T080000Z\ndtend:20260801T090000Z\nend:vevent\nend:vcalendar\n"
.to_string(),
);
for b in &gate_bodies {
let bf = before::from_ical(b);
let af = CalendarEvent::from_ical(calendar_id, b.clone());
match (bf, af) {
(Ok(bf), Ok(af)) => {
let same = bf.summary == af.summary()
&& bf.description.as_deref() == af.description()
&& bf.location.as_deref() == af.location()
&& bf.start_time == *af.start_time()
&& bf.end_time == *af.end_time()
&& bf.all_day == af.all_day()
&& bf.rrule.as_deref() == af.rrule()
&& bf.ical_uid.as_deref() == Some(af.ical_uid())
&& bf.recurrence_id.as_ref() == af.recurrence_id();
if !same {
eprintln!("GATE A FAIL: field mismatch for body:\n{b}\n before={bf:?}");
ok = false;
}
}
(Err(_), Err(_)) => {}
(bf, af) => {
eprintln!(
"GATE A FAIL: error parity broke (before_ok={} after_ok={}) for body:\n{b}",
bf.is_ok(),
af.is_ok()
);
ok = false;
}
}
}
// Gate B: parse_all_events equivalence on the import body — same
// events, same wrapped per-row ical_data.
let after_events =
CalendarEvent::parse_all_events(calendar_id, &import_body).expect("import parses");
let before_blocks = before::split_vevents(&import_body);
if after_events.len() != before_blocks.len() {
eprintln!(
"GATE B FAIL: event count {} != block count {}",
after_events.len(),
before_blocks.len()
);
ok = false;
}
for (evt, block) in after_events.iter().zip(&before_blocks) {
let wrapped = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n",
block,
);
if evt.ical_data() != wrapped {
eprintln!("GATE B FAIL: wrapped ical_data mismatch");
ok = false;
break;
}
let bf = before::from_ical(&wrapped).expect("before parses wrapped");
if bf.summary != evt.summary() || bf.recurrence_id.as_ref() != evt.recurrence_id() {
eprintln!("GATE B FAIL: field mismatch on wrapped block");
ok = false;
break;
}
}
// Gate C: chunk slices byte-identical (incl. mixed-case + no-terminator).
let mut chunk_bodies = bodies.clone();
chunk_bodies.push("BEGIN:VCALENDAR\r\nbegin:vevent\r\nUID:x@y\r\nend:vevent".to_string());
chunk_bodies.push("no vevent here at all".to_string());
for b in &chunk_bodies {
if before::extract_vevent_chunk(b) != caldav_bench::extract_vevent_chunk(b) {
eprintln!("GATE C FAIL: chunk mismatch for body:\n{b}");
ok = false;
}
}
// Gate D: grouping identity — same UID order, same per-bucket rows.
let g_before = before::group_events_by_uid(&dtos);
let g_after = caldav_bench::group_events_by_uid(&dtos);
let shape = |g: &Vec<Vec<&CalendarEventDto>>| -> Vec<Vec<(String, bool)>> {
g.iter()
.map(|bucket| {
bucket
.iter()
.map(|e| (e.id.clone(), e.recurrence_id.is_some()))
.collect()
})
.collect()
};
if shape(&g_before) != shape(&g_after) {
eprintln!("GATE D FAIL: grouping mismatch");
ok = false;
}
println!(
"[5] Equivalence gates: {}",
if ok { "OK (byte-identical)" } else { "FAILED" }
);
if !ok {
std::process::exit(1);
}
}