From 1165663201403997dec25d378faa7e9baf6c716d Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:56:43 +0000 Subject: [PATCH 1/4] feat(lower): T2-5 static folds for builtin calls on constants repr/str/hash/len/chr/ord/int/bool/abs fold to const nodes when the argument is compile-time constant. eval folds only through ast.literal_eval with a scalar result; hash folds only on int/bool; chr only inside Latin-1; int of text keeps ps_int_from_bytes. --- src/pyscriptc/lower/_calls.py | 9 +- src/pyscriptc/restlower/__init__.py | 13 +- src/pyscriptc/restlower/_builtins.py | 13 ++ src/pyscriptc/restlower/_folds.py | 173 +++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src/pyscriptc/restlower/_folds.py diff --git a/src/pyscriptc/lower/_calls.py b/src/pyscriptc/lower/_calls.py index 15a87771..ecf6c70d 100644 --- a/src/pyscriptc/lower/_calls.py +++ b/src/pyscriptc/lower/_calls.py @@ -40,7 +40,7 @@ _subst_type, ) from pyscriptc.lower._stdlib_shared import _mod_call, _synth_module -from pyscriptc.restlower import _mk_iter, builtin_call +from pyscriptc.restlower import _mk_iter, _static_builtin_fold, builtin_call from pyscriptc.restlower import method_spec as synth_method from pyscriptc.stdlib_queue import MATH_FUNCTIONS @@ -77,6 +77,10 @@ def _call_name(self, node: ast.Call, span: dict[str, Any], expect: dict[str, Any return {"kind": "const", "value": special, "type": {"kind": "float"}, "span": span} self._reject_keywords(node) args = [self._expr(arg) for arg in node.args] + if len(args) == 1: + folded = _static_builtin_fold(name, args[0], span) + if folded is not None: + return folded spec = builtin_spec(name, [arg["type"] for arg in args]) if spec is None: if name in {"str", "len"} and len(args) == 1 and args[0]["type"]["kind"] == "class": @@ -1490,6 +1494,9 @@ def _bool_call(self, node: ast.Call, span: dict[str, Any]) -> dict[str, Any]: if len(node.args) != 1: self.fail(node, "PS2003", "bool expects 1 argument(s)") arg = self._expr(node.args[0]) + folded = _static_builtin_fold("bool", arg, span) + if folded is not None: + return folded kind = arg["type"]["kind"] if kind not in {"bool", "int", "float", "str", "bytes", "list", "dict"}: self.fail(node, "PS2002", f"bool() of {kind} is not lowered") diff --git a/src/pyscriptc/restlower/__init__.py b/src/pyscriptc/restlower/__init__.py index 65c7aff9..2e9b4e82 100644 --- a/src/pyscriptc/restlower/__init__.py +++ b/src/pyscriptc/restlower/__init__.py @@ -31,6 +31,7 @@ from pyscriptc.restlower._csv import _csv_call from pyscriptc.restlower._ctypes import _sizeof from pyscriptc.restlower._datetime import _ints +from pyscriptc.restlower._folds import _eval_fold, _hash_fold, _static_builtin_fold from pyscriptc.restlower._functools import _reduce from pyscriptc.restlower._hmac import _hmac, _hmac_compare from pyscriptc.restlower._io import _input, _named_temp, _open, _print @@ -65,7 +66,15 @@ type_alias, ) -__all__ = ["_mk_iter", "alias_name", "builtin_call", "method_spec", "path_attr", "rest_call"] +__all__ = [ + "_mk_iter", + "_static_builtin_fold", + "alias_name", + "builtin_call", + "method_spec", + "path_attr", + "rest_call", +] # A registered handler takes the lowerer, the call, its span, the expected type # and the attribute name, whatever subset of those it actually needs. @@ -233,6 +242,8 @@ def rest_call( "range": lambda lo, node, span, name: _range_value(lo, node, span), "enumerate": lambda lo, node, span, name: _enumerate_value(lo, node, span), "zip": lambda lo, node, span, name: _zip_value(lo, node, span), + "eval": lambda lo, node, span, name: _eval_fold(lo, node, span), + "hash": lambda lo, node, span, name: _hash_fold(lo, node, span), } diff --git a/src/pyscriptc/restlower/_builtins.py b/src/pyscriptc/restlower/_builtins.py index 2c63269d..d0db820b 100644 --- a/src/pyscriptc/restlower/_builtins.py +++ b/src/pyscriptc/restlower/_builtins.py @@ -5,6 +5,7 @@ import ast from typing import Any +from pyscriptc.restlower._folds import _static_builtin_fold from pyscriptc.restlower._shared import _pin, _span_to @@ -53,6 +54,9 @@ def _abs(lo: Any, node: ast.Call, span: dict[str, Any]) -> dict[str, Any]: if len(node.args) != 1: lo.fail(node, "PS2003", "abs expects 1 argument(s)") arg = lo._expr(node.args[0]) + folded = _static_builtin_fold("abs", arg, span) + if folded is not None: + return folded kind = arg["type"]["kind"] if kind == "int": return lo._abi_call("builtins", "abs", [arg], {"kind": "int"}, "ps_abs_i64", span) @@ -123,6 +127,9 @@ def _repr(lo: Any, node: ast.Call, span: dict[str, Any]) -> dict[str, Any]: if len(node.args) != 1: lo.fail(node, "PS2003", "repr expects 1 argument(s)") arg = lo._expr(node.args[0]) + folded = _static_builtin_fold("repr", arg, span) + if folded is not None: + return folded kind = arg["type"]["kind"] style = "percent" if kind == "dict": @@ -147,6 +154,9 @@ def _ord(lo: Any, node: ast.Call, span: dict[str, Any]) -> dict[str, Any]: if len(node.args) != 1: lo.fail(node, "PS2003", "ord expects 1 argument(s)") arg = lo._expr(node.args[0]) + folded = _static_builtin_fold("ord", arg, span) + if folded is not None: + return folded if arg["type"]["kind"] != "str": lo.fail(node, "PS1003", "ord argument is str") return lo._abi_call("builtins", "ord", [arg], {"kind": "int"}, "ps_ord", span) @@ -156,6 +166,9 @@ def _chr(lo: Any, node: ast.Call, span: dict[str, Any]) -> dict[str, Any]: if len(node.args) != 1: lo.fail(node, "PS2003", "chr expects 1 argument(s)") arg = lo._expr(node.args[0]) + folded = _static_builtin_fold("chr", arg, span) + if folded is not None: + return folded if arg["type"]["kind"] != "int": lo.fail(node, "PS1003", "chr argument is int") return lo._abi_call("builtins", "chr", [arg], {"kind": "str"}, "ps_chr", span) diff --git a/src/pyscriptc/restlower/_folds.py b/src/pyscriptc/restlower/_folds.py new file mode 100644 index 00000000..f03b9282 --- /dev/null +++ b/src/pyscriptc/restlower/_folds.py @@ -0,0 +1,173 @@ +# T2-5 static-fold boundary: `eval()` folds only through +# `ast.literal_eval` with a scalar result; arbitrary eval/exec stay fenced +# (tier-3 dynamic). `hash()` folds only on int/bool — str/bytes +# hashing is seed-randomized in CPython. `chr` folds only inside the +# Latin-1 literal range (PS2005 boundary); `ord` only on single chars. +# `int` of str/bytes consts keeps the `ps_int_from_bytes` path so the +# subset's i64 fence and underscore rejection survive. `len` of a +# list/dict literal folds only when every element is itself a const (a +# call element's side effect must survive). + +from __future__ import annotations + +import ast +from typing import Any + +from pyscriptc.restlower._shared import _const_int, _const_text + +_STATIC_MISSING = object() + + +def _static_scalar(expr: dict[str, Any]) -> Any: + """The Python value of a folded scalar const, or _STATIC_MISSING. + + Covers the shapes the emitter treats as constants: scalar const nodes + and unary +/- wrapped around a numeric const. Collection literals stay + missing — `_static_builtin_fold` handles them only where the element + values do not matter (`len`). + """ + + if expr.get("kind") == "unary" and expr.get("op") in {"-", "+"} and isinstance(expr.get("operand"), dict): + inner = _static_scalar(expr["operand"]) + if isinstance(inner, bool) or not isinstance(inner, (int, float)): + return _STATIC_MISSING + return -inner if expr["op"] == "-" else inner + if expr.get("kind") != "const": + return _STATIC_MISSING + kind = expr.get("type", {}).get("kind") + value = expr.get("value") + if kind == "int" and isinstance(value, str): + body = value[1:] if value.startswith("-") else value + if body and body.isdigit(): + return int(value) + return _STATIC_MISSING + if kind == "float" and isinstance(value, str): + try: + return float(value) + except ValueError: + return _STATIC_MISSING + if kind == "str" and isinstance(value, str): + return value + if kind == "bytes" and isinstance(value, str): + return value.encode("latin1") + if kind == "bool": + return bool(value) + if kind == "none": + return None + return _STATIC_MISSING + + +def _static_const(value: Any, span: dict[str, Any]) -> dict[str, Any] | None: + """const node for a Python scalar the subset emits, or None when the + value has no literal spelling here (non-Latin-1 str, container, ...).""" + + if isinstance(value, bool): + return {"kind": "const", "value": value, "type": {"kind": "bool"}, "span": span} + if isinstance(value, int): + return _const_int(value, span) + if isinstance(value, float): + return {"kind": "const", "value": repr(value), "type": {"kind": "float"}, "span": span} + if isinstance(value, str): + if any(ord(ch) > 255 for ch in value): + return None + return _const_text(value, "str", span) + if isinstance(value, bytes): + return _const_text(value.decode("latin1"), "bytes", span) + if value is None: + return {"kind": "const", "value": None, "type": {"kind": "none"}, "span": span} + return None + + +def _static_builtin_fold(name: str, arg: dict[str, Any], span: dict[str, Any]) -> dict[str, Any] | None: + """Tier-2 fold: `name()` becomes a const node when CPython's + result is fully determined at lowering. + + Anything the fold cannot reproduce exactly stays unfurled and the + caller keeps its usual path: `chr` folds only inside the Latin-1 + boundary, `ord` only on single chars, `hash` only on int/bool + (str/bytes hashing is seed-randomized in CPython and belongs to the + tier-3 dynamic tier), and `int`/`str`/`repr`/`abs`/`bool`/`len` only + when Python's conversion cannot raise. + """ + + value = _static_scalar(arg) + if value is _STATIC_MISSING: + if name == "len": + return _static_len(arg, span) + return None + result: Any = _STATIC_MISSING + try: + if name == "abs" and isinstance(value, (int, float, bool)): + result = abs(value) + elif name == "bool": + result = bool(value) + elif name == "int" and not isinstance(value, (str, bytes)): + # int of text stays on the ps_int_from_bytes path, which keeps the + # subset's i64 fence and underscore rejection — folding it through + # Python's int() would silently widen the surface. + result = int(value) + elif name == "str": + result = str(value) + elif name == "repr": + result = repr(value) + elif name == "len" and isinstance(value, (str, bytes)): + result = len(value) + elif name == "ord" and isinstance(value, str) and len(value) == 1: + result = ord(value) + elif name == "chr" and isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 255: + result = chr(value) + elif name == "hash" and isinstance(value, (int, bool)): + result = hash(value) + except Exception: # any conversion failure means the fold does not apply + return None + if result is _STATIC_MISSING: + return None + return _static_const(result, span) + + +def _static_len(arg: dict[str, Any], span: dict[str, Any]) -> dict[str, Any] | None: + """len() of a collection literal folds only when every element is itself + a const — anything else may be a call whose side effect is the point.""" + + if arg.get("kind") == "list" and not arg["type"].get("set"): + if all(elem.get("kind") == "const" for elem in arg["elems"]): + return _const_int(len(arg["elems"]), span) + return None + if arg.get("kind") == "dict": + if all(pair["key"].get("kind") == "const" and pair["value"].get("kind") == "const" for pair in arg["pairs"]): + return _const_int(len(arg["pairs"]), span) + return None + return None + + +def _eval_fold(lo: Any, node: ast.Call, span: dict[str, Any]) -> dict[str, Any] | None: + """`eval()`: only the degenerate literal case folds. + + The fold boundary is `ast.literal_eval` — literals and arithmetic on + numbers only, never arbitrary eval. Anything it cannot prove safe, and + any non-scalar result, falls through to the generic call fence; general + `eval`/`exec` stay tier-3 dynamic (CPython island), never tier-2. + """ + + if node.keywords or len(node.args) != 1: + return None + arg = lo._expr(node.args[0]) + text = _static_scalar(arg) + if not isinstance(text, str): + return None + try: + value = ast.literal_eval(text) + except Exception: # literal_eval declined: the source is not provably safe + return None + return _static_const(value, span) + + +def _hash_fold(lo: Any, node: ast.Call, span: dict[str, Any]) -> dict[str, Any] | None: + """`hash()` folds only for int/bool: CPython fixes those hashes, + while str/bytes/tuple hashing is seed-randomized or element-wise, so it + stays on the fenced path (tier-3 dynamic).""" + + if node.keywords or len(node.args) != 1: + return None + arg = lo._expr(node.args[0]) + return _static_builtin_fold("hash", arg, span) From 346e2b60d94ed9bc51d39f8ea3c884b56e609402 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:56:43 +0000 Subject: [PATCH 2/4] test(t2): static-fold coverage + module-cond len-of-const un-fence Oracle-diff vs CPython including repr edge chars; fence assertions for arbitrary eval/exec, non-scalar eval, and hash of text. module_cond: len(NAME) folding makes the annotated assignment a constant again. --- tests/test_module_cond.py | 28 +++-- tests/test_t2_static_folds.py | 199 ++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 tests/test_t2_static_folds.py diff --git a/tests/test_module_cond.py b/tests/test_module_cond.py index ae4d6040..5e414d62 100644 --- a/tests/test_module_cond.py +++ b/tests/test_module_cond.py @@ -51,18 +51,10 @@ def test_module_annotated_ifexp_lowers_in_script_entry(tmp_path: Path) -> None: def test_module_conditional_fence_is_clean(tmp_path: Path) -> None: cases = [ - ( - "test_call.py", - "X: int = 1\nY: int = 2\nNAME: str = 'ab'\na: int = X if len(NAME) else Y\n", - ), ( "nested_boolop.py", "X: int = 1\nY: int = 2\na: list[int] = [X or Y]\n", ), - ( - "boolop_call.py", - "X: int = 1\nNAME: str = 'ab'\na: int = len(NAME) or X\n", - ), ] for name, source in cases: path = write_case(tmp_path, name, source) @@ -70,3 +62,23 @@ def test_module_conditional_fence_is_clean(tmp_path: Path) -> None: # Exit 3 is a fence; a SpikeError would die with exit 1 and a traceback. assert got.returncode == 3, f"{name}\n{got.stderr}" assert "a module assignment must be a constant" in got.stderr + + +def test_module_conditional_len_of_const_folds(tmp_path: Path) -> None: + # T2-5: len() of a const folds at lowering, so the annotated module + # assignment is a constant again and the conditional/boolop un-fences. + cases = [ + ( + "test_call.py", + "X: int = 1\nY: int = 2\nNAME: str = 'ab'\na: int = X if len(NAME) else Y\n" + "def probe() -> int:\n return a\n", + ), + ( + "boolop_call.py", + "X: int = 1\nNAME: str = 'ab'\na: int = len(NAME) or X\ndef probe() -> int:\n return a\n", + ), + ] + for name, source in cases: + path = write_case(tmp_path, name, source) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / f"{name}.ll")) + assert got.returncode == 0, f"{name}\n{got.stderr}" diff --git a/tests/test_t2_static_folds.py b/tests/test_t2_static_folds.py new file mode 100644 index 00000000..7befc93f --- /dev/null +++ b/tests/test_t2_static_folds.py @@ -0,0 +1,199 @@ +"""T2-5 static folds: builtin calls on compile-time constants fold at lowering. + +`repr`/`str`/`hash`/`len`/`chr`/`ord`/`int`/`bool`/`abs` of a const argument +become const nodes; the oracle tests diff compiled stdout against CPython, +including repr edge characters. `eval`/`exec` stay fenced — except the +degenerate `eval()` where `ast.literal_eval` proves the source +safe and the result is a scalar (the tier-2 boundary: no arbitrary eval). +`hash` folds only on int/bool; str/bytes hashing is seed-randomized and stays +tier-3. `int` of a text const keeps the ps_int_from_bytes path (the subset's +i64 fence and underscore rejection would be bypassed by folding). Each fence +test asserts the compile still fails with a fence. +""" + +from __future__ import annotations + +from pathlib import Path + +from tests.test_gates import oracle, run_cli, write_case + +from pyscriptc.lower import lower_source + + +def _oracle_diff(tmp_path: Path, name: str, source: str, func: str = "probe") -> None: + path = write_case(tmp_path, name, source) + out = str(tmp_path / name.removesuffix(".py")) + got = run_cli("compile", str(path), "--run", "--emit", "bin", "--call", func, "-o", out) + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, func) + + +def _fences(source: str) -> list[dict[str, object]]: + return lower_source(source)["fences"] + + +def test_scalar_folds_match_cpython(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_scalars.py", + """ +def probe() -> int: + return ( + len("hello") + + ord("A") + + ord(chr(66)) + + abs(-3) + + abs(2 - 5) + + int("41") + + int(2.9) + + int(True) + + hash(7) + + hash(-9) + + hash(True) + + len([1, 2, 3]) + + len({"a": 1, "b": 2}) + ) +""", + ) + + +def test_repr_folds_edge_chars_match_cpython(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_repr.py", + """ +def probe() -> str: + parts: str = ( + repr("it's") + + "|" + + repr('say "hi"') + + "|" + + repr("a\\nb") + + "|" + + repr("tab\\tnewline\\n") + + "|" + + repr("café") + + "|" + + repr("trail\\\\") + ) + return parts + "|" + repr(b"\\x00\\xffA") + "|" + repr(42) + "|" + repr(1.5) + "|" + repr(True) +""", + ) + + +def test_str_and_const_names_match_cpython(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_str_names.py", + """ +LIMIT: int = 40 +WORD: str = "xy" + + +def probe() -> str: + return ( + str(42) + + str(LIMIT + 2) + + str(True) + + repr(WORD) + + str(len(WORD)) + + str(bool("")) + + str(bool("x")) + ) +""", + ) + + +def test_eval_literal_scalar_folds(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_eval.py", + """ +def probe() -> int: + return eval("40") + eval("-2") + eval("0x10") +""", + ) + + +def test_folded_ir_has_no_builtin_call() -> None: + mod = lower_source( + """ +def probe() -> int: + return len("abc") + ord("z") + abs(-2) + int(9.7) + hash(5) +""" + ) + assert mod["fences"] == [] + body = mod["functions"][0]["body"] + + found: list[dict[str, object]] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + if node.get("kind") == "call": + found.append(node) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(body) + assert found == [] + + +def test_eval_non_literal_stays_fenced() -> None: + fences = _fences( + """ +def probe() -> int: + return eval("x + 1") +""" + ) + assert len(fences) == 1 + assert fences[0]["code"] == "PS2002" + assert fences[0]["message"] == "call to eval is not lowered" + + +def test_eval_non_scalar_stays_fenced() -> None: + fences = _fences( + """ +def probe() -> int: + return len(eval("[1, 2]")) +""" + ) + assert len(fences) == 1 + assert fences[0]["message"] == "call to eval is not lowered" + + +def test_exec_stays_fenced() -> None: + fences = _fences( + """ +def probe() -> int: + exec("x = 1") + return 0 +""" + ) + assert len(fences) == 1 + assert fences[0]["message"] == "call to exec is not lowered" + + +def test_hash_str_stays_fenced() -> None: + fences = _fences( + """ +def probe() -> int: + return hash("seed") +""" + ) + assert len(fences) == 1 + assert fences[0]["message"] == "call to hash is not lowered" + + +def test_chr_outside_latin1_keeps_runtime_path() -> None: + # chr(256) is valid CPython but outside the str-const Latin-1 boundary, + # so the fold declines and the call lowers to ps_chr like before. + fences = _fences( + """ +def probe() -> str: + return chr(256) +""" + ) + assert fences == [] From 84ac1e80e5e8bf42542c279ae7221f6668a67ebb Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:56:43 +0000 Subject: [PATCH 3/4] docs(plan): record the T2-5 static-fold boundary --- .../plans/2026-10-18-tiered-lowering-and-typefacts.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.agents/plans/2026-10-18-tiered-lowering-and-typefacts.md b/.agents/plans/2026-10-18-tiered-lowering-and-typefacts.md index 9f830486..8780ca1f 100644 --- a/.agents/plans/2026-10-18-tiered-lowering-and-typefacts.md +++ b/.agents/plans/2026-10-18-tiered-lowering-and-typefacts.md @@ -53,6 +53,15 @@ consuming `sigilco/pyright`'s native binary in JSON/LSP mode as an external oracle — NOT a vendored analyzer (Option C is only justified if broad typing compat becomes the primary product goal). +### T2-5 boundary (landed) + +`repr`/`str`/`hash`/`len`/`chr`/`ord`/`int`/`bool`/`abs` fold on +compile-time-constant arguments (`_static_builtin_fold` in `restlower.py`). +The boundary is CPython reproducibility: `chr` folds only inside the +Latin-1 literal range, `hash` only on int/bool (str/bytes hashing is +seed-randomized → tier 3), and `eval` only through `ast.literal_eval` +with a scalar result — arbitrary `eval`/`exec` stay fenced (tier 3). + ## Sequencing - E-TF (facts seam): extract the TypeFacts/CallableFacts interface + mypy From 279086e4d513d874d03b954864281c4dc26426ce Mon Sep 17 00:00:00 2001 From: Quino Terrasa <26749475+espetro@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:55:20 +0200 Subject: [PATCH 4/4] test(census): re-pin self census for the restlower fold module --- tests/test_census_self.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_census_self.py b/tests/test_census_self.py index 84757402..017e088c 100644 --- a/tests/test_census_self.py +++ b/tests/test_census_self.py @@ -13,7 +13,9 @@ def test_self_census_ballpark() -> None: census = build_self_census(SRC) # doctor.py is the 28th module; log.py, telemetry.py, debug.py and rtcache.py each added a module. - assert census["modules"] == 48 + # T2-5 static folds: restlower/_folds.py adds a module (+15 fences of + # fold-boundary docstrings and helpers, self-host only). + assert census["modules"] == 49 # E1 items 2+7a: print support added 3 self-host fences, then the # list.append flip removed 20 fenced call sites; headroom 14k for wave-C lowers. # telemetry.py added 36 fences, log.py 20, debug.py 6 union-type @@ -36,7 +38,9 @@ def test_self_census_ballpark() -> None: # move from union-types (11 to 9) into optional-types (0 to 2, PS1102). # T2-3 parameter forms normalize into the named-parameter machinery (+1 # _classes fence for the kwarg-dict binding path). - assert census["fences"] == 1435 + # T2-5 static folds: restlower/_folds.py adds +15 fences (fold-boundary + # docstrings and helpers, self-host only). + assert census["fences"] == 1450 # 22,164 lines after the `--facts` lane landed in typefacts.py. assert 11_000 <= census["lines"] <= 25_000 - assert sum(p["fences"] for p in census["packages"]) == 1435 + assert sum(p["fences"] for p in census["packages"]) == 1450