// runtime/elbench.el — growth-curve classifier and complexity gate. // // Given a geometric sweep of input sizes and the measurements taken at each, // classify the growth curve and decide whether it violates a declared bound. // // ── Why this exists ────────────────────────────────────────────────────────── // // Constant-factor regressions are annoying. Complexity regressions are outages. // An O(n) lookup inside an O(n) loop is invisible at n=100 in a unit test and // catastrophic at n=100000 in production. el #132 was exactly that: a strlen() // inside a per-character accessor, quadratic, shipped for months. // // ── THREE signals, not one ─────────────────────────────────────────────────── // // The gate fits time AND allocation-count AND allocation-bytes, and fails if // ANY of them exceeds its declared curve. This is not belt-and-braces; each // signal is blind to a real defect class the others catch: // // * A copy-on-write accumulator rebuilding its buffer allocates ONCE per // iteration — count is exactly linear — while bytes go quadratic. // Count alone passes it. // * el #132's strlen-per-character is pure CPU and allocates NOTHING. // Both allocation signals read FLAT. Only time catches it. // // The deterministic signals (count, bytes) are preferable where they apply: // no statistics, correct on the first run, machine-independent. They are // simply not sufficient. // // ── SCOPE LIMIT — read this before trusting a flat curve ───────────────────── // // The allocation counters track EL-LEVEL allocation only: strings, ElList and // ElMap bodies, their backing arrays, copy-on-write clones, and the realloc // growth path. malloc inside engram_*.c and inside libcurl is NOT counted. // // A flat allocation curve over a workload dominated by engram or HTTP calls is // therefore NOT evidence of anything. It means "no El-level allocation growth", // not "no allocation growth". Gate El-level complexity with this; do not read // third-party memory behaviour into it. // // ── Classification method ──────────────────────────────────────────────────── // // Sizes must form a geometric sweep (each n double the last). On such a sweep // the ratio between consecutive measurements IS the growth exponent, directly: // // O(1) -> 1.0 O(log n) -> ~1.1 O(n) -> 2.0 // O(n log n) -> ~2.2 O(n^2) -> 4.0 O(n^3) -> 8.0 // // DEVIATION FROM DESIGN.md 6.2, stated plainly: that section specified Google // Benchmark's one-parameter least-squares fit over candidate curves. This uses // consecutive ratios instead. The sweep is mandated geometric either way, and // on a geometric sweep ratios are directly interpretable and need no floating // point. The cost is weaker separation between O(n) and O(n log n), which is // reported honestly as an ambiguous band rather than guessed at. Least-squares // remains the better answer if that band ever needs to be resolved. // // All arithmetic is fixed-point, scaled by 1000 ("milli-ratio"), so a ratio of // 2.0 is 2000. El values are int64; this avoids float-in-list handling. // Curve identifiers. Ordered by growth — the ordering IS the comparison used // by the gate, so an index comparison decides "worse than declared". // 0 = O(1) 1 = O(log n) 2 = O(n) 3 = O(n log n) 4 = O(n^2) 5 = O(n^3) fn elb_curve_name(c: Int) -> String { if c == 0 { return "O(1)" } if c == 1 { return "O(log n)" } if c == 2 { return "O(n)" } if c == 3 { return "O(n log n)" } if c == 4 { return "O(n^2)" } if c == 5 { return "O(n^3)" } return "O(?)" } fn elb_curve_from_name(s: String) -> Int { if str_eq(s, "O(1)") { return 0 } if str_eq(s, "O(log n)") { return 1 } if str_eq(s, "O(n)") { return 2 } if str_eq(s, "O(n log n)") { return 3 } if str_eq(s, "O(n^2)") { return 4 } if str_eq(s, "O(n^3)") { return 5 } return -1 } // elb_classify_ratio — map a milli-ratio-per-doubling onto a curve. // // Bands are deliberately wide at the top (a quadratic measured at 3.4x is // still a quadratic) and deliberately overlap-averse at the bottom, where a // misclassification between O(1) and O(log n) matters least. fn elb_classify_ratio(milli: Int) -> Int { if milli < 1300 { return 0 } if milli < 1700 { return 1 } if milli < 2400 { return 2 } if milli < 3200 { return 3 } if milli < 6000 { return 4 } return 5 } // elb_ratio — milli-ratio between two consecutive measurements. // Returns -1 when the earlier measurement is zero (ratio undefined). fn elb_ratio(prev: Int, cur: Int) -> Int { if prev <= 0 { return -1 } return (cur * 1000) / prev } // ── The measurement floor ──────────────────────────────────────────────────── // // A benchmark whose largest measurement is at or near zero has not been // measured. Reporting it as O(1) would be a confident answer with nothing // behind it — the same failure as a test that never ran reporting pass, and // exactly what happened when clang closed a nested loop to a multiply and the // harness read 0 microseconds at every n. // // So: REFUSE. Never classify below the floor. fn elb_below_floor(vals: [Int], floor: Int) -> Bool { let n: Int = native_list_len(vals) let i: Int = 0 let mx: Int = 0 while i < n { let v: Int = native_list_get(vals, i) if v > mx { let mx = v } let i = i + 1 } if mx < floor { return true } return false } // elb_implausibly_flat — a measurement that does not move across a sweep whose // input grew by 8x or more is not a flat curve, it is a broken measurement. // Genuine O(1) work still shows noise; a hard-flat series means the work was // optimised away, the timer has insufficient resolution, or the benchmark body // never executed. fn elb_implausibly_flat(vals: [Int]) -> Bool { let n: Int = native_list_len(vals) if n < 3 { return false } let first: Int = native_list_get(vals, 0) let last: Int = native_list_get(vals, n - 1) if first == 0 { if last == 0 { return true } return false } let r: Int = (last * 1000) / first if r < 1100 { return true } return false } // elb_spread_ok — do the consecutive ratios agree with each other? // // This is the ratio-method analogue of a normalised-RMS threshold. If the // doublings disagree wildly the data is noise, a cache cliff, or a phase // change, and the honest report is INDETERMINATE rather than a classification. // Applies to the ASYMPTOTIC TAIL only — the last three ratios. // // The small-n end of any sweep is dominated by fixed overhead, cold caches and // branch predictors that have not warmed. Measured on a genuinely linear // character scan, the ratios ran 3.37, 2.92, 1.76, 1.65: the head looks // quadratic, the tail is the truth. Checking spread across the whole sweep // therefore rejects correct data. A complexity bound is an asymptotic claim, so // it is judged on the asymptotic region — the same reason a benchmark harness // discards warmup rather than averaging it in. fn elb_spread_ok(ratios: [Int]) -> Bool { let total: Int = native_list_len(ratios) if total < 2 { return true } let start: Int = total - 3 if start < 0 { let start = 0 } let n: Int = total let lo: Int = 999999 let hi: Int = 0 let i: Int = start while i < n { let r: Int = native_list_get(ratios, i) if r >= 0 { if r < lo { let lo = r } if r > hi { let hi = r } } let i = i + 1 } if lo <= 0 { return false } // Reject when the widest ratio is more than 2.2x the narrowest. That is // enough slack for real timing noise and tight enough to separate a clean // 2.0 series from a clean 4.0 series. if (hi * 1000) / lo > 2200 { return false } return true } // elb_ratios — consecutive milli-ratios across the sweep. fn elb_ratios(vals: [Int]) -> [Int] { let out: [Int] = native_list_empty() let n: Int = native_list_len(vals) let i: Int = 1 while i < n { let out = native_list_append(out, elb_ratio(native_list_get(vals, i - 1), native_list_get(vals, i))) let i = i + 1 } return out } // elb_mean_tail_ratio — mean of the LAST TWO ratios. // // The tail is used deliberately: asymptotic behaviour is what a complexity // bound claims, and the small-n end of any sweep is dominated by fixed // overhead. This is the same reason a benchmark harness discards warmup. fn elb_mean_tail_ratio(ratios: [Int]) -> Int { let n: Int = native_list_len(ratios) if n == 0 { return -1 } if n == 1 { return native_list_get(ratios, 0) } let a: Int = native_list_get(ratios, n - 1) let b: Int = native_list_get(ratios, n - 2) if a < 0 { return b } if b < 0 { return a } return (a + b) / 2 } // ── Verdicts ───────────────────────────────────────────────────────────────── // // 0 PASS measured curve is at or below the declared bound // 1 FAIL measured curve is strictly worse than declared // 2 INDETERMINATE ratios disagree; data is noise or a phase change // 3 REFUSED below the measurement floor, or implausibly flat // 4 BETTER measured strictly better than declared (warn, not fail) fn elb_verdict_name(v: Int) -> String { if v == 0 { return "PASS" } if v == 1 { return "FAIL" } if v == 2 { return "INDETERMINATE" } if v == 3 { return "REFUSED" } if v == 4 { return "BETTER" } return "?" } // elb_gate — classify one signal against its declared bound. // // vals measurements, one per sweep point, in sweep order // expect declared curve index (see elb_curve_name) // floor minimum largest-measurement below which we refuse to classify fn elb_gate(vals: [Int], expect: Int, floor: Int) -> Int { if elb_below_floor(vals, floor) { return 3 } if elb_implausibly_flat(vals) { return 3 } let ratios: [Int] = elb_ratios(vals) if !elb_spread_ok(ratios) { return 2 } let m: Int = elb_mean_tail_ratio(ratios) if m < 0 { return 2 } let got: Int = elb_classify_ratio(m) if got > expect { return 1 } if got < expect { return 4 } return 0 } // elb_measured_curve — the classified curve for a signal, or -1 if unclassifiable. fn elb_measured_curve(vals: [Int], floor: Int) -> Int { if elb_below_floor(vals, floor) { return -1 } if elb_implausibly_flat(vals) { return -1 } let ratios: [Int] = elb_ratios(vals) let m: Int = elb_mean_tail_ratio(ratios) if m < 0 { return -1 } return elb_classify_ratio(m) }