The edit-latency bench measures the log a session actually writes
Review found the first version generating every envelope with an empty causal context. Production gives only the root that shape: each later edit carries active_prior_context(), the head's context extended by the head, so it covers the whole active prefix. Reduction orders on those edges, so a context-free log exercises a different algorithm -- and a cheaper one. Counters were wrong the same way: EditorSession mints at authored.len(), so the root is counter zero, which is also what extend_context recognises as the start of a contiguous run. Remeasured on a session-shaped log, reduce is roughly three times its former self at depth ten thousand -- 54 ms, not 17 -- and the wall moves from about ten thousand edits to between three and five thousand. That is the number T4b is sequenced against, so the first table would have mis-sequenced it. Depths three and five thousand now bracket the crossing; sampling only decades hid it. Two findings survive the correction and one is weakened. Reduce is still the only depth-scaling stage, and is superlinear at about n^1.4 -- which does not contradict the reduction bench's subquadratic result at fifty thousand envelopes, because that log is generated across three replicas with a different causal shape, and two logs of equal length are not equal work. Engrave is still flat, at 260 to 327 microseconds, and is still the larger half of the core's portion at shallow depth, so criterion 2's "uninformative while reduction dominates" holds only past roughly depth five hundred. But "render dominates at realistic depths" is now bounded: paint leads by four and a half times at depth one hundred, is level by one thousand, and is left behind after. T4 before T4b still stands -- the canvas removes what dominates a session's first thousand-odd edits -- but the two are no longer comfortably separated. The gate now includes envelope construction, which the requirement names first and the first version silently dropped. It is forty nanoseconds and never moves a verdict; a gate that omits a named component is a proxy for the requirement rather than the requirement. The 98% claim is replaced by both figures with their denominators named: what a direct-IR canvas avoids is 83% of the full measured per-edit pipeline, and 99.8% of the render path alone. The unqualified number was supported by neither. One row changed marking for a reason worth recording. Depth four thousand passes clean at 12.99 ms, but that is 78% of budget, and a load-contaminated run measured it at 22.77 ms -- above the five thousand row, which is impossible clean. A Pass row that fails whenever the machine is busy teaches people to ignore the gate, so the last gated Pass is three thousand and four thousand's clean number is kept as data in the table instead. Also recorded: depth is per session, not per document. EditorSession::open starts with an empty applied log, so reopening resets it and the reduced score becomes the new pristine base. That is what keeps a four-figure wall from being catastrophic -- though note entry mints one operation per note, so it is reachable in a sitting. Verified in an isolated worktree at HEAD rather than in the working tree, which currently carries the genesis tranche's in-flight G2 work: fmt clean, clippy 0 with and without golden-gate, workspace tests green, gate OK across all five rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz
This commit is contained in:
parent
4043627898
commit
986c9cc5a1
|
|
@ -25,19 +25,38 @@
|
|||
//!
|
||||
//! | 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 two are gated. `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.
|
||||
//! 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
|
||||
//!
|
||||
|
|
@ -48,18 +67,27 @@
|
|||
//! 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 one column
|
||||
//! reads 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: depth 1,000 is 25 passes (odd — every
|
||||
//! pitch sits one semitone off the base, most carrying an accidental), while
|
||||
//! depth 10,000 is 250 passes (even — every pitch is back where it started).
|
||||
//! That is why `paint` is **non-monotonic** in depth below (2.78 ms at 1,000,
|
||||
//! 1.32 ms at 10,000): the deeper 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 as a scaling win would be a mistake.
|
||||
//! **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
|
||||
|
|
@ -121,42 +149,64 @@ struct ScalePoint {
|
|||
/// 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`:
|
||||
/// 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 | reduce | engrave | **core** | scene-build | paint | verdict |
|
||||
/// |-------|--------|---------|----------|-------------|-------|---------|
|
||||
/// | 100 | 194 µs | 276 µs | **471 µs** | 135 µs | 2.12 ms | Pass, ~35x margin |
|
||||
/// | 1,000 | 1.74 ms | 311 µs | **2.06 ms** | 155 µs | 2.78 ms | Pass, ~8x margin |
|
||||
/// | 10,000 | 16.99 ms | 263 µs | **17.26 ms** | 123 µs | 1.32 ms | Xfail, 3% over |
|
||||
/// | 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 does so
|
||||
/// close to linearly (194 µs → 1.74 ms → 16.99 ms for 100× the log). It is
|
||||
/// 99% of the core's portion at depth 10,000 and 41% of it at depth 100.
|
||||
/// 2. **`engrave` is flat** — 263–311 µs regardless of depth, because the score
|
||||
/// it engraves is the same size at every point. At *shallow* depth it is the
|
||||
/// larger half of the core's portion, which qualifies Ruling A criterion 2's
|
||||
/// "uninformative while reduction dominates": reduction does not dominate
|
||||
/// until roughly depth 500.
|
||||
/// 3. **The budget breaks at ~10,000 edits in one session** — and only just
|
||||
/// (17.26 ms against 16.7 ms, on a dev box rather than the reference
|
||||
/// hardware profile, at median rather than the requirement's p99). Read it
|
||||
/// as "the wall is at this order of magnitude", not as a precise crossing.
|
||||
/// 4. **`paint` is the largest single cost at every realistic depth** — 2.12 ms
|
||||
/// at depth 100 is 4.5× the entire core portion. That is the SVG-string
|
||||
/// path Ruling A demotes to export, and it is measured here as the number a
|
||||
/// canvas has to beat.
|
||||
/// 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** — 260–327 µ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.**
|
||||
/// Running the same rows *without* `golden-gate` — which drops the SVG
|
||||
/// string and leaves only `to_render` + `hit_test_map` — gives **3.5 µs** at
|
||||
/// depth 100 against the 135 µs above. So building the `RenderIR` and the
|
||||
/// hit-test map costs ~3.5 µs and serializing it to SVG costs ~130 µs. A
|
||||
/// canvas that consumes the IR directly (Ruling A) skips the 130 µs *and*
|
||||
/// the 2.12 ms rasterize; together that is ~98% of today's per-edit cost at
|
||||
/// depth 100, none of it in the core. Worth stating plainly because
|
||||
/// "scene-build 135 µs" invites attributing the cost to IR construction,
|
||||
/// which is off by a factor of nearly 40.
|
||||
/// The same rows *without* `golden-gate` — which drop the SVG string and
|
||||
/// leave only `to_render` + `hit_test_map` — measure **3–5 µs**, against
|
||||
/// 133–165 µ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
|
||||
|
|
@ -170,22 +220,51 @@ const SCALE_POINTS: &[ScalePoint] = &[
|
|||
},
|
||||
ScalePoint {
|
||||
// Drafted `Xfail` on the assumption that Fact 8 would already bite
|
||||
// here; it does not, with ~8x margin, and the gate's XPASS notice said
|
||||
// so. Promoted on first measurement rather than left stale.
|
||||
// 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: `apply` re-reduces the whole log onto the pristine base on \
|
||||
every edit, so one keystroke costs a frame once the session is ~10k \
|
||||
edits deep (measured 17.26 ms against a 16.7 ms budget — a 3% miss, \
|
||||
so treat the depth as an order of magnitude, not a threshold). T4b \
|
||||
(checkpointed reduction + per-system re-engrave) owns the fix; \
|
||||
engrave is NOT implicated at 263 µs",
|
||||
"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,
|
||||
|
|
@ -222,8 +301,13 @@ fn pitches(score: &Score) -> Vec<PitchId> {
|
|||
/// 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) -> OperationEnvelope {
|
||||
let id = OperationId::new(ReplicaId(1), counter);
|
||||
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,
|
||||
|
|
@ -232,7 +316,7 @@ fn edit_envelope(counter: u64, pitch: PitchId, direction: i32) -> OperationEnvel
|
|||
HybridLogicalClock::new(WallClockTime(counter as i64 + 1), 0),
|
||||
id,
|
||||
),
|
||||
causal_context: CausalContext::new(),
|
||||
causal_context,
|
||||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::TransposeInterval(
|
||||
TransposeIntervalOp {
|
||||
|
|
@ -246,20 +330,77 @@ fn edit_envelope(counter: u64, pitch: PitchId, direction: i32) -> OperationEnvel
|
|||
}
|
||||
}
|
||||
|
||||
/// A reproducible edit log of `depth` envelopes over `score`'s own pitches.
|
||||
/// 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"
|
||||
);
|
||||
(0..depth)
|
||||
.map(|i| {
|
||||
let pass = i / targets.len();
|
||||
let direction = if pass % 2 == 0 { 1 } else { -1 };
|
||||
edit_envelope(i as u64 + 1, targets[i % targets.len()], direction)
|
||||
})
|
||||
.collect()
|
||||
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
|
||||
|
|
@ -327,6 +468,10 @@ struct StageInputs {
|
|||
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,
|
||||
}
|
||||
|
|
@ -336,6 +481,11 @@ fn stage_inputs(depth: usize, engraver: &Engraver) -> StageInputs {
|
|||
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;
|
||||
|
|
@ -344,6 +494,8 @@ fn stage_inputs(depth: usize, engraver: &Engraver) -> StageInputs {
|
|||
log,
|
||||
edited,
|
||||
resolved,
|
||||
targets,
|
||||
next_context,
|
||||
#[cfg(feature = "golden-gate")]
|
||||
svg,
|
||||
}
|
||||
|
|
@ -365,6 +517,11 @@ fn criterion_measurements(criterion: &mut Criterion, quick: bool) {
|
|||
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,
|
||||
|
|
@ -419,6 +576,15 @@ fn budget_gate(quick: bool) -> Vec<budget::GateReport> {
|
|||
}
|
||||
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(),
|
||||
|
|
@ -429,12 +595,13 @@ fn budget_gate(quick: bool) -> Vec<budget::GateReport> {
|
|||
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.
|
||||
let core = reduce_median + engrave_median;
|
||||
// requirement bounds — "operation envelope construction, reduction,
|
||||
// incremental layout through ResolvedLayoutIR".
|
||||
let core = construct_median + reduce_median + engrave_median;
|
||||
println!(
|
||||
"stage edit/{}: reduce {:.2?} + engrave {:.2?} = core {:.2?}; \
|
||||
"stage edit/{}: construct {:.2?} + reduce {:.2?} + engrave {:.2?} = core {:.2?}; \
|
||||
scene-build {:.2?} (product layer, no core budget)",
|
||||
point.depth, reduce_median, engrave_median, core, scene_median
|
||||
point.depth, construct_median, reduce_median, engrave_median, core, scene_median
|
||||
);
|
||||
#[cfg(feature = "golden-gate")]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -543,24 +543,37 @@ spike decides it, bounded by these recorded criteria:
|
|||
number is uninformative while reduction or solving dominates; the spike
|
||||
measures the stages the toolkit actually owns.
|
||||
**MEASURED 2026-07-28** — `epiphany-testkit/benches/editor_pipeline.rs`,
|
||||
gating the core's portion against `req:perf:single-system-edit-latency`
|
||||
(16.7 ms). At log depth 100 / 1,000 / 10,000 on the ten-measure fixture:
|
||||
reduce **194 µs / 1.74 ms / 16.99 ms**, engrave **276 / 311 / 263 µs**,
|
||||
scene-build **135 / 155 / 123 µs**, paint **2.12 / 2.78 / 1.32 ms**. Four
|
||||
consequences, all of which sharpen this criterion rather than satisfy it:
|
||||
(a) **reduce is the only stage that scales with log depth** and it breaks
|
||||
the frame budget at ~10,000 edits (17.26 ms core, a 3% miss — an order of
|
||||
magnitude, not a threshold); (b) **engrave is flat and small**, and at
|
||||
shallow depth is the *larger* half of the core's portion, so "uninformative
|
||||
while reduction dominates" holds only past roughly depth 500; (c) **paint is
|
||||
the largest single cost at every realistic depth** — 2.12 ms at depth 100 is
|
||||
4.5× the whole core portion; (d) **scene-build is 3.5 µs of IR work plus
|
||||
~130 µs of SVG serialization** (measured by dropping the `golden-gate`
|
||||
feature), so a canvas consuming the IR directly skips ~98% of today's
|
||||
per-edit cost, none of it in the core. **Sequencing consequence: T4 before
|
||||
T4b stands** — the dominant cost at the depths real sessions reach is the
|
||||
demoted render path, not reduction. T4b's trigger is a session ~10k edits
|
||||
deep, which the bench now watches for as an `Xfail` row.
|
||||
gating the core's portion (envelope construction + reduction + layout, all
|
||||
three the requirement names) against `req:perf:single-system-edit-latency`
|
||||
(16.7 ms), on a **session-shaped log** — counters from 0 and each envelope
|
||||
carrying `active_prior_context()`, because a context-free log measures a
|
||||
different algorithm and understated reduce by ~3× in this bench's first
|
||||
version. At depth 100 / 1,000 / 3,000 / 5,000 / 10,000 on the ten-measure
|
||||
fixture: reduce **223 µs / 2.30 ms / 8.79 ms / 17.53 ms / 54.07 ms**,
|
||||
engrave **268 / 314 / 327 / 317 / 260 µs**, scene-build **133 / 165 / 152 /
|
||||
151 / 123 µs**, paint **2.21 / 2.86 / 2.80 / 2.78 / 1.36 ms** (depth 4,000
|
||||
measured clean but ungated at 12.99 ms core — a `Pass` row at 78% of budget
|
||||
flaps under load). Five consequences, all of which sharpen this criterion
|
||||
rather than satisfy it: (a) **reduce is the only depth-scaling stage and is
|
||||
superlinear** (~`n^1.4`; 10× the log costs ~23.5× the time), breaking the
|
||||
frame **between 3,000 and 5,000 edits** — 9.11 ms then 17.84 ms core, so the
|
||||
wall is ~4,500; (b) **engrave is flat and small** (260–327 µs) and at depth
|
||||
100 is the *larger* half of the core's portion, so "uninformative while
|
||||
reduction dominates" holds only past roughly depth 500; (c) **paint
|
||||
dominates early and is overtaken by ~1,000 edits** — 4.5× the core at depth
|
||||
100, level at 1,000, far behind after; (d) **scene-build is 3–5 µs of IR
|
||||
work plus ~130 µs of SVG serialization**, so a direct-IR canvas avoids
|
||||
2.34 ms at depth 100 = **83% of the full per-edit pipeline, 99.8% of the
|
||||
render path alone** (both denominators stated; an unqualified "98%" was
|
||||
supported by neither); (e) **depth is per session, not per document** —
|
||||
`EditorSession::open` starts with an empty applied log — so the wall is a
|
||||
budget on one sitting, though note entry mints one operation per note.
|
||||
**Sequencing: T4 before T4b still stands** — the canvas removes what
|
||||
dominates a session's first ~1,000 edits and is the architecture later
|
||||
tranches build on — **but the two are no longer comfortably separated**, and
|
||||
T4b's trigger is ~4,500 edits in a sitting rather than the ~10,000 this
|
||||
bench's first, context-free version reported. The bench watches for it as an
|
||||
`Xfail` row at depth 5,000.
|
||||
3. **Text pipeline (hard criterion):** shaping, font fallback, bidi/complex
|
||||
scripts, and metrics consistent between interactive canvas, SVG/PDF
|
||||
export, hit testing, and the accessibility tree. A stack with no credible
|
||||
|
|
|
|||
Loading…
Reference in New Issue