Files
Martin Vogel 0cdcf9a442 test(scale): calibrate the py-LSP quadratic detector to detect quadratic (#1527)
The detector's 100x bound blocked the v0.10.1 release twice on macos-15-intel
with 101.1x — and control runs on a quiet arm64 host measure the SAME curve
as 57-68x, deterministically. The resolve path has never been linear for this
fixture shape: per-function cost grows 0.5ms -> 1.7ms from 100 to 2000
functions, the audited short-name-lookup/no-negative-memo superlinearity now
tracked as #1527. A bound calibrated as "linear plus generous overhead" on a
curve that was never linear measures host constants, not complexity.

The bound moves to 200x — mid-way in log space between the known ~60-100x
curve and clear quadratic near 400x — so a real quadratic regression still
fails by 2x margin while known behavior stops producing release-blocking
false alarms on slower hosts. The measured table is pinned in the test
comment; when #1527 fixes the curve, the bound comes back down to ~40x.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-11 05:30:17 +02:00

117 lines
4.5 KiB
C

/*
* test_py_lsp_scale.c — measure scaling behavior at 100 / 500 / 2000
* classes-and-calls. Asserts that doubling the input doesn't more than
* 4x the runtime (catches accidental O(n^2) in the resolver).
*/
#include "test_framework.h"
#include "cbm.h"
#include "lsp/py_lsp.h"
#include <time.h>
static double elapsed_ms(struct timespec t0, struct timespec t1) {
double s = (double)(t1.tv_sec - t0.tv_sec);
double ns = (double)(t1.tv_nsec - t0.tv_nsec);
return s * 1000.0 + ns / 1000000.0;
}
/* Build N synthetic class/call pairs into an arena-backed buffer. */
static char *build_fixture(int n_classes, int *out_len) {
/* Per class: ~140 chars (5-line def). Per call: ~50 chars. Overhead
* for the class number digits scales with log10(n) but the constant
* 256 covers up to 9-digit indices comfortably. */
int approx = n_classes * 256 + 1024;
char *buf = (char *)malloc((size_t)approx);
if (!buf)
return NULL;
int pos = 0;
pos += snprintf(buf + pos, (size_t)(approx - pos), "from typing import Self\n");
for (int i = 0; i < n_classes; i++) {
int n = snprintf(buf + pos, (size_t)(approx - pos),
"class Cls%d:\n"
" def method(self) -> int:\n"
" return %d\n"
" def chain(self) -> Self:\n"
" return self\n",
i, i);
if (n < 0 || pos + n >= approx)
break;
pos += n;
}
int n = snprintf(buf + pos, (size_t)(approx - pos), "def use():\n");
pos += n;
for (int i = 0; i < n_classes; i++) {
int m = snprintf(buf + pos, (size_t)(approx - pos),
" Cls%d().chain().chain().method()\n", i);
if (m < 0 || pos + m >= approx)
break;
pos += m;
}
*out_len = pos;
return buf;
}
static double measure(int n_classes, int *out_calls, int *out_resolved) {
int slen = 0;
char *src = build_fixture(n_classes, &slen);
if (!src)
return -1.0;
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
CBMFileResult *r =
cbm_extract_file(src, slen, CBM_LANG_PYTHON, "test", "scale.py", 0, NULL, NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
double ms = elapsed_ms(t0, t1);
if (out_calls)
*out_calls = r ? r->calls.count : 0;
if (out_resolved)
*out_resolved = r ? r->resolved_calls.count : 0;
if (r)
cbm_free_result(r);
free(src);
return ms;
}
TEST(pylsp_scale_linear_growth) {
int c100 = 0, r100 = 0;
int c500 = 0, r500 = 0;
int c2000 = 0, r2000 = 0;
double t100 = measure(100, &c100, &r100);
double t500 = measure(500, &c500, &r500);
double t2000 = measure(2000, &c2000, &r2000);
printf(" scale: 100=%.1fms (calls=%d resolved=%d) 500=%.1fms (calls=%d resolved=%d) "
"2000=%.1fms (calls=%d resolved=%d)\n",
t100, c100, r100, t500, c500, r500, t2000, c2000, r2000);
/* Sanity: each scale produces roughly the same resolution ratio. */
double r_pct_100 = c100 ? (double)r100 / c100 : 0.0;
double r_pct_2000 = c2000 ? (double)r2000 / c2000 : 0.0;
ASSERT(r_pct_100 > 0.5);
ASSERT(r_pct_2000 > 0.5);
/* Quadratic-growth detector. 20x input: linear ~20-30x time, clear
* quadratic ~400x. The bound sits at 200x — mid-way in log space — so a
* real quadratic regression still fails by 2x while the CURRENT, KNOWN
* superlinear resolve curve does not produce false release blocks.
*
* The old bound of 100x was calibrated as "linear plus generous overhead",
* but the resolve path has never been linear here: measured 2026-08-11
* (sanitized builds, deterministic across runs) — quiet arm64 host
* 57-68x, macos-15-intel CI runner 101.1x, per-function cost growing
* 0.5ms -> 1.7ms from 100 to 2000 functions. That ~O(n^1.4-1.5) curve is
* the audited short-name-lookup/negative-memo gap, tracked as #1527; this
* detector was flagging host CONSTANTS, not a complexity change. When
* #1527 lands, tighten this back down (~40x holds linear honestly). */
if (t100 > 0.5) { // skip when t100 too small to compare reliably
double ratio = t2000 / t100;
printf(" scale ratio 2000/100: %.1fx (linear ~20x, known-superlinear ~60-100x, "
"quadratic ~400x)\n",
ratio);
ASSERT(ratio < 200.0); // flags clear quadratic; #1527 tracks the curve itself
}
PASS();
}
SUITE(py_lsp_scale) {
RUN_TEST(pylsp_scale_linear_growth);
}