Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .agents/plans/2026-10-18-tiered-lowering-and-typefacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/pyscriptc/lower/_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 12 additions & 1 deletion src/pyscriptc/restlower/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
}


Expand Down
13 changes: 13 additions & 0 deletions src/pyscriptc/restlower/_builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand All @@ -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)
Expand Down
173 changes: 173 additions & 0 deletions src/pyscriptc/restlower/_folds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# T2-5 static-fold boundary: `eval(<string-const>)` folds only through
# `ast.literal_eval` with a scalar result; arbitrary eval/exec stay fenced
# (tier-3 dynamic). `hash(<const>)` 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(<const>)` 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(<string-const>)`: 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(<const>)` 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)
10 changes: 7 additions & 3 deletions tests/test_census_self.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
28 changes: 20 additions & 8 deletions tests/test_module_cond.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,22 +51,34 @@ 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)
got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / f"{name}.ll"))
# 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}"
Loading
Loading