Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 56 additions & 28 deletions tests/test_ts_import_type_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,36 +295,64 @@ def test_ts_normalizer_scales_linearly_on_large_files():
each match by scanning every range made extraction quadratic, so `extract`
spun at 100% CPU in the regex/scan path and never finished.

Assert on scaling, not absolute wall-clock, so the test is not
machine-dependent: doubling the input must not roughly quadruple the time.
Measure CPU time (``process_time``), not wall-clock (``perf_counter``): under
a busy full-suite run the process is preempted, and wall-clock would count
time slices spent scheduled off-CPU — inflating the larger measurement and
flaking the ratio. CPU time counts only work actually done, so it isolates
the algorithmic scaling regardless of load.
Measure process CPU time so scheduler preemption is excluded, and suspend
cyclic GC inside each timed call so a sample cannot be charged for scanning
objects retained by unrelated tests. Distinct, fixed-width sources prevent a
future input cache from hiding cold work. Each source also contains an
import-type sentinel whose normalized result is checked after timing.

Compare the marginal cost from n to 2n with the cost from 2n to 4n. A fixed
per-call environmental cost cancels in the increments: linear work grows by
about 2x while the historical quadratic scan grows by about 4x.
"""
import gc
import time

def build(n: int) -> bytes:
lines = []
def build(n: int, sample: int) -> bytes:
lines = [f"load{sample:02d}<typeof import('./sentinel-{sample:02d}')>();\n"]
for i in range(n):
lines.append(f"const a{i} = fn<Foo{i}, Bar{i}>(x);")
lines.append(f"type T{i} = import('./m{i}').Thing;")
return "\n".join(lines).encode()

def timed(n: int) -> float:
source = build(n)
start = time.process_time()
_normalize_ts_import_types(source)
return time.process_time() - start

timed(200) # warm the grammar/parser import off the measured path
small = min(timed(1000) for _ in range(5))
large = min(timed(2000) for _ in range(5))

# Linear work doubles (~2x). Quadratic work quadruples (~4x). A generous
# 3x ceiling separates the two without being flaky under load.
assert large < small * 3, (
f"scaling looks super-linear: {small:.4f}s -> {large:.4f}s "
f"({large / small:.1f}x for 2x input)"
lines.append(
f"const a{sample:02d}_{i:05d} = fn<Foo{i:05d}, Bar{i:05d}>(x);\n"
)
lines.append(
f"type T{sample:02d}_{i:05d} = import('./m{sample:02d}_{i:05d}').Thing;\n"
)
return "".join(lines).encode()

def timed(n: int, sample: int) -> float:
source = build(n, sample)
gc_was_enabled = gc.isenabled()
if gc_was_enabled:
gc.disable()
try:
start = time.process_time()
normalized = _normalize_ts_import_types(source)
elapsed = time.process_time() - start
finally:
if gc_was_enabled:
gc.enable()

# Correctness checks deliberately sit outside the measured interval.
assert normalized is not None
assert f"import('./sentinel-{sample:02d}')".encode() not in normalized
assert f"import('./m{sample:02d}_00000')".encode() in normalized
assert len(normalized) == len(source)
return elapsed

timed(200, 99) # warm the grammar/parser import off the measured path

def fastest(n: int) -> float:
return min(timed(n, sample) for sample in range(5))

small, medium, large = (fastest(n) for n in (2000, 4000, 8000))
first_increment = medium - small
second_increment = large - medium

assert first_increment > 0
# Linear increments double (~2x). Quadratic increments quadruple (~4x).
# The unchanged 3x ceiling separates the two.
assert second_increment < first_increment * 3, (
f"scaling increments look quadratic: {first_increment:.4f}s -> "
f"{second_increment:.4f}s ({second_increment / first_increment:.1f}x "
"for doubled input)"
)
49 changes: 49 additions & 0 deletions tests/test_ts_normalizer_scaling_measurement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Regression coverage for the TypeScript normalizer scaling benchmark."""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest


_COLLECTED_SUITE_CHECK = """
import contextlib
import io
import pytest
import runpy

with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
collection_status = pytest.main(["--collect-only", "-qq"])
if collection_status != pytest.ExitCode.OK:
raise SystemExit(collection_status)

test = runpy.run_path("tests/test_ts_import_type_arguments.py")[
"test_ts_normalizer_scales_linearly_on_large_files"
]
for _ in range(5):
test()
"""


def test_ts_normalizer_scaling_is_stable_after_suite_collection():
"""Suite imports must not turn linear normalization into a quadratic signal."""
timeout_seconds = 60
try:
result = subprocess.run(
[sys.executable, "-c", _COLLECTED_SUITE_CHECK],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
check=False,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as exc:
pytest.fail(
f"collected-suite normalizer scaling check exceeded {timeout_seconds} seconds: {exc}",
pytrace=False,
)

assert result.returncode == 0, result.stderr or result.stdout