Files
improvise/crates/improvise-core/examples/fold_bench.rs
T
Edward Langley 5fbcb1718a fit: one evaluator for the fold — which was also the stall
Review of #54 found the fold reintroducing exactly the divergence the ruling
exists to remove, and the same line turned out to be the 244 ms stall. One
defect, two symptoms.

`eval_expr_cached`'s `Expr::Ref` looks the value up in `formula_cache` at
`context.with(cat, name)`. Inside the fold `context` is a MEMBER-RECORD key
carrying the none_cat coordinates, while `formula_cache` is keyed by stems with
none_cats STRIPPED — so the lookup could never hit, fell through to
`aggregate_raw`, found no raw cell behind a formula target, and answered
Err("ref:…"). A routing formula over a derived measure gave Number(11.0)
uncached and Error("ref:Margin") cached, and the cached path is the one a pivot
grid runs whenever any category sits on Axis::None.

`eval_expr`/`eval_bool` are hoisted to module level and BOTH evaluators now hand
the fold the same record-level `eval_expr`. The two agree by construction rather
than by a test. Note the repair not taken: teaching the fold to hit the cache by
stripping none_cats off the member key returns the GROUP's value to every
member — aggregate-then-route through a cache lookup, and silent.

That same per-member `aggregate_raw` was one poorly-narrowed scan per member, an
O(n^2) shape. Measured with the new `examples/fold_bench.rs`, five runs a point,
at 18,050 cells: 51.1-53.3 ms before, 18.1-19.2 ms after, against 7.1-9.0 ms for
a linear formula. Scaling went from superlinear (45x cells, 85x time) to linear
(45x cells, 45x time). Debug 2.75 s -> 132.9-135.1 ms. The fixed point runs
exactly 2 passes, measured by counting, so hoisting the member set has a 2x
ceiling and is declined on improvise-9iyv — a memo keyed by less than the whole
`partial` is a second answer to what a cell depends on.

`Expr::Is` has an arm in each evaluator and the cached one was untested: the
earlier fault was injected into the shared `eval_is`, which both arms call.
Replacing the cached arm alone left the suite green. The first replacement test
ALSO stayed green, which is the interesting part — once the fold routes through
`eval_expr`, an `Is` inside a SUM never reaches the cached arm, so that arm is
reachable only by a bare `Is` outside an aggregate. That is what the new test
uses, and injecting into that arm alone now fails it.

`IS` gained a word boundary: `Region ISLAND` parsed as Is(Region, "LAND"),
silently wrong where it was a parse error. `is_expr` is compound-atomic with
`sp+` written out, because a negative lookahead in an ordinary rule tests the
character AFTER pest's auto-inserted whitespace and fails backwards. The
lookahead was then redundant and removed — deleting it left the suite green,
while relaxing `sp+` to `sp*` fails the test, so `sp+` is the mechanism. `WHERE`
has the same hole, is far less exposed, and is filed as improvise-r08u.

Also: `ambiguous.sort_unstable()` before `dedup`, which only removes consecutive
duplicates, so the refusal could name a column twice.

Every fault re-injected after the refactor: no-fold 10 failures, shared eval_is
5, record-level Is arm 4, cached Is arm 1, feature_roles order 2, context-vs-
partial 1, the new cached-fold divergence 1, `sp+` relaxed 1. Baseline 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:20:53 -07:00

82 lines
2.8 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 cost of a routing formula against a linear one, at `gotcha 41`'s own
//! scale. improvise-9iyv; the argument is `context/code/core.md` §13.
use std::time::Instant;
use improvise_core::formula::parse_formula;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::types::Model;
const MEASURE: &str = "_Measure";
/// `regions × tags` records carrying `Cost` and `Rev`, plus the formula under
/// test. Cost straddles the threshold so the routing really routes.
fn document(formula: &str, regions: usize, tags: usize) -> (Model, usize) {
let mut m = Model::new("bench");
for cat in ["Region", "Tag"] {
m.add_category(cat).expect("under the limit");
}
for r in 0..regions {
m.category_mut("Region")
.expect("just added")
.add_item(format!("r{r}"));
}
for t in 0..tags {
m.category_mut("Tag")
.expect("just added")
.add_item(format!("t{t}"));
}
let measure = m.category_mut(MEASURE).expect("_Measure always exists");
for name in ["Cost", "Rev"] {
measure.add_item(name);
}
let mut cells = 0;
for r in 0..regions {
for t in 0..tags {
for (name, value) in [("Cost", (t % 100) as f64), ("Rev", (t % 7) as f64)] {
m.set_cell(
CellKey::new(vec![
("Region".to_string(), format!("r{r}")),
("Tag".to_string(), format!("t{t}")),
(MEASURE.to_string(), name.to_string()),
]),
CellValue::Number(value),
);
cells += 1;
}
}
}
m.add_formula(parse_formula(formula, MEASURE).expect("the fixture parses"));
(m, cells)
}
/// Milliseconds for `runs` recomputes, as (best, worst) — a single figure has
/// misled this wave twice.
fn time(m: &mut Model, none_cats: &[String], runs: usize) -> (f64, f64) {
let mut best = f64::INFINITY;
let mut worst: f64 = 0.0;
for _ in 0..runs {
let t = Instant::now();
m.recompute_formulas(none_cats);
let ms = t.elapsed().as_secs_f64() * 1000.0;
best = best.min(ms);
worst = worst.max(ms);
}
(best, worst)
}
fn main() {
let none_cats = ["Tag".to_string()];
println!(
"{:>7} {:>26} {:>20}",
"cells", "routing SUM(IF(..))", "linear Rev-Cost"
);
for (regions, tags) in [(5, 40), (5, 200), (25, 361)] {
let (mut routing, cells) = document("Pred = SUM(IF(Cost < 50, 1, 10))", regions, tags);
let (mut linear, _) = document("Pred = Rev - Cost", regions, tags);
let (rb, rw) = time(&mut routing, &none_cats, 5);
let (lb, lw) = time(&mut linear, &none_cats, 5);
println!("{cells:>7} {rb:>11.1}-{rw:<13.1} {lb:>8.1}-{lw:<10.1}");
}
}