epiphany/crates/epiphany-testkit/benches/editor_pipeline.rs

647 lines
29 KiB
Rust
Raw 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.

//! The staged interactive-edit latency bench + budget gate (Ruling A
//! criterion 2; `spec/PLAN_EDITOR_APP.md` Fact 8).
//!
//! The normative budget (`spec/core_spec.tex`,
//! `req:perf:single-system-edit-latency`):
//!
//! > The *core's portion* of a single-system edit (operation envelope
//! > construction, reduction, incremental layout through `ResolvedLayoutIR`)
//! > MUST complete within one frame (16.7 ms at 60 Hz) at the p99 percentile,
//! > measured on the reference hardware profile.
//!
//! ## Why this bench exists, and what it is allowed to conclude
//!
//! Ruling A's toolkit spike is bounded by six criteria, of which criterion 2 is
//! *staged* latency: "reduce / engrave / scene-build / paint measured
//! **separately** … a toolkit verdict from an end-to-end number is
//! uninformative while reduction or solving dominates." That sentence is a
//! sequencing claim nobody had measured. This bench measures it, so the
//! decision to run the spike — or to run T4b (incremental materialization)
//! first — rests on numbers instead of assumption.
//!
//! The stage split is not invented here; it is the real seam
//! `EditorSession::materialize` walks (`epiphany-editor-core/src/lib.rs`), read
//! off its private `render_score` and reproduced stage for stage:
//!
//! | stage | what runs | in the core's budget? |
//! |---|---|---|
//! | construct | mint the one new `OperationEnvelope` | **yes** |
//! | reduce | `OperationSet::accept` × log + `reduce_onto(&base)` | **yes** |
//! | engrave | `to_logical` → `to_constrained` → `Engraver::solve` | **yes** |
//! | scene-build | `to_render` + `hit_test_map` (+ SVG string) | no — product layer |
//! | paint | `usvg` parse + `resvg` rasterize | no — product layer |
//!
//! Only the first three are gated, and all three are gated: the requirement
//! names "operation envelope **construction**, reduction, incremental layout",
//! so `construct` is timed and summed even though it is tens of nanoseconds and
//! never moves the verdict. A gate that silently drops a named component is a
//! proxy for the requirement rather than the requirement.
//!
//! `req:perf:single-system-edit-latency` bounds "the core's portion" and says
//! so explicitly — "End-to-end edit-to-pixel latency (input handling, hit
//! testing, render submission, display flip) is a product-layer obligation" —
//! so charging the SVG serializer and `resvg` against a core budget would be a
//! category error. They are measured and printed because the ruling asks for
//! the stages *separately*, and because today's scene-build+paint is the path
//! Ruling A demotes: the number is the baseline a canvas must beat, not a
//! budget to defend.
//!
//! ## The log must be shaped like a session's, or the reduce column lies
//!
//! `reduce`'s cost is dominated by ordering work over the log's **causal
//! edges**, so a log whose envelopes carry empty causal contexts measures a
//! different algorithm than the one production runs. The first version of this
//! bench made exactly that mistake and understated `reduce` by ~3× at depth
//! 10,000 (17 ms rather than 54 ms), which moved the reported wall by more than
//! a factor of two and would have mis-sequenced T4b. [`edit_log`] now
//! reproduces `EditorSession`'s minting shape: counters from 0, only the root
//! context empty, every later envelope carrying the head's context extended by
//! the head.
//!
//! ## What the scale points vary, and what they deliberately do not
//!
//! The variable is **log depth**: Fact 8's finding is that `apply` reduces the
//! *entire accumulated log* onto the pristine open-time base on every edit, so
//! the cost of one keystroke grows with the number of keystrokes before it.
//! The score stays a fixed 10-measure fixture, so the engrave column is held
//! still while the reduce column moves — which is what makes the two
//! attributable.
//!
//! **The score's *size* is held fixed; its *content* is not, and three columns
//! read that.** The edits are transpositions, so the reduced score differs from
//! the base by up to ±1 semitone per pitch, and a transposed pitch may acquire
//! an accidental. Because the edit log cycles the pitch list and alternates
//! direction per pass, the accidental count depends on the *parity of the pass
//! count* at that depth: depths 1,000 / 3,000 / 5,000 are 25 / 75 / 125 passes
//! (odd — every pitch sits one semitone off the base, most carrying an
//! accidental), while 10,000 is 250 (even — every pitch is back where it
//! started). That is why `paint` is **non-monotonic** in depth below: it sits
//! near 2.8 ms at every odd-parity depth and drops to 1.36 ms at 10,000, where
//! the score simply has less ink. `reduce` is the only column that tracks
//! depth; `engrave`, `scene-build`, and `paint` track score content. Reading
//! paint's dip at 10,000 as a scaling win would be a mistake.
//!
//! **Depth is per session, not per document.** `EditorSession::open` starts
//! with an empty `applied` log (`editor-core/src/lib.rs`), so reopening a saved
//! score resets the depth this bench varies: the reduced score becomes the new
//! pristine base. The wall below is therefore a budget on **one sitting**, not
//! on a document's lifetime — which is what keeps a four-figure number from
//! being catastrophic. It is still reachable: note entry mints one operation
//! per note.
//!
//! **The honest limitation:** no orchestral-scale score fixture exists in the
//! testkit (the largest are three staves × ten measures), so the engrave and
//! scene-build columns are measured at *small score size* and are lower
//! bounds. The spec's budget contemplates "a 100-page orchestral score". This
//! bench therefore cannot prove the budget holds at scale; it can only show
//! where the time goes at the scale we can build, and any row that already
//! misses at this size misses by more at a realistic one. A score-size axis
//! belongs with the engraving-quality track that needs large fixtures anyway.
//!
//! The edits are ±1-semitone `TransposeInterval`s cycled across the fixture's
//! pitches, alternating direction **per pass** (see [`edit_envelope`] for why
//! per-pass and not per-operation): distinct operation ids so the log genuinely
//! deepens, drift bounded to one semitone so nothing wanders out of range at
//! depth 10,000, and the same operation the edit-loop slice already uses
//! (`src/editloop.rs`).
//!
//! Run: `cargo bench -p epiphany-testkit --bench editor_pipeline`. Add
//! `--features golden-gate` for the scene-build and paint columns (they need
//! `epiphany-render-svg` and `resvg`, which are optional for the MSRV reason
//! documented in `Cargo.toml`); without it those columns print an explicit
//! skip, never a silent absence. `EPIPHANY_BENCH_QUICK=1` gives the reduced
//! PR-CI shape. Under `cargo test --benches` criterion runs each measurement
//! once and the gate is skipped, exactly as the sibling benches do.
use std::time::Duration;
use criterion::{BatchSize, BenchmarkId, Criterion, SamplingMode};
use epiphany_core::{OperationId, PitchId, ReplicaId, Score, TranspositionInterval, WallClockTime};
use epiphany_engrave::Engraver;
use epiphany_layout_ir::{
to_constrained, to_logical, to_render, ConstraintSolver, ResolvedLayoutIR, SolverConfig,
};
use epiphany_ops::{
AuthorId, CausalContext, HybridLogicalClock, OperationEnvelope, OperationKind,
OperationPayload, OperationSet, OperationStamp, TransposeIntervalOp,
};
use epiphany_testkit::budget::{self, Expectation};
use epiphany_testkit::fixtures;
/// `req:perf:single-system-edit-latency`: one frame at 60 Hz.
const FRAME_BUDGET: Duration = Duration::from_micros(16_700);
/// One documented scale point of THE STAGE TABLE below.
struct ScalePoint {
/// Log depth: how many edits precede the one being timed.
depth: usize,
/// The documented expectation for `edit/core/<depth>` against
/// [`FRAME_BUDGET`].
expectation: Expectation,
/// Budget-gate timed iterations (full mode, quick mode); `0` skips the row
/// in that mode, printed as an explicit skip.
gate_iters: (usize, usize),
/// Criterion measurement time (full mode), or `None` to leave the point
/// gate-only.
criterion_time: Option<Duration>,
}
/// THE STAGE TABLE. Budget: the core's portion (reduce + engrave) within
/// 16.7 ms, `req:perf:single-system-edit-latency`.
///
/// Measured, dev profile, 2026-07-28, `--features golden-gate`, on a
/// **session-shaped log** (see the module note — the first published table used
/// empty causal contexts and understated `reduce` by ~3× at depth 10,000):
///
/// | depth | construct | reduce | engrave | **core** | scene-build | paint | verdict |
/// |-------|-----------|--------|---------|----------|-------------|-------|---------|
/// | 100 | 40 ns | 223 µs | 268 µs | **491 µs** | 133 µs | 2.21 ms | Pass, 34× margin |
/// | 1,000 | 40 ns | 2.30 ms | 314 µs | **2.61 ms** | 165 µs | 2.86 ms | Pass, 6.4× margin |
/// | 3,000 | 40 ns | 8.79 ms | 327 µs | **9.11 ms** | 152 µs | 2.80 ms | Pass, 55% of budget |
/// | 5,000 | 40 ns | 17.53 ms | 317 µs | **17.84 ms** | 151 µs | 2.78 ms | **Xfail, 107%** |
/// | 10,000 | 40 ns | 54.07 ms | 260 µs | **54.33 ms** | 123 µs | 1.36 ms | Xfail, 3.3× over |
///
/// (Depth 4,000, measured clean but not gated — see the note on the scale
/// points: core **12.99 ms**, 78% of budget.)
///
/// What the table says, in the order it matters:
///
/// 1. **`reduce` is the only column that scales with depth, and it is
/// superlinear** — 10× the log costs ~23.5× the time between depths 1,000
/// and 10,000, roughly `O(n^1.4)`. It is 45% of the core's portion at depth
/// 100 and 99.5% at depth 10,000. (This does not contradict
/// `benches/reduction.rs`'s subquadratic result at 50K envelopes: that log
/// is generated across three replicas with a different causal shape. Two
/// logs of equal length are not equal work.)
/// 2. **The frame budget breaks between 3,000 and 5,000 edits** — 9.11 ms
/// (55%), 12.99 ms at 4,000 (78%), then 17.84 ms (107%). Call the wall
/// ~4,500 in one sitting, on a dev box rather than the reference hardware
/// profile and at median rather than the requirement's p99, so treat it as
/// an order of magnitude rather than a threshold.
/// 3. **`engrave` is flat and small** — 260327 µs at every depth, because the
/// score it engraves is the same size throughout. At depth 100 it is the
/// *larger* half of the core's portion, so criterion 2's "uninformative
/// while reduction dominates" holds only past roughly depth 500, not from
/// the start.
/// 4. **`paint` dominates early and is overtaken by depth ~1,000.** At depth
/// 100 it is 2.21 ms against a 491 µs core — 4.5×. By 1,000 they are level
/// (2.86 ms vs 2.61 ms). Past that the core runs away. The earlier claim
/// that the render path dominates "at realistic depths" holds only for the
/// first thousand-odd edits of a session.
/// 5. **Almost all of `scene-build` is the SVG serializer, not the IR work.**
/// The same rows *without* `golden-gate` — which drop the SVG string and
/// leave only `to_render` + `hit_test_map` — measure **35 µs**, against
/// 133165 µs with it. So the `RenderIR` and hit-test map cost a few
/// microseconds and serializing to SVG costs ~130 µs. Stating that plainly
/// matters because "scene-build 133 µs" invites attributing the cost to IR
/// construction, which is off by a factor of ~30.
/// 6. **What a direct-IR canvas avoids, with the denominator named.** It skips
/// the ~129 µs serialize and the 2.21 ms rasterize: 2.34 ms at depth 100.
/// That is **83% of the full measured per-edit pipeline** (2.83 ms) and
/// **99.8% of the render path alone** (2.34 ms). Both figures are worth
/// having and they answer different questions; an unqualified "98%" was
/// supported by neither.
///
/// **Sequencing, stated carefully.** T4 (the canvas) still comes first: it
/// removes the cost that dominates a session's first ~1,000 edits, and it is
/// the architecture every later tranche builds on. But T4b's trigger is much
/// nearer than the first version of this table suggested — ~4,500 edits in one
/// sitting, not ~10,000 — and the two are no longer comfortably separated.
///
/// A row that starts missing after being marked `Pass` is a fresh regression —
/// fix the pipeline, do not re-mark it `Xfail` without a written decision (the
/// discipline `benches/reduction.rs` records).
const SCALE_POINTS: &[ScalePoint] = &[
ScalePoint {
depth: 100,
expectation: Expectation::Pass,
gate_iters: (9, 5),
criterion_time: Some(Duration::from_secs(6)),
},
ScalePoint {
// Drafted `Xfail` on the assumption that Fact 8 would already bite
// here; it does not, and the gate's XPASS notice said so. Promoted on
// first measurement rather than left stale.
depth: 1_000,
expectation: Expectation::Pass,
gate_iters: (5, 3),
criterion_time: Some(Duration::from_secs(10)),
},
// 3,000 and 5,000 bracket the crossing. They exist because the first
// version of this bench put the wall at ~10,000 on a context-free log; with
// production-shaped contexts it arrives here instead, and a table that only
// sampled decades would have reported the wrong order of magnitude for the
// trigger T4b is sequenced against.
//
// The last `Pass` row is 3,000 rather than 4,000 deliberately. A clean run
// puts 4,000 at 12.99 ms — a real pass, but only 78% of budget, and a row
// that close flaps the moment the machine is doing anything else (a
// load-contaminated run measured it at 22.77 ms, *above* the 5,000 row,
// which is impossible clean). A `Pass` row that fails under load teaches
// people to ignore the gate. 4,000's clean number is kept as data in THE
// STAGE TABLE instead of as a gated row.
ScalePoint {
depth: 3_000,
expectation: Expectation::Pass,
gate_iters: (5, 3),
criterion_time: Some(Duration::from_secs(12)),
},
ScalePoint {
depth: 5_000,
expectation: Expectation::Xfail(
"Fact 8: `apply` re-reduces the whole log onto the pristine base on \
every edit, and each envelope's causal context makes that ordering \
work real, so one keystroke costs more than a frame from roughly \
this depth. T4b (checkpointed reduction + per-system re-engrave) \
owns the fix; engrave is NOT implicated, staying flat in the \
hundreds of microseconds at every depth",
),
gate_iters: (5, 3),
criterion_time: Some(Duration::from_secs(12)),
},
ScalePoint {
depth: 10_000,
expectation: Expectation::Xfail(
"Fact 8, well past the wall — see the 5,000 row. Kept as the \
order-of-magnitude datum, and gate-only because a single timed \
reduction here is tens of milliseconds",
),
gate_iters: (3, 0),
criterion_time: None,
},
];
/// The fixture every scale point edits: the QUICKSTART 10-measure single-staff
/// score (40 quarter notes), fixed so the engrave column is held still while
/// log depth moves.
fn base_score() -> Score {
fixtures::ten_measure_single_staff(0x0000_ED17_5EED)
}
/// Every pitch in the fixture, in arena order — the targets the edit log
/// cycles through.
fn pitches(score: &Score) -> Vec<PitchId> {
let mut out = Vec::new();
for event in score.events.iter() {
let mut ips = Vec::new();
event.collect_identified_pitches(&mut ips);
out.extend(ips.into_iter().map(|ip| ip.id));
}
out
}
/// One `TransposeInterval` envelope: `counter` gives it a distinct operation id
/// (so the log genuinely deepens rather than re-delivering one operation), and
/// `direction` alternates **per pass over the pitch list**, so a pitch edited
/// many times oscillates by one semitone instead of drifting.
///
/// The alternation must key on the pass, not on `counter`: with an even number
/// of target pitches, `counter % 2` is *constant for a given pitch*, so every
/// edit to it pushes the same way. That version of this fixture drifted each
/// pitch by ±25 semitones at depth 1,000 (and would have by ±250 at 10,000),
/// which silently inflated the engrave and paint columns with ledger lines and
/// accidentals — a score-content change masquerading as a log-depth cost.
fn edit_envelope(
counter: u64,
pitch: PitchId,
direction: i32,
causal_context: CausalContext,
) -> OperationEnvelope {
let id = OperationId::new(REPLICA, counter);
let chromatic_steps = direction;
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(
HybridLogicalClock::new(WallClockTime(counter as i64 + 1), 0),
id,
),
causal_context,
transaction: None,
payload: OperationPayload::Primitive(OperationKind::TransposeInterval(
TransposeIntervalOp {
targets: [pitch].into_iter().collect(),
interval: TranspositionInterval {
diatonic_steps: 0,
chromatic_steps,
},
},
)),
}
}
/// The replica the synthetic session authors as.
const REPLICA: ReplicaId = ReplicaId(1);
/// `EditorSession`'s `extend_context`, reproduced (`editor-core/src/lib.rs`):
/// a context grows by absorbing the head into its contiguous vector when the
/// head continues that replica's run, and by a dot otherwise. A single-replica
/// session with no undo always takes the contiguous branch.
fn extend_context(context: CausalContext, op: OperationId) -> CausalContext {
let continues = context
.vector
.get(&op.replica)
.map_or(op.counter == 0, |&high| op.counter == high + 1);
if continues {
context.with_seen(op.replica, op.counter)
} else {
context.with_dot(op)
}
}
/// A reproducible edit log of `depth` envelopes over `score`'s own pitches,
/// **shaped like a real `EditorSession` log**.
///
/// Two details are load-bearing, and the first version of this bench got both
/// wrong — with the empty-context version understating `reduce` by ~3x at depth
/// 10,000, because a context-free log gives the reducer no causal edges to
/// order and so skips most of `canonical_reduction_order`'s work:
///
/// * **Counters start at 0.** `EditorSession` mints with
/// `counter = self.authored.len()`, so the root op is counter 0 — which is
/// also what `extend_context` recognises as the start of a contiguous run.
/// * **Only the root context is empty.** Every later envelope carries
/// `active_prior_context()` — the head's own context extended by the head —
/// so it covers the whole active prefix. This is what makes two sequential
/// edits to one target read as intentional overwrites rather than concurrent
/// conflicts, and it is what the reducer's topological ordering consumes.
fn edit_log(score: &Score, depth: usize) -> Vec<OperationEnvelope> {
let targets = pitches(score);
assert!(
!targets.is_empty(),
"the fixture must carry pitches to transpose"
);
let mut log: Vec<OperationEnvelope> = Vec::with_capacity(depth);
let mut context = CausalContext::new();
for i in 0..depth {
let pass = i / targets.len();
let direction = if pass % 2 == 0 { 1 } else { -1 };
let envelope = edit_envelope(
i as u64,
targets[i % targets.len()],
direction,
context.clone(),
);
context = extend_context(context, envelope.id);
log.push(envelope);
}
log
}
/// **Stage 0 — envelope construction.** The requirement names it first
/// ("operation envelope construction, reduction, incremental layout"), so the
/// gated core includes it rather than treating it as setup: this builds the
/// *one new* envelope an edit mints, on top of a log already `depth` deep.
fn construct(targets: &[PitchId], depth: usize, context: &CausalContext) -> OperationEnvelope {
let pass = depth / targets.len();
let direction = if pass % 2 == 0 { 1 } else { -1 };
edit_envelope(
depth as u64,
targets[depth % targets.len()],
direction,
context.clone(),
)
}
/// **Stage 1 — reduce.** `EditorSession::materialize`'s first half: accept the
/// whole log into a fresh set and reduce it onto the pristine base. This is
/// the work Fact 8 identifies as growing with every keystroke.
fn reduce(base: &Score, log: Vec<OperationEnvelope>) -> Score {
let mut set = OperationSet::new();
for envelope in log {
set.accept(envelope);
}
set.reduce_onto(base).score
}
/// **Stage 2 — engrave.** `materialize`'s second half, up to the resolved
/// layout: project to logical, to constrained, and solve. `to_render` and the
/// hit-test map belong to scene-build below.
fn engrave(score: &Score, engraver: &Engraver) -> Option<ResolvedLayoutIR> {
let logical = to_logical(score);
let report = engraver.solve(&to_constrained(&logical), &SolverConfig::default());
report.status.is_renderable().then_some(report.layout)
}
/// **Stage 3 — scene-build.** The `RenderIR` and hit-test map every consumer
/// needs, plus (with `golden-gate`) the SVG string today's GUI feeds to the
/// rasterizer. Not in the core's budget; see the module note.
fn scene_build(resolved: &ResolvedLayoutIR) -> usize {
let render = to_render(resolved);
let map = render.hit_test_map();
#[cfg(feature = "golden-gate")]
{
let svg =
epiphany_render_svg::render(resolved, &epiphany_render_svg::RenderOptions::default());
map.regions.len() + svg.svg.len()
}
#[cfg(not(feature = "golden-gate"))]
{
map.regions.len()
}
}
/// **Stage 4 — paint.** The `usvg` parse + `resvg` rasterize the demo binary
/// performs per edit — the path Ruling A demotes to export. Not in the core's
/// budget, and measured as the baseline a canvas must beat.
#[cfg(feature = "golden-gate")]
fn paint(svg: &str) -> Option<usize> {
use resvg::tiny_skia;
use resvg::usvg;
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let size = tree.size().to_int_size();
let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?;
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
Some(pixmap.data().len())
}
/// The pre-built inputs for one scale point, produced **outside** every timed
/// section: the base score, the log, the already-reduced score (the engrave
/// stage's input), and its resolved layout (scene-build's input).
struct StageInputs {
base: Score,
log: Vec<OperationEnvelope>,
edited: Score,
resolved: ResolvedLayoutIR,
/// The construct stage's inputs: the fixture's pitch list and the causal
/// context the *next* edit would carry (the head's, extended by the head).
targets: Vec<PitchId>,
next_context: CausalContext,
#[cfg(feature = "golden-gate")]
svg: String,
}
fn stage_inputs(depth: usize, engraver: &Engraver) -> StageInputs {
let base = base_score();
let log = edit_log(&base, depth);
let edited = reduce(&base, log.clone());
let resolved = engrave(&edited, engraver).expect("the fixture engraves renderably");
let targets = pitches(&base);
let next_context = match log.last() {
None => CausalContext::new(),
Some(head) => extend_context(head.causal_context.clone(), head.id),
};
#[cfg(feature = "golden-gate")]
let svg =
epiphany_render_svg::render(&resolved, &epiphany_render_svg::RenderOptions::default()).svg;
StageInputs {
base,
log,
edited,
resolved,
targets,
next_context,
#[cfg(feature = "golden-gate")]
svg,
}
}
fn criterion_measurements(criterion: &mut Criterion, quick: bool) {
let engraver = Engraver::default();
let mut group = criterion.benchmark_group("editor_pipeline");
group.sampling_mode(SamplingMode::Flat);
group.sample_size(10);
for point in SCALE_POINTS {
let Some(time) = point.criterion_time else {
continue; // the deepest point is gate-only; see THE STAGE TABLE.
};
if quick && point.depth > 100 {
continue; // quick mode: the gate still measures the deeper points.
}
let inputs = stage_inputs(point.depth, &engraver);
group.measurement_time(if quick { Duration::from_secs(2) } else { time });
group.warm_up_time(Duration::from_millis(if quick { 500 } else { 1500 }));
group.bench_with_input(
BenchmarkId::new("construct", point.depth),
&inputs,
|b, inputs| b.iter(|| construct(&inputs.targets, point.depth, &inputs.next_context)),
);
group.bench_with_input(
BenchmarkId::new("reduce", point.depth),
&inputs,
|b, inputs| {
b.iter_batched(
|| inputs.log.clone(),
|log| reduce(&inputs.base, log),
BatchSize::PerIteration,
)
},
);
group.bench_with_input(
BenchmarkId::new("engrave", point.depth),
&inputs,
|b, inputs| b.iter(|| engrave(&inputs.edited, &engraver)),
);
group.bench_with_input(
BenchmarkId::new("scene_build", point.depth),
&inputs,
|b, inputs| b.iter(|| scene_build(&inputs.resolved)),
);
#[cfg(feature = "golden-gate")]
group.bench_with_input(
BenchmarkId::new("paint", point.depth),
&inputs,
|b, inputs| b.iter(|| paint(&inputs.svg)),
);
}
group.finish();
}
/// The budget-gate side. Gates the **core's portion** (reduce + engrave) against
/// `req:perf:single-system-edit-latency`; prints the product-layer stages as
/// attributed measurements with no budget attached.
fn budget_gate(quick: bool) -> Vec<budget::GateReport> {
let engraver = Engraver::default();
let mut reports = Vec::new();
println!("\n== staged edit latency (Ruling A criterion 2) ==");
for point in SCALE_POINTS {
let iters = if quick {
point.gate_iters.1
} else {
point.gate_iters.0
};
if iters == 0 {
println!(
"skip edit/core/{}: deepest scale point; full/nightly runs only \
(unset EPIPHANY_BENCH_QUICK)",
point.depth
);
continue;
}
let inputs = stage_inputs(point.depth, &engraver);
// Envelope construction is the requirement's first named component, so
// it is timed and summed rather than treated as setup — even though it
// is sub-microsecond and never moves the verdict, because a gate that
// silently drops a named component is a proxy, not the gate.
let construct_median = budget::median_time(
iters,
|| (),
|()| construct(&inputs.targets, point.depth, &inputs.next_context),
);
let reduce_median = budget::median_time(
iters,
|| inputs.log.clone(),
|log| reduce(&inputs.base, log),
);
let engrave_median =
budget::median_time(iters, || (), |()| engrave(&inputs.edited, &engraver));
let scene_median = budget::median_time(iters, || (), |()| scene_build(&inputs.resolved));
// The gated row: the core's portion, which is exactly what the
// requirement bounds — "operation envelope construction, reduction,
// incremental layout through ResolvedLayoutIR".
let core = construct_median + reduce_median + engrave_median;
println!(
"stage edit/{}: construct {:.2?} + reduce {:.2?} + engrave {:.2?} = core {:.2?}; \
scene-build {:.2?} (product layer, no core budget)",
point.depth, construct_median, reduce_median, engrave_median, core, scene_median
);
#[cfg(feature = "golden-gate")]
{
let paint_median = budget::median_time(iters, || (), |()| paint(&inputs.svg));
println!(
"stage edit/{}: paint {:.2?} (product layer, no core budget; the \
SVG-string path Ruling A demotes to export)",
point.depth, paint_median
);
}
#[cfg(not(feature = "golden-gate"))]
println!(
"skip edit/{}: scene-build's SVG column and the paint column need \
`--features golden-gate` (epiphany-render-svg + resvg)",
point.depth
);
reports.push(budget::latency_gate(
format!("edit/core/{}", point.depth),
core,
FRAME_BUDGET,
point.expectation,
));
}
reports
}
fn main() {
let bench_mode = std::env::args().any(|arg| arg == "--bench");
let quick = budget::quick_mode();
let mut criterion = Criterion::default().configure_from_args();
criterion_measurements(&mut criterion, quick);
criterion.final_summary();
if !bench_mode {
return;
}
if !budget::verdict(&budget_gate(quick)) {
std::process::exit(1);
}
}