Files
Oxicloud/examples/bench_caldav_parse.rs
Claude 12dc648cff 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
2026-07-17 13:48:37 +00:00

573 lines
23 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}