5fbcb1718a
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>
82 lines
2.8 KiB
Rust
82 lines
2.8 KiB
Rust
//! 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}");
|
||
}
|
||
}
|