From 4431486d81a11500a31cdebade784caee224bb88 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 23:03:12 -0700 Subject: [PATCH 01/15] Serve OP1's slots from DirectRNGStreams by default The sealed loop builds one ProjectionRNGRegistry per run and puts it on each period's context; its cost per person draw grows with the horizon. OP1's harness wrapper now gives every slot (the exit and birth adapters included) that context with one DirectRNGStreams of the same draw and horizon in its place, built at the first period, after checking that the loop's registry is the same object every period. The loop's positional module generators are untouched, and no sealed file changes. _project, run_production and run_gate take rng_streams ("direct" by default, or "registry" for differential checks); OpenPopulationResult records the mode and the class that served the draws. tests/open_population/test_stream_injection.py pins the coordinator's conditions: introspected interface parity; an alias-aware AST scan of src/populace_dynamics/ outside open_population/ for type checks on the registry, which skips the two closed modules by path before reading them and reports only path, line and node kind; a fresh-process check that a projection imports neither closed module; and projection-hash identity between the two classes over three sizes, three draws and two horizons (2027 and 2100), with a Hypothesis extension. The scan finds one existing sealed check, mortality_observer.py:381 (type(registry) is not ProjectionRNGRegistry, present since e4e512e1). It raises rather than diverging; the test pins it and two behaviour tests show the refusal, so an OP1 run composing that observer passes rng_streams="registry". Co-Authored-By: Claude Opus 5.5 --- .../open_population/gates.py | 10 +- .../open_population/harness.py | 119 +- .../open_population/test_stream_injection.py | 1536 +++++++++++++++++ 3 files changed, 1653 insertions(+), 12 deletions(-) create mode 100644 tests/open_population/test_stream_injection.py diff --git a/src/populace_dynamics/open_population/gates.py b/src/populace_dynamics/open_population/gates.py index 61332731..7f5172fe 100644 --- a/src/populace_dynamics/open_population/gates.py +++ b/src/populace_dynamics/open_population/gates.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: import pandas as pd @@ -57,8 +57,13 @@ def run_gate( emigration_hazard: EmigrationHazard | None = None, reserved_real_ids: Iterable[int] = (), metadata: Mapping[str, Any] | None = None, + rng_streams: Literal["registry", "direct"] = "direct", ) -> OpenPopulationResult: - """Run the fixed gate horizon through the OP1 accounting harness.""" + """Run the fixed gate horizon through the OP1 accounting harness. + + ``rng_streams="registry"`` is for differential checks; both classes give + the same projection (design §§2.1, 5.4). + """ if type(request) is not GateRunRequest: raise TypeError("run_gate requires a GateRunRequest") from populace_dynamics.open_population.errors import GateTargetRefused @@ -86,4 +91,5 @@ def run_gate( metadata=gate_metadata, kind="gate", scenario=None, + rng_streams=rng_streams, ) diff --git a/src/populace_dynamics/open_population/harness.py b/src/populace_dynamics/open_population/harness.py index 9d4ad577..b86a63d3 100644 --- a/src/populace_dynamics/open_population/harness.py +++ b/src/populace_dynamics/open_population/harness.py @@ -7,6 +7,13 @@ ``engine/accounting.py:1044–1086`` reports revaluations separately from summation residuals; OP1 refuses revaluation because its identity fixes weights. ``engine/accounting_history.py:172–187`` supplies the single-presence contract. + +The loop builds one registry per run (``engine/loop.py:216–217``) and puts it +on each period's context (``:287–294``). By default every slot here receives +that context with ``DirectRNGStreams`` in its place, so the adapters' person +and tagged draws avoid the registry's horizon-proportional spawn +(``engine/rng.py:61–67``) and stay identical (design §§2.1, 5.4). The module +generators the loop passes positionally stay on its registry. """ from __future__ import annotations @@ -37,6 +44,7 @@ ) from populace_dynamics.engine.loop import ( SCHEDULED_ENTRIES_KEY, + PeriodContext, PeriodModules, ProjectionEngine, ProjectionResult, @@ -56,10 +64,13 @@ from populace_dynamics.open_population.schedule import ( prepare_scheduled_entries, ) +from populace_dynamics.open_population.streams import DirectRNGStreams REGISTERED_PRODUCTION_HORIZONS = MappingProxyType( {"tr2026_intermediate": 2100} ) +RNG_STREAM_MODES = ("registry", "direct") +RNGStreamMode = Literal["registry", "direct"] def _registered_horizon(scenario: str) -> int: @@ -106,6 +117,8 @@ class OpenPopulationResult: horizon: int kind: Literal["production", "gate"] scenario: str | None + rng_streams: RNGStreamMode + rng_stream_class: str def read_prefix(self, end_year: int) -> ProjectionResult: """Copy a prefix of this run without scheduling or simulating again.""" @@ -155,6 +168,72 @@ def checked(frame, *args): return checked +class _AdapterStreams: + """Serve every slot's context draws from one stream class (§§2.1, 5.4). + + ``"direct"`` replaces the loop's registry on the context with one + ``DirectRNGStreams`` of the same draw and horizon, built at the first + period. ``"registry"`` passes the loop's context through unchanged. Both + refuse a run whose loop changes registry between periods, since the + direct instance is built once (``engine/loop.py:217``, ``:292``). + """ + + def __init__(self, mode: RNGStreamMode) -> None: + if type(mode) is not str or mode not in RNG_STREAM_MODES: + raise ValueError( + f"rng_streams must be one of {RNG_STREAM_MODES}, got {mode!r}" + ) + self.mode = mode + self.loop_registry = None + self.streams = None + self._bound: tuple[PeriodContext, PeriodContext] | None = None + + @property + def stream_class(self) -> str: + """Name the class that served the adapters' context draws.""" + if self.streams is None: + raise IdOrStreamCollision("no period bound the run's streams") + cls = type(self.streams) + return f"{cls.__module__}.{cls.__qualname__}" + + def bind(self, context: PeriodContext) -> PeriodContext: + """Return the context every slot of this period receives.""" + registry = context.rng_registry + if registry is None: + raise IdOrStreamCollision( + "OP1 slots need the projection's person-keyed RNG registry" + ) + if self.loop_registry is None: + self.loop_registry = registry + self.streams = ( + registry + if self.mode == "registry" + else DirectRNGStreams(registry.draw_index, registry.n_periods) + ) + elif registry is not self.loop_registry: + raise IdOrStreamCollision( + "the sealed loop changed its RNG registry between periods" + ) + if self.mode == "registry": + return context + # The loop gives all eight slots one context per period; so does this. + if self._bound is None or self._bound[0] is not context: + self._bound = ( + context, + replace(context, rng_registry=self.streams), + ) + return self._bound[1] + + def wrap(self, step: Callable) -> Callable: + """Call ``step`` with this period's bound context, other args as-is.""" + + @wraps(step) + def bound(frame, context, *args): + return step(frame, self.bind(context), *args) + + return bound + + def _project( initial_slice: pd.DataFrame, modules: PeriodModules, @@ -175,8 +254,14 @@ def _project( reserved_real_ids: Iterable[int] = (), kind: Literal["production", "gate"], scenario: str | None, + rng_streams: RNGStreamMode = "direct", ) -> OpenPopulationResult: - """Wrap the sealed loop and reconcile every transition (design §§5.4, 7).""" + """Wrap the sealed loop and reconcile every transition (design §§5.4, 7). + + ``rng_streams`` picks the class behind every slot's + ``context.rng_registry``; the projection is identical either way. + """ + adapter_streams = _AdapterStreams(rng_streams) require_emigration_compatible(modules.marital_core, emigration_hazard) ProjectionEngine._validate_slice(initial_slice, "initial_slice") # Run the initializer exactly once, before constructing allocator metadata. @@ -273,17 +358,22 @@ def close_period(frame, context, marital, rng): opening = transition.closing return closing + # Every slot, the exit and birth adapters included, reads its context + # through the run's stream binding; the loop's generators pass unchanged. + streamed = adapter_streams.wrap wrapped = replace( modules, initialize=None, - mortality=mortality, - aging=_fixed_stock(modules.aging), - marital_core=_fixed_stock(modules.marital_core, marital=True), - fertility=BirthEntryAdapter(modules.fertility, ledger), - disability=_fixed_stock(modules.disability), - earnings=_fixed_stock(modules.earnings), - claiming=_fixed_stock(modules.claiming), - household_composition=close_period, + mortality=streamed(mortality), + aging=streamed(_fixed_stock(modules.aging)), + marital_core=streamed( + _fixed_stock(modules.marital_core, marital=True) + ), + fertility=streamed(BirthEntryAdapter(modules.fertility, ledger)), + disability=streamed(_fixed_stock(modules.disability)), + earnings=streamed(_fixed_stock(modules.earnings)), + claiming=streamed(_fixed_stock(modules.claiming)), + household_composition=streamed(close_period), ) try: projection = ProjectionEngine(wrapped).project( @@ -306,6 +396,8 @@ def close_period(frame, context, marital, rng): horizon=end_year, kind=kind, scenario=scenario, + rng_streams=adapter_streams.mode, + rng_stream_class=adapter_streams.stream_class, ) @@ -313,9 +405,15 @@ def run_production( initial_slice: pd.DataFrame, modules: PeriodModules, request: ProductionRequest, + *, + rng_streams: RNGStreamMode = "direct", **kwargs: Any, ) -> OpenPopulationResult: - """Execute the registered production horizon through the sealed loop.""" + """Execute the registered production horizon through the sealed loop. + + ``rng_streams="registry"`` serves the adapters from the loop's own + registry, for differential checks; the default is identical and faster. + """ if type(request) is not ProductionRequest: raise TypeError("run_production requires a ProductionRequest") return _project( @@ -326,5 +424,6 @@ def run_production( start_year=request.start_year, kind="production", scenario=request.scenario, + rng_streams=rng_streams, **kwargs, ) diff --git a/tests/open_population/test_stream_injection.py b/tests/open_population/test_stream_injection.py new file mode 100644 index 00000000..60e9968f --- /dev/null +++ b/tests/open_population/test_stream_injection.py @@ -0,0 +1,1536 @@ +"""Check OP1's slots drawing from ``DirectRNGStreams`` (design §§2.1, 5.4). + +The coordinator's ruling of 24 October 2026 put the substitution in OP1's +harness wrapper: the sealed loop builds its registry (``engine/loop.py:217``) +and places it on each period's context (``:292``), and each OP1 slot receives +that context with ``DirectRNGStreams`` in its place. Duck typing is safe only +while the two classes share a public surface and no sealed code checks the +registry's type, so this module pins both, then checks the projection itself. + +The AST scan reads ``src/populace_dynamics/`` outside ``open_population/``. +The project's data-access record keeps two modules out of automated reads, so +the scan skips them by path before anything opens them; a fresh-process run +shows a projection imports neither. Every message names a module path, line +number and node kind only, never source text or literal values. +""" + +from __future__ import annotations + +import ast +import dataclasses +import hashlib +import inspect +import json +import os +import re +import subprocess +import sys +import textwrap +from collections.abc import Iterable +from dataclasses import dataclass, replace +from functools import partial +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from populace_dynamics.baselines.fertility import AsfrFertilityStep +from populace_dynamics.engine import loop as engine_loop +from populace_dynamics.engine import rng as engine_rng +from populace_dynamics.engine.loop import PeriodContext, PeriodModules +from populace_dynamics.engine.rng import ( + ProjectionModule, + ProjectionRNGRegistry, +) +from populace_dynamics.engine.steps import ( + AgeSexMortalityModel, + advance_age, + apply_mortality, +) +from populace_dynamics.mortality_observer import observe_mortality +from populace_dynamics.open_population import exits +from populace_dynamics.open_population.errors import IdOrStreamCollision +from populace_dynamics.open_population.exits import no_marital_transitions +from populace_dynamics.open_population.gates import GateRunRequest, run_gate +from populace_dynamics.open_population.harness import ( + RNG_STREAM_MODES, + ProductionRequest, + _AdapterStreams, + run_production, +) +from populace_dynamics.open_population.streams import DirectRNGStreams +from populace_dynamics.person_identity import PersonIdentity, PersonIdentityMap + +ROOT = Path(__file__).resolve().parents[2] +START = 2025 +DIRECT_CLASS = "populace_dynamics.open_population.streams.DirectRNGStreams" +REGISTRY_CLASS = "populace_dynamics.engine.rng.ProjectionRNGRegistry" + +# --------------------------------------------------------------------------- +# Condition 2, part 1: the two classes share the registry's public surface. +# --------------------------------------------------------------------------- + +EXPECTED_FIELDS = ("draw_index", "n_periods") +EXPECTED_MEMBERS = frozenset( + { + "draw_seed", + "seed_sequence", + "generator", + "child_generator", + "tagged_generator", + "tagged_child_generator", + "person_generator", + } +) + + +def _field_surface(cls): + """Describe each dataclass field as a caller sees it.""" + return tuple( + ( + field.name, + field.type, + field.default, + field.default_factory, + field.init, + field.kw_only, + ) + for field in dataclasses.fields(cls) + ) + + +def _member_surface(cls): + """Describe every public class member by kind and signature.""" + surface = {} + for name in dir(cls): + if name.startswith("_"): + continue + member = inspect.getattr_static(cls, name) + if isinstance(member, property): + surface[name] = ( + "property", + inspect.signature(member.fget), + member.fset is None, + member.fdel is None, + ) + elif isinstance(member, staticmethod | classmethod): + surface[name] = ( + type(member).__name__, + inspect.signature(member.__func__), + ) + elif inspect.isfunction(member): + surface[name] = ("method", inspect.signature(member)) + else: + surface[name] = ("attribute", type(member)) + return surface + + +def _dataclass_parameters(cls): + params = cls.__dataclass_params__ + return { + name: getattr(params, name) + for name in ("init", "repr", "eq", "order", "unsafe_hash", "frozen") + } + + +def test_direct_streams_share_the_registry_public_surface(): + """Every public registry member exists on the direct class (§2.1). + + The surface is derived by introspection, so a member added to the + registry fails here until the direct class has it with the same + signature. The pinned names make additions and removals loud as well. + """ + registry_fields = _field_surface(ProjectionRNGRegistry) + registry_members = _member_surface(ProjectionRNGRegistry) + assert tuple(field[0] for field in registry_fields) == EXPECTED_FIELDS + assert set(registry_members) == EXPECTED_MEMBERS + assert _field_surface(DirectRNGStreams) == registry_fields + direct_members = _member_surface(DirectRNGStreams) + for name, description in registry_members.items(): + assert name in direct_members, name + assert direct_members[name] == description, name + assert set(direct_members) == set(registry_members) + assert inspect.signature(DirectRNGStreams) == inspect.signature( + ProjectionRNGRegistry + ) + assert _dataclass_parameters(DirectRNGStreams) == _dataclass_parameters( + ProjectionRNGRegistry + ) + for streams in (ProjectionRNGRegistry(3, 7), DirectRNGStreams(3, 7)): + public = {name for name in vars(streams) if not name.startswith("_")} + assert public == set(EXPECTED_FIELDS) + + +def test_direct_streams_are_not_a_registry_subclass(): + """Condition 1: no registry subclass, and no sealed attribute patched.""" + assert ProjectionRNGRegistry not in DirectRNGStreams.__mro__ + assert DirectRNGStreams.__module__ == ( + "populace_dynamics.open_population.streams" + ) + + +# --------------------------------------------------------------------------- +# Condition 2, part 2: no sealed code checks the registry's type. +# --------------------------------------------------------------------------- + +REGISTRY_MODULE = "populace_dynamics.engine.rng" +REGISTRY_NAME = "ProjectionRNGRegistry" +OWN_PACKAGE = Path("src/populace_dynamics/open_population") +# The project's data-access record keeps these two modules out of automated +# reads. The scan skips them by path, before anything opens them; the +# fresh-process test below shows a projection imports neither. +CLOSED_MODULES = ( + Path("src/populace_dynamics/estimates/adjusted_poverty.py"), + Path("src/populace_dynamics/uniform_cut_track_u/rows.py"), +) +# Private names that exist on only one class: probing for one tells the +# classes apart without a type check. +PROBE_NAMES = frozenset( + name + for name in set(dir(ProjectionRNGRegistry)) ^ set(dir(DirectRNGStreams)) + if name.startswith("_") and not name.startswith("__") +) | {"_root"} +_TYPE_CHECK_CALLS = frozenset({"isinstance", "issubclass"}) +_DUNDER_CHECKS = frozenset({"__instancecheck__", "__subclasscheck__"}) +_COMPARISONS = (ast.Is, ast.IsNot, ast.Eq, ast.NotEq, ast.In, ast.NotIn) + + +class ScanError(AssertionError): + """A scanned module could not be read; the message carries no source.""" + + +@dataclass(frozen=True, order=True) +class ScanHit: + """One type check on the registry, located without its source text.""" + + path: str + line: int + kind: str + + def __str__(self) -> str: + return f"{self.path}:{self.line}: {self.kind}" + + +@dataclass(frozen=True) +class ScanSource: + """A module to scan: its dotted name, display path and text.""" + + module: str + path: str + text: str + is_package: bool = False + + +def describe(hits: Iterable[ScanHit]) -> str: + """Format hits for an assertion message: paths, lines and kinds only.""" + return "\n".join(str(hit) for hit in sorted(hits)) or "(no hits)" + + +def _call_name(func: ast.expr) -> str | None: + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + +def _is_type_call(node: ast.expr) -> bool: + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "type" + and len(node.args) == 1 + ) + + +class _ModuleBindings: + """Resolve, flow-insensitively, the names a module binds to the registry. + + Over-approximation is deliberate: any binding anywhere in the module + counts everywhere in it, and any name or attribute spelled exactly + ``ProjectionRNGRegistry`` counts, whatever it was imported from. + """ + + def __init__(self, source: ScanSource, exports: set[tuple[str, str]]): + self.source = source + self.exports = exports + self.package = ( + source.module + if source.is_package + else source.module.rpartition(".")[0] + ) + self.modules: dict[str, str] = {} + self.classes: set[str] = set() + + def _from_base(self, node: ast.ImportFrom) -> str: + if not node.level: + return node.module or "" + parts = self.package.split(".") + if node.level > 1: + parts = parts[: len(parts) - (node.level - 1)] + base = ".".join(parts) + return f"{base}.{node.module}" if node.module else base + + def module_path(self, node: ast.expr) -> str | None: + """Return the dotted module a name or attribute chain denotes.""" + if isinstance(node, ast.Name): + return self.modules.get(node.id) + if isinstance(node, ast.Attribute): + base = self.module_path(node.value) + return f"{base}.{node.attr}" if base else None + return None + + def is_registry(self, node: ast.expr) -> bool: + """Return whether ``node`` denotes the registry class itself.""" + if isinstance(node, ast.Name): + return node.id in self.classes or node.id == REGISTRY_NAME + if isinstance(node, ast.Attribute): + if node.attr == REGISTRY_NAME: + return True + if ( + node.attr == "__class__" + and isinstance(node.value, ast.Call) + and self.is_registry(node.value.func) + ): + return True + base = self.module_path(node.value) + return base is not None and (base, node.attr) in self.exports + if isinstance(node, ast.Call): + if _is_type_call(node) and isinstance(node.args[0], ast.Call): + return self.is_registry(node.args[0].func) + if ( + _call_name(node.func) == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + name = node.args[1].value + base = self.module_path(node.args[0]) + return name == REGISTRY_NAME or ( + base is not None and (base, name) in self.exports + ) + return False + + def contains_registry(self, node: ast.expr) -> bool: + """Return whether a class-info expression includes the registry.""" + if self.is_registry(node): + return True + if isinstance(node, ast.Tuple | ast.List | ast.Set): + return any(self.contains_registry(item) for item in node.elts) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return self.contains_registry(node.left) or self.contains_registry( + node.right + ) + if isinstance(node, ast.Starred): + return self.contains_registry(node.value) + return False + + def _bind(self, name: str, value: ast.expr) -> None: + if self.is_registry(value): + self.classes.add(name) + return + path = self.module_path(value) + if path is not None: + self.modules[name] = path + + def _bind_targets(self, target: ast.expr, value: ast.expr) -> None: + if isinstance(target, ast.Name): + self._bind(target.id, value) + elif ( + isinstance(target, ast.Tuple | ast.List) + and isinstance(value, ast.Tuple | ast.List) + and len(target.elts) == len(value.elts) + ): + for item, item_value in zip(target.elts, value.elts, strict=True): + self._bind_targets(item, item_value) + + def _visit(self, node: ast.AST) -> None: + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + self.modules[alias.asname] = alias.name + else: + top = alias.name.split(".")[0] + self.modules[top] = top + elif isinstance(node, ast.ImportFrom): + base = self._from_base(node) + for alias in node.names: + if alias.name == "*": + self.classes.update( + name for module, name in self.exports if module == base + ) + continue + local = alias.asname or alias.name + if ( + alias.name == REGISTRY_NAME + or (base, alias.name) in self.exports + ): + self.classes.add(local) + else: + self.modules[local] = f"{base}.{alias.name}" + elif isinstance(node, ast.Assign): + for target in node.targets: + self._bind_targets(target, node.value) + elif isinstance(node, ast.AnnAssign | ast.NamedExpr): + if node.value is not None: + self._bind_targets(node.target, node.value) + elif isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda + ): + arguments = node.args + positional = [*arguments.posonlyargs, *arguments.args] + defaults = list(arguments.defaults) + pairs = list( + zip( + positional[len(positional) - len(defaults) :], + defaults, + strict=True, + ) + ) + pairs += [ + (argument, default) + for argument, default in zip( + arguments.kwonlyargs, arguments.kw_defaults, strict=True + ) + if default is not None + ] + for argument, default in pairs: + if self.is_registry(default): + self.classes.add(argument.arg) + + def bind(self, tree: ast.Module) -> None: + """Bind to a fixed point, so chained aliases resolve in any order.""" + while True: + before = (dict(self.modules), set(self.classes)) + for node in ast.walk(tree): + self._visit(node) + if before == (self.modules, self.classes): + return + + def _kind(self, node: ast.AST) -> str | None: + if isinstance(node, ast.Call): + name = _call_name(node.func) + if ( + name in _TYPE_CHECK_CALLS + and len(node.args) >= 2 + and self.contains_registry(node.args[1]) + ): + return name + if ( + name in _DUNDER_CHECKS + and isinstance(node.func, ast.Attribute) + and self.is_registry(node.func.value) + ): + return "dunder-check" + if ( + name == "register" + and node.args + and self.contains_registry(node.args[0]) + ): + return "dispatch-register" + if ( + name in {"hasattr", "getattr"} + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value in PROBE_NAMES + ): + return "private-member-probe" + elif isinstance(node, ast.Compare) and isinstance( + node.ops[0], _COMPARISONS + ): + operands = [node.left, *node.comparators] + if any(self.contains_registry(item) for item in operands): + if any(_is_type_call(item) for item in operands): + return "type-compare" + if any( + isinstance(item, ast.Attribute) + and item.attr == "__class__" + for item in operands + ): + return "__class__-compare" + return "class-compare" + if any( + isinstance(item, ast.Constant) + and isinstance(item.value, str) + and REGISTRY_NAME in item.value + for item in operands + ): + return "name-compare" + elif isinstance(node, ast.MatchClass): + if self.is_registry(node.cls): + return "match-class" + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + registers = any( + isinstance(decorator, ast.Attribute) + and decorator.attr == "register" + for decorator in node.decorator_list + ) + positional = [*node.args.posonlyargs, *node.args.args] + if ( + registers + and positional + and positional[0].annotation is not None + and self.contains_registry(positional[0].annotation) + ): + return "dispatch-annotation" + return None + + def hits(self, tree: ast.Module) -> list[ScanHit]: + found = [] + for node in ast.walk(tree): + kind = self._kind(node) + if kind is not None: + found.append(ScanHit(self.source.path, node.lineno, kind)) + return found + + +def _parse(source: ScanSource) -> ast.Module: + try: + return ast.parse(source.text) + except (SyntaxError, ValueError) as error: + line = getattr(error, "lineno", None) + raise ScanError(f"{source.path}:{line}: unparsable source") from None + + +def scan_sources(sources: Iterable[ScanSource]) -> tuple[ScanHit, ...]: + """Find type checks on the registry across ``sources`` (condition 2). + + A module is parsed only if its text holds a tracked identifier as a + word: the registry's name, a name some scanned module re-exports it + under, or a probe name. Every static spelling of the class holds one, so + the filter skips only modules that cannot name it. Re-exports are + followed to a fixed point across modules. + """ + sources = tuple(sources) + exports = {(REGISTRY_MODULE, REGISTRY_NAME)} + parsed: dict[str, ast.Module] = {} + while True: + tokens = sorted({name for _, name in exports} | PROBE_NAMES) + pattern = re.compile( + r"(? str: + try: + return path.read_bytes().decode("utf-8") + except UnicodeDecodeError: + raise ScanError(f"{relative.as_posix()}: not UTF-8") from None + + +def repository_sources(root: Path = ROOT) -> tuple[ScanSource, ...]: + """Collect ``src/populace_dynamics/`` outside OP1's own package. + + ``__pycache__`` folders are pruned, and each closed module is dropped by + its path before anything opens, reads, parses, hashes or stats it. + """ + package = root / "src" / "populace_dynamics" + own = root / OWN_PACKAGE + closed = {root / path for path in CLOSED_MODULES} + sources = [] + for directory, subdirectories, files in os.walk(package): + current = Path(directory) + subdirectories[:] = sorted( + name + for name in subdirectories + if name != "__pycache__" and current / name != own + ) + for name in sorted(files): + path = current / name + if not name.endswith(".py") or path in closed: + continue + relative = path.relative_to(root) + parts = path.relative_to(root / "src").with_suffix("").parts + is_package = name == "__init__.py" + sources.append( + ScanSource( + module=".".join(parts[:-1] if is_package else parts), + path=relative.as_posix(), + text=_read(path, relative), + is_package=is_package, + ) + ) + return tuple(sources) + + +# The one type check the scan finds in master's sealed code. It refuses the +# direct class loudly instead of diverging (pinned by the behaviour tests +# below), so an OP1 run that composes it must pass rng_streams="registry". +KNOWN_SEALED_TYPE_CHECKS = frozenset( + { + ScanHit( + "src/populace_dynamics/mortality_observer.py", 381, "type-compare" + ) + } +) + + +def test_closed_module_exclusions_are_pinned_exactly(): + assert CLOSED_MODULES == ( + Path("src/populace_dynamics/estimates/adjusted_poverty.py"), + Path("src/populace_dynamics/uniform_cut_track_u/rows.py"), + ) + assert OWN_PACKAGE == Path("src/populace_dynamics/open_population") + + +def test_sealed_code_holds_only_the_known_registry_type_check(): + """No sealed module checks the registry's type, except the pinned one. + + A new check in any reachable sealed module fails this test, by path, + line and node kind, before the direct default could silently diverge. + """ + sources = repository_sources() + paths = {source.path for source in sources} + assert not paths & {path.as_posix() for path in CLOSED_MODULES} + assert not any(path.startswith(f"{OWN_PACKAGE}/") for path in paths) + assert "src/populace_dynamics/engine/rng.py" in paths + assert "src/populace_dynamics/baselines/asfr.py" in paths + hits = scan_sources(sources) + assert set(hits) == KNOWN_SEALED_TYPE_CHECKS, describe(hits) + + +_REGISTRY_IMPORT = ( + "from populace_dynamics.engine.rng import ProjectionRNGRegistry\n" +) +_IMPORT = "from populace_dynamics.engine.rng import ProjectionRNGRegistry" + + +def _check(expression, imports=_IMPORT): + """Wrap one expression as an invented module; mark the expected line.""" + return f"{imports}\ndef f(x):\n return {expression} # check\n" + + +# Each invented module marks the line the scan must report with "# check". +SPELLINGS = { + "isinstance": ( + _check("isinstance(x, ProjectionRNGRegistry)"), + "isinstance", + ), + "isinstance through an import alias": ( + _check("isinstance(x, X)", f"{_IMPORT} as X"), + "isinstance", + ), + "isinstance with a tuple": ( + _check("isinstance(x, (int, ProjectionRNGRegistry))"), + "isinstance", + ), + "isinstance with a union": ( + _check("isinstance(x, int | ProjectionRNGRegistry)"), + "isinstance", + ), + "builtins.isinstance": ( + _check( + "builtins.isinstance(x, ProjectionRNGRegistry)", + f"{_IMPORT}\nimport builtins", + ), + "isinstance", + ), + "issubclass": ( + _check("issubclass(type(x), ProjectionRNGRegistry)"), + "issubclass", + ), + "type is": (_check("type(x) is ProjectionRNGRegistry"), "type-compare"), + "type is not": ( + _check("type(x) is not ProjectionRNGRegistry"), + "type-compare", + ), + "type ==": (_check("type(x) == ProjectionRNGRegistry"), "type-compare"), + "type !=": (_check("type(x) != ProjectionRNGRegistry"), "type-compare"), + "type, reversed operands": ( + _check("ProjectionRNGRegistry is type(x)"), + "type-compare", + ), + "type in a tuple": ( + _check("type(x) in (ProjectionRNGRegistry, int)"), + "type-compare", + ), + "__class__ is": ( + _check("x.__class__ is ProjectionRNGRegistry"), + "__class__-compare", + ), + "__class__ ==": ( + _check("x.__class__ == ProjectionRNGRegistry"), + "__class__-compare", + ), + "__class__ !=": ( + _check("x.__class__ != ProjectionRNGRegistry"), + "__class__-compare", + ), + "module attribute": ( + _check( + "isinstance(x, rng.ProjectionRNGRegistry)", + "from populace_dynamics.engine import rng", + ), + "isinstance", + ), + "aliased module attribute": ( + _check( + "type(x) is streams.ProjectionRNGRegistry", + "from populace_dynamics.engine import rng as streams", + ), + "type-compare", + ), + "dotted module attribute": ( + textwrap.dedent(""" + import populace_dynamics.engine.rng + def f(x): + return isinstance( # check + x, populace_dynamics.engine.rng.ProjectionRNGRegistry + ) + """), + "isinstance", + ), + "aliased dotted module": ( + _check( + "x.__class__ == r.ProjectionRNGRegistry", + "import populace_dynamics.engine.rng as r", + ), + "__class__-compare", + ), + "relative import": ( + _check( + "isinstance(x, R)", "from .rng import ProjectionRNGRegistry as R" + ), + "isinstance", + ), + "chained local alias": ( + _check( + "isinstance(x, S)", f"{_IMPORT}\nS = R\nR = ProjectionRNGRegistry" + ), + "isinstance", + ), + "type of an instance": ( + _check( + "isinstance(x, T)", + f"{_IMPORT}\nT = type(ProjectionRNGRegistry(0, 0))", + ), + "isinstance", + ), + "getattr on the module": ( + _check( + "isinstance(x, getattr(rng, 'ProjectionRNGRegistry'))", + "from populace_dynamics.engine import rng", + ), + "isinstance", + ), + "class name comparison": ( + _check("type(x).__name__ == 'ProjectionRNGRegistry'", ""), + "name-compare", + ), + "private-member probe": ( + _check("hasattr(x, '_period_sequence')", ""), + "private-member-probe", + ), + "instance-check dunder": ( + _check("ProjectionRNGRegistry.__instancecheck__(x)"), + "dunder-check", + ), + "default argument": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x, cls=ProjectionRNGRegistry): + return isinstance(x, cls) # check + """), + "isinstance", + ), + "two-step type comparison": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x): + kind = type(x) + return kind is ProjectionRNGRegistry # check + """), + "class-compare", + ), + "match class pattern": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x): + match x: + case ProjectionRNGRegistry(): # check + return 1 + """), + "match-class", + ), + "single-dispatch registration": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @f.register(ProjectionRNGRegistry) # check + def _(x): + return 1 + """), + "dispatch-register", + ), + "single-dispatch annotation": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @f.register + def _(x: ProjectionRNGRegistry): # check + return 1 + """), + "dispatch-annotation", + ), +} + + +def _invented(text, module="populace_dynamics.engine.invented_case"): + return ScanSource(module, f"invented/{module.rpartition('.')[2]}.py", text) + + +@pytest.mark.parametrize("spelling", tuple(SPELLINGS)) +def test_scan_catches_every_spelling_of_a_registry_type_check(spelling): + """Each spelling gives exactly one hit, of its kind, on its line.""" + text, kind = SPELLINGS[spelling] + marked = [ + number + for number, line in enumerate(text.splitlines(), start=1) + if line.endswith("# check") + ] + hits = scan_sources([_invented(text)]) + assert [(hit.line, hit.kind) for hit in hits] == [ + (marked[0], kind) + ], describe(hits) + + +def test_scan_follows_re_exports_across_modules(): + """A module that never spells the class can still check its type.""" + package = "populace_dynamics.invented" + sources = [ + ScanSource( + f"{package}.reexport", + "invented/reexport.py", + "from populace_dynamics.engine.rng import " + "ProjectionRNGRegistry as Streams\n", + ), + ScanSource( + f"{package}.second", + "invented/second.py", + "from populace_dynamics.invented.reexport import " + "Streams as Streams2\n", + ), + ScanSource( + f"{package}.by_name", + "invented/by_name.py", + "from populace_dynamics.invented.reexport import Streams\n" + "def f(x):\n return isinstance(x, Streams)\n", + ), + ScanSource( + f"{package}.by_module", + "invented/by_module.py", + "from populace_dynamics.invented import reexport as rx\n" + "def f(x):\n return type(x) is rx.Streams\n", + ), + ScanSource( + f"{package}.by_star", + "invented/by_star.py", + "from populace_dynamics.invented.reexport import *\n" + "def f(x):\n return isinstance(x, Streams)\n", + ), + ScanSource( + f"{package}.by_chain", + "invented/by_chain.py", + "from populace_dynamics.invented.second import Streams2\n" + "def f(x):\n return x.__class__ == Streams2\n", + ), + ScanSource( + f"{package}.own_class", + "invented/own_class.py", + "class Streams:\n pass\n" + "def f(x):\n return isinstance(x, Streams)\n", + ), + ] + hits = scan_sources(sources) + assert [(hit.path, hit.kind) for hit in hits] == [ + ("invented/by_chain.py", "__class__-compare"), + ("invented/by_module.py", "type-compare"), + ("invented/by_name.py", "isinstance"), + ("invented/by_star.py", "isinstance"), + ], describe(hits) + + +@pytest.mark.parametrize( + "body", + ( + "def f(x):\n return isinstance(x, int)\n", + "from populace_dynamics.engine.rng import ProjectionModule\n" + "def f(x):\n return isinstance(x, ProjectionModule)\n", + "def f(x):\n return type(x) is dict or x.__class__ == list\n", + "def f(n):\n return ProjectionRNGRegistry(0, n)\n", + "def f(registry: ProjectionRNGRegistry | None = None):\n" + " return registry is None or registry.n_periods == 3\n", + "def f(context):\n" + " if context.rng_registry is None:\n return None\n" + " return context.rng_registry.person_generator(1, 'aging', 0)\n", + "def f(x):\n return hasattr(x, 'person_generator')\n", + "class Streams:\n pass\n" + "def f(x):\n return isinstance(x, Streams) or x == 'Registry'\n", + ), +) +def test_scan_ignores_unrelated_checks_and_ordinary_registry_use(body): + """Each negative names the registry, so the scan parses it.""" + hits = scan_sources([_invented(_REGISTRY_IMPORT + body)]) + assert hits == (), describe(hits) + + +def test_scan_messages_never_show_source_text(): + """Hits and refusals give a path, line and node kind only.""" + sentinel = "ZQX_INVENTED_LITERAL_7781" + text = ( + _REGISTRY_IMPORT + + f"LABEL = {sentinel!r}\n" + + "def f(x, y):\n" + + " return isinstance(x, ProjectionRNGRegistry) and (\n" + + f" y == {sentinel!r}\n" + + " )\n" + ) + hits = scan_sources([_invented(text)]) + assert [str(hit) for hit in hits] == [ + "invented/invented_case.py:4: isinstance" + ] + message = describe(hits) + assert sentinel not in message + assert all(sentinel not in repr(hit) for hit in hits) + assert {field.name for field in dataclasses.fields(ScanHit)} == { + "path", + "line", + "kind", + } + for broken in ( + f"isinstance(x, ProjectionRNGRegistry) {sentinel} (\n", + f"ProjectionRNGRegistry = {sentinel!r}\x00\n", + ): + with pytest.raises(ScanError) as refused: + scan_sources([_invented(broken)]) + assert sentinel not in str(refused.value) + assert refused.value.__cause__ is None + assert refused.value.__suppress_context__ + + +def test_scan_walk_skips_closed_modules_without_opening_them( + tmp_path, monkeypatch +): + """The walk drops closed paths before any read of them. + + Every read is recorded, and the decoys are also made unreadable where + the process is not the superuser, so a read would fail either way. + """ + package = tmp_path / "src" / "populace_dynamics" + check = ( + _REGISTRY_IMPORT + + "def f(x):\n return isinstance(x, ProjectionRNGRegistry)\n" + ) + files = { + "engine/__init__.py": "", + "engine/checks.py": check, + "open_population/own.py": check, + "engine/__pycache__/cached.py": check, + } + for relative, text in files.items(): + path = package / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + decoys = [tmp_path / relative for relative in CLOSED_MODULES] + for decoy in decoys: + decoy.parent.mkdir(parents=True, exist_ok=True) + decoy.write_text(check) + read = [] + original = Path.read_bytes + + def recording(path): + read.append(path) + return original(path) + + monkeypatch.setattr(Path, "read_bytes", recording) + unreadable = os.geteuid() != 0 + try: + for decoy in decoys if unreadable else (): + decoy.chmod(0) + sources = repository_sources(tmp_path) + finally: + for decoy in decoys: + decoy.chmod(0o600) + assert not set(read) & set(decoys) + assert {path.relative_to(tmp_path).as_posix() for path in read} == { + "src/populace_dynamics/engine/__init__.py", + "src/populace_dynamics/engine/checks.py", + } + assert [source.path for source in sources] == [ + "src/populace_dynamics/engine/__init__.py", + "src/populace_dynamics/engine/checks.py", + ] + assert [source.module for source in sources] == [ + "populace_dynamics.engine", + "populace_dynamics.engine.checks", + ] + hits = scan_sources(sources) + assert [str(hit) for hit in hits] == [ + "src/populace_dynamics/engine/checks.py:3: isinstance" + ] + + +# --------------------------------------------------------------------------- +# The pinned sealed check refuses the direct class loudly. +# --------------------------------------------------------------------------- + + +def _observer_inputs(size=3): + identities = [PersonIdentity("int64", value) for value in range(size)] + frame = pd.DataFrame( + { + "person_id": np.arange(size, dtype="int64"), + "age": np.full(size, 40, dtype="int64"), + "year": np.full(size, START, dtype="int64"), + "sex": ["female", "male", "female"][:size], + "weight": np.ones(size), + } + ) + model = AgeSexMortalityModel( + ((0, 64), (65, 120)), + { + ("0-64", "female"): 0.4, + ("0-64", "male"): 0.4, + ("65+", "female"): 0.4, + ("65+", "male"): 0.4, + }, + ) + keywords = dict( + model=model, + identity_map=PersonIdentityMap.from_identities(identities), + realization_id="invented-draw-0", + source_contract_digest="a" * 64, + ) + return frame, keywords + + +def test_pinned_sealed_check_refuses_direct_streams_loudly(): + frame, keywords = _observer_inputs() + ordinals = {person: index for index, person in enumerate(frame.person_id)} + base = PeriodContext(1, START + 1, 0, {}, person_ordinals=ordinals) + registry = replace(base, rng_registry=ProjectionRNGRegistry(0, 2)) + direct = replace(base, rng_registry=DirectRNGStreams(0, 2)) + rng = np.random.default_rng(0) + survivors, _ = observe_mortality(frame, registry, rng, **keywords) + expected = apply_mortality(frame, registry, rng, model=keywords["model"]) + pd.testing.assert_frame_equal(survivors, expected) + with pytest.raises(ValueError, match="existing RNG registry required"): + observe_mortality(frame, direct, rng, **keywords) + + +def test_pinned_sealed_check_refuses_a_direct_op1_run_loudly(): + """Composed into OP1, the observer runs on the registry and refuses + the direct default before any draw, rather than diverging.""" + frame, keywords = _observer_inputs() + + def observed(frame, context, rng): + return observe_mortality(frame, context, rng, **keywords)[0] + + modules = replace(_modules(), mortality=observed, fertility=_unchanged) + request = GateRunRequest(START + 2) + result = run_gate(frame, modules, request, rng_streams="registry") + assert result.rng_stream_class == REGISTRY_CLASS + assert len(result.accounting.periods) == 2 + with pytest.raises(ValueError, match="existing RNG registry required"): + run_gate(frame, modules, request) + + +# --------------------------------------------------------------------------- +# The fresh-process check: a projection imports neither closed module. +# --------------------------------------------------------------------------- + +_FRESH_PROCESS_RUN = textwrap.dedent(""" + import json + import sys + from functools import partial + + import numpy as np + import pandas as pd + + from populace_dynamics.baselines.fertility import AsfrFertilityStep + from populace_dynamics.engine.loop import PeriodModules + from populace_dynamics.engine.steps import advance_age, apply_mortality + from populace_dynamics.open_population.exits import no_marital_transitions + from populace_dynamics.open_population.gates import ( + GateRunRequest, + run_gate, + ) + from populace_dynamics.open_population.harness import ( + ProductionRequest, + run_production, + ) + + class Constant: + def probabilities(self, frame): + return np.full(len(frame), 0.1) + + def unchanged(frame, *args): + return frame + + modules = PeriodModules( + mortality=partial(apply_mortality, model=Constant()), + aging=advance_age, + marital_core=no_marital_transitions, + fertility=AsfrFertilityStep( + asfr=lambda year: {age: 0.2 for age in range(18, 45)} + ), + disability=unchanged, + earnings=unchanged, + claiming=unchanged, + household_composition=unchanged, + ) + initial = pd.DataFrame( + { + "person_id": [1, 2, 3], + "year": 2025, + "age": [25, 30, 35], + "sex": ["female", "male", "female"], + "weight": [1.0, 1.5, 2.0], + } + ) + hazard = lambda frame, context: np.full(len(frame), 0.05) + production = run_production( + initial, + modules, + ProductionRequest("tr2026_intermediate"), + emigration_hazard=hazard, + ) + classes = [production.rng_stream_class] + for streams in ("registry", "direct"): + gate = run_gate( + initial, + modules, + GateRunRequest(2027), + emigration_hazard=hazard, + rng_streams=streams, + ) + classes.append(gate.rng_stream_class) + closed = ( + "populace_dynamics.estimates.adjusted_poverty", + "populace_dynamics.uniform_cut_track_u.rows", + ) + print(json.dumps({ + "loaded": {name: name in sys.modules for name in closed}, + "classes": classes, + })) +""") + + +def test_projection_runs_import_neither_closed_module(): + """A fresh process runs OP1 to 2100 and both ways to 2027, then + checks ``sys.modules``. + + Code a projection never imports cannot check its registry's type, so + this closes the gap the path exclusion leaves in the scan. + """ + environment = dict(os.environ) + environment["PYTHONPATH"] = str(ROOT / "src") + environment["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run( + [sys.executable, "-c", _FRESH_PROCESS_RUN], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + timeout=600, + check=False, + ) + assert result.returncode == 0, result.stderr[-2000:] + report = json.loads(result.stdout.strip().splitlines()[-1]) + assert report["loaded"] == { + "populace_dynamics.estimates.adjusted_poverty": False, + "populace_dynamics.uniform_cut_track_u.rows": False, + } + assert report["classes"] == [DIRECT_CLASS, REGISTRY_CLASS, DIRECT_CLASS] + + +# --------------------------------------------------------------------------- +# The binding: one direct instance, one context per period, loop generators. +# --------------------------------------------------------------------------- + + +def _unchanged(frame, *args): + return frame + + +class _ConstantMortality: + def __init__(self, probability): + self.probability = probability + + def probabilities(self, frame): + return np.full(len(frame), self.probability) + + +def _modules(death=0.1, birth=0.2, probe=None): + return PeriodModules( + mortality=partial(apply_mortality, model=_ConstantMortality(death)), + aging=advance_age, + marital_core=no_marital_transitions, + fertility=AsfrFertilityStep( + asfr=lambda year: {age: birth for age in range(18, 45)} + ), + disability=_unchanged, + earnings=probe or _unchanged, + claiming=_unchanged, + household_composition=_unchanged, + ) + + +def _roster(size, *, first_id=1, year=START): + index = np.arange(size) + return pd.DataFrame( + { + "person_id": (first_id + index).astype("int64"), + "year": year, + "age": 20 + (index * 7) % 30, + "sex": np.where(index % 2, "male", "female"), + "birth_year": year - 20 - (index * 7) % 30, + "weight": 0.5 + index / 4, + } + ) + + +def _entrants(years): + return { + year: _roster(2, first_id=10_001 + 2 * offset, year=year - 1) + for offset, year in enumerate(years) + } + + +_SLOT_MODULES = { + "mortality": ProjectionModule.MORTALITY, + "aging": ProjectionModule.AGING, + "marital_core": ProjectionModule.MARITAL_CORE, + "fertility": ProjectionModule.FERTILITY, + "disability": ProjectionModule.DISABILITY, + "earnings": ProjectionModule.EARNINGS, + "claiming": ProjectionModule.CLAIMING, + "household_composition": ProjectionModule.HOUSEHOLD_COMPOSITION, +} + + +def _recording(modules, calls): + def record(slot, step): + def recorded(frame, context, *args): + rng = args[-1] + calls.append((slot, context, dict(rng.bit_generator.state))) + return step(frame, context, *args) + + return recorded + + return replace( + modules, + **{ + slot: record(slot, getattr(modules, slot)) + for slot in _SLOT_MODULES + }, + ) + + +@pytest.mark.parametrize("mode", RNG_STREAM_MODES) +def test_each_period_gives_every_slot_one_context_and_the_loop_generators( + mode, +): + """Condition 1: only ``context.rng_registry`` changes, and only in + direct mode; the loop's positional module generators pass unchanged.""" + calls = [] + modules = _recording(_modules(), calls) + draw, end = 2, START + 3 + result = run_gate( + _roster(4), + modules, + GateRunRequest(end, draw_index=draw), + rng_streams=mode, + ) + loop_streams = ProjectionRNGRegistry(draw, end - START) + assert len(calls) == 8 * (end - START) + contexts = {} + for slot, context, state in calls: + contexts.setdefault(context.period_index, set()).add(id(context)) + expected = loop_streams.generator( + context.period_index, _SLOT_MODULES[slot] + ) + assert state == expected.bit_generator.state, (slot, context.year) + assert all(len(ids) == 1 for ids in contexts.values()) + streams = {id(context.rng_registry) for _, context, _ in calls} + assert len(streams) == 1 + served = calls[0][1].rng_registry + expected_class = ( + DirectRNGStreams if mode == "direct" else (ProjectionRNGRegistry) + ) + assert type(served) is expected_class + assert (served.draw_index, served.n_periods) == (draw, end - START) + assert len({id(context.metadata) for _, context, _ in calls}) == 1 + assert len({id(context.person_ordinals) for _, context, _ in calls}) == 1 + assert result.rng_streams == mode + assert result.rng_stream_class == ( + DIRECT_CLASS if mode == "direct" else REGISTRY_CLASS + ) + + +def _context(registry, period=1): + return PeriodContext(period, START + period, 0, {}, rng_registry=registry) + + +@pytest.mark.parametrize("mode", RNG_STREAM_MODES) +def test_binding_builds_once_and_refuses_an_unstable_loop_registry(mode): + registry = ProjectionRNGRegistry(0, 4) + binding = _AdapterStreams(mode) + with pytest.raises(IdOrStreamCollision, match="no period bound"): + _ = binding.stream_class + first = binding.bind(_context(registry, 1)) + again = binding.bind(_context(registry, 1)) + later = binding.bind(_context(registry, 2)) + assert first.rng_registry is again.rng_registry is later.rng_registry + if mode == "direct": + assert type(first.rng_registry) is DirectRNGStreams + same_period = _context(registry, 3) + assert binding.bind(same_period) is binding.bind(same_period) + else: + assert first.rng_registry is registry + with pytest.raises(IdOrStreamCollision, match="changed its RNG registry"): + binding.bind(_context(ProjectionRNGRegistry(0, 4), 3)) + with pytest.raises(IdOrStreamCollision, match="person-keyed RNG"): + binding.bind(_context(None, 3)) + + +@pytest.mark.parametrize("value", ("Direct", "", None, 1, ("direct",))) +def test_unknown_stream_modes_are_refused_before_any_adapter(value): + def refuses(*args): + raise AssertionError("an adapter ran") + + modules = replace(_modules(), initialize=refuses, mortality=refuses) + with pytest.raises(ValueError, match="rng_streams must be one of"): + run_gate( + _roster(2), modules, GateRunRequest(START + 1), rng_streams=value + ) + with pytest.raises(ValueError, match="rng_streams must be one of"): + run_production( + _roster(2), + modules, + ProductionRequest("tr2026_intermediate"), + rng_streams=value, + ) + + +@pytest.mark.parametrize("mode", RNG_STREAM_MODES) +def test_emigration_draws_stay_direct_in_both_modes(mode, monkeypatch): + built = [] + + class Recording(DirectRNGStreams): + def __post_init__(self): + built.append((self.draw_index, self.n_periods)) + super().__post_init__() + + monkeypatch.setattr(exits, "DirectRNGStreams", Recording) + run_gate( + _roster(6), + _modules(), + GateRunRequest(START + 2, draw_index=1), + emigration_hazard=lambda frame, context: np.full(len(frame), 0.3), + rng_streams=mode, + ) + assert built == [(1, 2), (1, 2)] + + +def test_a_direct_run_patches_no_sealed_module_attribute(): + """Condition 1: the engine's modules and class are untouched by a run.""" + + def snapshot(): + return ( + {name: id(value) for name, value in vars(engine_rng).items()}, + {name: id(value) for name, value in vars(engine_loop).items()}, + { + name: id(value) + for name, value in vars(ProjectionRNGRegistry).items() + }, + {name: id(value) for name, value in vars(PeriodContext).items()}, + ) + + before = snapshot() + result = run_gate( + _roster(5), + _modules(), + GateRunRequest(START + 2), + emigration_hazard=lambda frame, context: np.full(len(frame), 0.05), + ) + assert result.rng_stream_class == DIRECT_CLASS + assert snapshot() == before + assert engine_loop.ProjectionRNGRegistry is ProjectionRNGRegistry + + +# --------------------------------------------------------------------------- +# Condition 4: the projection is identical whichever class serves the draws. +# --------------------------------------------------------------------------- + +PROBE_TAG = 0xE1 +SIZES = (1, 3, 6) +DRAWS = (0, 3, 17) +SHORT_HORIZON = START + 2 +LONG_HORIZON = 2100 +# Every size with every draw at the short horizon. At 2100 each size runs +# with its own draw, so all three sizes and all three draws reach the +# registered horizon: one 2100 run costs about as much as 35 short ones. +IDENTITY_GRID = ( + *((size, draw, SHORT_HORIZON) for size in SIZES for draw in DRAWS), + *( + (size, draw, LONG_HORIZON) + for size, draw in zip(SIZES, DRAWS, strict=True) + ), +) + + +def _stream_probe(served): + """Exercise every stream method the context carries, in the earnings + slot, and keep the draws as a column; IDs and weights are unchanged.""" + + def probe(frame, context, rng): + streams = context.rng_registry + served.add(type(streams)) + out = frame.copy() + values = np.zeros(len(out)) + if len(out): + period, module = context.period_index, ProjectionModule.EARNINGS + first = int(out["person_id"].min()) + ordinal = context.person_ordinals[first] + values[:] = ( + streams.draw_seed + + streams.generator(period, module).random() + + streams.seed_sequence(period, module).generate_state(1)[0] + + streams.child_generator(period, module, ordinal).random() + + streams.person_generator(period, module, ordinal).random() + + streams.tagged_generator(period, module, PROBE_TAG).random() + + streams.tagged_child_generator( + period, module, PROBE_TAG, ordinal + ).random() + + context.person_generator(module, first).random() + ) + out["stream_probe"] = values + return out + + return probe + + +def _digest(result): + """Hash every projected slice, as ``op1_run_size.py`` does.""" + digest = hashlib.sha256() + for frame in result.projection.slices: + digest.update(repr(list(frame.columns)).encode()) + digest.update(pd.util.hash_pandas_object(frame, index=False).values) + return digest.hexdigest() + + +def _run(size, draw, horizon, mode, *, death=0.05, birth=0.1, emigrate=0.05): + served = set() + modules = _modules(death, birth, probe=_stream_probe(served)) + midpoint = START + 1 + (horizon - START - 1) // 2 + entrant_years = sorted({START + 1, midpoint, horizon}) + keywords = dict( + scheduled_entries=_entrants(entrant_years), + emigration_hazard=lambda frame, context: np.full(len(frame), emigrate), + rng_streams=mode, + ) + if horizon == LONG_HORIZON: + result = run_production( + _roster(size), + modules, + ProductionRequest( + "tr2026_intermediate", draw_index=draw, start_year=START + ), + **keywords, + ) + else: + result = run_gate( + _roster(size), + modules, + GateRunRequest(horizon, draw_index=draw, start_year=START), + **keywords, + ) + return result, served + + +def _assert_identical(direct, registry): + (direct, direct_served), (registry, registry_served) = direct, registry + assert direct_served == {DirectRNGStreams} + assert registry_served == {ProjectionRNGRegistry} + assert (direct.rng_stream_class, registry.rng_stream_class) == ( + DIRECT_CLASS, + REGISTRY_CLASS, + ) + assert _digest(direct) == _digest(registry) + for expected, actual in zip( + registry.projection.slices, direct.projection.slices, strict=True + ): + pd.testing.assert_frame_equal(actual, expected) + assert direct.events == registry.events + assert direct.accounting.to_dict() == registry.accounting.to_dict() + + +@pytest.mark.parametrize(("size", "draw", "horizon"), IDENTITY_GRID) +def test_projection_hash_is_identical_for_both_stream_classes( + size, draw, horizon +): + """Three sizes, three draws and two horizons, one of them 2100 (§7). + + The invariant: OP1's projection is identical whichever stream class + serves the adapters' person and tagged draws. + """ + direct = _run(size, draw, horizon, "direct") + registry = _run(size, draw, horizon, "registry") + _assert_identical(direct, registry) + result = direct[0] + assert result.horizon == horizon + assert len(result.projection.slices) == horizon - START + 1 + assert result.events, "the invented run declares no flow" + + +@settings(max_examples=10, deadline=None) +@given( + size=st.integers(0, 8), + draw=st.integers(0, 2**20), + horizon=st.integers(START + 1, START + 4), + death=st.sampled_from((0.0, 0.2, 1.0)), + birth=st.sampled_from((0.0, 0.4, 1.0)), + emigrate=st.sampled_from((0.0, 0.3, 1.0)), +) +def test_projection_identity_holds_on_generated_runs( + size, draw, horizon, death, birth, emigrate +): + """Hypothesis extends the grid's rosters, draws and rates (§7).""" + rates = dict(death=death, birth=birth, emigrate=emigrate) + direct = _run(size, draw, horizon, "direct", **rates) + registry = _run(size, draw, horizon, "registry", **rates) + _assert_identical(direct, registry) From be6843f7a5d3dcda96d1118960edece069e4d125 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 23:03:57 -0700 Subject: [PATCH 02/15] Extend the stream differential to every period and state Condition 3 of the injection ruling: OP1's slots now draw their person and tagged streams from DirectRNGStreams, so the stream differential covers every period 0 to n at horizons 0, 1, 7 and 75, all eight modules, draws 0, 1 and 9, and every method. Both generator comparisons now also check bit_generator.state before and after drawing. A bad-address grid on the same horizons compares refusal type and message, and a Hypothesis test draws addresses, valid or not, across them. Co-Authored-By: Claude Opus 5.5 --- tests/open_population/test_streams.py | 115 +++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/tests/open_population/test_streams.py b/tests/open_population/test_streams.py index fb6fa5a8..e8c25d15 100644 --- a/tests/open_population/test_streams.py +++ b/tests/open_population/test_streams.py @@ -38,11 +38,13 @@ def _assert_sequences_equal(expected, actual): def _assert_generators_equal(expected, actual): - """Compare generator seeds and first four draws (design §5.4).""" + """Compare seeds, ``bit_generator.state`` and four draws (§5.4).""" _assert_sequences_equal( expected.bit_generator.seed_seq, actual.bit_generator.seed_seq ) + assert actual.bit_generator.state == expected.bit_generator.state assert np.array_equal(actual.random(4), expected.random(4)) + assert actual.bit_generator.state == expected.bit_generator.state @pytest.mark.parametrize( @@ -191,9 +193,10 @@ def _sequence_identity(sequence): def _generator_identity(generator): - """Summarize a generator by its seed and first draws (design §5.4).""" + """Summarize a generator by seed, state and first draws (§5.4).""" return ( _sequence_identity(generator.bit_generator.seed_seq), + generator.bit_generator.state, tuple(generator.random(2).tolist()), ) @@ -659,3 +662,111 @@ def test_negative_position_wraps_as_the_list_lookup_does(): "tagged_child_generator", (0, MODULE_ORDER[0], 5, _WrappingIndex(-3)), ) + + +# The injection ruling's condition 3 (24 October 2026): OP1's slots draw +# their person and tagged streams from the direct class by default, so it +# must match the registry at every period of the horizons OP1 runs at. +INJECTION_HORIZONS = (0, 1, 7, 75) +INJECTION_DRAWS = (0, 1, 9) + + +def _every_method(streams, period, module): + """Call each stream method once at one address (design §§2.1, 5.4).""" + return ( + ("seed_sequence", streams.seed_sequence(period, module)), + ("generator", streams.generator(period, module)), + ("child_generator", streams.child_generator(period, module, 2)), + ( + "person_generator", + streams.person_generator(period, module, 12345), + ), + ( + "tagged_generator", + streams.tagged_generator(period, module, EMIGRATION_STREAM_TAG), + ), + ( + "tagged_child_generator", + streams.tagged_child_generator(period, module, GATE_M6_TAG, 3), + ), + ) + + +@pytest.mark.parametrize( + ("draw_index", "n_periods"), + tuple(product(INJECTION_DRAWS, INJECTION_HORIZONS)), +) +def test_every_method_matches_registry_at_every_period(draw_index, n_periods): + """Exhaustive over periods 0 to the horizon and all eight modules. + + Each method's stream equals the registry's in seed identity, + ``bit_generator.state`` before and after four draws, and the draws. + """ + registry = ProjectionRNGRegistry(draw_index, n_periods) + direct = DirectRNGStreams(draw_index, n_periods) + for period, module in product(range(n_periods + 1), MODULE_ORDER): + expected = _every_method(registry, period, module) + actual = _every_method(direct, period, module) + for (name, want), (other, got) in zip(expected, actual, strict=True): + assert name == other + if name == "seed_sequence": + _assert_sequences_equal(want, got) + else: + _assert_generators_equal(want, got) + + +@pytest.mark.parametrize( + ("draw_index", "n_periods"), + tuple(product(INJECTION_DRAWS, INJECTION_HORIZONS)), +) +def test_bad_addresses_on_injection_horizons_match_registry( + draw_index, n_periods +): + """Every bad address is refused alike: exception type and message. + + Periods just outside the horizon, an unknown module and negative + children and tags are crossed with valid values, so each refusal and + its precedence over the others is compared, as are the valid streams. + """ + for period, module in product( + (-1, 0, n_periods, n_periods + 1), + (MODULE_ORDER[0], MODULE_ORDER[-1].value, "unknown"), + ): + for method, length in _TAIL_LENGTHS.items(): + for tail in product((-1, 0), repeat=length): + _assert_outcomes_match( + draw_index, n_periods, method, (period, module, *tail) + ) + + +@st.composite +def _injection_addresses(draw): + """Draw an address, valid or not, on one of the injection horizons.""" + n_periods = draw(st.sampled_from(INJECTION_HORIZONS)) + method = draw(st.sampled_from(tuple(_TAIL_LENGTHS))) + tail = draw( + st.lists( + st.integers(-1, 2**16), + min_size=_TAIL_LENGTHS[method], + max_size=_TAIL_LENGTHS[method], + ) + ) + return ( + draw(st.integers(0, 2**16)), + n_periods, + method, + ( + draw(st.integers(-1, n_periods + 1)), + draw(st.sampled_from((*MODULE_ORDER, "unknown"))), + *tail, + ), + ) + + +@settings(max_examples=60, deadline=None) +@given(address=_injection_addresses()) +def test_generated_addresses_on_injection_horizons_match_registry(address): + """Hypothesis extends both grids over draws, periods, modules and + tails: the same stream and state, or the same refusal (§§2.1, 5.4).""" + draw_index, n_periods, method, args = address + _assert_outcomes_match(draw_index, n_periods, method, args) From ff17e72a61c302e2bb0205dc5d4f715ea1ab724d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 23:23:21 -0700 Subject: [PATCH 03/15] Compare three stream modes in the OP1 run-size measurement op1_run_size.py now runs each invented size in three modes: registry (adapters and emigration on ProjectionRNGRegistry), direct_emigration (OP1 before the injection) and direct (the default). Each record names the stream classes the adapters and the exit adapter were observed to receive, the SHA-256 of streams.py, harness.py and exits.py, and the process CPU time, and profiles the adapters' direct draws with disjoint setup wrappers. The script stops if a mode runs another class or if the modes' projection hashes differ. The committed op1_run_size.json is unchanged; the host rerun replaces it. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_run_size.py | 164 +++++++++++++++--- 1 file changed, 144 insertions(+), 20 deletions(-) diff --git a/docs/design/open_population_evidence/op1_run_size.py b/docs/design/open_population_evidence/op1_run_size.py index 9f8001ae..f8b5c44d 100644 --- a/docs/design/open_population_evidence/op1_run_size.py +++ b/docs/design/open_population_evidence/op1_run_size.py @@ -2,9 +2,21 @@ Run ``python docs/design/open_population_evidence/op1_run_size.py --sizes 25 100 --output docs/design/open_population_evidence/op1_run_size.json`` from the -repository root. Each size and emigration stream implementation runs in a -fresh worker, sequentially. The sealed loop always uses its own registry. -Only the exit adapter's stream-class binding changes for the comparison. +repository root. Each size and stream mode runs in a fresh worker, +sequentially. The sealed loop's own module generators always come from its +registry (``engine/loop.py:217``, ``:297–350``). Three stream modes compare +which class serves the other draws: + +- ``registry``: the adapters' person and tagged draws and the emigration + draws all come from ``ProjectionRNGRegistry`` (``rng_streams="registry"``, + with the exit adapter's binding patched to the registry); +- ``direct_emigration``: OP1's behavior before the injection, the adapters on + the registry and emigration on ``DirectRNGStreams``; +- ``direct``: the default, every person and tagged draw on + ``DirectRNGStreams`` (``rng_streams="direct"``). + +Each record names the stream classes the adapters and the exit adapter were +observed to receive, and the SHA-256 of ``open_population/streams.py``. Mortality draws person streams (``engine/steps.py:125–131``); fertility draws tagged person streams (``baselines/fertility.py:114–126``). Aging draws none @@ -19,7 +31,9 @@ also report complete setup, including leaves and generator construction. They wrap module, child, tagged and tagged-child methods; ``person_generator`` delegates to the child method (``engine/rng.py:158–170``) and is not added. -The direct wrapper measures ``_sequence`` construction. Complete emigration +The direct wrappers measure ``_sequence`` construction, for emigration and, +in ``direct`` mode, for the adapters; four disjoint adapter wrappers report +complete direct setup as the sealed ones do. Complete emigration tagged-generator setup is also reported. The sealed-loop and emigration profiler groups do not overlap. Profile time excludes imports and fixture construction. Peak RSS includes imports and the @@ -44,6 +58,26 @@ START_YEAR = 2025 REGISTERED_HORIZON = 2100 EMIGRATION_PROBABILITY = 0.02 +ROOT = Path(__file__).resolve().parents[3] +# Each mode: (harness rng_streams, emigration stream class). +STREAM_MODES = { + "registry": ("registry", "registry"), + "direct_emigration": ("registry", "direct"), + "direct": ("direct", "direct"), +} +HASHED_SOURCES = ( + "src/populace_dynamics/open_population/streams.py", + "src/populace_dynamics/open_population/harness.py", + "src/populace_dynamics/open_population/exits.py", +) + + +def _source_digests() -> dict: + """Hash the stream, harness and exit sources a run used (design §8).""" + return { + path: hashlib.sha256((ROOT / path).read_bytes()).hexdigest() + for path in HASHED_SOURCES + } def _positive_int(value: str) -> int: @@ -67,6 +101,11 @@ def _profile_summary(profile: cProfile.Profile) -> dict: "emigration_registry_sequences", "emigration_direct_sequences", "emigration_generator_setup", + "adapter_direct_sequences", + "adapter_direct_module_generator_setup", + "adapter_direct_person_generator_setup", + "adapter_direct_tagged_generator_setup", + "adapter_direct_tagged_child_generator_setup", ): matches = [v for k, v in stats.stats.items() if k[2] == name] seconds = sum(v[3] for v in matches) @@ -89,6 +128,20 @@ def _profile_summary(profile: cProfile.Profile) -> dict: "cumulative_seconds": setup_seconds, "share_of_profile_total": setup_seconds / stats.total_tt, } + direct_names = ( + "adapter_direct_module_generator_setup", + "adapter_direct_person_generator_setup", + "adapter_direct_tagged_generator_setup", + "adapter_direct_tagged_child_generator_setup", + ) + direct_seconds = sum( + out[name]["cumulative_seconds"] for name in direct_names + ) + out["adapter_direct_generator_setup_total"] = { + "calls": sum(out[name]["calls"] for name in direct_names), + "cumulative_seconds": direct_seconds, + "share_of_profile_total": direct_seconds / stats.total_tt, + } out["top_self_costs"] = [ { "function": f"{Path(filename).name}:{line}:{name}", @@ -105,6 +158,7 @@ def _profile_summary(profile: cProfile.Profile) -> dict: def _measure(size: int, stream_mode: str) -> dict: """Profile one invented 75-year production run (design §§2.9, 8).""" + rng_streams, emigration_stream = STREAM_MODES[stream_mode] started = time.perf_counter() load_start = os.getloadavg() import resource @@ -125,7 +179,7 @@ def _measure(size: int, stream_mode: str) -> dict: advance_age, apply_mortality, ) - from populace_dynamics.open_population import exits, harness + from populace_dynamics.open_population import exits, harness, streams from populace_dynamics.open_population.exits import no_marital_transitions from populace_dynamics.open_population.harness import ProductionRequest from populace_dynamics.open_population.streams import DirectRNGStreams @@ -184,9 +238,20 @@ def no_composition(frame, context, marital, rng): }, ) draw_counts = {"mortality": 0, "emigration": 0} + observed = {"adapters": set(), "emigration": set()} + + def stream_class(cls): + """Name the project class behind a profiled subclass.""" + base = next( + item + for item in cls.__mro__ + if item.__module__.startswith("populace_dynamics") + ) + return f"{base.__module__}.{base.__qualname__}" def mortality(frame, context, rng): draw_counts["mortality"] += len(frame) + observed["adapters"].add(stream_class(type(context.rng_registry))) return apply_mortality(frame, context, rng, model=model) def emigration(frame, context): @@ -235,6 +300,7 @@ def emigration_registry_sequences(self, period, module): seed_sequence = emigration_registry_sequences def emigration_generator_setup(self, *args): + observed["emigration"].add(stream_class(type(self))) return tagged_child_generator(self, *args) tagged_child_generator = emigration_generator_setup @@ -246,10 +312,39 @@ def emigration_direct_sequences(self, period, module, *tail): _sequence = emigration_direct_sequences def emigration_generator_setup(self, *args): + observed["emigration"].add(stream_class(type(self))) return super().tagged_child_generator(*args) tagged_child_generator = emigration_generator_setup + class ProfiledAdapterDirect(DirectRNGStreams): + """The adapters' direct streams, with disjoint setup wrappers.""" + + def adapter_direct_sequences(self, period, module, *tail): + return super()._sequence(period, module, *tail) + + _sequence = adapter_direct_sequences + + def adapter_direct_module_generator_setup(self, *args): + return super().generator(*args) + + generator = adapter_direct_module_generator_setup + + def adapter_direct_person_generator_setup(self, *args): + return super().child_generator(*args) + + child_generator = adapter_direct_person_generator_setup + + def adapter_direct_tagged_generator_setup(self, *args): + return super().tagged_generator(*args) + + tagged_generator = adapter_direct_tagged_generator_setup + + def adapter_direct_tagged_child_generator_setup(self, *args): + return super().tagged_child_generator(*args) + + tagged_child_generator = adapter_direct_tagged_child_generator_setup + def capture_snapshots(transitions, **kwargs): snapshots = [ frame @@ -271,7 +366,8 @@ def capture_snapshots(transitions, **kwargs): profile = cProfile.Profile() projection_load_start = os.getloadavg() projection_started = time.perf_counter() - cls = ProfiledDirect if stream_mode == "direct" else ProfiledRegistry + projection_cpu_started = time.process_time() + cls = ProfiledDirect if emigration_stream == "direct" else ProfiledRegistry with ( patch.object( ProjectionRNGRegistry, "seed_sequence", sealed_registry_sequences @@ -295,6 +391,7 @@ def capture_snapshots(transitions, **kwargs): sealed_tagged_child_generator_setup, ), patch.object(exits, "DirectRNGStreams", cls), + patch.object(harness, "DirectRNGStreams", ProfiledAdapterDirect), patch.object(harness, "reconcile_history", capture_snapshots), ): profile.enable() @@ -305,8 +402,10 @@ def capture_snapshots(transitions, **kwargs): schedule_builder=build_schedule, reserved_real_ids=initial["person_id"], emigration_hazard=emigration, + rng_streams=rng_streams, ) profile.disable() + projection_cpu_seconds = time.process_time() - projection_cpu_started projection_seconds = time.perf_counter() - projection_started projection_load_end = os.getloadavg() events = Counter(event.kind.value for event in result.events) @@ -323,9 +422,32 @@ def capture_snapshots(transitions, **kwargs): if system not in {"Darwin", "Linux"}: raise RuntimeError("peak RSS units are defined for macOS and Linux") peak_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + expected_adapters = ( + "populace_dynamics.open_population.streams.DirectRNGStreams" + if rng_streams == "direct" + else "populace_dynamics.engine.rng.ProjectionRNGRegistry" + ) + expected_emigration = ( + "populace_dynamics.open_population.streams.DirectRNGStreams" + if emigration_stream == "direct" + else "populace_dynamics.engine.rng.ProjectionRNGRegistry" + ) + if observed["adapters"] != {expected_adapters} or observed[ + "emigration" + ] != {expected_emigration}: + raise AssertionError(f"{stream_mode} ran other stream classes") summary = { "status": "completed", - "emigration_stream": stream_mode, + "stream_mode": stream_mode, + "rng_streams": result.rng_streams, + "adapter_stream_class": expected_adapters, + "emigration_stream": emigration_stream, + "emigration_stream_class": expected_emigration, + "result_rng_stream_class": result.rng_stream_class, + "streams_py_sha256": hashlib.sha256( + Path(streams.__file__).read_bytes() + ).hexdigest(), + "source_sha256": _source_digests(), "emigration_probability": EMIGRATION_PROBABILITY, "initial_people": size, "final_people": len(slices[-1]), @@ -350,6 +472,7 @@ def capture_snapshots(transitions, **kwargs): ), "projection_sha256": digest.hexdigest(), "projection_wall_time_seconds": projection_seconds, + "projection_process_cpu_seconds": projection_cpu_seconds, "seconds_per_mortality_person_year": projection_seconds / draw_counts["mortality"], "peak_memory_bytes": int( @@ -437,13 +560,9 @@ def _microbenchmark() -> dict: def _comparisons(measurements: list[dict]) -> dict: """Fit a two-size engineering extrapolation, without a full run (§8).""" comparisons = {} - for mode in ("direct", "registry"): + for mode in STREAM_MODES: records = sorted( - ( - item - for item in measurements - if item["emigration_stream"] == mode - ), + (item for item in measurements if item["stream_mode"] == mode), key=lambda item: item["initial_people"], ) if len(records) < 2: @@ -504,7 +623,7 @@ def main() -> None: ) parser.add_argument( "--worker-stream", - choices=("direct", "registry"), + choices=tuple(STREAM_MODES), help=argparse.SUPPRESS, ) arguments = parser.parse_args() @@ -523,7 +642,14 @@ def main() -> None: "registered_horizon": REGISTERED_HORIZON, "projected_years": 75, "requested_initial_sizes": arguments.sizes, - "process_isolation": "one fresh worker per size and emigration stream implementation; workers run sequentially", + "process_isolation": "one fresh worker per size and stream mode; workers run sequentially", + "stream_modes": { + "registry": "adapters' person and tagged draws and emigration draws on ProjectionRNGRegistry", + "direct_emigration": "adapters on ProjectionRNGRegistry, emigration on DirectRNGStreams (OP1 before the injection)", + "direct": "every person and tagged draw on DirectRNGStreams (the default)", + "loop_module_generators": "the sealed loop's 600 positional module generators stay on its registry in every mode", + }, + "source_sha256": _source_digests(), "peak_memory_method": "resource.getrusage(RUSAGE_SELF).ru_maxrss; bytes on macOS, KiB converted to bytes on Linux", "profile_method": "cProfile projection only; registry_sequences measure root/period/module seed_sequence cumulative cost, excluding child leaves and default_rng; sealed_generator_setup_total adds four disjoint module/child/tagged/tagged-child setup wrappers and includes all leaves/default_rng; emigration_direct_sequences measures direct construction; emigration_generator_setup includes complete tagged generator setup", "person_stream_adapters": [ @@ -541,7 +667,7 @@ def main() -> None: "full_size_measurement": "open OP1 item required before OP10a; intentionally not attempted in this build", } for size in arguments.sizes: - for mode in ("direct", "registry"): + for mode in STREAM_MODES: started = time.perf_counter() try: process = subprocess.run( @@ -585,7 +711,7 @@ def main() -> None: key: measurement[key] for key in ( "initial_people", - "emigration_stream", + "stream_mode", "projection_wall_time_seconds", "load_at_projection_start", ) @@ -598,9 +724,7 @@ def main() -> None: item for item in measurements if item["initial_people"] == size ] if len({item["projection_sha256"] for item in records}) != 1: - raise AssertionError( - "registry and direct projection outputs differ" - ) + raise AssertionError("the stream modes' projections differ") payload["stream_modes_have_identical_projection_hashes"] = True payload["rng_microbenchmark"] = _microbenchmark() _write(payload, arguments.output) From b4b182e36ba51f21043b779b7575a4a642756a70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 00:05:41 -0700 Subject: [PATCH 04/15] Report the stream injection follow-up in the OP1 report Section 6 of op1_report.md records the mechanism, the four invariants, each of the coordinator's five conditions with its tests and evidence, the one sealed type check the scan found (mortality_observer.py:381) and its disposition, the commands and results of this build's runs, and a restricted-files statement. Section 2 notes that it measured OP1 as merged. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 286 +++++++++++++++++- 1 file changed, 285 insertions(+), 1 deletion(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index 8cffa13a..43df35cc 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -56,7 +56,10 @@ rates and tagged person streams (`baselines/fertility.py:114–126`). `advance_age` draws nothing (`engine/steps.py:142–148`). The marital step is `no_marital_transitions`, required for nonzero emigration (`open_population/exits.py:64–67`). The sealed loop retains its original -registry for every module and adapter draw. +registry for every module and adapter draw. (This section measures OP1 as +merged in #566. Since the injection follow-up, §6, the adapters' person and +tagged draws come from `DirectRNGStreams` by default; the script now +compares three stream modes.) All six runs reconciled 75 periods with zero count and weight residuals and zero revaluations. For each roster size, the direct and registry runs give @@ -650,3 +653,284 @@ master, so runs of those tests load it without displaying it (review r10). All tests, probes, mutation checks and measurements use invented inputs; no microdata or external statistical table was read and no real-data statistic was computed. Every delegated agent reports the same boundary. + +## 6. Injection follow-up: direct streams for the adapters' draws + +The coordinator approved this follow-up as option 2 on 24 October 2026 and +dispatched it, at revision 3 of its brief, on 26 October. Base: master +`0f69bbb2`; branch `openpop-op1-rng-inject-20261026`. `4431486d` adds the +injection and `tests/open_population/test_stream_injection.py`; `be6843f7` +extends `tests/open_population/test_streams.py`; later commits extend +`op1_run_size.py` and add this section. + +### Mechanism + +`ProjectionEngine.project()` takes no registry. The loop builds one, +`streams = ProjectionRNGRegistry(draw_index, n_periods)` +(`engine/loop.py:216–217`), puts it on each period's `PeriodContext` +(`:287–294`) and passes each slot a module generator positionally +(`:297–350`). So the substitution lives in OP1's wrapper, +`_AdapterStreams` (`open_population/harness.py:171–234`): + +- At the first period it keeps the loop's registry and builds one + `DirectRNGStreams(registry.draw_index, registry.n_periods)` (`:205–212`). + Every later period must carry the same registry object, or the run is + refused with `ID_OR_STREAM_COLLISION` (`:213–216`). A context without a + registry is refused the same way (`:202–206`). +- Every slot of a period receives one + `dataclasses.replace(context, rng_registry=)`, built once per + period (`:217–225`), just as the loop gives its eight slots one context. + Metadata, the person ordinals and every other field are the loop's own + objects. +- All eight slots are bound (`:361–377`): the mortality wrapper, and through + it `_ExitAdapter` and the mortality adapter; aging; the marital core; the + birth-entry adapter, and through it fertility; disability; earnings; + claiming; and `close_period`, and through it household composition. +- The positional generator each slot receives is the loop's, unchanged. So + are the loop's 600 module generators per 75-year run. +- `rng_streams="registry"` passes the loop's context through unchanged, + which is OP1's behavior before this follow-up. +- Emigration draws from `DirectRNGStreams` in both modes. `_ExitAdapter` + builds its own from the context's draw and horizon + (`open_population/exits.py:149–152`, unchanged), and + `test_exact_emigration_stream` still checks it against the sealed registry + itself. + +`_project`, `run_production` and `run_gate` take +`rng_streams: Literal["registry", "direct"] = "direct"` +(`open_population/harness.py:257`, `:409`; `open_population/gates.py:60`). +Any other value is refused before the initializer or any adapter runs +(`harness.py:181–185`, `:264`). `OpenPopulationResult` records +`rng_streams` and `rng_stream_class`, the qualified name of the class that +served the slots (`:120–121`, `:191–197`, `:399–400`). No module was added, +so `POST_REVIEW_SOURCE_EXCLUSIONS` and its pin are unchanged. + +### Invariants + +1. **OP1's projection is identical whichever stream class serves the + adapters' person and tagged draws**: every slice, every declared flow and + the accounting history. +2. At every valid stream address the direct stream equals the registry's + (seed identity, `bit_generator.state` and draws), and at every bad + address both refuse with the same exception type and message (§2 states + the user-defined-type limit). +3. Within a run there is one direct instance and one context per period; + the generator each slot receives equals the loop's module generator for + its period and module. +4. The two classes share the registry's public surface, and no sealed code + checks the registry's type except the one check recorded below, which + refuses rather than diverges. + +### The coordinator's conditions and their evidence + +**1. No sealed code changes.** Against master `0f69bbb2` the branch changes +only `open_population/harness.py` and `gates.py`, OP1's tests and the two +`op1_*` evidence files (§3's commands, with `0f69bbb2` as the base). No +engine or baselines attribute is patched, and `DirectRNGStreams` is not a +registry subclass: +- `test_a_direct_run_patches_no_sealed_module_attribute` compares every + attribute of `engine/rng.py`, `engine/loop.py`, `ProjectionRNGRegistry` + and `PeriodContext`, by identity, before and after a direct run; +- `test_direct_streams_are_not_a_registry_subclass`; +- `test_each_period_gives_every_slot_one_context_and_the_loop_generators`, + in both modes, checks the state of the generator each of the eight slots + receives against the loop's module generator, and that one context, one + stream instance, one metadata mapping and one ordinal map serve the run. + +`op1_run_size.py` patches registry methods, as it did at OP1's merge, but +only inside its measurement worker, to time them. The harness patches +nothing. + +**2. Interface parity, and no type check on the registry.** +`test_direct_streams_share_the_registry_public_surface` derives the +registry's surface by introspection: its dataclass fields (name, annotation, +default, `init`, `kw_only`); every public member by kind and full signature +(`draw_seed` as a read-only property; `seed_sequence`, `generator`, +`child_generator`, `tagged_generator`, `tagged_child_generator` and +`person_generator` as methods); the constructor signature; and the dataclass +parameters. The direct class must match each. The expected names are also +pinned, so a member added to the registry fails the test until the direct +class matches it. + +`test_sealed_code_holds_only_the_known_registry_type_check` runs an AST scan +of `src/populace_dynamics/` outside `open_population/`: 305 modules at +`0f69bbb2`, once the two closed modules below are dropped. +- **What it reads.** The project's data-access record keeps two modules out + of automated reads: `estimates/adjusted_poverty.py` and + `uniform_cut_track_u/rows.py`. The walk drops both by path before anything + opens, reads, parses, hashes or stats them. + `test_closed_module_exclusions_are_pinned_exactly` pins that list, and + `test_scan_walk_skips_closed_modules_without_opening_them` records every + read on an invented tree whose decoys sit at those paths. Every other + module is read, `baselines/asfr.py` included. +- **Closing the gap at run time.** In a fresh interpreter, + `test_projection_runs_import_neither_closed_module` runs a 2100 production + projection and two 2027 gate runs, one in each mode, with person-stream, + tagged-stream and emigration draws. It then checks that neither closed + module is in `sys.modules`. Code a projection never imports can't check + its registry's type. +- **What it catches.** `isinstance` and `issubclass` (with tuples and + unions); `type(x)` and `x.__class__` compared by `is`, `is not`, `==`, + `!=`, `in` or `not in`; any other comparison against the class; `match` + class patterns; class-name string comparisons; `__instancecheck__` and + `__subclasscheck__`; `singledispatch` registration by argument or by + annotation; and `hasattr` or `getattr` probes of a private name found on + only one class. +- **How it names the class.** Directly; through an import alias; as a + module attribute (`rng.ProjectionRNGRegistry`, the dotted path, or an + aliased module); through relative imports, local assignment chains, + default arguments, `type(ProjectionRNGRegistry(...))` and `getattr` on the + module; and through re-exports across modules, followed to a fixed point. +- **Speed.** A module is parsed only if its text holds a tracked identifier + as a word: the class name, a name some module re-exports it under, or a + probe name. Every static spelling holds one, so the filter skips only + modules that cannot name the class. It keeps the scan away from two + generated data modules of 11 and 18.5 MB. +- **Its own tests.** `test_scan_catches_every_spelling_of_a_registry_type_check` + (31 invented snippets, each with exactly one hit of its kind on its + marked line), `test_scan_follows_re_exports_across_modules` (aliases one + and two modules away, a star import, a module alias, and a same-named + local class that is not caught) and + `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` (eight + negatives, each importing the class so that it is parsed). +- **Its messages.** A hit carries only a module path, line number and node + kind. `test_scan_messages_never_show_source_text` plants a distinctive + literal in an invented snippet and checks that it is absent from the + hits, their `repr` and the assertion message, and from the refusal raised + for unparsable source, whose original exception is suppressed. +- **Limits.** It does not resolve names built at run time (`getattr` with a + computed string, `importlib`, `eval`), or comparisons with a type taken + from another object at run time (`type(a) is type(b)`). + +**The scan finds one check in sealed code.** +`src/populace_dynamics/mortality_observer.py:381` is a `type-compare`: the +opt-in mortality observer refuses any context whose registry is not exactly +a `ProjectionRNGRegistry` (present since `e4e512e1`, before `3d69b1fc`, +where the coordinator's search for `isinstance` found none). It raises +`ValueError` before drawing, so it fails loudly rather than diverging. +- `test_pinned_sealed_check_refuses_direct_streams_loudly` shows it accepts + the registry, giving `apply_mortality`'s survivors, and refuses the direct + class. +- `test_pinned_sealed_check_refuses_a_direct_op1_run_loudly` composes it + into OP1: the registry mode completes and the direct default refuses. +- An OP1 run that composes this observer must pass + `rng_streams="registry"`. The scan test pins exactly this hit, by path, + line and kind, so any other check, or any move of this one, fails it. +- Whether to keep this disposition is the coordinator's call. Condition 2, + as written, asks for no check at all. + +**3. Stream identity.** +- `test_every_method_matches_registry_at_every_period` covers every period + from 0 to the horizon, all eight modules and all six methods, at + horizons 0, 1, 7 and 75 and draws 0, 1 and 9. +- Both generator comparisons in `test_streams.py` now also check + `bit_generator.state`, before and after four draws, so the existing + exhaustive grid, its Hypothesis test and every refusal-grid comparison + check state too. +- `test_bad_addresses_on_injection_horizons_match_registry` crosses periods + just outside each horizon, an unknown module and negative children and + tags with valid values, on the same horizons and draws, and compares + exception type and message. +- `test_generated_addresses_on_injection_horizons_match_registry` adds 60 + Hypothesis examples, valid or not. + +**4. End-to-end identity.** +`test_projection_hash_is_identical_for_both_stream_classes` runs each +configuration in both modes and compares: +- the SHA-256 over every slice, computed as `op1_run_size.py` does; +- every slice, frame by frame; +- the declared flows and the accounting history. + +The grid is: +- sizes 1, 3 and 6 with draws 0, 3 and 17, in full, at 2027 (gate runs); +- at 2100, sizes 1, 3 and 6 with draws 0, 3 and 17 respectively + (production runs at the registered horizon). + +So every size and every draw reaches both horizons. The full product at 2100 +is a one-line change (`zip` to `product` in `IDENTITY_GRID`); it was left +out for the unit tier's time, since one 2100 run costs about as much as 35 +two-year runs. + +Each run draws: +- a mortality person stream for everyone; +- an ASFR tagged child for women of childbearing age; +- an emigration tagged child for each survivor; +- scheduled entrants in three years; +- through an invented probe in the earnings slot, every method the context + exposes, from `context.rng_registry` and `context.person_generator`, kept + as a column. + +The test also checks that the adapters received `DirectRNGStreams` in +direct mode and `ProjectionRNGRegistry` in registry mode. +`test_projection_identity_holds_on_generated_runs` adds 10 Hypothesis +examples: rosters of 0 to 8, death, birth and emigration rates of 0 to 1, +and horizons of one to four years. + +`op1_run_size.py` compares three modes: +- `registry`: everything on the registry; +- `direct_emigration`: OP1's behavior before this follow-up; +- `direct`: the default. + +Each record names the stream classes the adapters and the exit adapter were +observed to receive, the SHA-256 of `open_population/streams.py`, `harness.py` +and `exits.py`, and the process CPU time. The script stops if a mode runs +another class, or if the three modes' projection hashes differ for any +size. In this build's sandbox one 10-person worker per mode completed, with +identical projection hashes across the three. `op1_run_size.json` still +holds OP1's two-mode measurement. The main session reruns the script on the +host, with §2's command, and replaces the JSON. + +**5. Seal bookkeeping.** No module was added, so the exclusion tuple and its +pin are unchanged. `tests/track_u2/test_u2_isolation.py` was not run, and CI +gives its result. `tests/tier_counts.json` and `tests/README-tiers.md` are +untouched, for the main session's recount. + +**Tier.** Every new test is in `tests/open_population/test_streams.py` or +`test_stream_injection.py`, and `_classify_test_module` classifies both +modules as `unit`. The stream differential and the hash identity therefore +run on every CI push. + +### Commands and results + +```sh +.venv/bin/python -m pytest -q -p no:cacheprovider tests/open_population tests/estimates/test_birth_evidence_artifact.py tests/test_entrant_schedule.py tests/test_entrant_domains.py tests/test_entrant_id_allocation.py +.venv/bin/python -m pytest --collect-only -q -p no:cacheprovider -m unit tests/open_population +.venv/bin/black --check -l 79 src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py +.venv/bin/ruff check src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py +git diff --check 0f69bbb2 +git diff --stat 0f69bbb2 +git diff --quiet 0f69bbb2 -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b scripts tests/estimates tests/tier_counts.json tests/README-tiers.md +``` + +The build ran the required command in its sandbox in five chunks of named +modules, one process each, because the sandbox could not finish it within +one tool call. Together they gave **440 passed and 1 strict xfail, with the +2 existing warnings**: 337 in `tests/open_population`, which are OP1's 238 +and the 99 tests this follow-up adds (74 in the new module and 25 in +`test_streams.py`), and 104 in the other four modules. `--collect-only -m +unit` collects all 337, none deselected. Black, Ruff and `git diff --check` +are clean, `git diff --stat` lists only this branch's six files, and the +sealed-path `git diff --quiet` exits 0. + +The sandbox's wall times are not usable, so the build timed the new tests +by process CPU time. In the sandbox, the existing 2100 production test took +1.67 CPU seconds, against the 0.59 s §3 reports for this host. The slowest +new tests took 12.7 (the hash grid), 6.8 (the every-period differential) and +2.3 (the scan) sandbox CPU seconds. The main session's host rerun gives the +times that count. + +### Restricted-files statement + +The build read `RESTRICTED-FILES.md` at its pinned hash and the +coordinator's three pending entries in full before any source work. It +opened no file they list, no other session's transcript or scratchpad and +no other Subfleet job folder. It displayed no line of any file under a +display bar. Its code reads the scanned modules without displaying them, +`baselines/asfr.py` included, as the ruling on the scan allows. No code it +wrote or ran imports the two closed modules, loads either by path, or reads +the committed MINT8 guide. Twice, a shell search or byte count over `src/` +in the build's own session did not apply its exclusions, because of how the +shell expanded them. Neither printed a line from any excluded file, and the +build's report to the main session lists both. Every test, probe and +measurement uses invented inputs. No microdata or external statistical table +was read, and no real-data statistic was computed. From d49d08131635fe7b373561cc54194fca76d92a88 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 00:30:06 -0700 Subject: [PATCH 05/15] Keep the scan's alias bindings monotone and catch chained comparisons Self-review found that a module binding overwritten on every fixed-point pass could grow without end on a cycle such as "a = b.c; b = a", which would hang the scan on such a module. Module bindings now keep their first value, so each pass only adds names; a negative case pins it. A comparison now counts when any of its operators, not only the first, is an identity, equality or membership test; a chained spelling pins it. The report's counts follow: 442 passed and 1 strict xfail. Co-Authored-By: Claude Opus 5.5 --- .../open_population/test_stream_injection.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/open_population/test_stream_injection.py b/tests/open_population/test_stream_injection.py index 60e9968f..10185bf4 100644 --- a/tests/open_population/test_stream_injection.py +++ b/tests/open_population/test_stream_injection.py @@ -334,7 +334,9 @@ def _bind(self, name: str, value: ast.expr) -> None: return path = self.module_path(value) if path is not None: - self.modules[name] = path + # First binding wins, so a cycle such as ``a = b.c; b = a`` + # cannot grow a path forever: each pass only adds names. + self.modules.setdefault(name, path) def _bind_targets(self, target: ast.expr, value: ast.expr) -> None: if isinstance(target, ast.Name): @@ -351,10 +353,10 @@ def _visit(self, node: ast.AST) -> None: if isinstance(node, ast.Import): for alias in node.names: if alias.asname: - self.modules[alias.asname] = alias.name + self.modules.setdefault(alias.asname, alias.name) else: top = alias.name.split(".")[0] - self.modules[top] = top + self.modules.setdefault(top, top) elif isinstance(node, ast.ImportFrom): base = self._from_base(node) for alias in node.names: @@ -370,7 +372,7 @@ def _visit(self, node: ast.AST) -> None: ): self.classes.add(local) else: - self.modules[local] = f"{base}.{alias.name}" + self.modules.setdefault(local, f"{base}.{alias.name}") elif isinstance(node, ast.Assign): for target in node.targets: self._bind_targets(target, node.value) @@ -402,7 +404,11 @@ def _visit(self, node: ast.AST) -> None: self.classes.add(argument.arg) def bind(self, tree: ast.Module) -> None: - """Bind to a fixed point, so chained aliases resolve in any order.""" + """Bind to a fixed point, so chained aliases resolve in any order. + + Bindings only accumulate, over the module's finitely many names, so + the loop ends. + """ while True: before = (dict(self.modules), set(self.classes)) for node in ast.walk(tree): @@ -438,8 +444,8 @@ def _kind(self, node: ast.AST) -> str | None: and node.args[1].value in PROBE_NAMES ): return "private-member-probe" - elif isinstance(node, ast.Compare) and isinstance( - node.ops[0], _COMPARISONS + elif isinstance(node, ast.Compare) and any( + isinstance(op, _COMPARISONS) for op in node.ops ): operands = [node.left, *node.comparators] if any(self.contains_registry(item) for item in operands): @@ -671,6 +677,11 @@ def _check(expression, imports=_IMPORT): _check("type(x) in (ProjectionRNGRegistry, int)"), "type-compare", ), + "type in a chained comparison": ( + # One comparison whose first operator is "<". + _check("0 < x.n_periods != type(x) is ProjectionRNGRegistry"), + "type-compare", + ), "__class__ is": ( _check("x.__class__ is ProjectionRNGRegistry"), "__class__-compare", @@ -899,6 +910,9 @@ def test_scan_follows_re_exports_across_modules(): "def f(x):\n return hasattr(x, 'person_generator')\n", "class Streams:\n pass\n" "def f(x):\n return isinstance(x, Streams) or x == 'Registry'\n", + # A binding cycle that would grow a module path on every pass. + "import os as b\na = b.path\nb = a\n" + "def f(x):\n return isinstance(x, a.sep)\n", ), ) def test_scan_ignores_unrelated_checks_and_ordinary_registry_use(body): From 441b9f77e1552e9b99063595fdf06ac6f4c2d6fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 00:32:29 -0700 Subject: [PATCH 06/15] Bring the OP1 report's section 6 up to d49d0813 d49d0813's message said the report's counts followed, but its report edit had failed, so only the test change landed. This commit adds them: 32 spellings and nine negatives, a binding cycle among them; chained comparisons; and 442 passed and 1 strict xfail, with all 76 of the new module's tests rerun after that change. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index 43df35cc..dc0d8a6f 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -771,8 +771,8 @@ of `src/populace_dynamics/` outside `open_population/`: 305 modules at its registry's type. - **What it catches.** `isinstance` and `issubclass` (with tuples and unions); `type(x)` and `x.__class__` compared by `is`, `is not`, `==`, - `!=`, `in` or `not in`; any other comparison against the class; `match` - class patterns; class-name string comparisons; `__instancecheck__` and + `!=`, `in` or `not in`, alone or in a chain; any other comparison against + the class; `match` class patterns; class-name string comparisons; `__instancecheck__` and `__subclasscheck__`; `singledispatch` registration by argument or by annotation; and `hasattr` or `getattr` probes of a private name found on only one class. @@ -781,18 +781,21 @@ of `src/populace_dynamics/` outside `open_population/`: 305 modules at aliased module); through relative imports, local assignment chains, default arguments, `type(ProjectionRNGRegistry(...))` and `getattr` on the module; and through re-exports across modules, followed to a fixed point. + Bindings only accumulate, so the fixed point ends even on a binding + cycle such as `a = b.c; b = a`. - **Speed.** A module is parsed only if its text holds a tracked identifier as a word: the class name, a name some module re-exports it under, or a probe name. Every static spelling holds one, so the filter skips only modules that cannot name the class. It keeps the scan away from two generated data modules of 11 and 18.5 MB. - **Its own tests.** `test_scan_catches_every_spelling_of_a_registry_type_check` - (31 invented snippets, each with exactly one hit of its kind on its + (32 invented snippets, each with exactly one hit of its kind on its marked line), `test_scan_follows_re_exports_across_modules` (aliases one and two modules away, a star import, a module alias, and a same-named local class that is not caught) and - `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` (eight - negatives, each importing the class so that it is parsed). + `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` (nine + negatives, each importing the class so that it is parsed, one of them a + binding cycle). - **Its messages.** A hit carries only a module path, line number and node kind. `test_scan_messages_never_show_source_text` plants a distinctive literal in an invented snippet and checks that it is absent from the @@ -904,11 +907,13 @@ git diff --quiet 0f69bbb2 -- src/populace_dynamics/engine src/populace_dynamics/ The build ran the required command in its sandbox in five chunks of named modules, one process each, because the sandbox could not finish it within -one tool call. Together they gave **440 passed and 1 strict xfail, with the -2 existing warnings**: 337 in `tests/open_population`, which are OP1's 238 -and the 99 tests this follow-up adds (74 in the new module and 25 in -`test_streams.py`), and 104 in the other four modules. `--collect-only -m -unit` collects all 337, none deselected. Black, Ruff and `git diff --check` +one tool call. Together they gave **442 passed and 1 strict xfail, with the +2 existing warnings**: 339 in `tests/open_population`, which are OP1's 238 +and the 101 tests this follow-up adds (76 in the new module and 25 in +`test_streams.py`), and 104 in the other four modules. After the new +module's last change (`d49d0813`: the binding-cycle fix and the chained +comparison), all 76 of its tests ran again and passed. `--collect-only -m +unit` collects all 339, none deselected. Black, Ruff and `git diff --check` are clean, `git diff --stat` lists only this branch's six files, and the sealed-path `git diff --quiet` exits 0. From b42d00c5a70d735ae9e07c41df9f96018be3e864 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 01:01:52 -0700 Subject: [PATCH 07/15] Record the host's three-mode run-size measurement The main session reran op1_run_size.py on the host at 441b9f77 with sizes 25, 100 and 400. The three stream modes give the same projection hash at each size, matching section 2's hashes, and the direct default takes 3.13 s at 400 people against 26.50 s on the registry. The run goes to op1_run_size_injection.json so that op1_run_size.json stays the evidence for section 2's two-mode run. Section 6 reports the run and the host's test rerun. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 57 +- .../open_population_evidence/op1_run_size.py | 8 +- .../op1_run_size_injection.json | 2139 +++++++++++++++++ 3 files changed, 2196 insertions(+), 8 deletions(-) create mode 100644 docs/design/open_population_evidence/op1_run_size_injection.json diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index dc0d8a6f..c93f9e5f 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -59,7 +59,9 @@ rates and tagged person streams (`baselines/fertility.py:114–126`). registry for every module and adapter draw. (This section measures OP1 as merged in #566. Since the injection follow-up, §6, the adapters' person and tagged draws come from `DirectRNGStreams` by default; the script now -compares three stream modes.) +compares three stream modes. §6 reports its host run, kept in +`op1_run_size_injection.json`; `op1_run_size.json` keeps this section's +run.) All six runs reconciled 75 periods with zero count and weight residuals and zero revaluations. For each roster size, the direct and registry runs give @@ -878,10 +880,41 @@ Each record names the stream classes the adapters and the exit adapter were observed to receive, the SHA-256 of `open_population/streams.py`, `harness.py` and `exits.py`, and the process CPU time. The script stops if a mode runs another class, or if the three modes' projection hashes differ for any -size. In this build's sandbox one 10-person worker per mode completed, with -identical projection hashes across the three. `op1_run_size.json` still -holds OP1's two-mode measurement. The main session reruns the script on the -host, with §2's command, and replaces the JSON. +size. In the build's sandbox, one 10-person worker per mode completed, with +identical projection hashes across the three. + +The main session then ran the script on the host at `441b9f77`, from +07:50 to 07:52 UTC on 26 October, with one-minute loads of 76 to 92 at the +projection starts: + +```sh +.venv/bin/python docs/design/open_population_evidence/op1_run_size.py --sizes 25 100 400 --output docs/design/open_population_evidence/op1_run_size_injection.json +``` + +`op1_run_size_injection.json` holds that run, and `op1_run_size.json` +keeps §2's two-mode run of `0d0c073c`. All nine workers reconciled 75 +periods with zero count and weight residuals and zero revaluations. Each +record's source hashes match the files at `441b9f77`. For each size the +three modes give the same projection hash, and it is §2's hash for that +size. + +| Initial people | Mortality person-years | Projection seconds, `registry` | `direct_emigration` | `direct` | Peak RSS (MB), `direct` | +|---:|---:|---:|---:|---:|---:| +| 25 | 4,299 | 4.87 | 3.40 | 1.40 | 194.4 | +| 100 | 11,936 | 8.03 | 5.00 | 1.77 | 194.9 | +| 400 | 43,793 | 26.50 | 17.60 | 3.13 | 203.0 | + +- Process CPU time over each projection is 94 to 99% of its wall time. +- At 400 people the direct default takes 12% of the `registry` mode's time + and 18% of the time OP1 took before this follow-up (`direct_emigration`). +- Peak RSS over the nine workers ranges from 192.3 to 203.0 MB. At each size + the direct mode is within 1.3 MB of the `registry` mode. +- The microbenchmark's median cost per address (one tagged-child generator + and one draw) is 6.7 µs at horizon 7 and 7.4 µs at horizon 75 on the + direct class, against 52.3 and 216.7 µs on the registry. + +These are single runs on a loaded host, so the ratios are indicative, not +bounds. The full-size measurement is still due before OP10a. **5. Seal bookkeeping.** No module was added, so the exclusion tuple and its pin are unchanged. `tests/track_u2/test_u2_isolation.py` was not run, and CI @@ -924,6 +957,20 @@ new tests took 12.7 (the hash grid), 6.8 (the every-period differential) and 2.3 (the scan) sandbox CPU seconds. The main session's host rerun gives the times that count. +On the host, at `441b9f77`, the main session ran the required command in two +named-module processes. The first started at a one-minute load of 114. +- `tests/open_population`: 337 passed and 1 strict xfail in 16.38 s, with + `test_sealed_code_holds_only_the_known_registry_type_check` deselected. + That test reads the source tree; it waits for the coordinator's ruling on + which modules the scan reads, and CI runs it. +- The other four modules: 104 passed in 1.63 s. +- Peak RSS was 223 MB. + +A timed run of the two stream modules, with the same test deselected, gave +245 passed in 12.23 s. The slowest tests took 1.38 s (the fresh-interpreter +import check), 1.0 to 1.2 s for each 2100 hash pair, and about 0.8 s (the +every-period differential). + ### Restricted-files statement The build read `RESTRICTED-FILES.md` at its pinned hash and the diff --git a/docs/design/open_population_evidence/op1_run_size.py b/docs/design/open_population_evidence/op1_run_size.py index f8b5c44d..d0d15ac9 100644 --- a/docs/design/open_population_evidence/op1_run_size.py +++ b/docs/design/open_population_evidence/op1_run_size.py @@ -1,9 +1,11 @@ """Measure full-horizon OP1 resource use on invented rosters (design §8). Run ``python docs/design/open_population_evidence/op1_run_size.py --sizes 25 -100 --output docs/design/open_population_evidence/op1_run_size.json`` from the -repository root. Each size and stream mode runs in a fresh worker, -sequentially. The sealed loop's own module generators always come from its +100 400 --output +docs/design/open_population_evidence/op1_run_size_injection.json`` from the +repository root. (``op1_run_size.json`` keeps the two-mode run of OP1 as +merged in #566, which the report's section 2 cites.) Each size and stream +mode runs in a fresh worker, sequentially. The sealed loop's own module generators always come from its registry (``engine/loop.py:217``, ``:297–350``). Three stream modes compare which class serves the other draws: diff --git a/docs/design/open_population_evidence/op1_run_size_injection.json b/docs/design/open_population_evidence/op1_run_size_injection.json new file mode 100644 index 00000000..bd640db5 --- /dev/null +++ b/docs/design/open_population_evidence/op1_run_size_injection.json @@ -0,0 +1,2139 @@ +{ + "draw_index": 0, + "full_size_measurement": "open OP1 item required before OP10a; intentionally not attempted in this build", + "marital_choice_reason": "nonnull emigration permits only no_marital_transitions until OP4; exits.py:64\u201367", + "measurements": [ + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 1, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 92, + "deaths": 37, + "emigration_probability": 0.02, + "emigration_stream": "registry", + "emigration_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "emigration_survivor_person_years": 4262, + "emigrations": 82, + "final_people": 73, + "initial_people": 25, + "load_at_projection_end": [ + 88.61669921875, + 90.19482421875, + 83.58447265625 + ], + "load_at_projection_start": [ + 91.978515625, + 90.87353515625, + 83.78271484375 + ], + "load_at_worker_start": [ + 92.3251953125, + 90.92236328125, + 83.75830078125 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 73, + "mortality_person_years": 4299, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 4058725, + "peak_memory_bytes": 193069056, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_generator_setup": { + "calls": 4262, + "cumulative_seconds": 1.2429892630000001, + "share_of_profile_total": 0.2554745011863984 + }, + "emigration_registry_sequences": { + "calls": 4262, + "cumulative_seconds": 1.1571496410000002, + "share_of_profile_total": 0.2378316821651379 + }, + "sealed_generator_setup_total": { + "calls": 6337, + "cumulative_seconds": 1.811536779, + "share_of_profile_total": 0.3723294068356243 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.182575433, + "share_of_profile_total": 0.03752515734688667 + }, + "sealed_person_generator_setup": { + "calls": 4299, + "cumulative_seconds": 1.217434507, + "share_of_profile_total": 0.2502221721950094 + }, + "sealed_registry_sequences": { + "calls": 6337, + "cumulative_seconds": 1.716033849, + "share_of_profile_total": 0.3527004654361606 + }, + "sealed_tagged_child_generator_setup": { + "calls": 1438, + "cumulative_seconds": 0.41152683900000003, + "share_of_profile_total": 0.08458207729372823 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 10599, + "cumulative_seconds": 2.540184789, + "function": "rng.py:61:_period_sequence", + "self_seconds": 2.432237448 + }, + { + "calls": 10599, + "cumulative_seconds": 2.8687881980000003, + "function": "rng.py:69:seed_sequence", + "self_seconds": 0.310586996 + }, + { + "calls": 3628056, + "cumulative_seconds": 0.11997917200000001, + "function": "multiarray.py:197:concatenate", + "self_seconds": 0.11997917200000001 + }, + { + "calls": 882711, + "cumulative_seconds": 0.143962571, + "function": "~:0:", + "self_seconds": 0.088605246 + }, + { + "calls": 5700, + "cumulative_seconds": 1.637622063, + "function": "rng.py:129:tagged_child_generator", + "self_seconds": 0.054463807 + }, + { + "calls": 10599, + "cumulative_seconds": 0.060140336, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.051705515 + }, + { + "calls": 11568, + "cumulative_seconds": 0.125923628, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.046797808 + }, + { + "calls": 273034, + "cumulative_seconds": 0.048789176000000004, + "function": "~:0:", + "self_seconds": 0.033928863000000004 + }, + { + "calls": 4299, + "cumulative_seconds": 1.215222607, + "function": "rng.py:85:child_generator", + "self_seconds": 0.032663629 + }, + { + "calls": 10834, + "cumulative_seconds": 0.095850696, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.031637247 + }, + { + "calls": 3078, + "cumulative_seconds": 0.047616461000000006, + "function": "series.py:9066:any", + "self_seconds": 0.031103193 + }, + { + "calls": 9645, + "cumulative_seconds": 0.067164734, + "function": "managers.py:1241:iget", + "self_seconds": 0.028366253 + } + ], + "total_seconds": 4.865414189 + }, + "projection_process_cpu_seconds": 4.57397, + "projection_sha256": "b3326c38cab80d174f4336ac390c549cfc75dd0d3a5f91ddec1bfa6dc87a38e2", + "projection_wall_time_seconds": 4.867697209003381, + "python_version": "3.14.7", + "result_rng_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "retained_person_year_rows": 4297, + "retained_slice_bytes": 2048303, + "rng_streams": "registry", + "scheduled_entries": 75, + "seconds_per_mortality_person_year": 0.0011322859290540548, + "shared_full_column_snapshot_counterfactual_bytes": 2048303, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "registry", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 7.314334249997046, + "unique_annual_snapshot_bytes": 117656, + "unique_annual_snapshots": 76, + "wall_time_seconds": 7.114830334001454 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 1, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 92, + "deaths": 37, + "emigration_probability": 0.02, + "emigration_stream": "direct", + "emigration_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "emigration_survivor_person_years": 4262, + "emigrations": 82, + "final_people": 73, + "initial_people": 25, + "load_at_projection_end": [ + 91.12939453125, + 90.689453125, + 83.79736328125 + ], + "load_at_projection_start": [ + 88.61669921875, + 90.19482421875, + 83.58447265625 + ], + "load_at_worker_start": [ + 88.61669921875, + 90.19482421875, + 83.58447265625 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 73, + "mortality_person_years": 4299, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 4058725, + "peak_memory_bytes": 192282624, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 4262, + "cumulative_seconds": 0.034404124, + "share_of_profile_total": 0.010123320844307476 + }, + "emigration_generator_setup": { + "calls": 4262, + "cumulative_seconds": 0.064852167, + "share_of_profile_total": 0.01908257550721563 + }, + "emigration_registry_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_generator_setup_total": { + "calls": 6337, + "cumulative_seconds": 1.7444898260000001, + "share_of_profile_total": 0.5133114337754429 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.17013703800000002, + "share_of_profile_total": 0.05006236529010689 + }, + "sealed_person_generator_setup": { + "calls": 4299, + "cumulative_seconds": 1.182451575, + "share_of_profile_total": 0.34793319186332733 + }, + "sealed_registry_sequences": { + "calls": 6337, + "cumulative_seconds": 1.6507920610000002, + "share_of_profile_total": 0.48574111873153925 + }, + "sealed_tagged_child_generator_setup": { + "calls": 1438, + "cumulative_seconds": 0.391901213, + "share_of_profile_total": 0.11531587662200857 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 6337, + "cumulative_seconds": 1.4629054570000002, + "function": "rng.py:61:_period_sequence", + "self_seconds": 1.401919106 + }, + { + "calls": 6337, + "cumulative_seconds": 1.6485635250000001, + "function": "rng.py:69:seed_sequence", + "self_seconds": 0.17580309200000002 + }, + { + "calls": 882711, + "cumulative_seconds": 0.13433653, + "function": "~:0:", + "self_seconds": 0.081200483 + }, + { + "calls": 2187650, + "cumulative_seconds": 0.06854330900000001, + "function": "multiarray.py:197:concatenate", + "self_seconds": 0.06854330900000001 + }, + { + "calls": 10599, + "cumulative_seconds": 0.048517357000000004, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.041730203 + }, + { + "calls": 11568, + "cumulative_seconds": 0.113484922, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.041564589000000006 + }, + { + "calls": 4299, + "cumulative_seconds": 1.180408421, + "function": "rng.py:85:child_generator", + "self_seconds": 0.036051529 + }, + { + "calls": 273034, + "cumulative_seconds": 0.044959796, + "function": "~:0:", + "self_seconds": 0.031298997 + }, + { + "calls": 10834, + "cumulative_seconds": 0.086908015, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.028274732 + }, + { + "calls": 4262, + "cumulative_seconds": 0.032783199, + "function": "streams.py:122:_sequence", + "self_seconds": 0.025814243 + }, + { + "calls": 9645, + "cumulative_seconds": 0.060006478, + "function": "managers.py:1241:iget", + "self_seconds": 0.024971534 + }, + { + "calls": 6087, + "cumulative_seconds": 0.097342891, + "function": "managers.py:410:apply", + "self_seconds": 0.024080961 + } + ], + "total_seconds": 3.3985017890000053 + }, + "projection_process_cpu_seconds": 3.262602, + "projection_sha256": "b3326c38cab80d174f4336ac390c549cfc75dd0d3a5f91ddec1bfa6dc87a38e2", + "projection_wall_time_seconds": 3.4006981670099776, + "python_version": "3.14.7", + "result_rng_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "retained_person_year_rows": 4297, + "retained_slice_bytes": 2048303, + "rng_streams": "registry", + "scheduled_entries": 75, + "seconds_per_mortality_person_year": 0.0007910440025610555, + "shared_full_column_snapshot_counterfactual_bytes": 2048303, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "direct_emigration", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 4.6255700000037905, + "unique_annual_snapshot_bytes": 117656, + "unique_annual_snapshots": 76, + "wall_time_seconds": 4.425728625006741 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 1, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 92, + "deaths": 37, + "emigration_probability": 0.02, + "emigration_stream": "direct", + "emigration_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "emigration_survivor_person_years": 4262, + "emigrations": 82, + "final_people": 73, + "initial_people": 25, + "load_at_projection_end": [ + 85.67333984375, + 89.5654296875, + 83.44091796875 + ], + "load_at_projection_start": [ + 91.12939453125, + 90.689453125, + 83.79736328125 + ], + "load_at_worker_start": [ + 91.12939453125, + 90.689453125, + 83.79736328125 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 73, + "mortality_person_years": 4299, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 4058725, + "peak_memory_bytes": 194428928, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 5737, + "cumulative_seconds": 0.059302122, + "share_of_profile_total": 0.04233927816000125 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 4299, + "cumulative_seconds": 0.042897757, + "share_of_profile_total": 0.03062723566726905 + }, + "adapter_direct_sequences": { + "calls": 5737, + "cumulative_seconds": 0.037031981000000005, + "share_of_profile_total": 0.02643931265014229 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 1438, + "cumulative_seconds": 0.016404365, + "share_of_profile_total": 0.011712042492732198 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 4262, + "cumulative_seconds": 0.029097968000000002, + "share_of_profile_total": 0.020774753406679364 + }, + "emigration_generator_setup": { + "calls": 4262, + "cumulative_seconds": 0.051349147000000005, + "share_of_profile_total": 0.03666118082775847 + }, + "emigration_registry_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_generator_setup_total": { + "calls": 600, + "cumulative_seconds": 0.14499669, + "share_of_profile_total": 0.10352167819879146 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.14499669, + "share_of_profile_total": 0.10352167819879146 + }, + "sealed_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_registry_sequences": { + "calls": 600, + "cumulative_seconds": 0.13972700200000002, + "share_of_profile_total": 0.09975933751815916 + }, + "sealed_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 600, + "cumulative_seconds": 0.123421634, + "function": "rng.py:61:_period_sequence", + "self_seconds": 0.11803000500000001 + }, + { + "calls": 882711, + "cumulative_seconds": 0.106486516, + "function": "~:0:", + "self_seconds": 0.06400979500000001 + }, + { + "calls": 9999, + "cumulative_seconds": 0.062937386, + "function": "streams.py:122:_sequence", + "self_seconds": 0.049879093000000006 + }, + { + "calls": 11568, + "cumulative_seconds": 0.084155451, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.029059838 + }, + { + "calls": 273034, + "cumulative_seconds": 0.037316922, + "function": "~:0:", + "self_seconds": 0.025147774 + }, + { + "calls": 10599, + "cumulative_seconds": 0.026607488000000002, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.022602153 + }, + { + "calls": 6087, + "cumulative_seconds": 0.074153735, + "function": "managers.py:410:apply", + "self_seconds": 0.018383427 + }, + { + "calls": 10834, + "cumulative_seconds": 0.059774351, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.018092093 + }, + { + "calls": 119610, + "cumulative_seconds": 0.039228103, + "function": "generic.py:45:_instancecheck", + "self_seconds": 0.016822800000000002 + }, + { + "calls": 9645, + "cumulative_seconds": 0.042945040000000004, + "function": "managers.py:1241:iget", + "self_seconds": 0.016734969000000002 + }, + { + "calls": 600, + "cumulative_seconds": 0.13951417500000002, + "function": "rng.py:69:seed_sequence", + "self_seconds": 0.015202872000000001 + }, + { + "calls": 22231, + "cumulative_seconds": 0.019271135000000002, + "function": "generic.py:6359:__finalize__", + "self_seconds": 0.015115120000000001 + } + ], + "total_seconds": 1.4006408370000007 + }, + "projection_process_cpu_seconds": 1.3916369999999998, + "projection_sha256": "b3326c38cab80d174f4336ac390c549cfc75dd0d3a5f91ddec1bfa6dc87a38e2", + "projection_wall_time_seconds": 1.4022743749956135, + "python_version": "3.14.7", + "result_rng_stream_class": "__main__._measure..ProfiledAdapterDirect", + "retained_person_year_rows": 4297, + "retained_slice_bytes": 2048303, + "rng_streams": "direct", + "scheduled_entries": 75, + "seconds_per_mortality_person_year": 0.0003261861770168908, + "shared_full_column_snapshot_counterfactual_bytes": 2048303, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "direct", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 5.514390625001397, + "unique_annual_snapshot_bytes": 117656, + "unique_annual_snapshots": 76, + "wall_time_seconds": 5.313682082982268 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 4, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 141, + "deaths": 148, + "emigration_probability": 0.02, + "emigration_stream": "registry", + "emigration_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "emigration_survivor_person_years": 11788, + "emigrations": 248, + "final_people": 145, + "initial_people": 100, + "load_at_projection_end": [ + 86.828125, + 89.6923828125, + 83.5576171875 + ], + "load_at_projection_start": [ + 85.67333984375, + 89.5654296875, + 83.44091796875 + ], + "load_at_worker_start": [ + 85.67333984375, + 89.5654296875, + 83.44091796875 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 179, + "mortality_person_years": 11936, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 11067075, + "peak_memory_bytes": 196067328, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_generator_setup": { + "calls": 11788, + "cumulative_seconds": 2.892233482, + "share_of_profile_total": 0.36045625031921646 + }, + "emigration_registry_sequences": { + "calls": 11788, + "cumulative_seconds": 2.750110487, + "share_of_profile_total": 0.3427435994628232 + }, + "sealed_generator_setup_total": { + "calls": 15153, + "cumulative_seconds": 3.63707803, + "share_of_profile_total": 0.45328550304508325 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.154433613, + "share_of_profile_total": 0.0192469112233412 + }, + "sealed_person_generator_setup": { + "calls": 11936, + "cumulative_seconds": 2.8490055990000003, + "share_of_profile_total": 0.355068801237947 + }, + "sealed_registry_sequences": { + "calls": 15153, + "cumulative_seconds": 3.490733596, + "share_of_profile_total": 0.43504673834540536 + }, + "sealed_tagged_child_generator_setup": { + "calls": 2617, + "cumulative_seconds": 0.633638818, + "share_of_profile_total": 0.07896979058379508 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 26941, + "cumulative_seconds": 5.529861207000001, + "function": "rng.py:61:_period_sequence", + "self_seconds": 5.283151297000001 + }, + { + "calls": 26941, + "cumulative_seconds": 6.2341446650000005, + "function": "rng.py:69:seed_sequence", + "self_seconds": 0.668485973 + }, + { + "calls": 9217526, + "cumulative_seconds": 0.273424278, + "function": "multiarray.py:197:concatenate", + "self_seconds": 0.273424278 + }, + { + "calls": 14405, + "cumulative_seconds": 3.498783012, + "function": "rng.py:129:tagged_child_generator", + "self_seconds": 0.084011982 + }, + { + "calls": 26941, + "cumulative_seconds": 0.09182984500000001, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.07862815100000001 + }, + { + "calls": 924352, + "cumulative_seconds": 0.12348626500000001, + "function": "~:0:", + "self_seconds": 0.074667673 + }, + { + "calls": 11936, + "cumulative_seconds": 2.844968234, + "function": "rng.py:85:child_generator", + "self_seconds": 0.062083497 + }, + { + "calls": 12055, + "cumulative_seconds": 0.102264454, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.037614444000000004 + }, + { + "calls": 297862, + "cumulative_seconds": 0.043180588000000006, + "function": "~:0:", + "self_seconds": 0.030018520000000003 + }, + { + "calls": 11263, + "cumulative_seconds": 0.075764704, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.024853667000000003 + }, + { + "calls": 9827, + "cumulative_seconds": 0.052569185000000004, + "function": "managers.py:1241:iget", + "self_seconds": 0.021034104 + }, + { + "calls": 6131, + "cumulative_seconds": 0.090553221, + "function": "managers.py:410:apply", + "self_seconds": 0.021003182000000002 + } + ], + "total_seconds": 8.023812818999994 + }, + "projection_process_cpu_seconds": 7.898680000000001, + "projection_sha256": "4b9f68442f0dc41eeff42decf3919a00c648e5ee0e6d8950853aa2e231bafc28", + "projection_wall_time_seconds": 8.026812291005626, + "python_version": "3.14.7", + "result_rng_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "retained_person_year_rows": 11781, + "retained_slice_bytes": 5573799, + "rng_streams": "registry", + "scheduled_entries": 300, + "seconds_per_mortality_person_year": 0.0006724876249166912, + "shared_full_column_snapshot_counterfactual_bytes": 5573799, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "registry", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 9.137604458024725, + "unique_annual_snapshot_bytes": 300732, + "unique_annual_snapshots": 76, + "wall_time_seconds": 8.965502291015582 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 4, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 141, + "deaths": 148, + "emigration_probability": 0.02, + "emigration_stream": "direct", + "emigration_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "emigration_survivor_person_years": 11788, + "emigrations": 248, + "final_people": 145, + "initial_people": 100, + "load_at_projection_end": [ + 84.51953125, + 89.166015625, + 83.40771484375 + ], + "load_at_projection_start": [ + 86.828125, + 89.6923828125, + 83.5576171875 + ], + "load_at_worker_start": [ + 86.828125, + 89.6923828125, + 83.5576171875 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 179, + "mortality_person_years": 11936, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 11067075, + "peak_memory_bytes": 195362816, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 11788, + "cumulative_seconds": 0.07924793200000001, + "share_of_profile_total": 0.01585534964518718 + }, + "emigration_generator_setup": { + "calls": 11788, + "cumulative_seconds": 0.14063627, + "share_of_profile_total": 0.028137481665072954 + }, + "emigration_registry_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_generator_setup_total": { + "calls": 15153, + "cumulative_seconds": 3.503757725, + "share_of_profile_total": 0.7010063530982812 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.14633537900000002, + "share_of_profile_total": 0.02927771792841208 + }, + "sealed_person_generator_setup": { + "calls": 11936, + "cumulative_seconds": 2.75070806, + "share_of_profile_total": 0.5503416551378844 + }, + "sealed_registry_sequences": { + "calls": 15153, + "cumulative_seconds": 3.372513048, + "share_of_profile_total": 0.6747478730296196 + }, + "sealed_tagged_child_generator_setup": { + "calls": 2617, + "cumulative_seconds": 0.606714286, + "share_of_profile_total": 0.12138698003198484 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 15153, + "cumulative_seconds": 2.991986024, + "function": "rng.py:61:_period_sequence", + "self_seconds": 2.8556592120000004 + }, + { + "calls": 15153, + "cumulative_seconds": 3.369168831, + "function": "rng.py:69:seed_sequence", + "self_seconds": 0.357964918 + }, + { + "calls": 5233332, + "cumulative_seconds": 0.15339357, + "function": "multiarray.py:197:concatenate", + "self_seconds": 0.15339357 + }, + { + "calls": 924352, + "cumulative_seconds": 0.11697504900000001, + "function": "~:0:", + "self_seconds": 0.07098915 + }, + { + "calls": 26941, + "cumulative_seconds": 0.073656204, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.062839305 + }, + { + "calls": 11788, + "cumulative_seconds": 0.075618481, + "function": "streams.py:122:_sequence", + "self_seconds": 0.060378335000000005 + }, + { + "calls": 11936, + "cumulative_seconds": 2.7471474860000002, + "function": "rng.py:85:child_generator", + "self_seconds": 0.05642525600000001 + }, + { + "calls": 12055, + "cumulative_seconds": 0.09320281300000001, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.033131966 + }, + { + "calls": 297862, + "cumulative_seconds": 0.040801548, + "function": "~:0:", + "self_seconds": 0.028110891000000002 + }, + { + "calls": 11263, + "cumulative_seconds": 0.067159862, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.021369538 + }, + { + "calls": 6131, + "cumulative_seconds": 0.083570424, + "function": "managers.py:410:apply", + "self_seconds": 0.019607999 + }, + { + "calls": 9827, + "cumulative_seconds": 0.048405000000000004, + "function": "managers.py:1241:iget", + "self_seconds": 0.019117119000000002 + } + ], + "total_seconds": 4.9981825550000005 + }, + "projection_process_cpu_seconds": 4.972636, + "projection_sha256": "4b9f68442f0dc41eeff42decf3919a00c648e5ee0e6d8950853aa2e231bafc28", + "projection_wall_time_seconds": 5.001278124982491, + "python_version": "3.14.7", + "result_rng_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "retained_person_year_rows": 11781, + "retained_slice_bytes": 5573799, + "rng_streams": "registry", + "scheduled_entries": 300, + "seconds_per_mortality_person_year": 0.00041900788580617385, + "shared_full_column_snapshot_counterfactual_bytes": 5573799, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "direct_emigration", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 6.0602542080159765, + "unique_annual_snapshot_bytes": 300732, + "unique_annual_snapshots": 76, + "wall_time_seconds": 5.908851167012472 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 4, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 141, + "deaths": 148, + "emigration_probability": 0.02, + "emigration_stream": "direct", + "emigration_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "emigration_survivor_person_years": 11788, + "emigrations": 248, + "final_people": 145, + "initial_people": 100, + "load_at_projection_end": [ + 84.51953125, + 89.166015625, + 83.40771484375 + ], + "load_at_projection_start": [ + 84.51953125, + 89.166015625, + 83.40771484375 + ], + "load_at_worker_start": [ + 84.51953125, + 89.166015625, + 83.40771484375 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 179, + "mortality_person_years": 11936, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 11067075, + "peak_memory_bytes": 194854912, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 14553, + "cumulative_seconds": 0.145789579, + "share_of_profile_total": 0.08259692280584421 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 11936, + "cumulative_seconds": 0.11721286500000001, + "share_of_profile_total": 0.0664068174739488 + }, + "adapter_direct_sequences": { + "calls": 14553, + "cumulative_seconds": 0.090917966, + "share_of_profile_total": 0.0515094718763566 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 2617, + "cumulative_seconds": 0.028576714000000003, + "share_of_profile_total": 0.016190105331895414 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 11788, + "cumulative_seconds": 0.07896378200000001, + "share_of_profile_total": 0.044736842311009835 + }, + "emigration_generator_setup": { + "calls": 11788, + "cumulative_seconds": 0.139712802, + "share_of_profile_total": 0.07915413159799436 + }, + "emigration_registry_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_generator_setup_total": { + "calls": 600, + "cumulative_seconds": 0.149306142, + "share_of_profile_total": 0.08458922763754201 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.149306142, + "share_of_profile_total": 0.08458922763754201 + }, + "sealed_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_registry_sequences": { + "calls": 600, + "cumulative_seconds": 0.142584772, + "share_of_profile_total": 0.08078124298701005 + }, + "sealed_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 26341, + "cumulative_seconds": 0.16191934500000002, + "function": "streams.py:122:_sequence", + "self_seconds": 0.129520666 + }, + { + "calls": 600, + "cumulative_seconds": 0.126151002, + "function": "rng.py:61:_period_sequence", + "self_seconds": 0.120554723 + }, + { + "calls": 924352, + "cumulative_seconds": 0.116309748, + "function": "~:0:", + "self_seconds": 0.070617372 + }, + { + "calls": 26941, + "cumulative_seconds": 0.064680602, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.055265024 + }, + { + "calls": 12055, + "cumulative_seconds": 0.092843305, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.033257641000000004 + }, + { + "calls": 297862, + "cumulative_seconds": 0.042102474, + "function": "~:0:", + "self_seconds": 0.029433551000000002 + }, + { + "calls": 11263, + "cumulative_seconds": 0.066690724, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.020704661000000003 + }, + { + "calls": 6131, + "cumulative_seconds": 0.082222454, + "function": "managers.py:410:apply", + "self_seconds": 0.019358064 + }, + { + "calls": 9827, + "cumulative_seconds": 0.047981521000000006, + "function": "managers.py:1241:iget", + "self_seconds": 0.01892496 + }, + { + "calls": 124116, + "cumulative_seconds": 0.041744033, + "function": "generic.py:45:_instancecheck", + "self_seconds": 0.017956913 + }, + { + "calls": 22530, + "cumulative_seconds": 0.021647931000000002, + "function": "generic.py:6359:__finalize__", + "self_seconds": 0.01733838 + }, + { + "calls": 124116, + "cumulative_seconds": 0.023030232, + "function": "generic.py:40:_check", + "self_seconds": 0.015659417000000002 + } + ], + "total_seconds": 1.765072766000001 + }, + "projection_process_cpu_seconds": 1.748469, + "projection_sha256": "4b9f68442f0dc41eeff42decf3919a00c648e5ee0e6d8950853aa2e231bafc28", + "projection_wall_time_seconds": 1.7678171249863226, + "python_version": "3.14.7", + "result_rng_stream_class": "__main__._measure..ProfiledAdapterDirect", + "retained_person_year_rows": 11781, + "retained_slice_bytes": 5573799, + "rng_streams": "direct", + "scheduled_entries": 300, + "seconds_per_mortality_person_year": 0.00014810800309872005, + "shared_full_column_snapshot_counterfactual_bytes": 5573799, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "direct", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 2.735384250001516, + "unique_annual_snapshot_bytes": 300732, + "unique_annual_snapshots": 76, + "wall_time_seconds": 2.5559256249980535 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 16, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 413, + "deaths": 599, + "emigration_probability": 0.02, + "emigration_stream": "registry", + "emigration_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "emigration_survivor_person_years": 43194, + "emigrations": 848, + "final_people": 566, + "initial_people": 400, + "load_at_projection_end": [ + 86.09716796875, + 88.87451171875, + 83.48681640625 + ], + "load_at_projection_start": [ + 80.6337890625, + 88.283203125, + 83.1298828125 + ], + "load_at_worker_start": [ + 84.51953125, + 89.166015625, + 83.40771484375 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 628, + "mortality_person_years": 43793, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 40445014, + "peak_memory_bytes": 202686464, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_generator_setup": { + "calls": 43194, + "cumulative_seconds": 11.216979618, + "share_of_profile_total": 0.4233701142758853 + }, + "emigration_registry_sequences": { + "calls": 43194, + "cumulative_seconds": 10.617094105000001, + "share_of_profile_total": 0.40072822609916936 + }, + "sealed_generator_setup_total": { + "calls": 51480, + "cumulative_seconds": 13.363794936000001, + "share_of_profile_total": 0.5043988294437692 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.165018153, + "share_of_profile_total": 0.006228392728920934 + }, + "sealed_person_generator_setup": { + "calls": 43793, + "cumulative_seconds": 11.363177699000001, + "share_of_profile_total": 0.4288881681877031 + }, + "sealed_registry_sequences": { + "calls": 51480, + "cumulative_seconds": 12.761152560000001, + "share_of_profile_total": 0.48165288710603116 + }, + "sealed_tagged_child_generator_setup": { + "calls": 7087, + "cumulative_seconds": 1.835599084, + "share_of_profile_total": 0.06928226852714518 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 94674, + "cumulative_seconds": 20.692070309, + "function": "rng.py:61:_period_sequence", + "self_seconds": 19.808926506000002 + }, + { + "calls": 94674, + "cumulative_seconds": 23.352410671, + "function": "rng.py:69:seed_sequence", + "self_seconds": 2.5295242040000003 + }, + { + "calls": 32382446, + "cumulative_seconds": 0.9815773000000001, + "function": "multiarray.py:197:concatenate", + "self_seconds": 0.9815773000000001 + }, + { + "calls": 94674, + "cumulative_seconds": 0.38618844700000005, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.33255432500000004 + }, + { + "calls": 50281, + "cumulative_seconds": 12.943766101000001, + "function": "rng.py:129:tagged_child_generator", + "self_seconds": 0.32570767 + }, + { + "calls": 43793, + "cumulative_seconds": 11.347398100000001, + "function": "rng.py:85:child_generator", + "self_seconds": 0.29581740100000004 + }, + { + "calls": 983326, + "cumulative_seconds": 0.134483551, + "function": "~:0:", + "self_seconds": 0.08161415300000001 + }, + { + "calls": 75, + "cumulative_seconds": 23.015921005000003, + "function": "exits.py:89:__call__", + "self_seconds": 0.071019989 + }, + { + "calls": 75, + "cumulative_seconds": 11.495912293, + "function": "steps.py:113:apply_mortality", + "self_seconds": 0.045407214 + }, + { + "calls": 12277, + "cumulative_seconds": 0.113392637, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.042918117000000006 + }, + { + "calls": 370037, + "cumulative_seconds": 0.05175627, + "function": "~:0:", + "self_seconds": 0.037876549 + }, + { + "calls": 43194, + "cumulative_seconds": 11.216979618, + "function": "op1_run_size.py:302:emigration_generator_setup", + "self_seconds": 0.032404761000000004 + } + ], + "total_seconds": 26.49450029600001 + }, + "projection_process_cpu_seconds": 25.510653, + "projection_sha256": "a4c37e549fca912944d4c973033d24847c6fd962307025113981561a84ea42a2", + "projection_wall_time_seconds": 26.501004624995403, + "python_version": "3.14.7", + "result_rng_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "retained_person_year_rows": 43159, + "retained_slice_bytes": 20380196, + "rng_streams": "registry", + "scheduled_entries": 1200, + "seconds_per_mortality_person_year": 0.0006051424799624461, + "shared_full_column_snapshot_counterfactual_bytes": 20380196, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "registry", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 27.671436167001957, + "unique_annual_snapshot_bytes": 1050396, + "unique_annual_snapshots": 76, + "wall_time_seconds": 27.46101104200352 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 16, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 413, + "deaths": 599, + "emigration_probability": 0.02, + "emigration_stream": "direct", + "emigration_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "emigration_survivor_person_years": 43194, + "emigrations": 848, + "final_people": 566, + "initial_people": 400, + "load_at_projection_end": [ + 76.18017578125, + 86.43603515625, + 82.73681640625 + ], + "load_at_projection_start": [ + 86.09716796875, + 88.87451171875, + 83.48681640625 + ], + "load_at_worker_start": [ + 86.09716796875, + 88.87451171875, + 83.48681640625 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 628, + "mortality_person_years": 43793, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 40445014, + "peak_memory_bytes": 201064448, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 43194, + "cumulative_seconds": 0.340070696, + "share_of_profile_total": 0.019325608575212018 + }, + "emigration_generator_setup": { + "calls": 43194, + "cumulative_seconds": 0.610949162, + "share_of_profile_total": 0.034719146645219315 + }, + "emigration_registry_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_generator_setup_total": { + "calls": 51480, + "cumulative_seconds": 14.846981710000001, + "share_of_profile_total": 0.8437273791176408 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.18669654000000002, + "share_of_profile_total": 0.010609629988190495 + }, + "sealed_person_generator_setup": { + "calls": 43793, + "cumulative_seconds": 12.592001656, + "share_of_profile_total": 0.7155809013966835 + }, + "sealed_registry_sequences": { + "calls": 51480, + "cumulative_seconds": 14.135873357000001, + "share_of_profile_total": 0.8033163650364932 + }, + "sealed_tagged_child_generator_setup": { + "calls": 7087, + "cumulative_seconds": 2.068283514, + "share_of_profile_total": 0.11753684773276685 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 51480, + "cumulative_seconds": 12.515159946, + "function": "rng.py:61:_period_sequence", + "self_seconds": 11.999372961 + }, + { + "calls": 51480, + "cumulative_seconds": 14.117422491000001, + "function": "rng.py:69:seed_sequence", + "self_seconds": 1.520602111 + }, + { + "calls": 17783024, + "cumulative_seconds": 0.581127729, + "function": "multiarray.py:197:concatenate", + "self_seconds": 0.581127729 + }, + { + "calls": 94674, + "cumulative_seconds": 0.38431405100000005, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.33110685900000003 + }, + { + "calls": 43793, + "cumulative_seconds": 12.571475561000002, + "function": "rng.py:85:child_generator", + "self_seconds": 0.318008345 + }, + { + "calls": 43194, + "cumulative_seconds": 0.325147232, + "function": "streams.py:122:_sequence", + "self_seconds": 0.263370627 + }, + { + "calls": 983326, + "cumulative_seconds": 0.14885752700000002, + "function": "~:0:", + "self_seconds": 0.09040123700000001 + }, + { + "calls": 75, + "cumulative_seconds": 12.760022751000001, + "function": "steps.py:113:apply_mortality", + "self_seconds": 0.064254481 + }, + { + "calls": 7087, + "cumulative_seconds": 2.0645368680000002, + "function": "rng.py:129:tagged_child_generator", + "self_seconds": 0.057088431 + }, + { + "calls": 12277, + "cumulative_seconds": 0.12816397000000002, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.049616160000000006 + }, + { + "calls": 43194, + "cumulative_seconds": 0.5298472320000001, + "function": "streams.py:191:tagged_child_generator", + "self_seconds": 0.048927974000000006 + }, + { + "calls": 75, + "cumulative_seconds": 13.678811127000001, + "function": "exits.py:89:__call__", + "self_seconds": 0.044570911000000005 + } + ], + "total_seconds": 17.596894539000004 + }, + "projection_process_cpu_seconds": 17.026541, + "projection_sha256": "a4c37e549fca912944d4c973033d24847c6fd962307025113981561a84ea42a2", + "projection_wall_time_seconds": 17.604304209002294, + "python_version": "3.14.7", + "result_rng_stream_class": "populace_dynamics.engine.rng.ProjectionRNGRegistry", + "retained_person_year_rows": 43159, + "retained_slice_bytes": 20380196, + "rng_streams": "registry", + "scheduled_entries": 1200, + "seconds_per_mortality_person_year": 0.0004019889984472928, + "shared_full_column_snapshot_counterfactual_bytes": 20380196, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "direct_emigration", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 19.50619633300812, + "unique_annual_snapshot_bytes": 1050396, + "unique_annual_snapshots": 76, + "wall_time_seconds": 19.300122791988542 + }, + { + "accounted_periods": 75, + "adapter_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "all_count_residuals_zero": true, + "all_revaluations_zero": true, + "annual_cohort_rows": 16, + "annual_snapshot_columns": [ + [ + "person_id", + "year", + "weight" + ] + ], + "annual_snapshot_references": 150, + "births": 413, + "deaths": 599, + "emigration_probability": 0.02, + "emigration_stream": "direct", + "emigration_stream_class": "populace_dynamics.open_population.streams.DirectRNGStreams", + "emigration_survivor_person_years": 43194, + "emigrations": 848, + "final_people": 566, + "initial_people": 400, + "load_at_projection_end": [ + 77.44677734375, + 86.5283203125, + 82.791015625 + ], + "load_at_projection_start": [ + 76.18017578125, + 86.43603515625, + 82.73681640625 + ], + "load_at_worker_start": [ + 76.18017578125, + 86.43603515625, + 82.73681640625 + ], + "maximum_absolute_weight_residual": 0.0, + "maximum_live_people": 628, + "mortality_person_years": 43793, + "numpy_version": "2.5.4", + "original_full_column_snapshot_counterfactual_bytes": 40445014, + "peak_memory_bytes": 203046912, + "platform": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "profile": { + "adapter_direct_generator_setup_total": { + "calls": 50880, + "cumulative_seconds": 0.5720793000000001, + "share_of_profile_total": 0.1832895904902548 + }, + "adapter_direct_module_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "adapter_direct_person_generator_setup": { + "calls": 43793, + "cumulative_seconds": 0.4857155, + "share_of_profile_total": 0.15561932600912032 + }, + "adapter_direct_sequences": { + "calls": 50880, + "cumulative_seconds": 0.355342604, + "share_of_profile_total": 0.11384890236528532 + }, + "adapter_direct_tagged_child_generator_setup": { + "calls": 7087, + "cumulative_seconds": 0.0863638, + "share_of_profile_total": 0.027670264481134462 + }, + "adapter_direct_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "emigration_direct_sequences": { + "calls": 43194, + "cumulative_seconds": 0.32503026900000004, + "share_of_profile_total": 0.10413707488096031 + }, + "emigration_generator_setup": { + "calls": 43194, + "cumulative_seconds": 0.576095019, + "share_of_profile_total": 0.18457619444714318 + }, + "emigration_registry_sequences": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_generator_setup_total": { + "calls": 600, + "cumulative_seconds": 0.176659964, + "share_of_profile_total": 0.056600426649912265 + }, + "sealed_module_generator_setup": { + "calls": 600, + "cumulative_seconds": 0.176659964, + "share_of_profile_total": 0.056600426649912265 + }, + "sealed_person_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_registry_sequences": { + "calls": 600, + "cumulative_seconds": 0.167608953, + "share_of_profile_total": 0.05370055577586947 + }, + "sealed_tagged_child_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "sealed_tagged_generator_setup": { + "calls": 0, + "cumulative_seconds": 0, + "share_of_profile_total": 0.0 + }, + "top_self_costs": [ + { + "calls": 94074, + "cumulative_seconds": 0.649650453, + "function": "streams.py:122:_sequence", + "self_seconds": 0.525480774 + }, + { + "calls": 94674, + "cumulative_seconds": 0.253042251, + "function": "_ufunc_config.py:496:inner", + "self_seconds": 0.217728637 + }, + { + "calls": 600, + "cumulative_seconds": 0.148393999, + "function": "rng.py:61:_period_sequence", + "self_seconds": 0.142268801 + }, + { + "calls": 983326, + "cumulative_seconds": 0.136620141, + "function": "~:0:", + "self_seconds": 0.08377311500000001 + }, + { + "calls": 50281, + "cumulative_seconds": 0.586136062, + "function": "streams.py:191:tagged_child_generator", + "self_seconds": 0.053733186 + }, + { + "calls": 43793, + "cumulative_seconds": 0.47384786100000004, + "function": "streams.py:152:child_generator", + "self_seconds": 0.042886478000000006 + }, + { + "calls": 12277, + "cumulative_seconds": 0.110078717, + "function": "construction.py:580:sanitize_array", + "self_seconds": 0.041162303000000004 + }, + { + "calls": 75, + "cumulative_seconds": 1.426090086, + "function": "exits.py:89:__call__", + "self_seconds": 0.040151318000000005 + }, + { + "calls": 370037, + "cumulative_seconds": 0.05059661100000001, + "function": "~:0:", + "self_seconds": 0.036544109000000005 + }, + { + "calls": 11461, + "cumulative_seconds": 0.08366946900000001, + "function": "managers.py:904:_slice_take_blocks_ax0", + "self_seconds": 0.027125533 + }, + { + "calls": 75, + "cumulative_seconds": 0.586485545, + "function": "steps.py:113:apply_mortality", + "self_seconds": 0.025776378000000003 + }, + { + "calls": 43194, + "cumulative_seconds": 0.576095019, + "function": "op1_run_size.py:314:emigration_generator_setup", + "self_seconds": 0.02395366 + } + ], + "total_seconds": 3.1211772500000023 + }, + "projection_process_cpu_seconds": 3.0486560000000003, + "projection_sha256": "a4c37e549fca912944d4c973033d24847c6fd962307025113981561a84ea42a2", + "projection_wall_time_seconds": 3.127639292011736, + "python_version": "3.14.7", + "result_rng_stream_class": "__main__._measure..ProfiledAdapterDirect", + "retained_person_year_rows": 43159, + "retained_slice_bytes": 20380196, + "rng_streams": "direct", + "scheduled_entries": 1200, + "seconds_per_mortality_person_year": 7.141870372004056e-05, + "shared_full_column_snapshot_counterfactual_bytes": 20380196, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "status": "completed", + "stream_mode": "direct", + "streams_py_sha256": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a", + "subprocess_wall_time_seconds": 4.405656750022899, + "unique_annual_snapshot_bytes": 1050396, + "unique_annual_snapshots": 76, + "wall_time_seconds": 4.214845292008249 + } + ], + "nondrawing_adapters": [ + "engine.steps.advance_age", + "open_population.exits.no_marital_transitions", + "no-op disability, earnings, claiming and household" + ], + "peak_memory_method": "resource.getrusage(RUSAGE_SELF).ru_maxrss; bytes on macOS, KiB converted to bytes on Linux", + "person_stream_adapters": [ + "engine.steps.apply_mortality", + "baselines.fertility.AsfrFertilityStep" + ], + "process_isolation": "one fresh worker per size and stream mode; workers run sequentially", + "profile_method": "cProfile projection only; registry_sequences measure root/period/module seed_sequence cumulative cost, excluding child leaves and default_rng; sealed_generator_setup_total adds four disjoint module/child/tagged/tagged-child setup wrappers and includes all leaves/default_rng; emigration_direct_sequences measures direct construction; emigration_generator_setup includes complete tagged generator setup", + "projected_years": 75, + "registered_horizon": 2100, + "requested_initial_sizes": [ + 25, + 100, + 400 + ], + "rng_microbenchmark": { + "draws_per_repetition": 400, + "limitation": "wall-clock samples include severe host scheduling variation; the direct path's medians do not establish a horizon cost relationship", + "load_at_end": [ + 77.44677734375, + 86.5283203125, + 82.791015625 + ], + "load_at_start": [ + 77.44677734375, + 86.5283203125, + 82.791015625 + ], + "maximum_microseconds_per_draw": { + "direct_7": 7.096562549122609, + "direct_75": 8.5842699627392, + "registry_7": 55.25676999241114, + "registry_75": 231.49354252382182 + }, + "median_microseconds_per_draw": { + "direct_7": 6.709375011269003, + "direct_75": 7.447707466781139, + "registry_7": 52.333019993966445, + "registry_75": 216.73916751751676 + }, + "method": "five interleaved repetitions; one tagged_child_generator plus random per address", + "microseconds_per_draw_samples": { + "direct_7": [ + 7.096562549122609, + 6.709375011269003, + 6.605102535104379, + 6.885312468511984, + 6.641352520091459 + ], + "direct_75": [ + 7.47728998248931, + 8.5842699627392, + 7.0404174766736105, + 7.447707466781139, + 6.7262499942444265 + ], + "registry_7": [ + 55.25676999241114, + 52.81375000777189, + 51.96177000470925, + 52.333019993966445, + 52.0922924624756 + ], + "registry_75": [ + 231.49354252382182, + 217.09874999942258, + 211.6769774875138, + 216.73916751751676, + 209.36323002388235 + ] + }, + "minimum_microseconds_per_draw": { + "direct_7": 6.605102535104379, + "direct_75": 6.7262499942444265, + "registry_7": 51.96177000470925, + "registry_75": 209.36323002388235 + } + }, + "scope": "invented data; arithmetic conformance and resource use only", + "size_comparisons": { + "direct": { + "average_cost_extrapolation_seconds_per_million_person_years": [ + 71.41870372004055, + 326.1861770168908 + ], + "extrapolation_formula": "multiply the observed average-cost envelope by the full run's mortality person-years; this includes fixed overhead and is not a marginal estimate", + "fitted_estimate_status": "unusable under varying host load; the raw marginal coefficient is not a runtime prediction", + "fitted_fixed_seconds_per_projected_year": 0.016192866557898904, + "limitation": "two small profiled sizes on a loaded host; the average-cost envelope is an engineering extrapolation, not a bound; full-size measurement remains open before OP10a", + "marginal_seconds_per_mortality_person_year": 4.3686760445032724e-05, + "observed_average_seconds_per_mortality_person_year_envelope": [ + 7.141870372004056e-05, + 0.0003261861770168908 + ] + }, + "direct_emigration": { + "average_cost_extrapolation_seconds_per_million_person_years": [ + 401.98899844729283, + 791.0440025610555 + ], + "extrapolation_formula": "multiply the observed average-cost envelope by the full run's mortality person-years; this includes fixed overhead and is not a marginal estimate", + "fitted_estimate_status": "unusable under varying host load; the raw marginal coefficient is not a runtime prediction", + "fitted_fixed_seconds_per_projected_year": 0.0247281008198265, + "limitation": "two small profiled sizes on a loaded host; the average-cost envelope is an engineering extrapolation, not a bound; full-size measurement remains open before OP10a", + "marginal_seconds_per_mortality_person_year": 0.00035963959188718074, + "observed_average_seconds_per_mortality_person_year_envelope": [ + 0.0004019889984472928, + 0.0007910440025610555 + ] + }, + "registry": { + "average_cost_extrapolation_seconds_per_million_person_years": [ + 605.1424799624461, + 1132.2859290540548 + ], + "extrapolation_formula": "multiply the observed average-cost envelope by the full run's mortality person-years; this includes fixed overhead and is not a marginal estimate", + "fitted_estimate_status": "unusable under varying host load; the raw marginal coefficient is not a runtime prediction", + "fitted_fixed_seconds_per_projected_year": 0.03350491888760481, + "limitation": "two small profiled sizes on a loaded host; the average-cost envelope is an engineering extrapolation, not a bound; full-size measurement remains open before OP10a", + "marginal_seconds_per_mortality_person_year": 0.0005477618730944453, + "observed_average_seconds_per_mortality_person_year_envelope": [ + 0.0006051424799624461, + 0.0011322859290540548 + ] + } + }, + "source_sha256": { + "src/populace_dynamics/open_population/exits.py": "5fd71d8918c851ce000019b2f7f8376a9a8bc3f30e40349cb249b56f9386e588", + "src/populace_dynamics/open_population/harness.py": "d88309faf4a3d8b3edde772d8a05b8a97242432292168fbfc8757074050e79ef", + "src/populace_dynamics/open_population/streams.py": "5dde79f3d249068e4b5b3dc1df83e55b1b469a80b66a67af4a508c5e6139bc3a" + }, + "start_year": 2025, + "stream_modes": { + "direct": "every person and tagged draw on DirectRNGStreams (the default)", + "direct_emigration": "adapters on ProjectionRNGRegistry, emigration on DirectRNGStreams (OP1 before the injection)", + "loop_module_generators": "the sealed loop's 600 positional module generators stay on its registry in every mode", + "registry": "adapters' person and tagged draws and emigration draws on ProjectionRNGRegistry" + }, + "stream_modes_have_identical_projection_hashes": true +} From 8634858c4900abc19fe9099b03fa7409d924840d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 02:23:12 -0700 Subject: [PATCH 08/15] Apply the coordinator's rulings on the stream injection - Keep the observer's loud refusal of the direct class, and re-raise it under the default as IdOrStreamCollision naming rng_streams="registry", chained from the sealed error. Only that exact plain ValueError is re-raised; run_production's and run_gate's docstrings say composing observe_mortality needs the registry mode. - Compare the scan's hits with the pinned set exactly, as sorted lists. - Skip the depletion-cut modules (bridge/depletion_cut*.py) by path, like the two closed modules. Tests pin the pattern and its three matches, check the tree's excluded set by directory listing, and check after a fresh projection that no depletion_cut module was imported. - Run the 2100 identity grid over the full product of sizes and draws. - Report the rulings, the host rerun (346 passed, 1 xfail) and six mutants. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 164 ++++++++---- .../open_population/gates.py | 5 +- .../open_population/harness.py | 38 ++- .../open_population/test_stream_injection.py | 241 ++++++++++++++---- 4 files changed, 349 insertions(+), 99 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index c93f9e5f..cbbd1513 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -663,7 +663,12 @@ dispatched it, at revision 3 of its brief, on 26 October. Base: master `0f69bbb2`; branch `openpop-op1-rng-inject-20261026`. `4431486d` adds the injection and `tests/open_population/test_stream_injection.py`; `be6843f7` extends `tests/open_population/test_streams.py`; later commits extend -`op1_run_size.py` and add this section. +`op1_run_size.py` and add this section. On 26 October the coordinator ruled +on the build's four open questions. The branch then merged master +`9cced8ff`, a docs-only ledger update, and applied the rulings: the +observer's refusal stays, with a clearer OP1 refusal under the default; the +2100 identity grid is the full product; and the scan skips the depletion-cut +modules by path. ### Mechanism @@ -692,6 +697,12 @@ extends `tests/open_population/test_streams.py`; later commits extend are the loop's 600 module generators per 75-year run. - `rng_streams="registry"` passes the loop's context through unchanged, which is OP1's behavior before this follow-up. +- Under `"direct"`, each slot's wrapper re-raises one error as an OP1 + refusal: a plain `ValueError` whose only argument is the sealed + observer's refusal text, `"existing RNG registry required"` + (`mortality_observer.py:382`). It becomes `IdOrStreamCollision` naming + `rng_streams="registry"`, chained from the original (see the observer + below). Every other error passes through as the object raised. - Emigration draws from `DirectRNGStreams` in both modes. `_ExitAdapter` builds its own from the context's draw and horizon (`open_population/exits.py:149–152`, unchanged), and @@ -755,22 +766,36 @@ pinned, so a member added to the registry fails the test until the direct class matches it. `test_sealed_code_holds_only_the_known_registry_type_check` runs an AST scan -of `src/populace_dynamics/` outside `open_population/`: 305 modules at -`0f69bbb2`, once the two closed modules below are dropped. +of `src/populace_dynamics/` outside `open_population/`: 302 modules at +`9cced8ff`, once the five excluded modules below are dropped. - **What it reads.** The project's data-access record keeps two modules out of automated reads: `estimates/adjusted_poverty.py` and - `uniform_cut_track_u/rows.py`. The walk drops both by path before anything - opens, reads, parses, hashes or stats them. - `test_closed_module_exclusions_are_pinned_exactly` pins that list, and - `test_scan_walk_skips_closed_modules_without_opening_them` records every - read on an invented tree whose decoys sit at those paths. Every other - module is read, `baselines/asfr.py` included. + `uniform_cut_track_u/rows.py`. The scan also skips the depletion-cut + modules, every path matching `src/populace_dynamics/bridge/depletion_cut*.py`, + so that what it reads does not depend on who runs it. At `9cced8ff` the + pattern matches three: `bridge/depletion_cut.py`, + `bridge/depletion_cut_population.py` and + `bridge/depletion_cut_population_child.py`. The walk decides from each + path alone and drops all five before anything opens, reads, parses, + hashes or stats them. + - `test_closed_module_exclusions_are_pinned_exactly` pins the two closed + paths, the pattern and the three paths it matches, and checks five + neighbouring paths the rule must not drop. + - `test_the_tree_excludes_exactly_the_pinned_modules` lists the tree by + directory listing alone and checks that the excluded set is exactly + those five, so a new match fails it and comes back for a decision. + - `test_scan_walk_skips_excluded_modules_without_opening_them` records + every read on an invented tree with decoys at all five paths and at one + more name the pattern matches. + + Every other module is read, `baselines/asfr.py` included. - **Closing the gap at run time.** In a fresh interpreter, - `test_projection_runs_import_neither_closed_module` runs a 2100 production + `test_projection_runs_import_no_excluded_module` runs a 2100 production projection and two 2027 gate runs, one in each mode, with person-stream, tagged-stream and emigration draws. It then checks that neither closed - module is in `sys.modules`. Code a projection never imports can't check - its registry's type. + module and no module whose name contains `depletion_cut` is in + `sys.modules`. Code a projection never imports can't check its + registry's type. - **What it catches.** `isinstance` and `issubclass` (with tuples and unions); `type(x)` and `x.__class__` compared by `is`, `is not`, `==`, `!=`, `in` or `not in`, alone or in a chain; any other comparison against @@ -817,12 +842,35 @@ where the coordinator's search for `isinstance` found none). It raises the registry, giving `apply_mortality`'s survivors, and refuses the direct class. - `test_pinned_sealed_check_refuses_a_direct_op1_run_loudly` composes it - into OP1: the registry mode completes and the direct default refuses. -- An OP1 run that composes this observer must pass - `rng_streams="registry"`. The scan test pins exactly this hit, by path, - line and kind, so any other check, or any move of this one, fails it. -- Whether to keep this disposition is the coordinator's call. Condition 2, - as written, asks for no check at all. + into OP1. The registry mode completes, with the observer called in both + years. Under the direct default, `run_gate` and `run_production` both + refuse in the first projected year, before the observer draws, with + `IdOrStreamCollision`: "a composed step requires the projection's own + ProjectionRNGRegistry (observe_mortality does, mortality_observer.py:381); + pass rng_streams="registry"". The sealed `ValueError` is its cause. +- `test_only_the_sealed_refusal_under_direct_streams_is_re_raised` checks + that only that error is re-raised. Four errors pass through unchanged + under the default: another `ValueError`, the refusal text with a second + argument, a `ValueError` subclass with the same text and a `TypeError`. + So does the refusal itself under the registry. + +The coordinator's ruling of 26 October keeps this disposition. An OP1 run +that composes `observe_mortality` must pass `rng_streams="registry"`, and +the harness does not switch modes for it. The docstrings of `run_production` +and `run_gate` say so. No entry point on this branch composes the observer. +Searching `src/`, the excluded modules aside, no module imports or calls +`observe_mortality` outside its own module; OP1's harness and gates name it +only in docstrings and in the refusal. The harness can't tell at setup that a +slot wraps the observer, since a slot is any callable. It therefore +re-raises the sealed refusal at the observer's first call, which comes +before any draw. + +Condition 2, as the coordinator amended it, requires the scan's set of +registry type checks in scanned sealed code to equal an exact pinned set. +Today that set is one entry: `mortality_observer.py`, line 381, +`type-compare`. The scan test compares the sorted hits with the sorted pin, +so a new hit, a moved line or a removed hit fails it, and any such change to +sealed code comes back for a decision. **3. Stream identity.** - `test_every_method_matches_registry_at_every_period` covers every period @@ -846,15 +894,11 @@ configuration in both modes and compares: - every slice, frame by frame; - the declared flows and the accounting history. -The grid is: -- sizes 1, 3 and 6 with draws 0, 3 and 17, in full, at 2027 (gate runs); -- at 2100, sizes 1, 3 and 6 with draws 0, 3 and 17 respectively - (production runs at the registered horizon). - -So every size and every draw reaches both horizons. The full product at 2100 -is a one-line change (`zip` to `product` in `IDENTITY_GRID`); it was left -out for the unit tier's time, since one 2100 run costs about as much as 35 -two-year runs. +The grid is sizes 1, 3 and 6 by draws 0, 3 and 17, in full, at both 2027 +(gate runs) and 2100 (production runs at the registered horizon): 18 +configurations. The build had zipped the 2100 runs to three; the +coordinator's ruling asked for the full product. The nine 2100 pairs took +0.76 to 0.97 s each on the host. Each run draws: - a mortality person stream for everyone; @@ -933,9 +977,9 @@ run on every CI push. .venv/bin/python -m pytest --collect-only -q -p no:cacheprovider -m unit tests/open_population .venv/bin/black --check -l 79 src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py .venv/bin/ruff check src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py -git diff --check 0f69bbb2 -git diff --stat 0f69bbb2 -git diff --quiet 0f69bbb2 -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b scripts tests/estimates tests/tier_counts.json tests/README-tiers.md +git diff --check 9cced8ff +git diff --stat 9cced8ff +git diff --quiet 9cced8ff -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b scripts tests/estimates tests/tier_counts.json tests/README-tiers.md ``` The build ran the required command in its sandbox in five chunks of named @@ -946,9 +990,9 @@ and the 101 tests this follow-up adds (76 in the new module and 25 in `test_streams.py`), and 104 in the other four modules. After the new module's last change (`d49d0813`: the binding-cycle fix and the chained comparison), all 76 of its tests ran again and passed. `--collect-only -m -unit` collects all 339, none deselected. Black, Ruff and `git diff --check` -are clean, `git diff --stat` lists only this branch's six files, and the -sealed-path `git diff --quiet` exits 0. +unit` collected all 339, none deselected. Black, Ruff and `git diff --check` +were clean, `git diff --stat` listed only the build's six files, and the +sealed-path `git diff --quiet` exited 0. The sandbox's wall times are not usable, so the build timed the new tests by process CPU time. In the sandbox, the existing 2100 production test took @@ -957,19 +1001,36 @@ new tests took 12.7 (the hash grid), 6.8 (the every-period differential) and 2.3 (the scan) sandbox CPU seconds. The main session's host rerun gives the times that count. -On the host, at `441b9f77`, the main session ran the required command in two -named-module processes. The first started at a one-minute load of 114. -- `tests/open_population`: 337 passed and 1 strict xfail in 16.38 s, with - `test_sealed_code_holds_only_the_known_registry_type_check` deselected. - That test reads the source tree; it waits for the coordinator's ruling on - which modules the scan reads, and CI runs it. -- The other four modules: 104 passed in 1.63 s. -- Peak RSS was 223 MB. - -A timed run of the two stream modules, with the same test deselected, gave -245 passed in 12.23 s. The slowest tests took 1.38 s (the fresh-interpreter -import check), 1.0 to 1.2 s for each 2100 hash pair, and about 0.8 s (the -every-period differential). +On the host, at `441b9f77`, the main session first ran the required +command with the real-tree scan test deselected, pending the ruling on +which modules the scan reads: 337 passed and 1 strict xfail in +`tests/open_population`, and 104 passed in the other four modules. + +After the rulings were applied, it ran the required command again, in two +named-module processes at one-minute loads under 8: +- `tests/open_population`: **346 passed and 1 strict xfail** in 18.15 s, + the real-tree scan test included. These are OP1's 238 and the 109 tests + this follow-up adds: 84 in `test_stream_injection.py` and 25 in + `test_streams.py`. +- The other four modules: 104 passed in 0.86 s. +- Peak RSS was 340 MB. +- `--collect-only -m unit tests/open_population` collects all 347, none + deselected. +- Black, Ruff and `git diff --check` are clean. `git diff --stat 9cced8ff` + lists this branch's seven files: the build's six and + `op1_run_size_injection.json`. The sealed-path `git diff --quiet` exits 0. + +The slowest tests took 1.16 s (the fresh-interpreter import check), 0.99 s +(the real-tree scan) and 0.76 to 0.97 s for each 2100 hash pair. + +Six mutants of the new code each fail the tests aimed at them: +- re-raising under both modes; +- accepting `ValueError` subclasses; +- matching the refusal text as a substring; +- never re-raising; +- re-raising without the chain; +- ignoring the depletion-cut pattern. This mutant ran only against the + pin and decoy tests, never against the real tree. ### Restricted-files statement @@ -983,6 +1044,11 @@ wrote or ran imports the two closed modules, loads either by path, or reads the committed MINT8 guide. Twice, a shell search or byte count over `src/` in the build's own session did not apply its exclusions, because of how the shell expanded them. Neither printed a line from any excluded file, and the -build's report to the main session lists both. Every test, probe and -measurement uses invented inputs. No microdata or external statistical table -was read, and no real-data statistic was computed. +build's report to the main session lists both. Since then, every scripted +search by the main session takes an explicit file list from `git ls-files`, +minus the excluded files, never a shell glob with exclusions. The main +session ran the real-tree scan test only after the depletion-cut exclusion +was in. With an audit hook on file opens, that run opened no excluded file, +and no projection run imported a closed or depletion-cut module. Every +test, probe and measurement uses invented inputs. No microdata or external +statistical table was read, and no real-data statistic was computed. diff --git a/src/populace_dynamics/open_population/gates.py b/src/populace_dynamics/open_population/gates.py index 7f5172fe..ba393383 100644 --- a/src/populace_dynamics/open_population/gates.py +++ b/src/populace_dynamics/open_population/gates.py @@ -62,7 +62,10 @@ def run_gate( """Run the fixed gate horizon through the OP1 accounting harness. ``rng_streams="registry"`` is for differential checks; both classes give - the same projection (design §§2.1, 5.4). + the same projection (design §§2.1, 5.4). A run that composes + ``observe_mortality`` must pass ``rng_streams="registry"``: the observer + refuses any other registry class (``mortality_observer.py:381``), and + under the default that refusal is re-raised as ``IdOrStreamCollision``. """ if type(request) is not GateRunRequest: raise TypeError("run_gate requires a GateRunRequest") diff --git a/src/populace_dynamics/open_population/harness.py b/src/populace_dynamics/open_population/harness.py index b86a63d3..a08108bf 100644 --- a/src/populace_dynamics/open_population/harness.py +++ b/src/populace_dynamics/open_population/harness.py @@ -14,6 +14,12 @@ and tagged draws avoid the registry's horizon-proportional spawn (``engine/rng.py:61–67``) and stay identical (design §§2.1, 5.4). The module generators the loop passes positionally stay on its registry. + +One sealed step checks the registry's type: ``observe_mortality`` refuses any +registry that is not exactly a ``ProjectionRNGRegistry`` +(``mortality_observer.py:381``) before it draws. A run that composes it must +pass ``rng_streams="registry"``; under the default its refusal is re-raised as +an OP1 refusal that says so. """ from __future__ import annotations @@ -71,6 +77,9 @@ ) RNG_STREAM_MODES = ("registry", "direct") RNGStreamMode = Literal["registry", "direct"] +# observe_mortality's refusal of any other registry class +# (mortality_observer.py:381–382), raised before it draws. +SEALED_REGISTRY_REFUSAL = "existing RNG registry required" def _registered_horizon(scenario: str) -> int: @@ -225,11 +234,31 @@ def bind(self, context: PeriodContext) -> PeriodContext: return self._bound[1] def wrap(self, step: Callable) -> Callable: - """Call ``step`` with this period's bound context, other args as-is.""" + """Call ``step`` with this period's bound context, other args as-is. + + Under ``"direct"``, the sealed observer's refusal of the direct class + is re-raised as ``IdOrStreamCollision`` naming the fix, chained from + the original. Every other error passes through unchanged. + """ @wraps(step) def bound(frame, context, *args): - return step(frame, self.bind(context), *args) + served = self.bind(context) + try: + return step(frame, served, *args) + except ValueError as error: + if ( + self.mode == "direct" + and type(error) is ValueError + and error.args == (SEALED_REGISTRY_REFUSAL,) + ): + raise IdOrStreamCollision( + "a composed step requires the projection's own " + "ProjectionRNGRegistry (observe_mortality does, " + "mortality_observer.py:381); pass " + 'rng_streams="registry"' + ) from error + raise return bound @@ -260,6 +289,7 @@ def _project( ``rng_streams`` picks the class behind every slot's ``context.rng_registry``; the projection is identical either way. + Composing ``observe_mortality`` needs ``rng_streams="registry"``. """ adapter_streams = _AdapterStreams(rng_streams) require_emigration_compatible(modules.marital_core, emigration_hazard) @@ -413,6 +443,10 @@ def run_production( ``rng_streams="registry"`` serves the adapters from the loop's own registry, for differential checks; the default is identical and faster. + A run that composes ``observe_mortality`` must pass + ``rng_streams="registry"``: the observer refuses any other registry class + (``mortality_observer.py:381``), and under the default that refusal is + re-raised as ``IdOrStreamCollision``. """ if type(request) is not ProductionRequest: raise TypeError("run_production requires a ProductionRequest") diff --git a/tests/open_population/test_stream_injection.py b/tests/open_population/test_stream_injection.py index 10185bf4..e5924fe4 100644 --- a/tests/open_population/test_stream_injection.py +++ b/tests/open_population/test_stream_injection.py @@ -8,16 +8,19 @@ registry's type, so this module pins both, then checks the projection itself. The AST scan reads ``src/populace_dynamics/`` outside ``open_population/``. -The project's data-access record keeps two modules out of automated reads, so -the scan skips them by path before anything opens them; a fresh-process run -shows a projection imports neither. Every message names a module path, line -number and node kind only, never source text or literal values. +The project's data-access record keeps two modules out of automated reads, +and the scan also skips the depletion-cut modules, so that what it reads does +not depend on who runs it. It drops all of them by path before anything opens +them; a fresh-process run shows a projection imports none of them. Every +message names a module path, line number and node kind only, never source +text or literal values. """ from __future__ import annotations import ast import dataclasses +import fnmatch import hashlib import inspect import json @@ -29,6 +32,7 @@ from collections.abc import Iterable from dataclasses import dataclass, replace from functools import partial +from itertools import product from pathlib import Path import numpy as np @@ -57,6 +61,7 @@ from populace_dynamics.open_population.gates import GateRunRequest, run_gate from populace_dynamics.open_population.harness import ( RNG_STREAM_MODES, + SEALED_REGISTRY_REFUSAL, ProductionRequest, _AdapterStreams, run_production, @@ -186,6 +191,15 @@ def test_direct_streams_are_not_a_registry_subclass(): Path("src/populace_dynamics/estimates/adjusted_poverty.py"), Path("src/populace_dynamics/uniform_cut_track_u/rows.py"), ) +# The scan also skips the depletion-cut modules by path, so that its input +# does not depend on who runs it. The pattern decides; the list pins what it +# matches today, so a new match comes back for a decision. +EXCLUDED_PATTERNS = ("src/populace_dynamics/bridge/depletion_cut*.py",) +PATTERN_EXCLUDED_MODULES = ( + Path("src/populace_dynamics/bridge/depletion_cut.py"), + Path("src/populace_dynamics/bridge/depletion_cut_population.py"), + Path("src/populace_dynamics/bridge/depletion_cut_population_child.py"), +) # Private names that exist on only one class: probing for one tells the # classes apart without a type check. PROBE_NAMES = frozenset( @@ -551,16 +565,22 @@ def _read(path: Path, relative: Path) -> str: raise ScanError(f"{relative.as_posix()}: not UTF-8") from None -def repository_sources(root: Path = ROOT) -> tuple[ScanSource, ...]: - """Collect ``src/populace_dynamics/`` outside OP1's own package. +def _excluded(relative: Path) -> bool: + """Decide from the path alone whether the scan skips a module.""" + return relative in CLOSED_MODULES or any( + fnmatch.fnmatchcase(relative.as_posix(), pattern) + for pattern in EXCLUDED_PATTERNS + ) - ``__pycache__`` folders are pruned, and each closed module is dropped by - its path before anything opens, reads, parses, hashes or stats it. + +def _walk(root: Path) -> Iterable[tuple[Path, Path, bool]]: + """List ``.py`` paths under ``src/populace_dynamics/`` by name only. + + ``__pycache__`` folders and OP1's own package are pruned. Each path comes + with whether ``_excluded`` drops it; nothing here opens or stats a file. """ package = root / "src" / "populace_dynamics" own = root / OWN_PACKAGE - closed = {root / path for path in CLOSED_MODULES} - sources = [] for directory, subdirectories, files in os.walk(package): current = Path(directory) subdirectories[:] = sorted( @@ -569,26 +589,49 @@ def repository_sources(root: Path = ROOT) -> tuple[ScanSource, ...]: if name != "__pycache__" and current / name != own ) for name in sorted(files): - path = current / name - if not name.endswith(".py") or path in closed: - continue - relative = path.relative_to(root) - parts = path.relative_to(root / "src").with_suffix("").parts - is_package = name == "__init__.py" - sources.append( - ScanSource( - module=".".join(parts[:-1] if is_package else parts), - path=relative.as_posix(), - text=_read(path, relative), - is_package=is_package, - ) + if name.endswith(".py"): + path = current / name + relative = path.relative_to(root) + yield path, relative, _excluded(relative) + + +def excluded_paths(root: Path = ROOT) -> tuple[str, ...]: + """Name the modules the scan skips, from directory listings alone.""" + return tuple( + sorted( + relative.as_posix() for _, relative, skip in _walk(root) if skip + ) + ) + + +def repository_sources(root: Path = ROOT) -> tuple[ScanSource, ...]: + """Collect ``src/populace_dynamics/`` outside OP1's own package. + + ``__pycache__`` folders are pruned, and each excluded module is dropped + by its path before anything opens, reads, parses, hashes or stats it. + """ + sources = [] + for path, relative, skip in _walk(root): + if skip: + continue + parts = path.relative_to(root / "src").with_suffix("").parts + is_package = path.name == "__init__.py" + sources.append( + ScanSource( + module=".".join(parts[:-1] if is_package else parts), + path=relative.as_posix(), + text=_read(path, relative), + is_package=is_package, ) + ) return tuple(sources) -# The one type check the scan finds in master's sealed code. It refuses the -# direct class loudly instead of diverging (pinned by the behaviour tests -# below), so an OP1 run that composes it must pass rng_streams="registry". +# The exact set of type checks the scan finds in master's sealed code: one. +# It refuses the direct class loudly instead of diverging (pinned by the +# behaviour tests below), so an OP1 run that composes it must pass +# rng_streams="registry". A new hit, a moved line or a removed hit fails the +# scan test, so any such change to sealed code comes back for a decision. KNOWN_SEALED_TYPE_CHECKS = frozenset( { ScanHit( @@ -603,7 +646,35 @@ def test_closed_module_exclusions_are_pinned_exactly(): Path("src/populace_dynamics/estimates/adjusted_poverty.py"), Path("src/populace_dynamics/uniform_cut_track_u/rows.py"), ) + assert EXCLUDED_PATTERNS == ( + "src/populace_dynamics/bridge/depletion_cut*.py", + ) + assert PATTERN_EXCLUDED_MODULES == ( + Path("src/populace_dynamics/bridge/depletion_cut.py"), + Path("src/populace_dynamics/bridge/depletion_cut_population.py"), + Path("src/populace_dynamics/bridge/depletion_cut_population_child.py"), + ) assert OWN_PACKAGE == Path("src/populace_dynamics/open_population") + assert all(_excluded(path) for path in CLOSED_MODULES) + assert all(_excluded(path) for path in PATTERN_EXCLUDED_MODULES) + for neighbour in ( + "src/populace_dynamics/bridge/__init__.py", + "src/populace_dynamics/bridge/depletion.py", + "src/populace_dynamics/bridge/depletion_cut.pyc", + "src/populace_dynamics/engine/depletion_cut.py", + "src/populace_dynamics/estimates/adjusted_poverty_other.py", + ): + assert not _excluded(Path(neighbour)), neighbour + + +def test_the_tree_excludes_exactly_the_pinned_modules(): + """The pattern matches exactly the pinned list, by directory listing.""" + assert excluded_paths() == tuple( + sorted( + path.as_posix() + for path in (*CLOSED_MODULES, *PATTERN_EXCLUDED_MODULES) + ) + ) def test_sealed_code_holds_only_the_known_registry_type_check(): @@ -614,12 +685,16 @@ def test_sealed_code_holds_only_the_known_registry_type_check(): """ sources = repository_sources() paths = {source.path for source in sources} - assert not paths & {path.as_posix() for path in CLOSED_MODULES} + assert not paths & set(excluded_paths()) + assert not paths & { + path.as_posix() + for path in (*CLOSED_MODULES, *PATTERN_EXCLUDED_MODULES) + } assert not any(path.startswith(f"{OWN_PACKAGE}/") for path in paths) assert "src/populace_dynamics/engine/rng.py" in paths assert "src/populace_dynamics/baselines/asfr.py" in paths hits = scan_sources(sources) - assert set(hits) == KNOWN_SEALED_TYPE_CHECKS, describe(hits) + assert sorted(hits) == sorted(KNOWN_SEALED_TYPE_CHECKS), describe(hits) _REGISTRY_IMPORT = ( @@ -955,13 +1030,15 @@ def test_scan_messages_never_show_source_text(): assert refused.value.__suppress_context__ -def test_scan_walk_skips_closed_modules_without_opening_them( +def test_scan_walk_skips_excluded_modules_without_opening_them( tmp_path, monkeypatch ): - """The walk drops closed paths before any read of them. + """The walk drops excluded paths before any read of them. - Every read is recorded, and the decoys are also made unreadable where - the process is not the superuser, so a read would fail either way. + Decoys sit at the closed paths, at the pinned depletion-cut paths and at + one more name the pattern matches. Every read is recorded, and the + decoys are also made unreadable where the process is not the superuser, + so a read would fail either way. """ package = tmp_path / "src" / "populace_dynamics" check = ( @@ -973,12 +1050,17 @@ def test_scan_walk_skips_closed_modules_without_opening_them( "engine/checks.py": check, "open_population/own.py": check, "engine/__pycache__/cached.py": check, + "bridge/depletion.py": "", } for relative, text in files.items(): path = package / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) - decoys = [tmp_path / relative for relative in CLOSED_MODULES] + unpinned = Path("src/populace_dynamics/bridge/depletion_cut_invented.py") + decoys = [ + tmp_path / relative + for relative in (*CLOSED_MODULES, *PATTERN_EXCLUDED_MODULES, unpinned) + ] for decoy in decoys: decoy.parent.mkdir(parents=True, exist_ok=True) decoy.write_text(check) @@ -1000,17 +1082,23 @@ def recording(path): decoy.chmod(0o600) assert not set(read) & set(decoys) assert {path.relative_to(tmp_path).as_posix() for path in read} == { + "src/populace_dynamics/bridge/depletion.py", "src/populace_dynamics/engine/__init__.py", "src/populace_dynamics/engine/checks.py", } assert [source.path for source in sources] == [ + "src/populace_dynamics/bridge/depletion.py", "src/populace_dynamics/engine/__init__.py", "src/populace_dynamics/engine/checks.py", ] assert [source.module for source in sources] == [ + "populace_dynamics.bridge.depletion", "populace_dynamics.engine", "populace_dynamics.engine.checks", ] + assert excluded_paths(tmp_path) == tuple( + sorted(decoy.relative_to(tmp_path).as_posix() for decoy in decoys) + ) hits = scan_sources(sources) assert [str(hit) for hit in hits] == [ "src/populace_dynamics/engine/checks.py:3: isinstance" @@ -1065,12 +1153,27 @@ def test_pinned_sealed_check_refuses_direct_streams_loudly(): observe_mortality(frame, direct, rng, **keywords) +def _assert_observer_refusal(refused): + """The OP1 refusal names the fix and chains the sealed refusal.""" + assert str(refused.value) == ( + "ID_OR_STREAM_COLLISION: a composed step requires the projection's " + "own ProjectionRNGRegistry (observe_mortality does, " + 'mortality_observer.py:381); pass rng_streams="registry"' + ) + cause = refused.value.__cause__ + assert type(cause) is ValueError + assert cause.args == (SEALED_REGISTRY_REFUSAL,) + + def test_pinned_sealed_check_refuses_a_direct_op1_run_loudly(): """Composed into OP1, the observer runs on the registry and refuses - the direct default before any draw, rather than diverging.""" + the direct default before any draw, rather than diverging. The harness + re-raises the refusal as its own, naming ``rng_streams="registry"``.""" frame, keywords = _observer_inputs() + draws = [] def observed(frame, context, rng): + draws.append(context.year) return observe_mortality(frame, context, rng, **keywords)[0] modules = replace(_modules(), mortality=observed, fertility=_unchanged) @@ -1078,8 +1181,52 @@ def observed(frame, context, rng): result = run_gate(frame, modules, request, rng_streams="registry") assert result.rng_stream_class == REGISTRY_CLASS assert len(result.accounting.periods) == 2 - with pytest.raises(ValueError, match="existing RNG registry required"): + assert draws == [START + 1, START + 2] + draws.clear() + with pytest.raises(IdOrStreamCollision) as refused: run_gate(frame, modules, request) + _assert_observer_refusal(refused) + assert draws == [START + 1] + with pytest.raises(IdOrStreamCollision) as refused: + run_production( + frame, modules, ProductionRequest("tr2026_intermediate") + ) + _assert_observer_refusal(refused) + assert draws == [START + 1, START + 1] + + +def test_only_the_sealed_refusal_under_direct_streams_is_re_raised(): + """Other errors, the same text from a subclass, and the same refusal + under the registry all pass through as the very object raised.""" + + class Subclass(ValueError): + pass + + def raising(error): + def step(frame, context, rng): + raise error + + return step + + registry = ProjectionRNGRegistry(0, 2) + rng = np.random.default_rng(0) + for mode, error in ( + ("direct", ValueError("another refusal")), + ("direct", ValueError(SEALED_REGISTRY_REFUSAL, "extra")), + ("direct", Subclass(SEALED_REGISTRY_REFUSAL)), + ("direct", TypeError(SEALED_REGISTRY_REFUSAL)), + ("registry", ValueError(SEALED_REGISTRY_REFUSAL)), + ): + step = _AdapterStreams(mode).wrap(raising(error)) + with pytest.raises(type(error)) as passed: + step(None, _context(registry), rng) + assert passed.value is error, (mode, error) + step = _AdapterStreams("direct").wrap( + raising(ValueError(SEALED_REGISTRY_REFUSAL)) + ) + with pytest.raises(IdOrStreamCollision) as refused: + step(None, _context(registry), rng) + _assert_observer_refusal(refused) # --------------------------------------------------------------------------- @@ -1158,17 +1305,20 @@ def unchanged(frame, *args): ) print(json.dumps({ "loaded": {name: name in sys.modules for name in closed}, + "depletion_cut": sorted( + name for name in sys.modules if "depletion_cut" in name + ), "classes": classes, })) """) -def test_projection_runs_import_neither_closed_module(): +def test_projection_runs_import_no_excluded_module(): """A fresh process runs OP1 to 2100 and both ways to 2027, then - checks ``sys.modules``. + checks ``sys.modules`` for the closed and depletion-cut modules. Code a projection never imports cannot check its registry's type, so - this closes the gap the path exclusion leaves in the scan. + this closes the gap the path exclusions leave in the scan. """ environment = dict(os.environ) environment["PYTHONPATH"] = str(ROOT / "src") @@ -1188,6 +1338,7 @@ def test_projection_runs_import_neither_closed_module(): "populace_dynamics.estimates.adjusted_poverty": False, "populace_dynamics.uniform_cut_track_u.rows": False, } + assert report["depletion_cut"] == [] assert report["classes"] == [DIRECT_CLASS, REGISTRY_CLASS, DIRECT_CLASS] @@ -1415,15 +1566,11 @@ def snapshot(): DRAWS = (0, 3, 17) SHORT_HORIZON = START + 2 LONG_HORIZON = 2100 -# Every size with every draw at the short horizon. At 2100 each size runs -# with its own draw, so all three sizes and all three draws reach the -# registered horizon: one 2100 run costs about as much as 35 short ones. -IDENTITY_GRID = ( - *((size, draw, SHORT_HORIZON) for size in SIZES for draw in DRAWS), - *( - (size, draw, LONG_HORIZON) - for size, draw in zip(SIZES, DRAWS, strict=True) - ), +# Every size with every draw, at the short horizon and at the registered one. +IDENTITY_GRID = tuple( + (size, draw, horizon) + for horizon in (SHORT_HORIZON, LONG_HORIZON) + for size, draw in product(SIZES, DRAWS) ) From 33e383b49410cb793eb98800940735fffe83e4aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 04:08:29 -0700 Subject: [PATCH 09/15] Close the scan's static gaps and pin every context field (review inject-r1) - Scan: a catch-all now reports every other mention of the class, its name or its module's name outside imports, docstrings, __all__, annotations and construction. The specific checks follow checking-function aliases, class info in containers, unions, + joins, subscripts and named expressions, loop and attribute bindings, name and module comparisons, match values, quoted dispatch hints and private-attribute reads. - The text filter is gone; inert literal containers are pruned after parsing, with a differential test and a Hypothesis property showing that pruning changes no result. The real tree still gives exactly the pinned hit. - 58 invented spellings, all twelve of the review's among them; 56 run and answer differently for the two classes. 16 negatives. - The binding test compares every PeriodContext field with the loop's own context, so a changed draw index, metadata or ordinals fails it. - Section 6: the review's findings and fixes, the scan's coverage and limits, RSS within 1.4 MB, base 9cced8ff and three evidence files, the generator helpers and entrant years stated exactly, refreshed line references and the host results. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 236 ++++- .../open_population/test_stream_injection.py | 980 +++++++++++++++--- 2 files changed, 1033 insertions(+), 183 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index cbbd1513..2e130276 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -668,7 +668,8 @@ on the build's four open questions. The branch then merged master `9cced8ff`, a docs-only ledger update, and applied the rulings: the observer's refusal stays, with a clearer OP1 refusal under the default; the 2100 identity grid is the full product; and the scan skips the depletion-cut -modules by path. +modules by path. Review inject-r1 then asked for revisions at `8634858c`; +its subsection, below, gives the findings and the fixes. ### Mechanism @@ -677,19 +678,19 @@ modules by path. (`engine/loop.py:216–217`), puts it on each period's `PeriodContext` (`:287–294`) and passes each slot a module generator positionally (`:297–350`). So the substitution lives in OP1's wrapper, -`_AdapterStreams` (`open_population/harness.py:171–234`): +`_AdapterStreams` (`open_population/harness.py:180–264`): - At the first period it keeps the loop's registry and builds one - `DirectRNGStreams(registry.draw_index, registry.n_periods)` (`:205–212`). + `DirectRNGStreams(registry.draw_index, registry.n_periods)` (`:215–221`). Every later period must carry the same registry object, or the run is - refused with `ID_OR_STREAM_COLLISION` (`:213–216`). A context without a - registry is refused the same way (`:202–206`). + refused with `ID_OR_STREAM_COLLISION` (`:222–225`). A context without a + registry is refused the same way (`:211–214`). - Every slot of a period receives one `dataclasses.replace(context, rng_registry=)`, built once per - period (`:217–225`), just as the loop gives its eight slots one context. + period (`:228–234`), just as the loop gives its eight slots one context. Metadata, the person ordinals and every other field are the loop's own objects. -- All eight slots are bound (`:361–377`): the mortality wrapper, and through +- All eight slots are bound (`:393–405`): the mortality wrapper, and through it `_ExitAdapter` and the mortality adapter; aging; the marital core; the birth-entry adapter, and through it fertility; disability; earnings; claiming; and `close_period`, and through it household composition. @@ -711,11 +712,11 @@ modules by path. `_project`, `run_production` and `run_gate` take `rng_streams: Literal["registry", "direct"] = "direct"` -(`open_population/harness.py:257`, `:409`; `open_population/gates.py:60`). +(`open_population/harness.py:286`, `:439`; `open_population/gates.py:60`). Any other value is refused before the initializer or any adapter runs -(`harness.py:181–185`, `:264`). `OpenPopulationResult` records +(`harness.py:191–194`, `:294`). `OpenPopulationResult` records `rng_streams` and `rng_stream_class`, the qualified name of the class that -served the slots (`:120–121`, `:191–197`, `:399–400`). No module was added, +served the slots (`:129–130`, `:200–206`, `:429–430`). No module was added, so `POST_REVIEW_SOURCE_EXCLUSIONS` and its pin are unchanged. ### Invariants @@ -736,11 +737,12 @@ so `POST_REVIEW_SOURCE_EXCLUSIONS` and its pin are unchanged. ### The coordinator's conditions and their evidence -**1. No sealed code changes.** Against master `0f69bbb2` the branch changes -only `open_population/harness.py` and `gates.py`, OP1's tests and the two -`op1_*` evidence files (§3's commands, with `0f69bbb2` as the base). No -engine or baselines attribute is patched, and `DirectRNGStreams` is not a -registry subclass: +**1. No sealed code changes.** Against master `9cced8ff`, the master it +merged, the branch changes only `open_population/harness.py` and `gates.py`, +OP1's two stream test modules and three files in this folder: this report, +`op1_run_size.py` and `op1_run_size_injection.json` (§3's commands, with +`9cced8ff` as the base). No engine or baselines attribute is patched, and +`DirectRNGStreams` is not a registry subclass: - `test_a_direct_run_patches_no_sealed_module_attribute` compares every attribute of `engine/rng.py`, `engine/loop.py`, `ProjectionRNGRegistry` and `PeriodContext`, by identity, before and after a direct run; @@ -749,6 +751,12 @@ registry subclass: in both modes, checks the state of the generator each of the eight slots receives against the loop's module generator, and that one context, one stream instance, one metadata mapping and one ordinal map serve the run. + It also records the loop's own context where the harness receives it and + compares every other `PeriodContext` field of each slot's context with + it: equal values, the requested draw index, and the loop's own metadata + and ordinal objects. Only `rng_registry` differs, and only in direct mode. + A replacement context with a changed draw index, or with copied metadata + or ordinals, fails it. `op1_run_size.py` patches registry methods, as it did at OP1's merge, but only inside its measurement worker, to time them. The harness patches @@ -796,41 +804,106 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at module and no module whose name contains `depletion_cut` is in `sys.modules`. Code a projection never imports can't check its registry's type. -- **What it catches.** `isinstance` and `issubclass` (with tuples and - unions); `type(x)` and `x.__class__` compared by `is`, `is not`, `==`, - `!=`, `in` or `not in`, alone or in a chain; any other comparison against - the class; `match` class patterns; class-name string comparisons; `__instancecheck__` and - `__subclasscheck__`; `singledispatch` registration by argument or by - annotation; and `hasattr` or `getattr` probes of a private name found on - only one class. -- **How it names the class.** Directly; through an import alias; as a +- **Two layers.** Specific checks name the kind of each type check they + find. Under them, a catch-all reports every other mention of the class, + or of its name or its module's name as text, outside a benign place. + Benign places are imports, string statements (docstrings among them), + `__all__` listings, annotations other than dispatch hints, and the callee + of a construction such as `ProjectionRNGRegistry(draw_index, n_periods)`. + So a static spelling the specific checks miss still fails the pin, as a + `class-reference`, `class-name-string` or `module-name-string` hit on its + line. +- **What the specific checks catch.** + - `isinstance` and `issubclass`, also through aliases such as + `check = isinstance` or `from builtins import issubclass as check`. + - `type(x)` or `x.__class__` compared with the class, by any comparison + operator, alone or in a chain; and any other comparison against the + class or class info. + - Comparisons with the class's name as text: `__name__` or + `__qualname__` against a string, a string held in a name or a + container, or `ProjectionRNGRegistry.__name__`. Comparisons with its + module's name count the same way. + - `match` class and value patterns. + - `__instancecheck__` and `__subclasscheck__`. + - `singledispatch` registration by argument or by hint, quoted hints + included. + - Private names found on only one class, whether read as an attribute, + probed with `hasattr` or `getattr`, or held as a string. +- **How it follows the class.** Directly; through an import alias; as a module attribute (`rng.ProjectionRNGRegistry`, the dotted path, or an - aliased module); through relative imports, local assignment chains, - default arguments, `type(ProjectionRNGRegistry(...))` and `getattr` on the - module; and through re-exports across modules, followed to a fixed point. - Bindings only accumulate, so the fixed point ends even on a binding - cycle such as `a = b.c; b = a`. -- **Speed.** A module is parsed only if its text holds a tracked identifier - as a word: the class name, a name some module re-exports it under, or a - probe name. Every static spelling holds one, so the filter skips only - modules that cannot name the class. It keeps the scan away from two - generated data modules of 11 and 18.5 MB. -- **Its own tests.** `test_scan_catches_every_spelling_of_a_registry_type_check` - (32 invented snippets, each with exactly one hit of its kind on its - marked line), `test_scan_follows_re_exports_across_modules` (aliases one - and two modules away, a star import, a module alias, and a same-named - local class that is not caught) and - `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` (nine - negatives, each importing the class so that it is parsed, one of them a - binding cycle). + aliased module); and through relative imports, local assignment chains, + class and instance attributes, default arguments, loop variables, named + expressions, `type(ProjectionRNGRegistry(...))` and `getattr` on the + module. It also follows the class inside class info: tuples, lists, + sets, unions written with `|` or `typing.Union`, tuples joined with `+`, + mappings, subscripts of any of these, and anything rebuilt or read from + them, such as `tuple(KINDS)` or `KINDS.get(key)`. Re-exports are followed + across modules to a fixed point. Bindings only accumulate, so the fixed + point ends even on a binding cycle such as `a = b.c; b = a`. The scan + over-approximates on purpose: a binding anywhere in a module counts + everywhere in it, and an attribute counts wherever a name spelled like it + is bound. +- **Text as the parser sees it.** Every module is parsed, with no text + filter first. The build's filter parsed a module only if its text held a + tracked name, and an escaped literal such as `"\x50rojectionRNGRegistry"` + passed it, though the parser decodes it to the class's name. The scan + reads strings after the parser has joined adjacent literals and decoded + escapes, and it folds `+` and f-strings made only of literals. It sees + identifiers after the parser's NFKC normalization, so a fullwidth first + letter still names the class. +- **Cost.** Right after parsing, each literal container of constants whose + strings hold none of the class's name, its module's name or a probe name + is replaced by `None`. No check can report or bind anything from such a + container. The generated data modules of 18.5, 11.1 and 1.6 MB are almost + all such containers: pruning leaves 178, 78 and 7 of their 706,994, + 251,826 and 85,417 syntax-tree nodes, and 930,518 of the 2,007,887 in all + 302 modules. `test_pruning_leaves_every_invented_scan_unchanged` + and `test_pruning_leaves_scans_unchanged_for_any_literal` (200 Hypothesis + literals, each placed in eight positions) check that pruning changes no + result. On the host the real-tree test takes 6.5 s, and its process peaks + near 1.3 GB, mostly while parsing the largest data module. +- **Its own tests.** + - `test_scan_catches_every_spelling_of_a_registry_type_check` covers 58 + invented spellings. Each gives exactly the hits its markers ask for: + one of its kind on its check line, plus one on each line that binds + the class or holds its name. All twelve of review inject-r1's misses + are among them. + - `test_every_spelling_tells_the_two_classes_apart` runs 56 of them and + checks that each answers differently for the two classes. The other + two cannot run: a relative import, and an alias chain written in + reverse order. + - `test_scan_follows_re_exports_across_modules` is unchanged: aliases one + and two modules away, a star import, a module alias, and a same-named + local class that is not caught. + - `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` has 16 + negatives. They include `__all__` listings, docstrings, annotations, a + keyword-only hint on a dispatch registration, a module function that + shares a probe's name, another use of the registry's module and pruned + literal tables. - **Its messages.** A hit carries only a module path, line number and node kind. `test_scan_messages_never_show_source_text` plants a distinctive literal in an invented snippet and checks that it is absent from the hits, their `repr` and the assertion message, and from the refusal raised for unparsable source, whose original exception is suppressed. -- **Limits.** It does not resolve names built at run time (`getattr` with a - computed string, `importlib`, `eval`), or comparisons with a type taken - from another object at run time (`type(a) is type(b)`). +- **Limits.** The scan does not see: + - names built at run time: concatenation with computed values, `chr`, + decoding, formatting, or `getattr` with a computed string; + - part of the class's or module's name, such as a prefix test or a + regular expression; + - the type of another value: `type(a) is type(b)`, + `isinstance(x, type(y))`, and the dataclass-generated `==` between a + registry and a direct instance, which is false across the two classes; + - annotations read at run time by anything but a `register` decorator. + Searching `src/` outside the excluded modules and `baselines/asfr.py`, + `typing.get_type_hints` appears only in `track_b/_provenance.py`, whose + schemas read the hints of the Track B classes and calls it decorates. + The one registry hint in Track B, on `track_b/replay.py`'s private + `_rng_signature`, carries no decorator; + - code it does not read: the excluded modules, OP1's own package, + scripts, tests and third-party code. + + Over-approximation can also give false hits. Any hit, false or not, + fails the pin and comes back for a decision. **The scan finds one check in sealed code.** `src/populace_dynamics/mortality_observer.py:381` is a `type-compare`: the @@ -877,9 +950,10 @@ sealed code comes back for a decision. from 0 to the horizon, all eight modules and all six methods, at horizons 0, 1, 7 and 75 and draws 0, 1 and 9. - Both generator comparisons in `test_streams.py` now also check - `bit_generator.state`, before and after four draws, so the existing - exhaustive grid, its Hypothesis test and every refusal-grid comparison - check state too. + `bit_generator.state`. `_assert_generators_equal`, which the exhaustive + grid and its Hypothesis test use, checks it before and after four draws. + `_generator_identity`, which the refusal-grid comparisons use, records it + before two draws. - `test_bad_addresses_on_injection_horizons_match_registry` crosses periods just outside each horizon, an unknown module and negative children and tags with valid values, on the same horizons and draws, and compares @@ -904,7 +978,8 @@ Each run draws: - a mortality person stream for everyone; - an ASFR tagged child for women of childbearing age; - an emigration tagged child for each survivor; -- scheduled entrants in three years; +- scheduled entrants in two distinct years for the 2027 runs (2026 and + 2027) and in three for the 2100 runs (2026, 2063 and 2100); - through an invented probe in the earnings slot, every method the context exposes, from `context.rng_registry` and `context.person_generator`, kept as a column. @@ -952,7 +1027,7 @@ size. - At 400 people the direct default takes 12% of the `registry` mode's time and 18% of the time OP1 took before this follow-up (`direct_emigration`). - Peak RSS over the nine workers ranges from 192.3 to 203.0 MB. At each size - the direct mode is within 1.3 MB of the `registry` mode. + the direct mode is within 1.4 MB of the `registry` mode. - The microbenchmark's median cost per address (one tagged-child generator and one draw) is 6.7 µs at horizon 7 and 7.4 µs at horizon 75 on the direct class, against 52.3 and 216.7 µs on the registry. @@ -970,6 +1045,36 @@ untouched, for the main session's recount. modules as `unit`. The stream differential and the hash identity therefore run on every CI push. +### Review inject-r1 + +Review inject-r1, a hard-tier Subfleet review on a Codex lane, read the +branch at `8634858c` and returned REVISE, with one major and three minor +findings. It confirmed the harness, the re-raise, the coordinator's +sealed-path and file checks, the evidence audit and the six mutants below. + +1. **Major: static type checks escaped the scan.** Twelve invented checks, + each true for the registry and false for the direct class, gave no hit: + class info bound to a name (a tuple and a union), aliases of + `isinstance` and `issubclass`, the class's name in a tuple, + `ProjectionRNGRegistry.__name__`, an escaped name literal (which also + passed the text filter), a class attribute, a subscript of a literal, a + named expression, dictionary dispatch and a quoted dispatch hint. + **Fix:** the two-layer scan under condition 2, with the text filter + removed. All twelve are among the invented spellings, and the scan still + finds exactly the one pinned check in sealed code. +2. **Minor: the binding test did not show that only `rng_registry` + changes.** A replacement context with `draw_index + 1` survived it. + **Fix:** the test now compares every other field with the loop's own + context (condition 1). That mutant fails it, and so do copies of the + metadata or the ordinals. +3. **Minor: the RSS gap at 25 people is 1,359,872 bytes,** so "within + 1.3 MB" understated it. **Fix:** "within 1.4 MB". +4. **Minor: evidence wording.** **Fix:** condition 1 names base `9cced8ff` + and the three files in this folder. The stream-identity text describes + the two generator helpers separately, the entrant years are given per + horizon, and this section's source line references match the current + files. + ### Commands and results ```sh @@ -1032,6 +1137,34 @@ Six mutants of the new code each fail the tests aimed at them: - ignoring the depletion-cut pattern. This mutant ran only against the pin and decoy tests, never against the real tree. +After the fixes for review inject-r1, the main session ran the same +commands on the host from 10:53 to 10:58 UTC on 26 October, at one-minute +loads of 18 to 25, each process under the load-check plugin: +- `tests/open_population`: **437 passed and 1 strict xfail** in 34.13 s. + These are OP1's 238 and the 200 tests this follow-up adds: 175 in + `test_stream_injection.py` and 25 in `test_streams.py`. +- The other four modules: 104 passed in 1.00 s. +- The required command plus the two Track B holdout-registry modules: 756 + passed, 2 skipped and 1 strict xfail. +- Peak RSS was 1.33 GB, against 340 MB before; the real-tree scan's parse + of the largest data module accounts for most of the rise. +- The slowest tests took 6.54 s (the real-tree scan), 2.04 s (the + fresh-interpreter import check) and 1.45 to 1.55 s for each 2100 hash + pair. +- No file under a bar was opened, the pattern line named only + `baselines/asfr.py` (read by the scan, never displayed), and neither + barred function was called. +- `--collect-only -m unit tests/open_population` collects all 438. Black, + Ruff and `git diff --check` are clean. `git diff --stat 9cced8ff` still + lists the seven files, and the sealed-path `git diff --quiet` exits 0. + +Thirteen more mutants each fail the tests aimed at them. Three change the +replacement context's draw index, metadata or ordinals. Ten disable a part +of the scan: the catch-all, checking-function aliases, container names, +pruning's check for tracked strings, `+` folding, parsing of quoted hints, +module-name comparisons, the benign `__all__` listing, private-attribute +reads and loop bindings. None ran against the real tree. + ### Restricted-files statement The build read `RESTRICTED-FILES.md` at its pinned hash and the @@ -1052,3 +1185,8 @@ was in. With an audit hook on file opens, that run opened no excluded file, and no projection run imported a closed or depletion-cut module. Every test, probe and measurement uses invented inputs. No microdata or external statistical table was read, and no real-data statistic was computed. + +The fixes for review inject-r1 followed the same rules. The main session's +measurement scripts read the scanned modules only through the test +module's own walk, and printed module paths, line numbers, node kinds and +counts. Its searches over `src/` took their file lists from the helper. diff --git a/tests/open_population/test_stream_injection.py b/tests/open_population/test_stream_injection.py index e5924fe4..89ae2619 100644 --- a/tests/open_population/test_stream_injection.py +++ b/tests/open_population/test_stream_injection.py @@ -8,6 +8,10 @@ registry's type, so this module pins both, then checks the projection itself. The AST scan reads ``src/populace_dynamics/`` outside ``open_population/``. +Specific checks name each type check they find; under them a catch-all +reports every other mention of the class, or of its name or module as text, +outside imports, docstrings, ``__all__``, annotations and construction. The +evidence report's §6 lists what it cannot see. The project's data-access record keeps two modules out of automated reads, and the scan also skips the depletion-cut modules, so that what it reads does not depend on who runs it. It drops all of them by path before anything opens @@ -25,7 +29,6 @@ import inspect import json import os -import re import subprocess import sys import textwrap @@ -209,7 +212,24 @@ def test_direct_streams_are_not_a_registry_subclass(): ) | {"_root"} _TYPE_CHECK_CALLS = frozenset({"isinstance", "issubclass"}) _DUNDER_CHECKS = frozenset({"__instancecheck__", "__subclasscheck__"}) -_COMPARISONS = (ast.Is, ast.IsNot, ast.Eq, ast.NotEq, ast.In, ast.NotIn) +_NAME_ATTRIBUTES = frozenset({"__name__", "__qualname__"}) +_CONTAINER_CALLS = frozenset({"tuple", "list", "set", "frozenset", "dict"}) +_LISTING_CALLS = frozenset({"append", "extend"}) +# The nodes that can bind a name; binding revisits only these. +_BINDING_NODES = ( + ast.Import, + ast.ImportFrom, + ast.Assign, + ast.AnnAssign, + ast.AugAssign, + ast.NamedExpr, + ast.For, + ast.AsyncFor, + ast.comprehension, + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.Lambda, +) class ScanError(AssertionError): @@ -260,12 +280,71 @@ def _is_type_call(node: ast.expr) -> bool: ) -class _ModuleBindings: - """Resolve, flow-insensitively, the names a module binds to the registry. +def _static_text(node: ast.expr) -> str | None: + """Return the text a string expression holds before run time, if any. - Over-approximation is deliberate: any binding anywhere in the module - counts everywhere in it, and any name or attribute spelled exactly - ``ProjectionRNGRegistry`` counts, whatever it was imported from. + The parser already joins adjacent literals and decodes escapes; this + also folds ``+`` and f-strings whose parts are all literals. Bytes are + read as Latin-1, so an ASCII name matches in either. + """ + if isinstance(node, ast.Constant): + if isinstance(node.value, str): + return node.value + if isinstance(node.value, bytes): + return node.value.decode("latin-1") + return None + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _static_text(node.left) + right = _static_text(node.right) + return None if left is None or right is None else left + right + if isinstance(node, ast.JoinedStr): + parts = [] + for value in node.values: + if isinstance(value, ast.FormattedValue): + if value.conversion != -1 or value.format_spec is not None: + return None + value = value.value + text = _static_text(value) + if text is None: + return None + parts.append(text) + return "".join(parts) + return None + + +def _is_all(target: ast.expr) -> bool: + return isinstance(target, ast.Name) and target.id == "__all__" + + +def _lists_names(node: ast.Call) -> bool: + """Return whether a call is ``__all__.append`` or ``__all__.extend``.""" + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr in _LISTING_CALLS + and _is_all(node.func.value) + ) + + +def _registers(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Return whether a bare ``register`` decorator dispatches on a hint.""" + return any( + (isinstance(decorator, ast.Attribute) and decorator.attr == "register") + or (isinstance(decorator, ast.Name) and decorator.id == "register") + for decorator in node.decorator_list + ) + + +class _ModuleBindings: + """Resolve, flow-insensitively, what a module binds to the registry. + + Five kinds of binding are tracked: modules; the class itself; + containers that may hold it (tuples, unions, mappings and anything read + or rebuilt from them); strings that hold its name; and aliases of + ``isinstance`` and ``issubclass``. Over-approximation is deliberate: a + binding anywhere in the module counts everywhere in it, an attribute + counts wherever a name spelled like it is bound, a loop over class info + binds its variable to the class, and any name or attribute spelled + exactly ``ProjectionRNGRegistry`` counts, whatever it came from. """ def __init__(self, source: ScanSource, exports: set[tuple[str, str]]): @@ -278,6 +357,9 @@ def __init__(self, source: ScanSource, exports: set[tuple[str, str]]): ) self.modules: dict[str, str] = {} self.classes: set[str] = set() + self.containers: set[str] = set() + self.names: set[str] = set() + self.checkers: dict[str, str] = {} def _from_base(self, node: ast.ImportFrom) -> str: if not node.level: @@ -302,7 +384,7 @@ def is_registry(self, node: ast.expr) -> bool: if isinstance(node, ast.Name): return node.id in self.classes or node.id == REGISTRY_NAME if isinstance(node, ast.Attribute): - if node.attr == REGISTRY_NAME: + if node.attr == REGISTRY_NAME or node.attr in self.classes: return True if ( node.attr == "__class__" @@ -312,6 +394,8 @@ def is_registry(self, node: ast.expr) -> bool: return True base = self.module_path(node.value) return base is not None and (base, node.attr) in self.exports + if isinstance(node, ast.NamedExpr): + return self.is_registry(node.value) if isinstance(node, ast.Call): if _is_type_call(node) and isinstance(node.args[0], ast.Call): return self.is_registry(node.args[0].func) @@ -328,26 +412,104 @@ def is_registry(self, node: ast.expr) -> bool: ) return False - def contains_registry(self, node: ast.expr) -> bool: - """Return whether a class-info expression includes the registry.""" - if self.is_registry(node): + def _any_part(self, node: ast.expr, test) -> bool: + """Apply ``test`` to ``node`` and to each part it holds or picks.""" + if test(node): return True if isinstance(node, ast.Tuple | ast.List | ast.Set): - return any(self.contains_registry(item) for item in node.elts) - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + parts = node.elts + elif isinstance(node, ast.Dict): + parts = [item for item in (*node.keys, *node.values) if item] + elif isinstance(node, ast.Starred | ast.NamedExpr | ast.Subscript): + parts = [node.value] + elif isinstance(node, ast.IfExp): + parts = [node.body, node.orelse] + elif isinstance(node, ast.BoolOp): + parts = node.values + else: + return False + return any(self._any_part(part, test) for part in parts) + + def _class_part(self, node: ast.expr) -> bool: + if self.is_registry(node): + return True + if isinstance(node, ast.Name): + return node.id in self.containers + if isinstance(node, ast.Attribute): + return node.attr in self.containers + if isinstance(node, ast.BinOp) and isinstance( + node.op, ast.BitOr | ast.Add + ): + # A union, or class-info tuples joined with "+". return self.contains_registry(node.left) or self.contains_registry( node.right ) - if isinstance(node, ast.Starred): - return self.contains_registry(node.value) + if isinstance(node, ast.Subscript): + # Union[int, cls] holds the class in its subscript. + return self.contains_registry(node.slice) + if isinstance(node, ast.Call): + # Class info read or rebuilt: KINDS.get(key), tuple(KINDS). + if isinstance(node.func, ast.Attribute) and self.contains_registry( + node.func.value + ): + return True + return _call_name(node.func) in _CONTAINER_CALLS and any( + self.contains_registry(item) for item in node.args + ) return False - def _bind(self, name: str, value: ast.expr) -> None: + def contains_registry(self, node: ast.expr) -> bool: + """Return whether a class-info expression may include the registry.""" + return self._any_part(node, self._class_part) + + def _name_part(self, node: ast.expr) -> bool: + text = _static_text(node) + if text is not None: + return REGISTRY_NAME in text + if isinstance(node, ast.Name): + return node.id in self.names + if isinstance(node, ast.Attribute): + return node.attr in self.names or ( + node.attr in _NAME_ATTRIBUTES and self.is_registry(node.value) + ) + return False + + def names_registry(self, node: ast.expr) -> bool: + """Return whether ``node`` may hold the class's name as text.""" + return self._any_part(node, self._name_part) + + def _module_part(self, node: ast.expr) -> bool: + text = _static_text(node) + if text is not None: + return REGISTRY_MODULE in text + if isinstance(node, ast.Attribute) and node.attr == "__name__": + node = node.value + return self.module_path(node) == REGISTRY_MODULE + + def names_module(self, node: ast.expr) -> bool: + """Return whether ``node`` may denote the registry's module.""" + return self._any_part(node, self._module_part) + + def checker(self, node: ast.expr) -> str | None: + """Name the type check a callee denotes, through any alias.""" + if isinstance(node, ast.Name): + name = node.id + elif isinstance(node, ast.Attribute): + name = node.attr + else: + return None + return name if name in _TYPE_CHECK_CALLS else self.checkers.get(name) + + def _bind(self, name: str, value: ast.expr, module: bool = True) -> None: if self.is_registry(value): self.classes.add(name) - return - path = self.module_path(value) - if path is not None: + elif self.contains_registry(value): + self.containers.add(name) + elif self.names_registry(value): + self.names.add(name) + elif (checker := self.checker(value)) is not None: + self.checkers.setdefault(name, checker) + elif module and (path := self.module_path(value)) is not None: # First binding wins, so a cycle such as ``a = b.c; b = a`` # cannot grow a path forever: each pass only adds names. self.modules.setdefault(name, path) @@ -355,13 +517,48 @@ def _bind(self, name: str, value: ast.expr) -> None: def _bind_targets(self, target: ast.expr, value: ast.expr) -> None: if isinstance(target, ast.Name): self._bind(target.id, value) - elif ( - isinstance(target, ast.Tuple | ast.List) - and isinstance(value, ast.Tuple | ast.List) - and len(target.elts) == len(value.elts) - ): - for item, item_value in zip(target.elts, value.elts, strict=True): - self._bind_targets(item, item_value) + elif isinstance(target, ast.Attribute): + self._bind(target.attr, value, module=False) + elif isinstance(target, ast.Subscript): + # TABLE[key] = cls puts class info in TABLE. + base = target.value + if isinstance(base, ast.Name | ast.Attribute): + name = base.id if isinstance(base, ast.Name) else base.attr + if self.contains_registry(value): + self.containers.add(name) + elif self.names_registry(value): + self.names.add(name) + elif isinstance(target, ast.Tuple | ast.List): + items = (*target.elts, *getattr(value, "elts", ())) + if ( + isinstance(value, ast.Tuple | ast.List) + and len(target.elts) == len(value.elts) + and not any(isinstance(item, ast.Starred) for item in items) + ): + for item, item_value in zip( + target.elts, value.elts, strict=True + ): + self._bind_targets(item, item_value) + else: + # Any other unpacking: each target may take any part. + for item in target.elts: + if isinstance(item, ast.Starred): + item = item.value + self._bind_targets(item, value) + elif isinstance(target, ast.Starred): + self._bind_targets(target.value, value) + + def _bind_loop(self, target: ast.expr) -> None: + """Bind a loop variable that iterates over class info to the class.""" + if isinstance(target, ast.Name): + self.classes.add(target.id) + elif isinstance(target, ast.Attribute): + self.classes.add(target.attr) + elif isinstance(target, ast.Starred): + self._bind_loop(target.value) + elif isinstance(target, ast.Tuple | ast.List): + for item in target.elts: + self._bind_loop(item) def _visit(self, node: ast.AST) -> None: if isinstance(node, ast.Import): @@ -385,14 +582,19 @@ def _visit(self, node: ast.AST) -> None: or (base, alias.name) in self.exports ): self.classes.add(local) + elif alias.name in _TYPE_CHECK_CALLS: + self.checkers.setdefault(local, alias.name) else: self.modules.setdefault(local, f"{base}.{alias.name}") elif isinstance(node, ast.Assign): for target in node.targets: self._bind_targets(target, node.value) - elif isinstance(node, ast.AnnAssign | ast.NamedExpr): + elif isinstance(node, ast.AnnAssign | ast.NamedExpr | ast.AugAssign): if node.value is not None: self._bind_targets(node.target, node.value) + elif isinstance(node, ast.For | ast.AsyncFor | ast.comprehension): + if self.contains_registry(node.iter): + self._bind_loop(node.target) elif isinstance( node, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda ): @@ -414,35 +616,75 @@ def _visit(self, node: ast.AST) -> None: if default is not None ] for argument, default in pairs: - if self.is_registry(default): - self.classes.add(argument.arg) + self._bind(argument.arg, default, module=False) + + def _state(self) -> tuple: + return ( + dict(self.modules), + set(self.classes), + set(self.containers), + set(self.names), + dict(self.checkers), + ) - def bind(self, tree: ast.Module) -> None: + def bind(self, nodes: list[ast.AST]) -> None: """Bind to a fixed point, so chained aliases resolve in any order. - Bindings only accumulate, over the module's finitely many names, so - the loop ends. + ``nodes`` are the module's binding nodes. Bindings only accumulate, + over the module's finitely many names, so the loop ends. """ while True: - before = (dict(self.modules), set(self.classes)) - for node in ast.walk(tree): + before = self._state() + for node in nodes: self._visit(node) - if before == (self.modules, self.classes): + if before == self._state(): return + def dispatch_annotations( + self, node: ast.FunctionDef | ast.AsyncFunctionDef + ) -> list[ast.expr]: + """Return the hints a ``register`` decorator may dispatch on. + + A string hint is parsed as the expression it names, so an alias in + quotes counts too. + """ + if not _registers(node): + return [] + found = [] + for argument in (*node.args.posonlyargs, *node.args.args): + hint = argument.annotation + if hint is None: + continue + text = _static_text(hint) + if text is not None: + try: + hint_value = ast.parse(text, mode="eval").body + except (SyntaxError, ValueError): + hint_value = hint + else: + hint_value = hint + if self.contains_registry(hint_value) or self.names_registry(hint): + found.append(hint) + return found + def _kind(self, node: ast.AST) -> str | None: + """Name the specific check ``node`` makes, if it makes one.""" if isinstance(node, ast.Call): - name = _call_name(node.func) + checker = self.checker(node.func) if ( - name in _TYPE_CHECK_CALLS + checker is not None and len(node.args) >= 2 and self.contains_registry(node.args[1]) ): - return name + return checker + name = _call_name(node.func) if ( name in _DUNDER_CHECKS and isinstance(node.func, ast.Attribute) - and self.is_registry(node.func.value) + and ( + self.is_registry(node.func.value) + or any(map(self.contains_registry, node.args[:1])) + ) ): return "dunder-check" if ( @@ -451,16 +693,7 @@ def _kind(self, node: ast.AST) -> str | None: and self.contains_registry(node.args[0]) ): return "dispatch-register" - if ( - name in {"hasattr", "getattr"} - and len(node.args) >= 2 - and isinstance(node.args[1], ast.Constant) - and node.args[1].value in PROBE_NAMES - ): - return "private-member-probe" - elif isinstance(node, ast.Compare) and any( - isinstance(op, _COMPARISONS) for op in node.ops - ): + elif isinstance(node, ast.Compare): operands = [node.left, *node.comparators] if any(self.contains_registry(item) for item in operands): if any(_is_type_call(item) for item in operands): @@ -472,39 +705,151 @@ def _kind(self, node: ast.AST) -> str | None: ): return "__class__-compare" return "class-compare" - if any( - isinstance(item, ast.Constant) - and isinstance(item.value, str) - and REGISTRY_NAME in item.value - for item in operands - ): + if any(self.names_registry(item) for item in operands): + return "name-compare" + if any(self.names_module(item) for item in operands): + return "module-compare" + elif isinstance(node, ast.MatchValue): + # case rng.ProjectionRNGRegistry: compares the subject by ==. + if self.contains_registry(node.value): + return "class-compare" + if self.names_registry(node.value): return "name-compare" + if self.names_module(node.value): + return "module-compare" elif isinstance(node, ast.MatchClass): - if self.is_registry(node.cls): + if self.contains_registry(node.cls): return "match-class" elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): - registers = any( - isinstance(decorator, ast.Attribute) - and decorator.attr == "register" - for decorator in node.decorator_list + if self.dispatch_annotations(node): + return "dispatch-annotation" + elif isinstance(node, ast.Attribute): + if node.attr in PROBE_NAMES and self.source.module != ( + REGISTRY_MODULE + ): + return "private-member-probe" + elif isinstance(node, ast.Constant): + if isinstance(node.value, str) and node.value in PROBE_NAMES: + return "private-member-probe" + return None + + def _mention(self, node: ast.expr, listing: bool) -> str | None: + """Name what a node outside every specific check says of the class. + + A reference to the class or to class info, a string holding the + class's name and a string holding its module's name each count. + """ + if self.is_registry(node) or ( + isinstance(node, ast.Name | ast.Attribute) + and self._class_part(node) + ): + return "class-reference" + text = None if listing else _static_text(node) + if text is not None and REGISTRY_NAME in text: + return "class-name-string" + if text is not None and REGISTRY_MODULE in text: + return "module-name-string" + return None + + def _children(self, node: ast.AST) -> Iterable[tuple[ast.AST, bool]]: + """Yield the children a mention can hide in, and which list names. + + Annotations are skipped, except the hints a ``register`` decorator + dispatches on; so is the callee of a construction. Strings inside + an ``__all__`` listing are flagged as listing names. + """ + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + arguments = node.args + yield from ((item, False) for item in node.decorator_list) + yield from ((item, False) for item in arguments.defaults) + yield from ( + (item, False) for item in arguments.kw_defaults if item + ) + if _registers(node): + yield from ( + (argument.annotation, False) + for argument in (*arguments.posonlyargs, *arguments.args) + if argument.annotation is not None + ) + yield from ((item, False) for item in node.body) + elif isinstance(node, ast.Lambda): + yield from ((item, False) for item in node.args.defaults) + yield from ( + (item, False) for item in node.args.kw_defaults if item ) - positional = [*node.args.posonlyargs, *node.args.args] + yield node.body, False + elif isinstance(node, ast.AnnAssign): + yield node.target, False + if node.value is not None: + yield node.value, _is_all(node.target) + elif isinstance(node, ast.Assign | ast.AugAssign): + targets = ( + node.targets if isinstance(node, ast.Assign) else [node.target] + ) + yield from ((item, False) for item in targets) + yield node.value, any(map(_is_all, targets)) + elif isinstance(node, ast.Call) and ( + self.is_registry(node.func) or _lists_names(node) + ): + listing = _lists_names(node) + if listing: + yield node.func, False + yield from ((item, listing) for item in node.args) + yield from ((item.value, listing) for item in node.keywords) + else: + yield from ((item, False) for item in ast.iter_child_nodes(node)) + + def references(self, tree: ast.Module, claimed: set[int]) -> set[ScanHit]: + """Report every other mention of the class outside a benign place. + + This is the catch-all under the specific checks: a reference to the + class, or a string holding its name, counts wherever it is not + benign. Benign places are imports, string statements (docstrings + among them), ``__all__`` listings, annotations other than dispatch + hints, and the callee of a construction. A node a specific check + reported is skipped with everything under it. The walk keeps its + own stack, so deep expressions cannot exhaust recursion. + """ + found = set() + stack: list[tuple[ast.AST, bool]] = [(tree, False)] + while stack: + node, listing = stack.pop() + if id(node) in claimed or isinstance( + node, ast.Import | ast.ImportFrom + ): + continue if ( - registers - and positional - and positional[0].annotation is not None - and self.contains_registry(positional[0].annotation) + isinstance(node, ast.Expr) + and _static_text(node.value) is not None ): - return "dispatch-annotation" - return None + continue + if isinstance(node, ast.expr) and not isinstance( + getattr(node, "ctx", None), ast.Store | ast.Del + ): + kind = self._mention(node, listing) + if kind is not None: + found.add(ScanHit(self.source.path, node.lineno, kind)) + continue + stack.extend( + (child, listing or listed) + for child, listed in self._children(node) + ) + return found - def hits(self, tree: ast.Module) -> list[ScanHit]: - found = [] + def hits(self, tree: ast.Module) -> set[ScanHit]: + """Report the specific checks, then every other mention.""" + found = set() + claimed: set[int] = set() for node in ast.walk(tree): kind = self._kind(node) - if kind is not None: - found.append(ScanHit(self.source.path, node.lineno, kind)) - return found + if kind is None: + continue + found.add(ScanHit(self.source.path, node.lineno, kind)) + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + claimed.update(map(id, self.dispatch_annotations(node))) + else: + claimed.add(id(node)) + return found | self.references(tree, claimed) def _parse(source: ScanSource) -> ast.Module: @@ -515,31 +860,94 @@ def _parse(source: ScanSource) -> ast.Module: raise ScanError(f"{source.path}:{line}: unparsable source") from None -def scan_sources(sources: Iterable[ScanSource]) -> tuple[ScanHit, ...]: +def _inert(node: ast.AST) -> bool: + """Return whether a literal container can matter to no check. + + A container of constants (tuples, lists, sets, dicts and signed + numbers, nested) refers to nothing, so only its strings could matter; + it is inert when none holds the class's name or its module's, and none + is a probe name. + """ + if not isinstance(node, ast.Tuple | ast.List | ast.Set | ast.Dict): + return False + stack = [node] + while stack: + item = stack.pop() + if isinstance(item, ast.Constant): + text = _static_text(item) + if text is not None and ( + REGISTRY_NAME in text + or REGISTRY_MODULE in text + or text in PROBE_NAMES + ): + return False + elif isinstance(item, ast.Tuple | ast.List | ast.Set): + stack.extend(item.elts) + elif isinstance(item, ast.Dict): + if any(key is None for key in item.keys): + return False + stack.extend((*item.keys, *item.values)) + elif isinstance(item, ast.UnaryOp): + stack.append(item.operand) + else: + return False + return True + + +def _prune(tree: ast.Module) -> ast.Module: + """Replace each inert literal container with ``None``, in place. + + No check reports or binds anything from an inert container, so the + hits are unchanged (a differential test pins that); the data modules' + large tables stop costing time and memory. + """ + stack: list[ast.AST] = [tree] + while stack: + node = stack.pop() + for field, value in ast.iter_fields(node): + items = value if isinstance(value, list) else [value] + for index, item in enumerate(items): + if not isinstance(item, ast.AST): + continue + if _inert(item): + stub = ast.copy_location(ast.Constant(None), item) + if isinstance(value, list): + value[index] = stub + else: + setattr(node, field, stub) + else: + stack.append(item) + return tree + + +def scan_sources( + sources: Iterable[ScanSource], prune: bool = True +) -> tuple[ScanHit, ...]: """Find type checks on the registry across ``sources`` (condition 2). - A module is parsed only if its text holds a tracked identifier as a - word: the registry's name, a name some scanned module re-exports it - under, or a probe name. Every static spelling of the class holds one, so - the filter skips only modules that cannot name it. Re-exports are - followed to a fixed point across modules. + Every module is parsed. No text filter runs first, because the parser + joins adjacent literals, decodes escapes and normalizes identifiers, so + a module can spell the class without its text holding the name. Inert + literal containers are pruned unless ``prune`` is false. Re-exports of + the class are followed to a fixed point across modules. """ sources = tuple(sources) + trees = { + source.path: _prune(_parse(source)) if prune else _parse(source) + for source in sources + } + binders = { + path: [ + node for node in ast.walk(tree) if isinstance(node, _BINDING_NODES) + ] + for path, tree in trees.items() + } exports = {(REGISTRY_MODULE, REGISTRY_NAME)} - parsed: dict[str, ast.Module] = {} while True: - tokens = sorted({name for _, name in exports} | PROBE_NAMES) - pattern = re.compile( - r"(? tuple[ScanHit, ...]: sorted( hit for path, scope in scopes.items() - for hit in scope.hits(parsed[path]) + for hit in scope.hits(trees[path]) ) ) @@ -708,7 +1116,20 @@ def _check(expression, imports=_IMPORT): return f"{imports}\ndef f(x):\n return {expression} # check\n" -# Each invented module marks the line the scan must report with "# check". +def _dispatch(hint, imports=_IMPORT): + """Invent a single-dispatch registration whose hint is ``hint``.""" + return ( + f"from functools import singledispatch\n{imports}\n" + "@singledispatch\ndef f(x):\n return 0\n" + f"@f.register\ndef _(x: {hint}): # check\n return 1\n" + ) + + +# Each invented module marks the line of its check with "# check". A line +# that binds the class or class info to a name ends "# alias" and adds a +# class-reference hit; a line that holds the class's name as text ends +# "# label" and adds a class-name-string hit. +MARKERS = {"# alias": "class-reference", "# label": "class-name-string"} SPELLINGS = { "isinstance": ( _check("isinstance(x, ProjectionRNGRegistry)"), @@ -726,6 +1147,13 @@ def _check(expression, imports=_IMPORT): _check("isinstance(x, int | ProjectionRNGRegistry)"), "isinstance", ), + "isinstance with typing.Union": ( + _check( + "isinstance(x, typing.Union[int, ProjectionRNGRegistry])", + f"{_IMPORT}\nimport typing", + ), + "isinstance", + ), "builtins.isinstance": ( _check( "builtins.isinstance(x, ProjectionRNGRegistry)", @@ -733,10 +1161,75 @@ def _check(expression, imports=_IMPORT): ), "isinstance", ), + "isinstance bound to a name": ( + _check( + "check(x, ProjectionRNGRegistry)", f"{_IMPORT}\ncheck = isinstance" + ), + "isinstance", + ), "issubclass": ( _check("issubclass(type(x), ProjectionRNGRegistry)"), "issubclass", ), + "issubclass imported under another name": ( + _check( + "check(type(x), ProjectionRNGRegistry)", + f"{_IMPORT}\nfrom builtins import issubclass as check", + ), + "issubclass", + ), + "class-info tuple bound to a name": ( + _check( + "isinstance(x, KINDS)", + f"{_IMPORT}\nKINDS = (int, ProjectionRNGRegistry) # alias", + ), + "isinstance", + ), + "union bound to a name": ( + _check( + "isinstance(x, KINDS)", + f"{_IMPORT}\nKINDS = int | ProjectionRNGRegistry # alias", + ), + "isinstance", + ), + "class-info tuples joined with +": ( + _check("isinstance(x, (int,) + (ProjectionRNGRegistry,))"), + "isinstance", + ), + "subscript of a literal": ( + _check("isinstance(x, (ProjectionRNGRegistry,)[0])"), + "isinstance", + ), + "named expression": ( + _check("isinstance(x, kind := ProjectionRNGRegistry)"), + "isinstance", + ), + "class attribute": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + class Holder: + kind = ProjectionRNGRegistry # alias + def f(x): + return isinstance(x, Holder.kind) # check + """), + "isinstance", + ), + "loop over class info": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x): + for kind in (int, ProjectionRNGRegistry): # alias + if isinstance(x, kind): # check + return True + return False + """), + "isinstance", + ), + "identifier the parser normalizes": ( + # A fullwidth first letter, which the parser folds to "P" (NFKC). + _check("isinstance(x, ProjectionRNGRegistry)"), + "isinstance", + ), "type is": (_check("type(x) is ProjectionRNGRegistry"), "type-compare"), "type is not": ( _check("type(x) is not ProjectionRNGRegistry"), @@ -808,14 +1301,15 @@ def f(x): ), "chained local alias": ( _check( - "isinstance(x, S)", f"{_IMPORT}\nS = R\nR = ProjectionRNGRegistry" + "isinstance(x, S)", + f"{_IMPORT}\nS = R # alias\nR = ProjectionRNGRegistry # alias", ), "isinstance", ), "type of an instance": ( _check( "isinstance(x, T)", - f"{_IMPORT}\nT = type(ProjectionRNGRegistry(0, 0))", + f"{_IMPORT}\nT = type(ProjectionRNGRegistry(0, 0)) # alias", ), "isinstance", ), @@ -830,18 +1324,67 @@ def f(x): _check("type(x).__name__ == 'ProjectionRNGRegistry'", ""), "name-compare", ), + "class name in a tuple": ( + _check("type(x).__name__ in ('ProjectionRNGRegistry',)", ""), + "name-compare", + ), + "class name attribute": ( + _check("type(x).__name__ == ProjectionRNGRegistry.__name__"), + "name-compare", + ), + "escaped class name": ( + _check("type(x).__name__ == '\\x50rojectionRNGRegistry'", ""), + "name-compare", + ), + "adjacent literals": ( + _check("type(x).__name__ == 'Projection' 'RNGRegistry'", ""), + "name-compare", + ), + "concatenated literals": ( + _check("type(x).__qualname__ == 'Projection' + 'RNGRegistry'", ""), + "name-compare", + ), + "class name bound to a name": ( + _check( + "type(x).__name__ == LABEL", + "LABEL = 'ProjectionRNGRegistry' # label", + ), + "name-compare", + ), + "module name comparison": ( + _check("type(x).__module__ == 'populace_dynamics.engine.rng'", ""), + "module-compare", + ), "private-member probe": ( _check("hasattr(x, '_period_sequence')", ""), "private-member-probe", ), + "private member read": ( + textwrap.dedent(""" + def f(x): + try: + return x._period_sequence is not None # check + except AttributeError: + return False + """), + "private-member-probe", + ), + "private member in a listing": ( + _check("'_period_sequence' in dir(x)", ""), + "private-member-probe", + ), "instance-check dunder": ( _check("ProjectionRNGRegistry.__instancecheck__(x)"), "dunder-check", ), + "subclass-check dunder through type": ( + _check("type.__subclasscheck__(ProjectionRNGRegistry, type(x))"), + "dunder-check", + ), "default argument": ( textwrap.dedent(""" from populace_dynamics.engine.rng import ProjectionRNGRegistry - def f(x, cls=ProjectionRNGRegistry): + def f(x, cls=ProjectionRNGRegistry): # alias return isinstance(x, cls) # check """), "isinstance", @@ -855,6 +1398,10 @@ def f(x): """), "class-compare", ), + "dictionary dispatch": ( + _check("{ProjectionRNGRegistry: True}.get(type(x), False)"), + "class-reference", + ), "match class pattern": ( textwrap.dedent(""" from populace_dynamics.engine.rng import ProjectionRNGRegistry @@ -865,6 +1412,25 @@ def f(x): """), "match-class", ), + "match value pattern": ( + textwrap.dedent(""" + from populace_dynamics.engine import rng + def f(x): + match type(x): + case rng.ProjectionRNGRegistry: # check + return 1 + """), + "class-compare", + ), + "match on the class name": ( + textwrap.dedent(""" + def f(x): + match type(x).__name__: + case "ProjectionRNGRegistry": # check + return 1 + """), + "name-compare", + ), "single-dispatch registration": ( textwrap.dedent(""" from functools import singledispatch @@ -879,16 +1445,15 @@ def _(x): "dispatch-register", ), "single-dispatch annotation": ( - textwrap.dedent(""" - from functools import singledispatch - from populace_dynamics.engine.rng import ProjectionRNGRegistry - @singledispatch - def f(x): - return 0 - @f.register - def _(x: ProjectionRNGRegistry): # check - return 1 - """), + _dispatch("ProjectionRNGRegistry"), + "dispatch-annotation", + ), + "single-dispatch string annotation": ( + _dispatch("'ProjectionRNGRegistry'"), + "dispatch-annotation", + ), + "single-dispatch string annotation through an alias": ( + _dispatch("'int | R'", f"{_IMPORT} as R"), "dispatch-annotation", ), } @@ -898,19 +1463,57 @@ def _invented(text, module="populace_dynamics.engine.invented_case"): return ScanSource(module, f"invented/{module.rpartition('.')[2]}.py", text) +def _expected(text, kind): + """The (line, kind) pairs an invented module's markers ask for.""" + expected = [] + for number, line in enumerate(text.splitlines(), start=1): + if line.endswith("# check"): + expected.append((number, kind)) + expected += [ + (number, marked) + for marker, marked in MARKERS.items() + if line.endswith(marker) + ] + return sorted(expected) + + @pytest.mark.parametrize("spelling", tuple(SPELLINGS)) def test_scan_catches_every_spelling_of_a_registry_type_check(spelling): - """Each spelling gives exactly one hit, of its kind, on its line.""" + """Each spelling gives exactly the hits its markers ask for. + + That is one hit of its kind on its check line, plus one on each line + that binds the class or holds its name. + """ text, kind = SPELLINGS[spelling] - marked = [ - number - for number, line in enumerate(text.splitlines(), start=1) - if line.endswith("# check") - ] + assert sum(line.endswith("# check") for line in text.splitlines()) == 1 hits = scan_sources([_invented(text)]) - assert [(hit.line, hit.kind) for hit in hits] == [ - (marked[0], kind) - ], describe(hits) + assert [(hit.line, hit.kind) for hit in hits] == _expected( + text, kind + ), describe(hits) + + +# Two spellings cannot run: a relative import needs a package, and the +# chained alias binds a name before the name it copies, to show the scan's +# bindings do not depend on order. +_NOT_RUN = frozenset({"relative import", "chained local alias"}) + + +@pytest.mark.parametrize("spelling", sorted(set(SPELLINGS) - _NOT_RUN)) +def test_every_spelling_tells_the_two_classes_apart(spelling): + """Each invented check answers differently for the two classes. + + So every spelling is a working type check that the scan must catch, + not a pattern that only looks like one. (Negated spellings answer + false for the registry and true for the direct class.) + """ + text, _ = SPELLINGS[spelling] + namespace = {"__name__": "invented_case"} + exec(compile(text, "invented_case.py", "exec"), namespace) + outcomes = [ + bool(namespace["f"](cls(0, 2))) + for cls in (ProjectionRNGRegistry, DirectRNGStreams) + ] + assert outcomes[0] != outcomes[1] def test_scan_follows_re_exports_across_modules(): @@ -969,33 +1572,114 @@ def test_scan_follows_re_exports_across_modules(): ], describe(hits) -@pytest.mark.parametrize( - "body", - ( - "def f(x):\n return isinstance(x, int)\n", - "from populace_dynamics.engine.rng import ProjectionModule\n" - "def f(x):\n return isinstance(x, ProjectionModule)\n", - "def f(x):\n return type(x) is dict or x.__class__ == list\n", - "def f(n):\n return ProjectionRNGRegistry(0, n)\n", - "def f(registry: ProjectionRNGRegistry | None = None):\n" - " return registry is None or registry.n_periods == 3\n", - "def f(context):\n" - " if context.rng_registry is None:\n return None\n" - " return context.rng_registry.person_generator(1, 'aging', 0)\n", - "def f(x):\n return hasattr(x, 'person_generator')\n", - "class Streams:\n pass\n" - "def f(x):\n return isinstance(x, Streams) or x == 'Registry'\n", - # A binding cycle that would grow a module path on every pass. - "import os as b\na = b.path\nb = a\n" - "def f(x):\n return isinstance(x, a.sep)\n", - ), +NEGATIVE_BODIES = ( + "def f(x):\n return isinstance(x, int)\n", + "from populace_dynamics.engine.rng import ProjectionModule\n" + "def f(x):\n return isinstance(x, ProjectionModule)\n", + "def f(x):\n return type(x) is dict or x.__class__ == list\n", + "def f(n):\n return ProjectionRNGRegistry(0, n)\n", + "def f(registry: ProjectionRNGRegistry | None = None):\n" + " return registry is None or registry.n_periods == 3\n", + "def f(context):\n" + " if context.rng_registry is None:\n return None\n" + " return context.rng_registry.person_generator(1, 'aging', 0)\n", + "def f(x):\n return hasattr(x, 'person_generator')\n", + "class Streams:\n pass\n" + "def f(x):\n return isinstance(x, Streams) or x == 'Registry'\n", + # A binding cycle that would grow a module path on every pass. + "import os as b\na = b.path\nb = a\n" + "def f(x):\n return isinstance(x, a.sep)\n", + # Listings, docstrings and annotations name the class harmlessly. + "__all__ = ['ProjectionRNGRegistry']\n" + "__all__ += ['ProjectionRNGRegistry']\n" + "__all__.extend(['ProjectionRNGRegistry'])\n", + 'def f(x):\n """Draw from a ProjectionRNGRegistry."""\n return x\n', + "class Holder:\n registry: ProjectionRNGRegistry | None = None\n" + "def f() -> 'ProjectionRNGRegistry':\n" + " return ProjectionRNGRegistry(draw_index=0, n_periods=1)\n", + # A dispatch hint on a keyword-only parameter is never dispatched on. + "from functools import singledispatch\n" + "@singledispatch\ndef f(x):\n return 0\n" + "@f.register\ndef _(x: int, *, registry: ProjectionRNGRegistry):\n" + " return 1\n", + # A module function that shares a probe's name, called by name. + "def _sequence(n):\n return list(range(n))\n" + "def f(x):\n return _sequence(x)\n", + # The registry's module, used for something else. + "from populace_dynamics.engine import rng\n" + "def f(x):\n return x is rng.ProjectionModule.EARNINGS\n", + # Literal tables that name nothing the scan tracks; pruning drops them. + "TABLE = {'a': (1, -2.5, None), 'b': [b'x', {3}]}\n" + "def f(x):\n return isinstance(x, (int, str)) and x in TABLE\n", ) + + +@pytest.mark.parametrize("body", NEGATIVE_BODIES) def test_scan_ignores_unrelated_checks_and_ordinary_registry_use(body): """Each negative names the registry, so the scan parses it.""" hits = scan_sources([_invented(_REGISTRY_IMPORT + body)]) assert hits == (), describe(hits) +# Inert-literal pruning must not change any result. The leaves include the +# class's name, its module's name and a probe name, so some generated +# literals are inert and some are not. +_LITERAL_LEAVES = st.one_of( + st.integers(-3, 3), + st.none(), + st.binary(max_size=2), + st.sampled_from( + ["a", REGISTRY_NAME, REGISTRY_MODULE, "_period_sequence", "RNG"] + ), +) +_LITERALS = st.recursive( + _LITERAL_LEAVES, + lambda inner: st.one_of( + st.lists(inner, max_size=3), + st.tuples(inner, inner), + st.dictionaries(st.text("ab", max_size=2), inner, max_size=3), + ), + max_leaves=8, +) +_LITERAL_PLACES = ( + _REGISTRY_IMPORT + + "TABLE = {0}\n" + + "KINDS = ({0}, ProjectionRNGRegistry)\n" + + "__all__ = {0}\n" + + "def f(x, default={0}):\n" + + " for item in {0}:\n" + + " pass\n" + + " return type(x).__name__ in {0} or isinstance(x, {0}) or g({0})\n" +) + + +def test_pruning_leaves_every_invented_scan_unchanged(): + """Pruned and unpruned scans agree on every invented module here.""" + texts = [text for text, _ in SPELLINGS.values()] + texts += [_REGISTRY_IMPORT + body for body in NEGATIVE_BODIES] + for text in texts: + assert scan_sources([_invented(text)]) == scan_sources( + [_invented(text)], prune=False + ) + + +@settings(max_examples=200, deadline=None) +@given(literal=_LITERALS) +def test_pruning_leaves_scans_unchanged_for_any_literal(literal): + """Inert literals contribute nothing, wherever they sit. + + Each generated literal is placed in every position a check could read + it from: a binding, a container beside the class, a listing, a + default, a loop, a comparison, a type check and a call. + """ + text = _LITERAL_PLACES.format(repr(literal)) + pruned = scan_sources([_invented(text)]) + assert pruned == scan_sources([_invented(text)], prune=False) + assert ScanHit("invented/invented_case.py", 3, "class-reference") in ( + pruned + ) + + def test_scan_messages_never_show_source_text(): """Hits and refusals give a path, line and node kind only.""" sentinel = "ZQX_INVENTED_LITERAL_7781" @@ -1427,11 +2111,24 @@ def recorded(frame, context, *args): @pytest.mark.parametrize("mode", RNG_STREAM_MODES) def test_each_period_gives_every_slot_one_context_and_the_loop_generators( - mode, + mode, monkeypatch ): """Condition 1: only ``context.rng_registry`` changes, and only in - direct mode; the loop's positional module generators pass unchanged.""" + direct mode; the loop's positional module generators pass unchanged. + + The loop's own context is recorded where the harness receives it, and + every other field of each slot's context is compared with it. + """ calls = [] + originals = {} + bind = _AdapterStreams.bind + + def recorded_bind(self, context): + served = bind(self, context) + originals[id(served)] = context + return served + + monkeypatch.setattr(_AdapterStreams, "bind", recorded_bind) modules = _recording(_modules(), calls) draw, end = 2, START + 3 result = run_gate( @@ -1450,6 +2147,21 @@ def test_each_period_gives_every_slot_one_context_and_the_loop_generators( ) assert state == expected.bit_generator.state, (slot, context.year) assert all(len(ids) == 1 for ids in contexts.values()) + for _, context, _ in calls: + original = originals[id(context)] + for item in dataclasses.fields(PeriodContext): + if item.name != "rng_registry": + assert getattr(context, item.name) == getattr( + original, item.name + ), item.name + assert context.metadata is original.metadata + assert context.person_ordinals is original.person_ordinals + assert context.draw_index == original.draw_index == draw + assert type(original.rng_registry) is ProjectionRNGRegistry + assert (context is original) is (mode == "registry") + assert (context.rng_registry is original.rng_registry) is ( + mode == "registry" + ) streams = {id(context.rng_registry) for _, context, _ in calls} assert len(streams) == 1 served = calls[0][1].rng_registry From 5a4fc14ceea01e116faeecb1fbc5a52903255914 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 10:20:16 -0700 Subject: [PATCH 10/15] Run each sealed stream reader with both classes; close r2's scan gaps Review inject-r2 found static checks the scan missed outside its stated limits. The scan now tracks DirectRNGStreams as well as the registry, treats each read of a place the catch-all trusts as a specific check, and reads text built from literals through a bounded folder. All 38 of r2's checks give hits, and the real-tree pin is unchanged. Following the coordinator's suggestion, the guarantee is now behavioural. A pinned census lists the 23 sealed places that reach a context's streams. The ten runnable readers and the composition helper run on invented inputs with both classes, under Hypothesis, and their outputs are compared exactly. Section 6 of the report presents the scan as a heuristic with four stated classes of limit. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 411 ++- .../open_population/test_stream_injection.py | 3182 +++++++++++++++-- 2 files changed, 3204 insertions(+), 389 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index 2e130276..01bbb498 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -668,8 +668,9 @@ on the build's four open questions. The branch then merged master `9cced8ff`, a docs-only ledger update, and applied the rulings: the observer's refusal stays, with a clearer OP1 refusal under the default; the 2100 identity grid is the full product; and the scan skips the depletion-cut -modules by path. Review inject-r1 then asked for revisions at `8634858c`; -its subsection, below, gives the findings and the fixes. +modules by path. Review inject-r1 asked for revisions at `8634858c`, and +review inject-r2 for more at `33e383b4`; their subsections, below, give +the findings and the fixes. ### Mechanism @@ -690,7 +691,7 @@ its subsection, below, gives the findings and the fixes. period (`:228–234`), just as the loop gives its eight slots one context. Metadata, the person ordinals and every other field are the loop's own objects. -- All eight slots are bound (`:393–405`): the mortality wrapper, and through +- All eight slots are bound (`:393–406`): the mortality wrapper, and through it `_ExitAdapter` and the mortality adapter; aging; the marital core; the birth-entry adapter, and through it fertility; disability; earnings; claiming; and `close_period`, and through it household composition. @@ -731,9 +732,12 @@ so `POST_REVIEW_SOURCE_EXCLUSIONS` and its pin are unchanged. 3. Within a run there is one direct instance and one context per period; the generator each slot receives equals the loop's module generator for its period and module. -4. The two classes share the registry's public surface, and no sealed code - checks the registry's type except the one check recorded below, which - refuses rather than diverges. +4. The two classes share the registry's public surface. Every sealed reader + of a context's streams that the census finds and that runs on invented + inputs gives exactly the same output from either class, and the one + sealed type check refuses the direct class rather than diverging. The + static scan, a heuristic with stated limits, finds no other check that + tells the classes apart in the code it reads. ### The coordinator's conditions and their evidence @@ -773,6 +777,22 @@ parameters. The direct class must match each. The expected names are also pinned, so a member added to the registry fails the test until the direct class matches it. +**What this condition claims.** The guarantee is behavioural, and three +sets of runs carry it: +- at every stream address, through the public interface, the direct stream + equals the registry's (condition 3); +- each sealed reader of a context's streams that runs on invented inputs + gives exactly the same output from either class (condition 4, by reader); +- OP1's projection is identical under either class (condition 4). + +The scan below is a heuristic. It searches the sealed code it reads for +anything that could tell the classes apart other than by behaviour: a type +check, a class or module name read as text, introspection. It finds one +check, which refuses the direct class loudly. A clean scan is not a proof; +its limits fall into four classes, listed at the end of its description. +Code the scan cannot see matters only where it changes what a projection +computes, and the runs listed above check that on the paths they take. + `test_sealed_code_holds_only_the_known_registry_type_check` runs an AST scan of `src/populace_dynamics/` outside `open_population/`: 302 modules at `9cced8ff`, once the five excluded modules below are dropped. @@ -804,104 +824,206 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at module and no module whose name contains `depletion_cut` is in `sys.modules`. Code a projection never imports can't check its registry's type. +- **Both classes.** The scan tracks `DirectRNGStreams` as well as the + registry, since a check against either tells them apart. OP1's package + is not scanned, so sealed code that names the direct class or its module + counts like code that names the registry. - **Two layers.** Specific checks name the kind of each type check they - find. Under them, a catch-all reports every other mention of the class, - or of its name or its module's name as text, outside a benign place. - Benign places are imports, string statements (docstrings among them), - `__all__` listings, annotations other than dispatch hints, and the callee - of a construction such as `ProjectionRNGRegistry(draw_index, n_periods)`. - So a static spelling the specific checks miss still fails the pin, as a - `class-reference`, `class-name-string` or `module-name-string` hit on its - line. + find, and each read of a place the catch-all trusts. Under them, a + catch-all reports every other use of either class; of an instance built + from one; of a module object through which one can be reached; and of + either class's or module's name as text, in any case, once literal + expressions are folded. It trusts only these places: + - imports; + - string statements, docstrings among them; + - literal `__all__` listings at module level; + - annotations that no untrusted decorator, base class or metaclass reads; + - the callee of a construction, such as + `ProjectionRNGRegistry(draw_index, n_periods)`; + - plain attribute reads on a module; + - the plain uses of an instance: binding it to a name or a parameter + default, returning it, passing it to a call that does not compare, + look up, show or introspect it, reading a public attribute, testing + its truth and comparing it with `None` by identity. + + A decorator is trusted only by the path it resolves to, such as + `functools.lru_cache` or `dataclasses.dataclass`; a local function + named `property` is not. A class is trusted only with trusted + decorators, no keywords and no base but `object`. A specific check is + reported at the outermost node that makes it, and nothing under that + node is reported again. So a spelling the specific checks miss still + fails the pin, as a catch-all hit on its line, whenever it uses a + tracked class, instance, module or name outside the trusted places. - **What the specific checks catch.** - `isinstance` and `issubclass`, also through aliases such as `check = isinstance` or `from builtins import issubclass as check`. - - `type(x)` or `x.__class__` compared with the class, by any comparison - operator, alone or in a chain; and any other comparison against the - class or class info. - - Comparisons with the class's name as text: `__name__` or + - `type(x)` or `x.__class__` compared with either class, by any + comparison operator, alone or in a chain; and any other comparison + against a class or class info. + - Comparisons with either class's name as text: `__name__` or `__qualname__` against a string, a string held in a name or a - container, or `ProjectionRNGRegistry.__name__`. Comparisons with its - module's name count the same way. + container, or `ProjectionRNGRegistry.__name__`. Comparisons with + either module's name count the same way. - `match` class and value patterns. - `__instancecheck__` and `__subclasscheck__`. - - `singledispatch` registration by argument or by hint, quoted hints - included. + - `singledispatch` registration by argument or by hint. The hint is the + one `register` dispatches on, the first in Python's order (positional + parameters, `*args`, keyword-only parameters, `**kwargs`, then the + return). Quoted hints are parsed at any depth, and so is + `typing.ForwardRef("…")`. `register` is followed through aliases such + as `reg = f.register`. - Private names found on only one class, whether read as an attribute, probed with `hasattr` or `getattr`, or held as a string. -- **How it follows the class.** Directly; through an import alias; as a + - Reads of the trusted places, below. +- **Reads of the trusted places.** Python can read each trusted place at + run time, so each read is a specific check of its own: + - `export-read`: any read of `__all__` other than a listing's own + `append` or `extend`, as a name, an attribute, a string, or + `from m import __all__`. + - `docstring-read`: in a module whose docstrings name either class, + either module or a probe name, a read of `__doc__`, `__code__`, + `co_consts`, `__dict__` or `__globals__`, or a call to `getdoc`, + `getmembers`, `globals`, `locals`, `vars` or `dir`. + - `annotation-read`: in a module whose trusted annotations name either + class, a read of `__annotations__`, `__annotate__`, + `__dataclass_fields__`, `__signature__` or `__dict__`, or a call such + as `typing.get_type_hints`, `inspect.get_annotations`, + `inspect.signature` or `dataclasses.fields`. + - `namespace-read`: `globals()`, `locals()`, `vars()` or `dir()` with + no argument, in a module that binds either class, class info, a name + string or an instance. + - `source-read`: `inspect.getsource` and its kin, and any call into + `linecache`, `dis`, `marshal` or `tokenize`. + - `dynamic-code`: `eval`, `exec` or `typing.ForwardRef` on text the + folder cannot read, in a module that binds anything tracked. Literal + code passed to `eval`, `exec`, `compile` or `ForwardRef` is parsed and + scanned with the module, and so is literal code nested in it. + - `member-introspection`: a dunder read on, or an introspection call + such as `inspect.getmodule` or `getattr` on, a member of a module + through which a class can be reached. +- **How it follows the classes.** Directly; through an import alias; as a module attribute (`rng.ProjectionRNGRegistry`, the dotted path, or an aliased module); and through relative imports, local assignment chains, class and instance attributes, default arguments, loop variables, named expressions, `type(ProjectionRNGRegistry(...))` and `getattr` on the - module. It also follows the class inside class info: tuples, lists, - sets, unions written with `|` or `typing.Union`, tuples joined with `+`, + module. It also follows a class inside class info: tuples, lists, sets, + unions written with `|` or `typing.Union`, tuples joined with `+`, mappings, subscripts of any of these, and anything rebuilt or read from - them, such as `tuple(KINDS)` or `KINDS.get(key)`. Re-exports are followed - across modules to a fixed point. Bindings only accumulate, so the fixed + them, such as `tuple(KINDS)` or `KINDS.get(key)`. It follows instances + the same way, from a construction to a name, and through `type(...)` + and `.__class__` back to the class. Modules are followed through + `importlib.import_module` and `__import__` with literal names and + through `sys.modules` read with a literal key. Re-exports of classes + and instances are followed across modules to a fixed point; re-exported + modules come from import statements alone, fixed before that loop, so + no path changes between passes. Bindings only accumulate, so the fixed point ends even on a binding cycle such as `a = b.c; b = a`. The scan over-approximates on purpose: a binding anywhere in a module counts - everywhere in it, and an attribute counts wherever a name spelled like it - is bound. + everywhere in it, and an attribute counts wherever a name spelled like + it is bound. - **Text as the parser sees it.** Every module is parsed, with no text filter first. The build's filter parsed a module only if its text held a tracked name, and an escaped literal such as `"\x50rojectionRNGRegistry"` passed it, though the parser decodes it to the class's name. The scan reads strings after the parser has joined adjacent literals and decoded - escapes, and it folds `+` and f-strings made only of literals. It sees - identifiers after the parser's NFKC normalization, so a fullwidth first - letter still names the class. -- **Cost.** Right after parsing, each literal container of constants whose - strings hold none of the class's name, its module's name or a probe name - is replaced by `None`. No check can report or bind anything from such a - container. The generated data modules of 18.5, 11.1 and 1.6 MB are almost - all such containers: pruning leaves 178, 78 and 7 of their 706,994, - 251,826 and 85,417 syntax-tree nodes, and 930,518 of the 2,007,887 in all - 302 modules. `test_pruning_leaves_every_invented_scan_unchanged` - and `test_pruning_leaves_scans_unchanged_for_any_literal` (200 Hypothesis - literals, each placed in eight positions) check that pruning changes no - result. On the host the real-tree test takes 6.5 s, and its process peaks - near 1.3 GB, mostly while parsing the largest data module. + escapes, reads bytes as Latin-1, and matches names whatever their case. + It sees identifiers after the parser's NFKC normalization, so a + fullwidth first letter still names the class. +- **The folder.** Before it reads text, the scan folds literal + expressions: `+`, `*` and `%`; f-strings with their conversions and + format specs; indexing and slicing; `str.format`, `join`, `replace` and + the other pure `str` and `bytes` methods it lists, such as `lower`, + `strip`, `split`, `encode` and `decode`; and `str`, `bytes`, `repr`, + `ascii`, `format`, `chr`, `tuple`, `list`, `sorted` and `reversed`. It + does not follow names. It holds no text over 64 KiB, no integer larger + than 4096 in magnitude and no tuple over 256 items, and it formats no + template over 4096 characters or with a four-digit width or precision. + It runs `str.format` one field at a time, so a template that reads one + argument in many fields stops at the limit. + - `test_folder_refuses_results_past_its_limits` checks + 19 expressions past those limits: the folder gives + no value, and holds under 1 MiB while refusing. + - `test_folder_agrees_with_python_on_literal_expressions` checks 300 + Hypothesis expressions against Python itself, by value and type. Where + Python fails or passes a limit, the folder must give no value. The one + exception it allows is the four-digit width rule; with that rule off, + the folder must agree. +- **Cost.** Right after parsing, each literal container of constants is + replaced by `None` unless a string in it holds either class's or + module's name in any case, a probe name, or a name a read of a trusted + place spells, or the folder could read it. No check can report or bind + anything from such a container. The generated data modules of + 18.5, 11.1 and 1.6 MB are almost all such containers: pruning leaves + 178, 78 and 7 of their 706,994, 251,826 and 85,417 syntax-tree nodes, and + 934,099 of the 2,007,887 in all 302 modules. + `test_pruning_leaves_every_invented_scan_unchanged` and + `test_pruning_leaves_scans_unchanged_for_any_literal` (200 Hypothesis + literals, each placed in fourteen positions, folded ones among them) + check that pruning changes no result. On the host, at a one-minute load near + 100, the real-tree test took 15.0 s and the census test + 4.8 s. A process that parses the tree peaks near + 1.2 GB, mostly while parsing the largest data module. - **Its own tests.** - - `test_scan_catches_every_spelling_of_a_registry_type_check` covers 58 - invented spellings. Each gives exactly the hits its markers ask for: - one of its kind on its check line, plus one on each line that binds - the class or holds its name. All twelve of review inject-r1's misses - are among them. - - `test_every_spelling_tells_the_two_classes_apart` runs 56 of them and - checks that each answers differently for the two classes. The other - two cannot run: a relative import, and an alias chain written in - reverse order. + - `test_scan_catches_every_spelling_of_a_registry_type_check` covers + 121 invented spellings. Each gives exactly the hits its + markers ask for: one of its kind on its check line, plus one on each + line that binds a class or holds its name. All twelve of review + inject-r1's misses and all nineteen of review inject-r2's are among + them. + - `test_every_spelling_tells_the_two_classes_apart` runs + 119 of them and checks that each answers differently for the + two classes. The other two cannot run: a relative import, and an alias + chain written in reverse order. + - `test_every_kind_has_a_spelling` checks that each of the 24 kinds, 19 + specific and 5 catch-all, has at least one spelling. - `test_scan_follows_re_exports_across_modules` is unchanged: aliases one and two modules away, a star import, a module alias, and a same-named local class that is not caught. - - `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` has 16 - negatives. They include `__all__` listings, docstrings, annotations, a - keyword-only hint on a dispatch registration, a module function that - shares a probe's name, another use of the registry's module and pruned - literal tables. + - `test_scan_ignores_unrelated_checks_and_ordinary_registry_use` has + 20 negatives. They include `__all__` listings, docstrings, + annotations, a keyword-only hint on a dispatch registration, a module + function that shares a probe's name, another use of the registry's + module, pruned literal tables, trusted decorators and a class whose + only base is `object`, the plain uses of an instance, and expressions + the folder refuses. + - `test_reads_count_only_where_something_is_tracked` gives two modules + that track nothing; their reads of namespaces, docstrings, annotations + and signatures give no hit. - **Its messages.** A hit carries only a module path, line number and node kind. `test_scan_messages_never_show_source_text` plants a distinctive literal in an invented snippet and checks that it is absent from the hits, their `repr` and the assertion message, and from the refusal raised for unparsable source, whose original exception is suppressed. -- **Limits.** The scan does not see: - - names built at run time: concatenation with computed values, `chr`, - decoding, formatting, or `getattr` with a computed string; - - part of the class's or module's name, such as a prefix test or a - regular expression; - - the type of another value: `type(a) is type(b)`, - `isinstance(x, type(y))`, and the dataclass-generated `==` between a - registry and a direct instance, which is false across the two classes; - - annotations read at run time by anything but a `register` decorator. - Searching `src/` outside the excluded modules and `baselines/asfr.py`, - `typing.get_type_hints` appears only in `track_b/_provenance.py`, whose - schemas read the hints of the Track B classes and calls it decorates. - The one registry hint in Track B, on `track_b/replay.py`'s private - `_rng_signature`, carries no decorator; - - code it does not read: the excluded modules, OP1's own package, - scripts, tests and third-party code. - + `test_every_hit_names_only_a_path_a_line_and_a_kind` checks that every + spelling's hits print as exactly `path:line: kind`. +- **Limits.** The scan is a heuristic. It reads source as written, + before it runs, and what it cannot see falls into four classes: + 1. **Values made at run time.** Text built from anything but literals + (a parameter, a call's result, input, `chr` of a computed code + point), a module or class reached through a computed `getattr` name + or `sys.modules` key, and part of a name, such as + `type(x).__name__.startswith("Projection")` or a regular expression. + So are objects that reach a check through parameters, return values, + attributes of objects built elsewhere, run-time containers, closures + or frames: `type(a) is type(b)`, `isinstance(x, type(y))` with `y` + passed in, and the dataclass-generated `==` between a registry and a + direct instance built elsewhere. + 2. **Introspection of run-time values.** The annotations, docstring, + source or members of an object that arrives at run time, such as a + function passed in. Among the readers of run-time annotations in + `src/`, outside the excluded modules and `baselines/asfr.py`, + `typing.get_type_hints` appears only in `track_b/_provenance.py`, + whose schemas read the hints of the Track B classes and calls it + decorates. The one registry hint in Track B, on + `track_b/replay.py`'s private `_rng_signature`, carries no decorator. + 3. **Files read as data**, a module's own source among them, as with + `open(__file__)` or `importlib.resources`. + 4. **Code it does not read**: the excluded modules, OP1's own package, + scripts, tests and third-party code. + + So a clean scan is evidence, not proof. Behaviour is checked + separately, at run time: on the paths the reader runs and §4's identity + take, no sealed code gives different output for the two classes. Over-approximation can also give false hits. Any hit, false or not, fails the pin and comes back for a decision. @@ -1035,6 +1157,52 @@ size. These are single runs on a loaded host, so the ratios are indicative, not bounds. The full-size measurement is still due before OP10a. +**4, by reader.** `test_every_stream_reader_is_run_or_named` takes a census +of sealed code: each function, class or module that uses `rng_registry`, +`person_generator` or `composition_rngs_from_registry` as an attribute, a +name, a keyword or a string. At `9cced8ff` it finds 23 places and pins each +with the run that checks it or the reason none does: +- ten run with both classes, below; +- six build their own `ProjectionRNGRegistry` and read only that one: + `ProjectionEngine.project`, `resume_p2_projection`, + `simulate_candidate9_internal_reference`, + `run_candidate9_recertification`, `project_earnings_on_realized_support` + and Track B's `replay_earnings_on_realized_support`; +- three closures in `assemble_period_modules` need certified fitted inputs. + Their stream reads are `generator`, `child_generator`, `n_periods` and + `composition_rngs_from_registry`; `test_streams.py` checks those methods + at every address, and the helper runs below; +- `observe_mortality` holds the pinned check and refuses the direct class; +- three are not reads: the field's declaration on `PeriodContext`, the + helper's name in `engine/__init__.py`'s `__all__`, and + `graph/runtime.py`'s `_GraphPeriodContext`, which binds itself as its + registry. + +A new place, a moved one or a removed one fails the census. Like the scan, +the census is a heuristic: a function handed the registry under another +name is found only through the call that hands it over. + +`test_each_stream_reader_gives_the_same_output_from_either_class` runs the +ten readers, and `composition_rngs_from_registry` at period 0 and at the +context's period, with each class from the same draw index, period and +generator seed. Twelve Hypothesis examples per reader vary the invented +roster (0 to 12 people, with women of childbearing age, people past fifty, +DI entitlement and conversions), the draw index, the period (1 to 3) and +the seed. Outputs are compared exactly: frames by exact value and dtype, +arrays element by element with their dtype, and the fertility capture's +JSON as text. +- The earnings step runs three invented models: a plain generator, one + with frame updates and one with an earnings domain. +- The SES mortality step takes an invented model in place of a fitted one; + the step's own stream code is what runs. +- The fertility capture runs through `capture_fertility`, every period from + the first, as a projection runs it. +- `di_aware_claiming` runs with entitled and converted rows. + +`test_each_stream_reader_draws_from_the_streams` checks that draw indices 0 +and 1 give different output for every reader, so no run passes the streams +by. + **5. Seal bookkeeping.** No module was added, so the exclusion tuple and its pin are unchanged. `tests/track_u2/test_u2_isolation.py` was not run, and CI gives its result. `tests/tier_counts.json` and `tests/README-tiers.md` are @@ -1042,8 +1210,8 @@ untouched, for the main session's recount. **Tier.** Every new test is in `tests/open_population/test_streams.py` or `test_stream_injection.py`, and `_classify_test_module` classifies both -modules as `unit`. The stream differential and the hash identity therefore -run on every CI push. +modules as `unit`. The stream differential, the reader runs and the hash +identity therefore run on every CI push. ### Review inject-r1 @@ -1075,6 +1243,45 @@ sealed-path and file checks, the evidence audit and the six mutants below. horizon, and this section's source line references match the current files. +### Review inject-r2 + +Review inject-r2, a hard-tier Subfleet review on a Codex lane, read the +branch at `33e383b4` and returned REVISE, with one major finding, one minor +finding and a nit. It found r1's twelve misses caught and r1's other three +findings fixed. In its sandbox, the required command plus the two Track B +holdout-registry modules gave 756 passed, 2 skipped and 1 strict xfail, +with the real-tree scan giving exactly the pinned hit, and it reproduced +and killed each of the author's thirteen mutants. + +1. **Major: static type checks still escaped the scan.** Of 38 more + invented checks, each true for one class and false for the other, + thirteen gave no hit outside the stated limits: + - six that reuse `__all__`: appended or extended and then searched, + held as a mapping, counted, holding the check itself, and unpacked + into `operator.eq`; + - two that read a docstring back, through `__doc__` and through + `__code__.co_consts`; + - three `singledispatch` registrations: through a `register` alias, a + hint in nested quotes and `typing.ForwardRef`; + - the module's name compared through `operator.eq` and the module's + `__name__`; + - `isinstance` against the direct class. + + Six more misses it set down to the stated limits: an annotation + evaluated as a check, and five names built from literals by `format`, + `%` and `join`. + + **Fix:** the scan tracks both classes and checks each read of a place + the catch-all trusts, and the catch-all also reports instances, module + objects and folded literal text (condition 2). All nineteen misses are + among the invented spellings, and all 38 of the review's checks now give + hits. The scan still finds exactly the one pinned check in sealed code. +2. **Minor: no regression held a full name as bytes,** so a mutant that + stopped decoding bytes survived. **Fix:** spellings hold a class's + name, the registry module's name and a probe name as bytes, one of them + inside a nested tuple, and the pruning property samples those bytes. +3. **Nit:** the slot-binding range is `:393–406`. **Fix:** cited. + ### Commands and results ```sh @@ -1165,6 +1372,49 @@ pruning's check for tracked strings, `+` folding, parsing of quoted hints, module-name comparisons, the benign `__all__` listing, private-attribute reads and loop bindings. None ran against the real tree. +After the fixes for review inject-r2 and the coordinator's steer, the main +session ran the same commands on the host from 17:12 to 17:19 UTC on 26 +October. Each test process ran under the load-check audit hook and started +only at a one-minute load under 100: +- `tests/open_population`: **614 passed and 1 strict xfail** in 68.59 s. + These are OP1's 238 and the 377 tests this follow-up adds: 352 in + `test_stream_injection.py` and 25 in `test_streams.py`. +- The other four modules: 104 passed in 5.34 s. +- The required command plus the two Track B holdout-registry modules: + 933 passed, 2 skipped and 1 strict xfail. The two Track B modules alone + gave 215 passed and 2 skipped. +- Peak RSS was 1.33 GB, most of it while the real-tree scan and the + census parse the largest data module. +- The slowest tests took 15.0 s (the real-tree scan), 4.8 s (the census), + 4.6 s (the fresh-interpreter import check) and up to 2.5 s for a 2100 + hash pair. +- No file under a bar was opened, the pattern line named only + `baselines/asfr.py` (read by the scan and the census, never displayed), + and neither barred function was called. +- `--collect-only -m unit tests/open_population` collects all 615. Black, + Ruff and `git diff --check` are clean. `git diff --stat 9cced8ff` still + lists the seven files, and the sealed-path `git diff --quiet` exits 0. +- All 38 of review inject-r2's checks, run through the current scan, give + hits. On the real tree the scan still gives exactly the pinned hit. + +Thirty-two mutants of the scan and its folder each fail the tests aimed at +them. Each disables one part: export reads of `__all__`, as a name and as an +attribute; `register` aliases; hint-string parsing; tracking the direct +class; instance uses; module-object uses; the docstring and annotation +holds; pruning's keep-list and fold contexts; the folder's tuple weight, +template field, formatter bound, field size, field spec, f-string size, +integer, item, repetition, replacement and join limits; namespace and +source reads; dynamic code; class and decorator trust; an instance's +`__class__`; embedded literal code; member introspection; folded method +calls; and case-folded names. None ran against the real tree. + +Three stream mutants of the direct class each fail the reader runs. Shifting +the person ordinal by one changes the output of the nine readers that use +person streams; shifting a tagged child's tag changes `_person_uniforms`, +`draw_asfr_births` and the composition streams; shifting a tagged +generator's tag changes the composition streams. Unmutated, no reader's +output differs. + ### Restricted-files statement The build read `RESTRICTED-FILES.md` at its pinned hash and the @@ -1190,3 +1440,12 @@ The fixes for review inject-r1 followed the same rules. The main session's measurement scripts read the scanned modules only through the test module's own walk, and printed module paths, line numbers, node kinds and counts. Its searches over `src/` took their file lists from the helper. + +The fixes for review inject-r2 and the reader runs followed the same rules. +The census reads the scanned modules only through the test module's own +walk and reports paths and qualified names. The reader runs use invented +rosters, rates, schedules and models, and import `cola_track_a.adapters` +only to run `di_aware_claiming`; no test calls a barred function. The main +session's searches over `src/` and `tests/` took their file lists from the +helper, and its measurement scripts printed paths, line numbers, node +kinds, qualified names and counts. diff --git a/tests/open_population/test_stream_injection.py b/tests/open_population/test_stream_injection.py index 89ae2619..72b2142d 100644 --- a/tests/open_population/test_stream_injection.py +++ b/tests/open_population/test_stream_injection.py @@ -3,15 +3,24 @@ The coordinator's ruling of 24 October 2026 put the substitution in OP1's harness wrapper: the sealed loop builds its registry (``engine/loop.py:217``) and places it on each period's context (``:292``), and each OP1 slot receives -that context with ``DirectRNGStreams`` in its place. Duck typing is safe only -while the two classes share a public surface and no sealed code checks the -registry's type, so this module pins both, then checks the projection itself. - -The AST scan reads ``src/populace_dynamics/`` outside ``open_population/``. -Specific checks name each type check they find; under them a catch-all -reports every other mention of the class, or of its name or module as text, -outside imports, docstrings, ``__all__``, annotations and construction. The -evidence report's §6 lists what it cannot see. +that context with ``DirectRNGStreams`` in its place. The substitution is safe +while sealed code behaves the same with either class. This module pins the +shared public surface, runs each sealed reader of a context's streams with +both classes, and checks the projection itself; those runs are the guarantee. + +The AST scan is a heuristic beside them. It reads ``src/populace_dynamics/`` +outside ``open_population/`` for code that could tell the classes apart other +than by behaviour, and tracks both classes, since a check against either +tells them apart. +Specific checks name each type check they find, and each read of a place +the catch-all trusts: export lists, docstrings, annotations, namespaces and +source text. Under them a catch-all reports every other use of either +class, of an instance built from one, of a module object that can reach +one, and of their names as text once literal expressions are folded. It +trusts imports, string statements, literal ``__all__`` listings, +annotations that no unknown decorator or base class reads, construction, +plain attribute reads on a module and the plain uses of an instance. The +evidence report's §6 lists what it cannot see, as classes of limit. The project's data-access record keeps two modules out of automated reads, and the scan also skips the depletion-cut modules, so that what it reads does not depend on who runs it. It drops all of them by path before anything opens @@ -23,15 +32,22 @@ from __future__ import annotations import ast +import builtins import dataclasses import fnmatch import hashlib +import importlib.util import inspect import json +import linecache import os +import re +import string import subprocess import sys import textwrap +import tracemalloc +import warnings from collections.abc import Iterable from dataclasses import dataclass, replace from functools import partial @@ -44,9 +60,20 @@ from hypothesis import given, settings from hypothesis import strategies as st -from populace_dynamics.baselines.fertility import AsfrFertilityStep +from populace_dynamics.assembled_history_observer import capture_fertility +from populace_dynamics.baselines.fertility import ( + AsfrFertilityStep, + draw_asfr_births, +) +from populace_dynamics.cola_track_a.adapters import di_aware_claiming +from populace_dynamics.engine import claiming as engine_claiming from populace_dynamics.engine import loop as engine_loop from populace_dynamics.engine import rng as engine_rng +from populace_dynamics.engine.composition import ( + composition_rngs_from_registry, +) +from populace_dynamics.engine.di_entitlement import _person_uniforms +from populace_dynamics.engine.earnings_domain import EARNINGS_DOMAIN_COLUMN from populace_dynamics.engine.loop import PeriodContext, PeriodModules from populace_dynamics.engine.rng import ( ProjectionModule, @@ -54,10 +81,14 @@ ) from populace_dynamics.engine.steps import ( AgeSexMortalityModel, + ClaimingSchedule, advance_age, + apply_claiming, + apply_earnings, apply_mortality, ) from populace_dynamics.mortality_observer import observe_mortality +from populace_dynamics.mortality_ses.step import apply_differential_mortality from populace_dynamics.open_population import exits from populace_dynamics.open_population.errors import IdOrStreamCollision from populace_dynamics.open_population.exits import no_marital_transitions @@ -186,6 +217,17 @@ def test_direct_streams_are_not_a_registry_subclass(): REGISTRY_MODULE = "populace_dynamics.engine.rng" REGISTRY_NAME = "ProjectionRNGRegistry" +DIRECT_MODULE = "populace_dynamics.open_population.streams" +DIRECT_NAME = "DirectRNGStreams" +# A check against either class tells the two apart, so the scan tracks +# both. OP1's own package holds the direct class and is not scanned, so +# sealed code that names it, as text or as a module, counts as well. +TRACKED_CLASSES = frozenset( + {(REGISTRY_MODULE, REGISTRY_NAME), (DIRECT_MODULE, DIRECT_NAME)} +) +TRACKED_NAMES = frozenset(name for _, name in TRACKED_CLASSES) +TRACKED_MODULES = frozenset(module for module, _ in TRACKED_CLASSES) +OWN_MODULE = "populace_dynamics.open_population" OWN_PACKAGE = Path("src/populace_dynamics/open_population") # The project's data-access record keeps these two modules out of automated # reads. The scan skips them by path, before anything opens them; the @@ -210,11 +252,149 @@ def test_direct_streams_are_not_a_registry_subclass(): for name in set(dir(ProjectionRNGRegistry)) ^ set(dir(DirectRNGStreams)) if name.startswith("_") and not name.startswith("__") ) | {"_root"} +# Text names a tracked class or module whatever its case. +_CLASS_TEXTS = tuple(sorted(name.casefold() for name in TRACKED_NAMES)) +_MODULE_TEXTS = (REGISTRY_MODULE, OWN_MODULE) _TYPE_CHECK_CALLS = frozenset({"isinstance", "issubclass"}) _DUNDER_CHECKS = frozenset({"__instancecheck__", "__subclasscheck__"}) _NAME_ATTRIBUTES = frozenset({"__name__", "__qualname__"}) _CONTAINER_CALLS = frozenset({"tuple", "list", "set", "frozenset", "dict"}) -_LISTING_CALLS = frozenset({"append", "extend"}) +# The places the catch-all treats as benign can still be read at run time: +# ``__all__``, docstrings and annotations. These reads are checked instead. +_EXPORT_NAME = "__all__" +_DOC_ATTRIBUTES = frozenset( + {"__doc__", "__code__", "co_consts", "__dict__", "__globals__"} +) +_ANNOTATION_ATTRIBUTES = frozenset( + { + "__annotations__", + "__annotate__", + "__dataclass_fields__", + "__signature__", + "__dict__", + } +) +_NAMESPACE_CALLS = frozenset({"globals", "locals", "vars", "dir"}) +_DOC_CALLS = _NAMESPACE_CALLS | {"getdoc", "getmembers", "getmembers_static"} +_ANNOTATION_CALLS = _NAMESPACE_CALLS | { + "get_type_hints", + "get_annotations", + "signature", + "fields", + "getfullargspec", + "getargspec", + "get_overloads", + "call_annotate_function", + "getmembers", + "getmembers_static", +} +_SOURCE_CALLS = frozenset( + {"getsource", "getsourcelines", "findsource", "getcomments", "getblock"} +) +_SOURCE_MODULES = frozenset({"linecache", "dis", "marshal", "tokenize"}) +_INTROSPECTION_CALLS = ( + frozenset( + { + "getmodule", + "getfile", + "getsourcefile", + "getattr", + "getattr_static", + "hasattr", + } + ) + | _DOC_CALLS + | _ANNOTATION_CALLS + | _SOURCE_CALLS +) +# Calls that compare an instance, look it up, render it as text or +# introspect it: an instance passed to one of these counts. +_INSTANCE_SINKS = frozenset( + { + "type", + "isinstance", + "issubclass", + "repr", + "str", + "ascii", + "format", + "dir", + "vars", + "getattr", + "getattr_static", + "hasattr", + "getmodule", + "dumps", + "eq", + "ne", + "lt", + "le", + "gt", + "ge", + "is_", + "is_not", + "contains", + "countOf", + "indexOf", + "count", + "index", + "get", + "pop", + "remove", + "discard", + "setdefault", + "__eq__", + "__ne__", + "__contains__", + "__getitem__", + } +) +# Decorators that leave a function's or a class's annotations unread, by +# the path they resolve to. A name the module rebinds resolves to nothing. +_BENIGN_DECORATORS = frozenset( + { + "builtins.staticmethod", + "builtins.classmethod", + "builtins.property", + "functools.cached_property", + "functools.cache", + "functools.lru_cache", + "functools.wraps", + "functools.singledispatch", + "functools.singledispatchmethod", + "functools.total_ordering", + "abc.abstractmethod", + "typing.overload", + "typing.override", + "typing.final", + "typing_extensions.overload", + "typing_extensions.override", + "typing_extensions.final", + "contextlib.contextmanager", + "contextlib.asynccontextmanager", + "dataclasses.dataclass", + } +) +# A property's own decorators, whatever the property is called. +_PROPERTY_METHODS = frozenset({"setter", "getter", "deleter"}) +# Calls whose literal first argument is code; the scan parses and checks it. +_CODE_CALLS = frozenset({"eval", "exec", "compile", "ForwardRef"}) + + +def _code_call(func: ast.expr) -> bool: + """Return whether a callee runs code: ``re.compile`` does not.""" + if isinstance(func, ast.Name): + return func.id in _CODE_CALLS + return isinstance(func, ast.Attribute) and func.attr in ( + _CODE_CALLS - {"compile"} + ) + + +# Strings a read of a benign place may spell; a literal holding one is +# never pruned. +_SPECIAL_STRINGS = ( + frozenset({_EXPORT_NAME}) | _DOC_ATTRIBUTES | _ANNOTATION_ATTRIBUTES +) # The nodes that can bind a name; binding revisits only these. _BINDING_NODES = ( ast.Import, @@ -229,6 +409,45 @@ def test_direct_streams_are_not_a_registry_subclass(): ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, + ast.ClassDef, +) +# Re-exports are followed at most this many steps, so a cycle ends. +_RESOLVE_STEPS = 16 +# A hint's nested strings are parsed into at most this many parts. +_HINT_PARTS = 64 +# Every kind of hit. A specific check names what the code does; the +# catch-all names any other use of a tracked class, module or instance. +SPECIFIC_KINDS = frozenset( + { + "isinstance", + "issubclass", + "type-compare", + "__class__-compare", + "class-compare", + "name-compare", + "module-compare", + "match-class", + "dispatch-register", + "dispatch-annotation", + "dunder-check", + "private-member-probe", + "export-read", + "docstring-read", + "annotation-read", + "namespace-read", + "dynamic-code", + "source-read", + "member-introspection", + } +) +CATCH_ALL_KINDS = frozenset( + { + "class-reference", + "class-name-string", + "module-name-string", + "module-reference", + "instance-reference", + } ) @@ -280,76 +499,695 @@ def _is_type_call(node: ast.expr) -> bool: ) -def _static_text(node: ast.expr) -> str | None: - """Return the text a string expression holds before run time, if any. +def _names_class(text: str) -> bool: + folded = text.casefold() + return any(name in folded for name in _CLASS_TEXTS) + + +def _names_module(text: str) -> bool: + folded = text.casefold() + return any(module in folded for module in _MODULE_TEXTS) + + +def _names_probe(text: str) -> bool: + """Return whether ``text`` holds a probe name as a whole word.""" + return any( + re.search(rf"(? bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _plain(value) -> bool: + return value is not _UNKNOWN and not isinstance(value, _Items) + + +def _weight(value) -> int: + """Measure a folded value: its characters, bytes and items, nested.""" + if isinstance(value, str | bytes): + return len(value) + if isinstance(value, tuple | list): + return len(value) + sum(map(_weight, value)) + return 1 + + +def _sized(value): + """Return ``value`` if the folder may hold it, else ``_UNKNOWN``.""" + if value is None or isinstance(value, bool): + return value + if isinstance(value, int): + return value if abs(value) <= _FOLD_INT_LIMIT else _UNKNOWN + if isinstance(value, str | bytes): + return value if len(value) <= _FOLD_TEXT_LIMIT else _UNKNOWN + if isinstance(value, tuple | list): + if len(value) > _FOLD_ITEMS_LIMIT: + return _UNKNOWN + items = [_sized(item) for item in value] + if not all(map(_plain, items)): + return _UNKNOWN + if _weight(items) > _FOLD_TEXT_LIMIT: + return _UNKNOWN + kind = _Items if isinstance(value, _Items) else type(value) + return kind(items) if kind in (_Items, tuple, list) else _UNKNOWN + return _UNKNOWN + + +def _apply(operation): + """Run a pure operation on folded values; any failure is unknown.""" + try: + return _sized(operation()) + except Exception: + return _UNKNOWN + + +def _template(value, kind: str) -> bool: + """Return whether a format template is small with narrow fields. + + ``kind`` is ``"percent"`` for ``%`` templates, ``"brace"`` for + ``str.format`` and ``"spec"`` for a format spec. + """ + if isinstance(value, bytes): + value = value.decode("latin-1") + return ( + isinstance(value, str) + and len(value) <= _FOLD_TEMPLATE_LIMIT + and not _WIDE_FIELDS[kind].search(value) + ) + + +def _fold_binop(left, op, right): + if not (_plain(left) and _plain(right)): + return _UNKNOWN + if _is_int(left) and _is_int(right): + operations = { + ast.Add: lambda: left + right, + ast.Sub: lambda: left - right, + ast.Mult: lambda: left * right, + } + operation = operations.get(type(op)) + return _UNKNOWN if operation is None else _apply(operation) + if isinstance(op, ast.Add): + if type(left) is not type(right) or not isinstance(left, _SEQUENCES): + return _UNKNOWN + # Each side is within the limits, so the sum is at most twice them. + return _apply(lambda: left + right) + if isinstance(op, ast.Mult): + sequence, count = (left, right) if _is_int(right) else (right, left) + if not (isinstance(sequence, _SEQUENCES) and _is_int(count)): + return _UNKNOWN + if _weight(sequence) * max(count, 0) > _FOLD_TEXT_LIMIT: + return _UNKNOWN + return _apply(lambda: sequence * count) + if isinstance(op, ast.Mod) and isinstance(left, str | bytes): + if not _template(left, "percent"): + return _UNKNOWN + return _apply(lambda: left % right) + return _UNKNOWN + + +class _BoundedFormatter(string.Formatter): + """``str.format``, refusing a field spec or text past the limits. + + A template can read one argument in many fields, so its text can far + outgrow its arguments; each field is checked as it is formatted, and + the first one past the limit stops the call. + """ + + def __init__(self): + super().__init__() + self.size = 0 + + def format_field(self, value, format_spec): + if not _template(format_spec, "spec"): + raise ValueError("format spec past the folder's limits") + text = super().format_field(value, format_spec) + self.size += len(text) + if self.size > _FOLD_TEXT_LIMIT: + raise ValueError("formatted text past the folder's limits") + return text + + +def _fold_method(receiver, name, args, keywords): + """Call a pure ``str`` or ``bytes`` method within the size limits.""" + if not isinstance(receiver, str | bytes): + return _UNKNOWN + if name == "join" and len(args) == 1 and not keywords: + # join reads an iterator's items; nothing else may take one. + parts = args[0] + if not isinstance(parts, _SEQUENCES): + return _UNKNOWN + separators = len(receiver) * max(len(parts) - 1, 0) + if sum(map(_weight, parts)) + separators > _FOLD_TEXT_LIMIT: + return _UNKNOWN + return _apply(lambda: receiver.join(parts)) + if not all(map(_plain, (*args, *keywords.values()))): + return _UNKNOWN + if name == "format": + if not (isinstance(receiver, str) and _template(receiver, "brace")): + return _UNKNOWN + return _apply( + lambda: _BoundedFormatter().vformat(receiver, args, keywords) + ) + if name == "replace" and len(args) >= 2: + old, new = args[0], args[1] + if type(old) is not type(receiver) or type(new) is not type(receiver): + return _UNKNOWN + count = receiver.count(old) if old else len(receiver) + 1 + if len(receiver) + count * len(new) > _FOLD_TEXT_LIMIT: + return _UNKNOWN + method = getattr(receiver, name, None) + if method is None: + return _UNKNOWN + return _apply(lambda: method(*args, **keywords)) + + +def _fold_function(name, args, keywords): + """Call a pure builtin on folded values within the size limits.""" + if name in {"reversed", "sorted", "tuple", "list"}: + if keywords or len(args) != 1: + return _UNKNOWN + if not isinstance(args[0], _SEQUENCES): + return _UNKNOWN + build = { + "reversed": lambda items: _Items(reversed(items)), + "sorted": sorted, + "tuple": tuple, + "list": list, + }[name] + return _apply(lambda: build(args[0])) + if not all(map(_plain, (*args, *keywords.values()))): + return _UNKNOWN + if name == "format" and len(args) >= 2 and not _template(args[1], "spec"): + return _UNKNOWN + function = { + "str": str, + "bytes": bytes, + "repr": repr, + "ascii": ascii, + "format": format, + "chr": chr, + }[name] + return _apply(lambda: function(*args, **keywords)) + + +def _fold_callee(func: ast.expr) -> bool: + """Return whether the folder calls this callee.""" + if isinstance(func, ast.Attribute): + return func.attr in _FOLD_METHODS + return isinstance(func, ast.Name) and func.id in _FOLD_FUNCTIONS + + +def _fold_children(node: ast.AST) -> list[ast.AST]: + """List the children whose values ``_fold_value`` reads.""" + if isinstance(node, ast.Tuple | ast.List): + return list(node.elts) + if isinstance(node, ast.UnaryOp): + return [node.operand] + if isinstance(node, ast.BinOp): + return [node.left, node.right] + if isinstance(node, ast.JoinedStr): + return list(node.values) + if isinstance(node, ast.FormattedValue): + return [node.value] + ([node.format_spec] if node.format_spec else []) + if isinstance(node, ast.Starred): + return [node.value] + if isinstance(node, ast.Subscript): + index = node.slice + if isinstance(index, ast.Slice): + parts = [index.lower, index.upper, index.step] + return [node.value, *(part for part in parts if part is not None)] + return [node.value, index] + if isinstance(node, ast.Call) and _fold_callee(node.func): + func = node.func + receiver = [func.value] if isinstance(func, ast.Attribute) else [] + return [ + *receiver, + *node.args, + *(keyword.value for keyword in node.keywords), + ] + return [] + + +def _unpacked(nodes: list[ast.expr], value) -> list | None: + """Return the values of call arguments or display items, unpacked. - The parser already joins adjacent literals and decodes escapes; this - also folds ``+`` and f-strings whose parts are all literals. Bytes are - read as Latin-1, so an ASCII name matches in either. + A starred item spreads its items, an iterator's among them; ``None`` + means some item has no value. """ + items = [] + for item in nodes: + if isinstance(item, ast.Starred): + inner = value(item.value) + if not isinstance(inner, _SEQUENCES): + return None + items.extend(inner) + else: + items.append(value(item)) + return items if all(map(_plain, items)) else None + + +def _fold_value(node: ast.AST, value): + """Fold one node, given ``value`` for each child it reads.""" if isinstance(node, ast.Constant): - if isinstance(node.value, str): + if isinstance(node.value, str | bytes): return node.value - if isinstance(node.value, bytes): - return node.value.decode("latin-1") - return None - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): - left = _static_text(node.left) - right = _static_text(node.right) - return None if left is None or right is None else left + right + if node.value is None or _is_int(node.value): + return _sized(node.value) + return _UNKNOWN + if isinstance(node, ast.Tuple | ast.List): + items = _unpacked(node.elts, value) + if items is None: + return _UNKNOWN + return _sized(tuple(items) if isinstance(node, ast.Tuple) else items) + if isinstance(node, ast.UnaryOp): + operand = value(node.operand) + if not _is_int(operand): + return _UNKNOWN + if isinstance(node.op, ast.USub): + return _sized(-operand) + if isinstance(node.op, ast.UAdd): + return operand + if isinstance(node.op, ast.Invert): + return _sized(~operand) + return _UNKNOWN + if isinstance(node, ast.BinOp): + return _fold_binop(value(node.left), node.op, value(node.right)) if isinstance(node, ast.JoinedStr): - parts = [] - for value in node.values: - if isinstance(value, ast.FormattedValue): - if value.conversion != -1 or value.format_spec is not None: - return None - value = value.value - text = _static_text(value) - if text is None: - return None - parts.append(text) + parts = [value(item) for item in node.values] + if not all(isinstance(part, str) for part in parts): + return _UNKNOWN + if sum(map(len, parts)) > _FOLD_TEXT_LIMIT: + return _UNKNOWN return "".join(parts) + if isinstance(node, ast.FormattedValue): + inner = value(node.value) + spec = value(node.format_spec) if node.format_spec else "" + convert = _CONVERSIONS.get(node.conversion, _UNKNOWN) + if not _plain(inner) or convert is _UNKNOWN: + return _UNKNOWN + if not _template(spec, "spec"): + return _UNKNOWN + return _apply( + lambda: format(convert(inner) if convert else inner, spec) + ) + if isinstance(node, ast.Subscript): + sequence = value(node.value) + if not (_plain(sequence) and isinstance(sequence, _SEQUENCES)): + return _UNKNOWN + index = node.slice + if isinstance(index, ast.Slice): + parts = [ + None if part is None else value(part) + for part in (index.lower, index.upper, index.step) + ] + if not all(part is None or _is_int(part) for part in parts): + return _UNKNOWN + return _apply(lambda: sequence[slice(*parts)]) + position = value(index) + if not _is_int(position): + return _UNKNOWN + return _apply(lambda: sequence[position]) + if isinstance(node, ast.Call) and _fold_callee(node.func): + return _fold_call(node, value) + return _UNKNOWN + + +def _fold_call(node: ast.Call, value): + args = [] + for item in node.args: + if isinstance(item, ast.Starred): + inner = value(item.value) + if not isinstance(inner, _SEQUENCES): + return _UNKNOWN + args.extend(inner) + else: + args.append(value(item)) + keywords = {} + for keyword in node.keywords: + if keyword.arg is None: + return _UNKNOWN + keywords[keyword.arg] = value(keyword.value) + if any(item is _UNKNOWN for item in (*args, *keywords.values())): + return _UNKNOWN + func = node.func + if isinstance(func, ast.Attribute): + owner = func.value + if isinstance(owner, ast.Name) and owner.id in {"str", "bytes"}: + # An unbound method: str.join(sep, parts), bytes.fromhex(text). + if func.attr == "fromhex" and owner.id == "bytes": + if not all(map(_plain, args)) or keywords: + return _UNKNOWN + return _apply(lambda: bytes.fromhex(*args)) + if not args: + return _UNKNOWN + return _fold_method(args[0], func.attr, args[1:], keywords) + return _fold_method(value(owner), func.attr, args, keywords) + return _fold_function(func.id, args, keywords) + + +def _fold(node: ast.AST, memo: dict): + """Return the value an expression holds before run time, or unknown. + + Literals fold, and so do the pure operations a module might build + text with: ``+``, ``*`` and ``%``, f-strings with their conversions and + format specs, indexing and slicing, the ``str`` and ``bytes`` methods + in ``_FOLD_METHODS`` and the builtins in ``_FOLD_FUNCTIONS``. Names + are not followed. The walk keeps its own stack, so a deep expression + cannot exhaust recursion, and ``memo`` keeps each node's value. + """ + + def known(item): + entry = memo.get(id(item)) + return entry is not None and entry[0] is item + + stack = [(node, False)] + while stack: + item, ready = stack.pop() + if known(item): + continue + children = [ + child for child in _fold_children(item) if not known(child) + ] + if ready or not children: + memo[id(item)] = ( + item, + _fold_value(item, lambda child: memo[id(child)][1]), + ) + else: + stack.append((item, True)) + stack.extend((child, False) for child in children) + return memo[id(node)][1] + + +def _static_text(node: ast.expr, memo: dict | None = None) -> str | None: + """Return the text a string expression holds before run time, if any. + + The parser already joins adjacent literals and decodes escapes; the + folder does the rest. Bytes are read as Latin-1, so an ASCII name + matches in either. + """ + value = _fold(node, {} if memo is None else memo) + if isinstance(value, str): + return value + if isinstance(value, bytes): + return value.decode("latin-1") return None +def _value_texts(value) -> list[str]: + """List the texts a folded value holds, inside tuples too.""" + if isinstance(value, str): + return [value] + if isinstance(value, bytes): + return [value.decode("latin-1")] + if isinstance(value, tuple | list): + return [text for item in value for text in _value_texts(item)] + return [] + + +# --------------------------------------------------------------------------- +# Syntax helpers. +# --------------------------------------------------------------------------- + + def _is_all(target: ast.expr) -> bool: - return isinstance(target, ast.Name) and target.id == "__all__" + return isinstance(target, ast.Name) and target.id == _EXPORT_NAME -def _lists_names(node: ast.Call) -> bool: - """Return whether a call is ``__all__.append`` or ``__all__.extend``.""" - return ( - isinstance(node.func, ast.Attribute) - and node.func.attr in _LISTING_CALLS - and _is_all(node.func.value) +def _is_string_constant(node: ast.AST) -> bool: + return isinstance(node, ast.Constant) and isinstance( + node.value, str | bytes ) -def _registers(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: - """Return whether a bare ``register`` decorator dispatches on a hint.""" - return any( - (isinstance(decorator, ast.Attribute) and decorator.attr == "register") - or (isinstance(decorator, ast.Name) and decorator.id == "register") - for decorator in node.decorator_list +def _is_name_literal(node: ast.AST) -> bool: + return isinstance(node, ast.Constant) and isinstance(node.value, str) + + +def _literal_names(node: ast.AST) -> bool: + """Return whether ``node`` is a list or tuple of string literals.""" + return isinstance(node, ast.List | ast.Tuple) and all( + map(_is_name_literal, node.elts) ) +def _lists_exports(statement: ast.stmt) -> bool: + """Return whether a statement sets or adds to ``__all__`` by literal. + + That is ``__all__ = [...]``, ``__all__: list[str] = [...]`` or + ``__all__ += [...]``, with a list or tuple of string literals. + """ + if isinstance(statement, ast.Assign): + targets = statement.targets + elif isinstance(statement, ast.AnnAssign) or ( + isinstance(statement, ast.AugAssign) + and isinstance(statement.op, ast.Add) + ): + targets = [statement.target] + else: + return False + return all(map(_is_all, targets)) and _literal_names(statement.value) + + +def _extends_exports(node: ast.expr) -> bool: + """Return whether ``node`` is ``__all__.append`` or ``.extend`` of a + literal name or literal names.""" + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and _is_all(node.func.value) + and len(node.args) == 1 + and not node.keywords + ): + return False + if node.func.attr == "append": + return _is_name_literal(node.args[0]) + return node.func.attr == "extend" and _literal_names(node.args[0]) + + +def _statements(body: list[ast.stmt]) -> Iterable[ast.stmt]: + """Yield the statements of a body, inside compound statements too. + + Nested functions and classes are not entered: they are other scopes. + """ + stack = list(reversed(body)) + while stack: + statement = stack.pop() + yield statement + if isinstance( + statement, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef + ): + continue + inner = [] + for field in ("body", "orelse", "finalbody"): + inner += getattr(statement, field, []) + for handler in getattr(statement, "handlers", []): + inner += handler.body + for case in getattr(statement, "cases", []): + inner += case.body + stack.extend(reversed(inner)) + + +def _hints(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]: + """List a function's hints in the order Python stores them. + + That is the parameters in order (positional, ``*args``, keyword-only, + ``**kwargs``), then the return hint; ``singledispatch`` dispatches on + the first. + """ + arguments = node.args + parameters = [*arguments.posonlyargs, *arguments.args] + if arguments.vararg is not None: + parameters.append(arguments.vararg) + parameters += arguments.kwonlyargs + if arguments.kwarg is not None: + parameters.append(arguments.kwarg) + hints = [item.annotation for item in parameters if item.annotation] + if node.returns is not None: + hints.append(node.returns) + return hints + + +def _class_annotations(node: ast.ClassDef) -> list[ast.expr]: + return [ + statement.annotation + for statement in _statements(node.body) + if isinstance(statement, ast.AnnAssign) + ] + + +def _binds_by_name(target: ast.expr) -> bool: + """Return whether an assignment target only binds names.""" + if isinstance(target, ast.Name | ast.Attribute): + return True + if isinstance(target, ast.Starred): + return _binds_by_name(target.value) + if isinstance(target, ast.Tuple | ast.List): + return all(map(_binds_by_name, target.elts)) + return False + + +def _relocate(tree: ast.AST, origin: ast.AST) -> None: + """Give every node in ``tree`` the location of ``origin``.""" + for node in ast.walk(tree): + for field in ("lineno", "col_offset", "end_lineno", "end_col_offset"): + if field in node._attributes: + setattr(node, field, getattr(origin, field, None)) + + +def _annotation_nodes(tree: ast.AST) -> set[int]: + """Return the ids of every node inside an annotation.""" + hints = [] + for node in ast.walk(tree): + if isinstance(node, ast.arg | ast.AnnAssign) and node.annotation: + hints.append(node.annotation) + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + if node.returns is not None: + hints.append(node.returns) + return {id(item) for hint in hints for item in ast.walk(hint)} + + +def _embedded(tree: ast.AST, depth: int = 0) -> list[ast.Module]: + """Parse the literal code a module evaluates, nested code too. + + A literal passed to ``eval``, ``exec``, ``compile`` or ``ForwardRef`` + is code the module runs, so the scan checks it as part of the module; + each parsed tree takes its call's location. Inside an annotation the + hint checks parse such literals instead, so they are skipped here. + """ + found = [] + hinted = _annotation_nodes(tree) + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) + and node.args + and _code_call(node.func) + and id(node) not in hinted + ): + continue + text = _static_text(node.args[0]) + if text is None: + continue + try: + parsed = ast.parse(text) + except (SyntaxError, ValueError): + continue + _relocate(parsed, node) + found.append(parsed) + if depth < 4: + found += _embedded(parsed, depth + 1) + return found + + class _ModuleBindings: - """Resolve, flow-insensitively, what a module binds to the registry. - - Five kinds of binding are tracked: modules; the class itself; - containers that may hold it (tuples, unions, mappings and anything read - or rebuilt from them); strings that hold its name; and aliases of - ``isinstance`` and ``issubclass``. Over-approximation is deliberate: a - binding anywhere in the module counts everywhere in it, an attribute - counts wherever a name spelled like it is bound, a loop over class info - binds its variable to the class, and any name or attribute spelled - exactly ``ProjectionRNGRegistry`` counts, whatever it came from. + """Resolve, flow-insensitively, what a module binds to a tracked class. + + Seven kinds of binding are tracked: modules; the tracked classes + themselves; containers that may hold one (tuples, unions, mappings and + anything read or rebuilt from them); strings that hold a tracked name; + aliases of ``isinstance`` and ``issubclass``; instances built from a + tracked class; and aliases of a dispatcher's ``register``. + Over-approximation is deliberate: a binding anywhere in the module + counts everywhere in it, an attribute counts wherever a name spelled + like it is bound, a loop over class info binds its variable to the + class, and any name or attribute spelled exactly like a tracked class + counts, whatever it came from. Imported names are followed through + other modules' re-exports. """ - def __init__(self, source: ScanSource, exports: set[tuple[str, str]]): + def __init__( + self, + source: ScanSource, + exports: set[tuple[str, str]], + instance_exports: set[tuple[str, str]], + module_exports: dict[tuple[str, str], str], + memo: dict, + ): self.source = source self.exports = exports + self.instance_exports = instance_exports + self.module_exports = module_exports + self.memo = memo self.package = ( source.module if source.is_package @@ -360,6 +1198,27 @@ def __init__(self, source: ScanSource, exports: set[tuple[str, str]]): self.containers: set[str] = set() self.names: set[str] = set() self.checkers: dict[str, str] = {} + self.instances: set[str] = set() + self.registrars: set[str] = set() + # Names bound other than by importing a name under its own name; a + # decorator so named may not be the one it looks like. + self.assigned: set[str] = set() + # Set by facts(): what the module holds in the places the catch-all + # treats as benign, and the statements that list its exports. + self.holds_docstring = False + self.holds_annotations = False + self.listing_values: set[int] = set() + self.listing_calls: set[int] = set() + self.listing_loads: set[int] = set() + # Set by scan_sources: the modules whose objects and members count, + # and the modules the scan reads. + self.introspected: frozenset[str] = frozenset() + self.known_modules: frozenset[str] = frozenset() + self.parents: dict[int, ast.AST] = {} + # Each hint's parsed parts, kept so that their nodes stay alive and + # their parents stay known. + self.hint_cache: dict[int, tuple[ast.expr, list[ast.expr]]] = {} + self.hint_parents: dict[int, ast.AST] = {} def _from_base(self, node: ast.ImportFrom) -> str: if not node.level: @@ -370,48 +1229,114 @@ def _from_base(self, node: ast.ImportFrom) -> str: base = ".".join(parts) return f"{base}.{node.module}" if node.module else base + def text(self, node: ast.expr) -> str | None: + return _static_text(node, self.memo) + + def texts(self, node: ast.expr) -> list[str]: + return _value_texts(_fold(node, self.memo)) + + def _resolve(self, path: str) -> str: + """Follow re-exports: ``a.b`` is what module ``a`` binds to ``b``.""" + for _ in range(_RESOLVE_STEPS): + parent, _, name = path.rpartition(".") + target = self.module_exports.get((parent, name)) + if target is None or target == path: + break + path = target + return path + def module_path(self, node: ast.expr) -> str | None: - """Return the dotted module a name or attribute chain denotes.""" + """Return the dotted path a name, attribute or import denotes.""" if isinstance(node, ast.Name): - return self.modules.get(node.id) + path = self.modules.get(node.id) + return None if path is None else self._resolve(path) if isinstance(node, ast.Attribute): base = self.module_path(node.value) - return f"{base}.{node.attr}" if base else None + return self._resolve(f"{base}.{node.attr}") if base else None + if isinstance(node, ast.Call): + return self._imported(node) + if ( + isinstance(node, ast.Subscript) + and isinstance(node.value, ast.Attribute) + and node.value.attr == "modules" + and self.module_path(node.value.value) == "sys" + ): + return self.text(node.slice) return None - def is_registry(self, node: ast.expr) -> bool: - """Return whether ``node`` denotes the registry class itself.""" + def _imported(self, node: ast.Call) -> str | None: + """Name the module a literal ``import_module`` or ``__import__`` gives.""" + name = _call_name(node.func) + if name not in {"import_module", "__import__"} or not node.args: + return None + target = self.text(node.args[0]) + if target is None: + return None + if name == "import_module": + package = node.args[1] if len(node.args) > 1 else None + for keyword in node.keywords: + if keyword.arg == "package": + package = keyword.value + anchor = None if package is None else self.text(package) + try: + return importlib.util.resolve_name(target, anchor) + except (ImportError, ValueError): + return None + fromlist = node.args[3] if len(node.args) > 3 else None + for keyword in node.keywords: + if keyword.arg == "fromlist": + fromlist = keyword.value + listed = None if fromlist is None else _fold(fromlist, self.memo) + return target if listed else target.split(".")[0] + + def is_tracked(self, node: ast.expr) -> bool: + """Return whether ``node`` denotes a tracked class itself.""" if isinstance(node, ast.Name): - return node.id in self.classes or node.id == REGISTRY_NAME + return node.id in self.classes or node.id in TRACKED_NAMES if isinstance(node, ast.Attribute): - if node.attr == REGISTRY_NAME or node.attr in self.classes: + if node.attr in TRACKED_NAMES or node.attr in self.classes: return True - if ( - node.attr == "__class__" - and isinstance(node.value, ast.Call) - and self.is_registry(node.value.func) - ): + if node.attr == "__class__" and self.instance_valued(node.value): return True base = self.module_path(node.value) return base is not None and (base, node.attr) in self.exports if isinstance(node, ast.NamedExpr): - return self.is_registry(node.value) + return self.is_tracked(node.value) if isinstance(node, ast.Call): - if _is_type_call(node) and isinstance(node.args[0], ast.Call): - return self.is_registry(node.args[0].func) - if ( - _call_name(node.func) == "getattr" - and len(node.args) >= 2 - and isinstance(node.args[1], ast.Constant) - and isinstance(node.args[1].value, str) - ): - name = node.args[1].value + if _is_type_call(node) and self.instance_valued(node.args[0]): + return True + if _call_name(node.func) == "getattr" and len(node.args) >= 2: + name = self.text(node.args[1]) base = self.module_path(node.args[0]) - return name == REGISTRY_NAME or ( - base is not None and (base, name) in self.exports + return name is not None and ( + name in TRACKED_NAMES + or (base is not None and (base, name) in self.exports) ) return False + def instance_valued(self, node: ast.expr) -> bool: + """Return whether ``node`` may be an instance of a tracked class.""" + if isinstance(node, ast.Call): + return self.is_tracked(node.func) + if isinstance(node, ast.Name): + return node.id in self.instances + if isinstance(node, ast.Attribute): + if node.attr in self.instances: + return True + base = self.module_path(node.value) + return base is not None and (base, node.attr) in ( + self.instance_exports + ) + if isinstance(node, ast.NamedExpr): + return self.instance_valued(node.value) + if isinstance(node, ast.IfExp): + return self.instance_valued(node.body) or self.instance_valued( + node.orelse + ) + if isinstance(node, ast.BoolOp): + return any(map(self.instance_valued, node.values)) + return False + def _any_part(self, node: ast.expr, test) -> bool: """Apply ``test`` to ``node`` and to each part it holds or picks.""" if test(node): @@ -431,7 +1356,7 @@ def _any_part(self, node: ast.expr, test) -> bool: return any(self._any_part(part, test) for part in parts) def _class_part(self, node: ast.expr) -> bool: - if self.is_registry(node): + if self.is_tracked(node): return True if isinstance(node, ast.Name): return node.id in self.containers @@ -441,53 +1366,80 @@ def _class_part(self, node: ast.expr) -> bool: node.op, ast.BitOr | ast.Add ): # A union, or class-info tuples joined with "+". - return self.contains_registry(node.left) or self.contains_registry( + return self.contains_tracked(node.left) or self.contains_tracked( node.right ) if isinstance(node, ast.Subscript): # Union[int, cls] holds the class in its subscript. - return self.contains_registry(node.slice) + return self.contains_tracked(node.slice) if isinstance(node, ast.Call): # Class info read or rebuilt: KINDS.get(key), tuple(KINDS). - if isinstance(node.func, ast.Attribute) and self.contains_registry( + if isinstance(node.func, ast.Attribute) and self.contains_tracked( node.func.value ): return True return _call_name(node.func) in _CONTAINER_CALLS and any( - self.contains_registry(item) for item in node.args + self.contains_tracked(item) for item in node.args ) return False - def contains_registry(self, node: ast.expr) -> bool: - """Return whether a class-info expression may include the registry.""" + def contains_tracked(self, node: ast.expr) -> bool: + """Return whether a class-info expression may include either class.""" return self._any_part(node, self._class_part) def _name_part(self, node: ast.expr) -> bool: - text = _static_text(node) - if text is not None: - return REGISTRY_NAME in text + texts = self.texts(node) + if texts: + return any(map(_names_class, texts)) if isinstance(node, ast.Name): return node.id in self.names if isinstance(node, ast.Attribute): return node.attr in self.names or ( - node.attr in _NAME_ATTRIBUTES and self.is_registry(node.value) + node.attr in _NAME_ATTRIBUTES and self.is_tracked(node.value) ) return False - def names_registry(self, node: ast.expr) -> bool: - """Return whether ``node`` may hold the class's name as text.""" + def names_class(self, node: ast.expr) -> bool: + """Return whether ``node`` may hold a tracked class's name as text.""" return self._any_part(node, self._name_part) + def _tracked_module(self, path: str | None) -> bool: + return path is not None and ( + path == REGISTRY_MODULE + or path == OWN_MODULE + or path.startswith(f"{OWN_MODULE}.") + ) + + def _owner(self, path: str | None) -> str | None: + """Return the module that defines a member path, if the scan knows it.""" + if path is None or path in self.known_modules: + return None + parts = path.split(".") + for size in range(len(parts) - 1, 0, -1): + prefix = ".".join(parts[:size]) + if prefix in self.known_modules or prefix in self.introspected: + return prefix + return None + def _module_part(self, node: ast.expr) -> bool: - text = _static_text(node) - if text is not None: - return REGISTRY_MODULE in text + texts = self.texts(node) + if texts: + return any(map(_names_module, texts)) if isinstance(node, ast.Attribute) and node.attr == "__name__": - node = node.value - return self.module_path(node) == REGISTRY_MODULE + return self._tracked_module(self.module_path(node.value)) + if isinstance(node, ast.Attribute) and node.attr == "__module__": + # Anything a tracked module defines carries its name, and so + # does a tracked class or its instance. + if self.is_tracked(node.value) or self.instance_valued(node.value): + return True + path = self.module_path(node.value) + return self._tracked_module(path) or self._tracked_module( + self._owner(path) + ) + return self._tracked_module(self.module_path(node)) def names_module(self, node: ast.expr) -> bool: - """Return whether ``node`` may denote the registry's module.""" + """Return whether ``node`` may denote a tracked class's module.""" return self._any_part(node, self._module_part) def checker(self, node: ast.expr) -> str | None: @@ -500,12 +1452,28 @@ def checker(self, node: ast.expr) -> str | None: return None return name if name in _TYPE_CHECK_CALLS else self.checkers.get(name) + def registrar(self, node: ast.expr) -> bool: + """Return whether ``node`` denotes a dispatcher's ``register``.""" + if isinstance(node, ast.Attribute): + return node.attr == "register" or node.attr in self.registrars + if isinstance(node, ast.Name): + return node.id == "register" or node.id in self.registrars + if isinstance(node, ast.Call) and _call_name(node.func) == "getattr": + return ( + len(node.args) >= 2 and self.text(node.args[1]) == "register" + ) + return False + def _bind(self, name: str, value: ast.expr, module: bool = True) -> None: - if self.is_registry(value): + if self.instance_valued(value): + self.instances.add(name) + if self.registrar(value): + self.registrars.add(name) + if self.is_tracked(value): self.classes.add(name) - elif self.contains_registry(value): + elif self.contains_tracked(value): self.containers.add(name) - elif self.names_registry(value): + elif self.names_class(value): self.names.add(name) elif (checker := self.checker(value)) is not None: self.checkers.setdefault(name, checker) @@ -516,17 +1484,19 @@ def _bind(self, name: str, value: ast.expr, module: bool = True) -> None: def _bind_targets(self, target: ast.expr, value: ast.expr) -> None: if isinstance(target, ast.Name): + self.assigned.add(target.id) self._bind(target.id, value) elif isinstance(target, ast.Attribute): + self.assigned.add(target.attr) self._bind(target.attr, value, module=False) elif isinstance(target, ast.Subscript): # TABLE[key] = cls puts class info in TABLE. base = target.value if isinstance(base, ast.Name | ast.Attribute): name = base.id if isinstance(base, ast.Name) else base.attr - if self.contains_registry(value): + if self.contains_tracked(value): self.containers.add(name) - elif self.names_registry(value): + elif self.names_class(value): self.names.add(name) elif isinstance(target, ast.Tuple | ast.List): items = (*target.elts, *getattr(value, "elts", ())) @@ -564,6 +1534,7 @@ def _visit(self, node: ast.AST) -> None: if isinstance(node, ast.Import): for alias in node.names: if alias.asname: + self.assigned.add(alias.asname) self.modules.setdefault(alias.asname, alias.name) else: top = alias.name.split(".")[0] @@ -575,15 +1546,26 @@ def _visit(self, node: ast.AST) -> None: self.classes.update( name for module, name in self.exports if module == base ) + self.instances.update( + name + for module, name in self.instance_exports + if module == base + ) continue local = alias.asname or alias.name + if local != alias.name: + self.assigned.add(local) if ( - alias.name == REGISTRY_NAME + alias.name in TRACKED_NAMES or (base, alias.name) in self.exports ): self.classes.add(local) + elif (base, alias.name) in self.instance_exports: + self.instances.add(local) elif alias.name in _TYPE_CHECK_CALLS: self.checkers.setdefault(local, alias.name) + elif alias.name == "register": + self.registrars.add(local) else: self.modules.setdefault(local, f"{base}.{alias.name}") elif isinstance(node, ast.Assign): @@ -592,12 +1574,21 @@ def _visit(self, node: ast.AST) -> None: elif isinstance(node, ast.AnnAssign | ast.NamedExpr | ast.AugAssign): if node.value is not None: self._bind_targets(node.target, node.value) + elif isinstance(node.target, ast.Name): + self.assigned.add(node.target.id) elif isinstance(node, ast.For | ast.AsyncFor | ast.comprehension): - if self.contains_registry(node.iter): + for item in ast.walk(node.target): + if isinstance(item, ast.Name): + self.assigned.add(item.id) + if self.contains_tracked(node.iter): self._bind_loop(node.target) + elif isinstance(node, ast.ClassDef): + self.assigned.add(node.name) elif isinstance( node, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda ): + if not isinstance(node, ast.Lambda): + self.assigned.add(node.name) arguments = node.args positional = [*arguments.posonlyargs, *arguments.args] defaults = list(arguments.defaults) @@ -618,6 +1609,26 @@ def _visit(self, node: ast.AST) -> None: for argument, default in pairs: self._bind(argument.arg, default, module=False) + def imports(self, tree: ast.AST) -> dict[str, str]: + """Name the path each import statement binds, unresolved.""" + bound: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + bound.setdefault(alias.asname, alias.name) + else: + top = alias.name.split(".")[0] + bound.setdefault(top, top) + elif isinstance(node, ast.ImportFrom): + base = self._from_base(node) + for alias in node.names: + if alias.name != "*": + bound.setdefault( + alias.asname or alias.name, f"{base}.{alias.name}" + ) + return bound + def _state(self) -> tuple: return ( dict(self.modules), @@ -625,6 +1636,9 @@ def _state(self) -> tuple: set(self.containers), set(self.names), dict(self.checkers), + set(self.instances), + set(self.registrars), + set(self.assigned), ) def bind(self, nodes: list[ast.AST]) -> None: @@ -640,62 +1654,285 @@ def bind(self, nodes: list[ast.AST]) -> None: if before == self._state(): return - def dispatch_annotations( + def _benign_decorator(self, node: ast.expr) -> bool: + """Return whether a decorator leaves annotations unread. + + A dispatcher's ``register`` reads only the hint it dispatches on, + which is checked on its own; called with a class, it reads none. + """ + if self.registrar(node) or ( + isinstance(node, ast.Call) and self.registrar(node.func) + ): + return True + target = node.func if isinstance(node, ast.Call) else node + if isinstance(target, ast.Attribute) and ( + target.attr in _PROPERTY_METHODS + ): + return True + root = target + while isinstance(root, ast.Attribute): + root = root.value + if not isinstance(root, ast.Name) or root.id in self.assigned: + return False + if isinstance(target, ast.Name) and target.id not in self.modules: + path = f"builtins.{target.id}" + else: + path = self.module_path(target) + return path in _BENIGN_DECORATORS + + def trusts_class(self, node: ast.ClassDef) -> bool: + """Return whether a class leaves its annotations unread. + + Its decorators must all be benign, and it may have no base but + ``object`` and no keywords, since a base or metaclass may read them. + """ + return ( + all(map(self._benign_decorator, node.decorator_list)) + and not node.keywords + and all( + isinstance(base, ast.Name) + and base.id == "object" + and base.id not in self.assigned + and base.id not in self.modules + for base in node.bases + ) + ) + + def registers(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Return whether a decorator dispatches on the function's hints.""" + return any(map(self.registrar, node.decorator_list)) + + def dispatch_hint( self, node: ast.FunctionDef | ast.AsyncFunctionDef - ) -> list[ast.expr]: - """Return the hints a ``register`` decorator may dispatch on. + ) -> ast.expr | None: + """Return the hint a ``register`` decorator dispatches on, if any.""" + if not self.registers(node): + return None + hints = _hints(node) + return hints[0] if hints else None - A string hint is parsed as the expression it names, so an alias in - quotes counts too. + def _hint_parts(self, hint: ast.expr) -> list[ast.expr]: + """Return a hint and each expression its strings spell, nested too. + + Python evaluates a string hint as the expression it spells, and a + string inside that again, so ``"'R'"`` and ``ForwardRef("R")`` both + name ``R``. Each parsed part takes the hint's location. """ - if not _registers(node): - return [] - found = [] - for argument in (*node.args.posonlyargs, *node.args.args): - hint = argument.annotation - if hint is None: - continue - text = _static_text(hint) - if text is not None: + cached = self.hint_cache.get(id(hint)) + if cached is not None and cached[0] is hint: + return cached[1] + parts = [hint] + index = 0 + while index < len(parts) and len(parts) < _HINT_PARTS: + for node in ast.walk(parts[index]): + text = self.text(node) if isinstance(node, ast.expr) else None + if text is None: + continue try: - hint_value = ast.parse(text, mode="eval").body + parsed = ast.parse(text.strip(), mode="eval").body except (SyntaxError, ValueError): - hint_value = hint - else: - hint_value = hint - if self.contains_registry(hint_value) or self.names_registry(hint): - found.append(hint) - return found + continue + _relocate(parsed, hint) + for item in ast.walk(parsed): + for child in ast.iter_child_nodes(item): + self.hint_parents[id(child)] = item + parts.append(parsed) + index += 1 + self.hint_cache[id(hint)] = (hint, parts) + return parts + + def _names_tracked(self, node: ast.expr) -> bool: + """Return whether a node names a tracked class, as code or text.""" + if self.is_tracked(node) or ( + isinstance(node, ast.Name | ast.Attribute) + and self._class_part(node) + ): + return True + text = self.text(node) + return text is not None and (_names_class(text) or _names_module(text)) + + def mentions(self, hint: ast.expr) -> bool: + """Return whether a hint names a tracked class, strings included.""" + return any( + isinstance(node, ast.expr) and self._names_tracked(node) + for part in self._hint_parts(hint) + for node in ast.walk(part) + ) - def _kind(self, node: ast.AST) -> str | None: - """Name the specific check ``node`` makes, if it makes one.""" - if isinstance(node, ast.Call): - checker = self.checker(node.func) - if ( - checker is not None - and len(node.args) >= 2 - and self.contains_registry(node.args[1]) + def walked_hints( + self, node: ast.FunctionDef | ast.AsyncFunctionDef + ) -> list[ast.expr]: + """Return the hints the catch-all reads. + + A decorator outside the benign ones may read any hint, so all of a + function's hints count then, except the one dispatched on, which + has its own check. + """ + if all(map(self._benign_decorator, node.decorator_list)): + return [] + dispatch = self.dispatch_hint(node) + return [hint for hint in _hints(node) if hint is not dispatch] + + def facts(self, tree: ast.Module) -> None: + """Record what the module holds where the catch-all does not look. + + That is its export listings, whether any docstring names a tracked + class or module or a probe name, and whether any annotation left to + Python (not read by a decorator the catch-all distrusts) names a + tracked class. Reads of these are then checked. + """ + for statement in _statements(tree.body): + if _lists_exports(statement): + self.listing_values.add(id(statement.value)) + elif isinstance(statement, ast.Expr) and _extends_exports( + statement.value ): - return checker - name = _call_name(node.func) - if ( - name in _DUNDER_CHECKS - and isinstance(node.func, ast.Attribute) - and ( - self.is_registry(node.func.value) - or any(map(self.contains_registry, node.args[:1])) - ) - ): - return "dunder-check" - if ( - name == "register" - and node.args - and self.contains_registry(node.args[0]) - ): - return "dispatch-register" - elif isinstance(node, ast.Compare): + self.listing_calls.add(id(statement.value)) + self.listing_loads.add(id(statement.value.func.value)) + docstrings = [ + ast.get_docstring(node, clean=False) + for node in ast.walk(tree) + if isinstance( + node, + ast.Module + | ast.FunctionDef + | ast.AsyncFunctionDef + | ast.ClassDef, + ) + ] + self.holds_docstring = any( + _names_class(text) or _names_module(text) or _names_probe(text) + for text in docstrings + if text is not None + ) + hints = [] + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + if all(map(self._benign_decorator, node.decorator_list)): + dispatch = self.dispatch_hint(node) + hints += [h for h in _hints(node) if h is not dispatch] + elif isinstance(node, ast.ClassDef) and self.trusts_class(node): + hints += _class_annotations(node) + hints += [ + statement.annotation + for statement in _statements(tree.body) + if isinstance(statement, ast.AnnAssign) + ] + self.holds_annotations = any(map(self.mentions, hints)) + + def binds_tracked(self) -> bool: + return bool(self.classes or self.containers or self.names) or bool( + self.instances + ) + + def _module_object(self, node: ast.expr) -> bool: + """Return whether ``node`` is a module object whose use counts.""" + if not isinstance( + node, ast.Name | ast.Attribute | ast.Call | ast.Subscript + ): + return False + path = self.module_path(node) + return path is not None and ( + path in self.introspected + or path == OWN_MODULE + or path.startswith(f"{OWN_MODULE}.") + ) + + def _member(self, node: ast.expr) -> bool: + """Return whether ``node`` is defined by a module whose use counts.""" + path = self.module_path(node) + return path is not None and ( + path.startswith(f"{OWN_MODULE}.") + or self._owner(path) in self.introspected + ) + + def _parent(self, node: ast.AST) -> ast.AST | None: + parent = self.parents.get(id(node)) + return ( + parent if parent is not None else self.hint_parents.get(id(node)) + ) + + def _benign_module_use(self, node: ast.expr) -> bool: + parent = self._parent(node) + return ( + isinstance(parent, ast.Attribute) + and parent.value is node + and not parent.attr.startswith("__") + ) + + def _benign_instance_use(self, node: ast.expr) -> bool: + """Return whether an instance is only used, not compared or shown. + + It may be bound to a name (which is then tracked) or a parameter + default, returned, passed to a call that is not a comparison, + lookup, text or introspection function, have a public attribute + read, be tested for truth or be compared with ``None`` by identity. + Every other use counts. + """ + parent = self._parent(node) + if isinstance(parent, ast.Attribute): + return parent.value is node and not parent.attr.startswith("__") + if isinstance(parent, ast.arguments): + return True + if isinstance(parent, ast.keyword): + parent = self._parent(parent) + return isinstance(parent, ast.Call) and ( + _call_name(parent.func) not in _INSTANCE_SINKS + ) + if isinstance(parent, ast.Call): + return node is not parent.func and ( + _call_name(parent.func) not in _INSTANCE_SINKS + ) + if isinstance(parent, ast.Assign | ast.AnnAssign | ast.NamedExpr): + targets = ( + parent.targets + if isinstance(parent, ast.Assign) + else [parent.target] + ) + return node is parent.value and all(map(_binds_by_name, targets)) + if isinstance(parent, ast.Compare): + others = [ + item + for item in (parent.left, *parent.comparators) + if item is not node + ] + return all( + isinstance(op, ast.Is | ast.IsNot) for op in parent.ops + ) and all( + isinstance(item, ast.Constant) and item.value is None + for item in others + ) + if isinstance(parent, ast.UnaryOp): + return isinstance(parent.op, ast.Not) + return isinstance( + parent, + ast.If + | ast.While + | ast.IfExp + | ast.Assert + | ast.BoolOp + | ast.Return + | ast.Yield + | ast.YieldFrom + | ast.Lambda + | ast.Expr, + ) + + def _kind(self, node: ast.AST) -> str | None: + """Name the specific check ``node`` makes, if it makes one.""" + if isinstance(node, ast.expr) and not isinstance( + node, ast.Name | ast.Attribute + ): + text = self.text(node) + if text in PROBE_NAMES: + return "private-member-probe" + if text is not None and (kind := self._read_string(text)): + return kind + if isinstance(node, ast.Call): + return self._call_kind(node) + if isinstance(node, ast.Compare): operands = [node.left, *node.comparators] - if any(self.contains_registry(item) for item in operands): + if any(self.contains_tracked(item) for item in operands): if any(_is_type_call(item) for item in operands): return "type-compare" if any( @@ -705,72 +1942,184 @@ def _kind(self, node: ast.AST) -> str | None: ): return "__class__-compare" return "class-compare" - if any(self.names_registry(item) for item in operands): + if any(self.names_class(item) for item in operands): return "name-compare" if any(self.names_module(item) for item in operands): return "module-compare" elif isinstance(node, ast.MatchValue): # case rng.ProjectionRNGRegistry: compares the subject by ==. - if self.contains_registry(node.value): + if self.contains_tracked(node.value): return "class-compare" - if self.names_registry(node.value): + if self.names_class(node.value): return "name-compare" if self.names_module(node.value): return "module-compare" elif isinstance(node, ast.MatchClass): - if self.contains_registry(node.cls): + if self.contains_tracked(node.cls): return "match-class" elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): - if self.dispatch_annotations(node): + hint = self.dispatch_hint(node) + if hint is not None and self.mentions(hint): return "dispatch-annotation" + elif isinstance(node, ast.ImportFrom): + if any(alias.name == _EXPORT_NAME for alias in node.names): + return "export-read" + elif isinstance(node, ast.Name): + return self._read_name(node) elif isinstance(node, ast.Attribute): - if node.attr in PROBE_NAMES and self.source.module != ( - REGISTRY_MODULE - ): - return "private-member-probe" - elif isinstance(node, ast.Constant): - if isinstance(node.value, str) and node.value in PROBE_NAMES: - return "private-member-probe" + return self._read_attribute(node) + return None + + def _call_kind(self, node: ast.Call) -> str | None: + checker = self.checker(node.func) + if ( + checker is not None + and len(node.args) >= 2 + and self.contains_tracked(node.args[1]) + ): + return checker + name = _call_name(node.func) + if ( + name in _DUNDER_CHECKS + and isinstance(node.func, ast.Attribute) + and ( + self.is_tracked(node.func.value) + or any(map(self.contains_tracked, node.args[:1])) + ) + ): + return "dunder-check" + if ( + self.registrar(node.func) + and node.args + and self.contains_tracked(node.args[0]) + ): + return "dispatch-register" + path = self.module_path(node.func) + if name in _SOURCE_CALLS or ( + path is not None and path.split(".")[0] in _SOURCE_MODULES + ): + return "source-read" + if self.holds_docstring and name in _DOC_CALLS: + return "docstring-read" + if self.holds_annotations and name in _ANNOTATION_CALLS: + return "annotation-read" + if ( + name in _NAMESPACE_CALLS + and not node.args + and not node.keywords + and self.binds_tracked() + ): + return "namespace-read" + if ( + _code_call(node.func) + and node.args + and self.text(node.args[0]) is None + and self.binds_tracked() + ): + # Code the folder cannot read may name what the module binds. + return "dynamic-code" + if ( + name in _INTROSPECTION_CALLS + and node.args + and self._member(node.args[0]) + ): + return "member-introspection" + return None + + def _read_string(self, text: str) -> str | None: + """Name the read a string spells: an export, docstring or hint.""" + if text == _EXPORT_NAME: + return "export-read" + if self.holds_docstring and text in _DOC_ATTRIBUTES: + return "docstring-read" + if self.holds_annotations and text in _ANNOTATION_ATTRIBUTES: + return "annotation-read" + return None + + def _read_name(self, node: ast.Name) -> str | None: + if not isinstance(node.ctx, ast.Load): + return None + if node.id == _EXPORT_NAME and id(node) not in self.listing_loads: + return "export-read" + if node.id == "__doc__" and self.holds_docstring: + return "docstring-read" + if node.id in {"__annotations__", "__annotate__"} and ( + self.holds_annotations + ): + return "annotation-read" + return None + + def _read_attribute(self, node: ast.Attribute) -> str | None: + attribute = node.attr + if attribute in PROBE_NAMES and ( + self.source.module not in TRACKED_MODULES + ): + return "private-member-probe" + if not isinstance(node.ctx, ast.Load): + return None + if attribute == _EXPORT_NAME: + return "export-read" + if self.holds_docstring and attribute in _DOC_ATTRIBUTES: + return "docstring-read" + if self.holds_annotations and attribute in _ANNOTATION_ATTRIBUTES: + return "annotation-read" + if attribute.startswith("__") and self._member(node.value): + return "member-introspection" return None def _mention(self, node: ast.expr, listing: bool) -> str | None: - """Name what a node outside every specific check says of the class. + """Name what a node outside every specific check says of a class. - A reference to the class or to class info, a string holding the - class's name and a string holding its module's name each count. + A reference to a tracked class or to class info, a string holding a + tracked class's or module's name, a module object put to any use + but a plain attribute read, and an instance put to any use the + benign list leaves out each count. """ - if self.is_registry(node) or ( + if self.is_tracked(node) or ( isinstance(node, ast.Name | ast.Attribute) and self._class_part(node) ): return "class-reference" - text = None if listing else _static_text(node) - if text is not None and REGISTRY_NAME in text: + text = None if listing else self.text(node) + if text is not None and _names_class(text): return "class-name-string" - if text is not None and REGISTRY_MODULE in text: + if text is not None and _names_module(text): return "module-name-string" + if self._module_object(node) and not self._benign_module_use(node): + return "module-reference" + if self.instance_valued(node) and not self._benign_instance_use(node): + return "instance-reference" return None def _children(self, node: ast.AST) -> Iterable[tuple[ast.AST, bool]]: """Yield the children a mention can hide in, and which list names. - Annotations are skipped, except the hints a ``register`` decorator - dispatches on; so is the callee of a construction. Strings inside - an ``__all__`` listing are flagged as listing names. + Annotations are skipped, except where a decorator the catch-all + does not trust may read them; so is the callee of a construction. + Strings inside a module-level ``__all__`` listing are flagged as + listing names. """ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): arguments = node.args yield from ((item, False) for item in node.decorator_list) + yield from ((item, False) for item in node.type_params) yield from ((item, False) for item in arguments.defaults) yield from ( (item, False) for item in arguments.kw_defaults if item ) - if _registers(node): - yield from ( - (argument.annotation, False) - for argument in (*arguments.posonlyargs, *arguments.args) - if argument.annotation is not None - ) + for hint in self.walked_hints(node): + yield from ((part, False) for part in self._hint_parts(hint)) + yield from ((item, False) for item in node.body) + elif isinstance(node, ast.ClassDef): + yield from ((item, False) for item in node.decorator_list) + yield from ((item, False) for item in node.type_params) + yield from ((item, False) for item in node.bases) + yield from ((item.value, False) for item in node.keywords) + if not self.trusts_class(node): + for hint in _class_annotations(node): + yield from ( + (part, False) for part in self._hint_parts(hint) + ) yield from ((item, False) for item in node.body) elif isinstance(node, ast.Lambda): yield from ((item, False) for item in node.args.defaults) @@ -781,34 +2130,34 @@ def _children(self, node: ast.AST) -> Iterable[tuple[ast.AST, bool]]: elif isinstance(node, ast.AnnAssign): yield node.target, False if node.value is not None: - yield node.value, _is_all(node.target) + yield node.value, id(node.value) in self.listing_values elif isinstance(node, ast.Assign | ast.AugAssign): targets = ( node.targets if isinstance(node, ast.Assign) else [node.target] ) yield from ((item, False) for item in targets) - yield node.value, any(map(_is_all, targets)) - elif isinstance(node, ast.Call) and ( - self.is_registry(node.func) or _lists_names(node) - ): - listing = _lists_names(node) - if listing: - yield node.func, False - yield from ((item, listing) for item in node.args) - yield from ((item.value, listing) for item in node.keywords) + yield node.value, id(node.value) in self.listing_values + elif isinstance(node, ast.Call) and self.is_tracked(node.func): + yield from ((item, False) for item in node.args) + yield from ((item.value, False) for item in node.keywords) + elif isinstance(node, ast.Call) and id(node) in self.listing_calls: + yield node.func, False + yield from ((item, True) for item in node.args) else: yield from ((item, False) for item in ast.iter_child_nodes(node)) def references(self, tree: ast.Module, claimed: set[int]) -> set[ScanHit]: - """Report every other mention of the class outside a benign place. - - This is the catch-all under the specific checks: a reference to the - class, or a string holding its name, counts wherever it is not - benign. Benign places are imports, string statements (docstrings - among them), ``__all__`` listings, annotations other than dispatch - hints, and the callee of a construction. A node a specific check - reported is skipped with everything under it. The walk keeps its - own stack, so deep expressions cannot exhaust recursion. + """Report every other use of a tracked class, module or instance. + + This is the catch-all under the specific checks. Benign places are + imports, string statements (a docstring is the only one Python + keeps, and reading one is a specific check), module-level + ``__all__`` listings, annotations no distrusted decorator reads, + the callee of a construction, plain attribute reads on a module, + and the uses of an instance ``_benign_instance_use`` lists. A node + a specific check reported is skipped with everything under it. The + walk keeps its own stack, so deep expressions cannot exhaust + recursion. """ found = set() stack: list[tuple[ast.AST, bool]] = [(tree, False)] @@ -818,10 +2167,7 @@ def references(self, tree: ast.Module, claimed: set[int]) -> set[ScanHit]: node, ast.Import | ast.ImportFrom ): continue - if ( - isinstance(node, ast.Expr) - and _static_text(node.value) is not None - ): + if isinstance(node, ast.Expr) and _is_string_constant(node.value): continue if isinstance(node, ast.expr) and not isinstance( getattr(node, "ctx", None), ast.Store | ast.Del @@ -837,19 +2183,35 @@ def references(self, tree: ast.Module, claimed: set[int]) -> set[ScanHit]: return found def hits(self, tree: ast.Module) -> set[ScanHit]: - """Report the specific checks, then every other mention.""" - found = set() - claimed: set[int] = set() - for node in ast.walk(tree): - kind = self._kind(node) - if kind is None: - continue - found.add(ScanHit(self.source.path, node.lineno, kind)) - if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): - claimed.update(map(id, self.dispatch_annotations(node))) - else: - claimed.add(id(node)) - return found | self.references(tree, claimed) + """Report the specific checks, then every other use. + + A specific check is reported at the outermost node that makes it, + and nothing under that node is reported again, except in the body + of a function whose dispatch hint was reported. + """ + self.parents = { + id(child): node + for node in ast.walk(tree) + for child in ast.iter_child_nodes(node) + } + try: + found = set() + claimed: set[int] = set() + stack: list[ast.AST] = [tree] + while stack: + node = stack.pop() + kind = self._kind(node) + if kind is not None: + found.add(ScanHit(self.source.path, node.lineno, kind)) + if not isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef + ): + claimed.add(id(node)) + continue + stack.extend(ast.iter_child_nodes(node)) + return found | self.references(tree, claimed) + finally: + self.parents = {} def _parse(source: ScanSource) -> ast.Module: @@ -865,8 +2227,9 @@ def _inert(node: ast.AST) -> bool: A container of constants (tuples, lists, sets, dicts and signed numbers, nested) refers to nothing, so only its strings could matter; - it is inert when none holds the class's name or its module's, and none - is a probe name. + it is inert when none names a tracked class or module, whatever the + case, and none is a probe name or a name a read of a benign place + spells. """ if not isinstance(node, ast.Tuple | ast.List | ast.Set | ast.Dict): return False @@ -874,11 +2237,14 @@ def _inert(node: ast.AST) -> bool: while stack: item = stack.pop() if isinstance(item, ast.Constant): - text = _static_text(item) - if text is not None and ( - REGISTRY_NAME in text - or REGISTRY_MODULE in text - or text in PROBE_NAMES + value = item.value + if isinstance(value, bytes): + value = value.decode("latin-1") + if isinstance(value, str) and ( + _names_class(value) + or _names_module(value) + or value in PROBE_NAMES + or value in _SPECIAL_STRINGS ): return False elif isinstance(item, ast.Tuple | ast.List | ast.Set): @@ -894,59 +2260,125 @@ def _inert(node: ast.AST) -> bool: return True +def _folds_through(parent: ast.AST, field: str) -> bool: + """Return whether the folder or a hint parser reads this child. + + It reads operands, subscripts, f-string parts, method receivers, the + arguments of the calls it folds and of the calls whose literal is + code, the items of a container it reads, and annotations, whose + strings are parsed as code. + """ + if isinstance( + parent, + ast.BinOp + | ast.UnaryOp + | ast.Subscript + | ast.Starred + | ast.Attribute + | ast.FormattedValue + | ast.JoinedStr + | ast.Tuple + | ast.List + | ast.Set + | ast.Dict + | ast.arg, + ): + return True + if isinstance(parent, ast.FunctionDef | ast.AsyncFunctionDef): + return field == "returns" + if isinstance(parent, ast.AnnAssign): + return field == "annotation" + if isinstance(parent, ast.Call) and field in {"args", "keywords"}: + return ( + _call_name(parent.func) + in _FOLD_FUNCTIONS | _FOLD_METHODS | _CODE_CALLS + ) + return False + + def _prune(tree: ast.Module) -> ast.Module: """Replace each inert literal container with ``None``, in place. - No check reports or binds anything from an inert container, so the - hits are unchanged (a differential test pins that); the data modules' - large tables stop costing time and memory. + No check reports or binds anything from an inert container, and none + is pruned where the folder or a hint parser could read it, so the hits + are unchanged (a differential test pins that); the data modules' large + tables stop costing time and memory. """ - stack: list[ast.AST] = [tree] + stack: list[tuple[ast.AST, bool]] = [(tree, False)] while stack: - node = stack.pop() + node, inherited = stack.pop() for field, value in ast.iter_fields(node): items = value if isinstance(value, list) else [value] for index, item in enumerate(items): if not isinstance(item, ast.AST): continue - if _inert(item): + # A keyword's value is read as its call's arguments are. + read = ( + inherited + if isinstance(node, ast.keyword) + else _folds_through(node, field) + ) + if not read and _inert(item): stub = ast.copy_location(ast.Constant(None), item) if isinstance(value, list): value[index] = stub else: setattr(node, field, stub) else: - stack.append(item) + stack.append((item, read)) return tree def scan_sources( sources: Iterable[ScanSource], prune: bool = True ) -> tuple[ScanHit, ...]: - """Find type checks on the registry across ``sources`` (condition 2). + """Find type checks on either class across ``sources`` (condition 2). Every module is parsed. No text filter runs first, because the parser joins adjacent literals, decodes escapes and normalizes identifiers, so - a module can spell the class without its text holding the name. Inert - literal containers are pruned unless ``prune`` is false. Re-exports of - the class are followed to a fixed point across modules. + a module can spell a class without its text holding the name. Inert + literal containers are pruned unless ``prune`` is false. Literal code a + module evaluates is parsed and checked with it. Re-exports of the + classes and of instances are followed to a fixed point across modules; + modules re-exported by import statements are followed too. """ sources = tuple(sources) - trees = { - source.path: _prune(_parse(source)) if prune else _parse(source) - for source in sources - } + trees = {} + embedded = {} + for source in sources: + tree = _parse(source) + trees[source.path] = _prune(tree) if prune else tree + embedded[source.path] = _embedded(trees[source.path]) binders = { path: [ - node for node in ast.walk(tree) if isinstance(node, _BINDING_NODES) + node + for root in (tree, *embedded[path]) + for node in ast.walk(root) + if isinstance(node, _BINDING_NODES) ] for path, tree in trees.items() } - exports = {(REGISTRY_MODULE, REGISTRY_NAME)} + memos = {path: {} for path in trees} + exports = set(TRACKED_CLASSES) + instance_exports: set[tuple[str, str]] = set() + # What each module's import statements bind: fixed before the loop, so + # a path never changes between passes and the loop settles. + module_exports: dict[tuple[str, str], str] = {} + for source in sources: + reader = _ModuleBindings(source, set(), set(), {}, memos[source.path]) + for root in (trees[source.path], *embedded[source.path]): + for name, target in reader.imports(root).items(): + module_exports.setdefault((source.module, name), target) while True: scopes = {} for source in sources: - scope = _ModuleBindings(source, exports) + scope = _ModuleBindings( + source, + exports, + instance_exports, + module_exports, + memos[source.path], + ) scope.bind(binders[source.path]) scopes[source.path] = scope found = { @@ -954,16 +2386,34 @@ def scan_sources( for scope in scopes.values() for name in scope.classes } - if found <= exports: + found_instances = { + (scope.source.module, name) + for scope in scopes.values() + for name in scope.instances + } + if found <= exports and found_instances <= instance_exports: break - exports |= found - return tuple( - sorted( - hit - for path, scope in scopes.items() - for hit in scope.hits(trees[path]) - ) + exports = exports | found + instance_exports = instance_exports | found_instances + known = frozenset(source.module for source in sources) | TRACKED_MODULES + for path, scope in scopes.items(): + scope.known_modules = known + scope.facts(trees[path]) + introspected = frozenset( + {module for module, _ in exports} + | TRACKED_MODULES + | { + scope.source.module + for scope in scopes.values() + if scope.holds_docstring or scope.holds_annotations + } ) + hits = set() + for path, scope in scopes.items(): + scope.introspected = introspected + for root in (trees[path], *embedded[path]): + hits |= scope.hits(root) + return tuple(sorted(hits)) def _read(path: Path, relative: Path) -> str: @@ -1109,6 +2559,10 @@ def test_sealed_code_holds_only_the_known_registry_type_check(): "from populace_dynamics.engine.rng import ProjectionRNGRegistry\n" ) _IMPORT = "from populace_dynamics.engine.rng import ProjectionRNGRegistry" +_DIRECT_IMPORT = ( + "from populace_dynamics.open_population.streams import DirectRNGStreams" +) +_INSTANCE_IMPORT = f"{_IMPORT}\nREFERENCE = ProjectionRNGRegistry(0, 2)" def _check(expression, imports=_IMPORT): @@ -1373,88 +2827,558 @@ def f(x): _check("'_period_sequence' in dir(x)", ""), "private-member-probe", ), - "instance-check dunder": ( - _check("ProjectionRNGRegistry.__instancecheck__(x)"), - "dunder-check", + "instance-check dunder": ( + _check("ProjectionRNGRegistry.__instancecheck__(x)"), + "dunder-check", + ), + "subclass-check dunder through type": ( + _check("type.__subclasscheck__(ProjectionRNGRegistry, type(x))"), + "dunder-check", + ), + "default argument": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x, cls=ProjectionRNGRegistry): # alias + return isinstance(x, cls) # check + """), + "isinstance", + ), + "two-step type comparison": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x): + kind = type(x) + return kind is ProjectionRNGRegistry # check + """), + "class-compare", + ), + "dictionary dispatch": ( + _check("{ProjectionRNGRegistry: True}.get(type(x), False)"), + "class-reference", + ), + "match class pattern": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def f(x): + match x: + case ProjectionRNGRegistry(): # check + return 1 + """), + "match-class", + ), + "match value pattern": ( + textwrap.dedent(""" + from populace_dynamics.engine import rng + def f(x): + match type(x): + case rng.ProjectionRNGRegistry: # check + return 1 + """), + "class-compare", + ), + "match on the class name": ( + textwrap.dedent(""" + def f(x): + match type(x).__name__: + case "ProjectionRNGRegistry": # check + return 1 + """), + "name-compare", + ), + "single-dispatch registration": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @f.register(ProjectionRNGRegistry) # check + def _(x): + return 1 + """), + "dispatch-register", + ), + "single-dispatch annotation": ( + _dispatch("ProjectionRNGRegistry"), + "dispatch-annotation", + ), + "single-dispatch string annotation": ( + _dispatch("'ProjectionRNGRegistry'"), + "dispatch-annotation", + ), + "single-dispatch string annotation through an alias": ( + _dispatch("'int | R'", f"{_IMPORT} as R"), + "dispatch-annotation", + ), + "single-dispatch annotation in nested quotes": ( + _dispatch("\"'R'\"", f"{_IMPORT} as R"), + "dispatch-annotation", + ), + "single-dispatch annotation through ForwardRef": ( + _dispatch( + 'ForwardRef("R")', + f"{_IMPORT} as R\nfrom typing import ForwardRef", + ), + "dispatch-annotation", + ), + "register bound to a name": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + reg = f.register + @reg + def _(x: ProjectionRNGRegistry): # check + return 1 + """), + "dispatch-annotation", + ), + "register read through getattr": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @getattr(f, "register") + def _(x: ProjectionRNGRegistry): # check + return 1 + """), + "dispatch-annotation", + ), + "dispatch on the return hint": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @f.register + def _(x) -> ProjectionRNGRegistry: # check + return 1 + """), + "dispatch-annotation", + ), + "dispatch on *args": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @f.register + def _(*args: ProjectionRNGRegistry): # check + return 1 + """), + "dispatch-annotation", + ), + "dispatch on the first, keyword-only hint": ( + textwrap.dedent(""" + from functools import singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @f.register + def _(x, *, y: ProjectionRNGRegistry = None): # check + return 1 + """), + "dispatch-annotation", + ), + "register wrapped in partial": ( + # An unknown decorator may read any hint, so the hint itself counts. + textwrap.dedent(""" + from functools import partial, singledispatch + from populace_dynamics.engine.rng import ProjectionRNGRegistry + @singledispatch + def f(x): + return 0 + @partial(f.register) + def _(x: ProjectionRNGRegistry): # check + return 1 + """), + "class-reference", + ), + "local decorator named like a trusted one": ( + textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry + def property(function): + function.kind = function.__annotations__["x"] + return function + @property + def g(x: ProjectionRNGRegistry): # check + pass + def f(x): + return isinstance(x, g.kind) + """), + "class-reference", + ), + "annotation of a class with a base": ( + textwrap.dedent(""" + from typing import NamedTuple + from populace_dynamics.engine.rng import ProjectionRNGRegistry + class Holder(NamedTuple): + kind: ProjectionRNGRegistry # check + def f(x): + return isinstance(x, Holder.__annotations__["kind"]) + """), + "class-reference", + ), + "the direct class": ( + _check("not isinstance(x, DirectRNGStreams)", _DIRECT_IMPORT), + "isinstance", + ), + "the direct class's name": ( + _check("type(x).__name__ == 'DirectRNGStreams'", ""), + "name-compare", + ), + "module of the direct class": ( + _check( + "type(x).__module__ == DirectRNGStreams.__module__", _DIRECT_IMPORT + ), + "module-compare", + ), + "module of another class in the registry's module": ( + _check( + "type(x).__module__ == ProjectionModule.__module__", + "from populace_dynamics.engine.rng import ProjectionModule", + ), + "module-compare", + ), + "module found by inspect": ( + _check( + "type(x).__module__ == inspect.getmodule(ProjectionModule).__name__", + "import inspect\n" + "from populace_dynamics.engine.rng import ProjectionModule", + ), + "member-introspection", + ), + "module name passed to operator.eq": ( + _check( + "operator.eq(type(x).__module__, rng.__name__)", + "from populace_dynamics.engine import rng\nimport operator", + ), + "module-reference", + ), + "module of a member of the registry's module": ( + _check( + "operator.eq(type(x).__module__, rng.ProjectionModule.__module__)", + "from populace_dynamics.engine import rng\nimport operator", + ), + "member-introspection", + ), + "exports read from a module": ( + _check( + "type(x).__name__ in engine.__all__", + "from populace_dynamics import engine", + ), + "export-read", + ), + "module passed to getattr with a computed name": ( + _check( + "isinstance(x, getattr(rng, ''.join(PARTS)))", + "from populace_dynamics.engine import rng\n" + "PARTS = ['Projection', 'RNGRegistry']", + ), + "module-reference", + ), + "module namespace read": ( + textwrap.dedent(""" + from populace_dynamics.engine import rng + def f(x): + table = vars(rng) # check + return isinstance(x, table["ProjectionRNGRegistry"]) # label + """), + "module-reference", + ), + "module name tested by a method": ( + _check( + "type(x).__module__.startswith('populace_dynamics.open_population')", + "", + ), + "module-name-string", + ), + "class name tested by a method": ( + _check("type(x).__name__.endswith('ProjectionRNGRegistry')", ""), + "class-name-string", + ), + "equality with an instance": ( + _check("x == ProjectionRNGRegistry(0, 2)"), + "instance-reference", + ), + "membership in a set of instances": ( + _check("x in {ProjectionRNGRegistry(0, 2)}"), + "instance-reference", + ), + "lookup keyed by an instance": ( + _check("{ProjectionRNGRegistry(0, 2): True}.get(x, False)"), + "instance-reference", + ), + "equality with an instance bound to a name": ( + _check("x == REFERENCE", _INSTANCE_IMPORT), + "instance-reference", + ), + "type of an instance bound to a name": ( + _check("type(x) is type(REFERENCE)", _INSTANCE_IMPORT), + "type-compare", + ), + "class of an instance bound to a name": ( + _check("isinstance(x, REFERENCE.__class__)", _INSTANCE_IMPORT), + "isinstance", + ), + "module of an instance bound to a name": ( + _check("type(x).__module__ == REFERENCE.__module__", _INSTANCE_IMPORT), + "module-compare", + ), + "instance passed to operator.eq": ( + _check( + "operator.eq(REFERENCE, x)", f"{_INSTANCE_IMPORT}\nimport operator" + ), + "instance-reference", + ), + "instance rendered as text": ( + _check("repr(x)[:4] == repr(REFERENCE)[:4]", _INSTANCE_IMPORT), + "instance-reference", + ), + "exports appended, then read": ( + textwrap.dedent(""" + __all__ = [] + __all__.append("ProjectionRNGRegistry") + def f(x): + return type(x).__name__ in __all__ # check + """), + "export-read", + ), + "exports extended, then read": ( + textwrap.dedent(""" + __all__ = [] + __all__.extend(["ProjectionRNGRegistry"]) + def f(x): + return type(x).__name__ in __all__ # check + """), + "export-read", + ), + "exports counted": ( + textwrap.dedent(""" + __all__ = ["ProjectionRNGRegistry"] + def f(x): + return bool(__all__.count(type(x).__name__)) # check + """), + "export-read", + ), + "exports held as a mapping": ( + textwrap.dedent(""" + __all__ = {"ProjectionRNGRegistry": True} # label + def f(x): + return __all__.get(type(x).__name__, False) # check + """), + "export-read", + ), + "a local named __all__": ( + textwrap.dedent(""" + def f(x): + name = type(x).__name__ + __all__ = [name.endswith("ProjectionRNGRegistry")] # label + return __all__[0] # check + """), + "export-read", + ), + "a local __all__ unpacked into operator.eq": ( + textwrap.dedent(""" + import operator + def f(x): + __all__ = ("ProjectionRNGRegistry", type(x).__name__) # label + return operator.eq(*__all__) # check + """), + "export-read", + ), + "function docstring read from code constants": ( + textwrap.dedent(""" + def f(x): + "ProjectionRNGRegistry" + return type(x).__name__ in f.__code__.co_consts # check + """), + "docstring-read", + ), + "function docstring read": ( + textwrap.dedent(""" + def label(): + "ProjectionRNGRegistry" + def f(x): + return type(x).__name__ == label.__doc__ # check + """), + "docstring-read", + ), + "class docstring read": ( + textwrap.dedent(""" + class Label: + "ProjectionRNGRegistry" + def f(x): + return type(x).__name__ == Label.__doc__ # check + """), + "docstring-read", + ), + "module docstring read": ( + textwrap.dedent(''' + """ProjectionRNGRegistry""" + def f(x): + return type(x).__name__ == __doc__ # check + '''), + "docstring-read", ), - "subclass-check dunder through type": ( - _check("type.__subclasscheck__(ProjectionRNGRegistry, type(x))"), - "dunder-check", + "docstring read by inspect": ( + textwrap.dedent(""" + import inspect + def label(): + "ProjectionRNGRegistry" + def f(x): + return type(x).__name__ == inspect.getdoc(label) # check + """), + "docstring-read", ), - "default argument": ( + "annotation evaluated as a check": ( textwrap.dedent(""" from populace_dynamics.engine.rng import ProjectionRNGRegistry - def f(x, cls=ProjectionRNGRegistry): # alias - return isinstance(x, cls) # check + check = lambda c: lambda y: isinstance(y, c) + def inner(x: check(ProjectionRNGRegistry)): + pass + def f(x): + return inner.__annotations__["x"](x) # check """), - "isinstance", + "annotation-read", ), - "two-step type comparison": ( + "type hints read": ( textwrap.dedent(""" + import typing from populace_dynamics.engine.rng import ProjectionRNGRegistry + class Holder: + kind: ProjectionRNGRegistry def f(x): - kind = type(x) - return kind is ProjectionRNGRegistry # check + hints = typing.get_type_hints(Holder) # check + return isinstance(x, hints["kind"]) """), - "class-compare", - ), - "dictionary dispatch": ( - _check("{ProjectionRNGRegistry: True}.get(type(x), False)"), - "class-reference", + "annotation-read", ), - "match class pattern": ( + "dataclass fields read": ( textwrap.dedent(""" + from dataclasses import dataclass, fields from populace_dynamics.engine.rng import ProjectionRNGRegistry + @dataclass + class Holder: + kind: ProjectionRNGRegistry | None = None def f(x): - match x: - case ProjectionRNGRegistry(): # check - return 1 + return isinstance(x, fields(Holder)[0].type) # check """), - "match-class", + "annotation-read", ), - "match value pattern": ( + "module namespace searched": ( + _check("type(x) in globals().values()"), + "namespace-read", + ), + "source text read": ( textwrap.dedent(""" - from populace_dynamics.engine import rng + import inspect + def label(): + pass # ProjectionRNGRegistry def f(x): - match type(x): - case rng.ProjectionRNGRegistry: # check - return 1 + return type(x).__name__ in inspect.getsource(label) # check """), - "class-compare", + "source-read", ), - "match on the class name": ( + "code passed to eval": ( + _check("eval('isinstance(x, R)')", f"{_IMPORT} as R"), + "isinstance", + ), + "code held in a name, passed to eval": ( textwrap.dedent(""" + from populace_dynamics.engine.rng import ProjectionRNGRegistry as R + CODE = "isinstance(x, " + "R)" def f(x): - match type(x).__name__: - case "ProjectionRNGRegistry": # check - return 1 + return eval(CODE) # check """), - "name-compare", + "dynamic-code", ), - "single-dispatch registration": ( + "code passed to exec": ( textwrap.dedent(""" - from functools import singledispatch - from populace_dynamics.engine.rng import ProjectionRNGRegistry - @singledispatch - def f(x): - return 0 - @f.register(ProjectionRNGRegistry) # check - def _(x): - return 1 + from populace_dynamics.engine.rng import ProjectionRNGRegistry as R + exec("def f(x):\\n return type(x) is R") # check """), - "dispatch-register", + "type-compare", ), - "single-dispatch annotation": ( - _dispatch("ProjectionRNGRegistry"), - "dispatch-annotation", + "class name built by format": ( + _check( + "type(x).__name__ == '{}RNG{}'.format('Projection', 'Registry')", + "", + ), + "name-compare", ), - "single-dispatch string annotation": ( - _dispatch("'ProjectionRNGRegistry'"), - "dispatch-annotation", + "class name built by %": ( + _check("type(x).__name__ == '%sRNGRegistry' % 'Projection'", ""), + "name-compare", ), - "single-dispatch string annotation through an alias": ( - _dispatch("'int | R'", f"{_IMPORT} as R"), - "dispatch-annotation", + "class name built by join": ( + _check( + "type(x).__name__ == ''.join(('Projection', 'RNG', 'Registry'))", + "", + ), + "name-compare", + ), + "class name built in an f-string": ( + _check("type(x).__name__ == f\"{'Projection'}RNG{'Registry'!s}\"", ""), + "name-compare", + ), + "class name reversed by a slice": ( + _check("type(x).__name__ == 'yrtsigeRGNRnoitcejorP'[::-1]", ""), + "name-compare", + ), + "class name built by replace": ( + _check( + "type(x).__name__ == 'ProjectionRNG-'.replace('-', 'Registry')", "" + ), + "name-compare", + ), + "class name in lower case": ( + _check("type(x).__name__.lower() == 'projectionrngregistry'", ""), + "name-compare", + ), + "class name as bytes": ( + _check("type(x).__name__.encode() == b'ProjectionRNGRegistry'", ""), + "name-compare", + ), + "class name as bytes in a nested tuple": ( + _check( + "type(x).__name__.encode() in (b'a', (b'ProjectionRNGRegistry',))[1]", + "", + ), + "name-compare", + ), + "module name built by join": ( + _check( + "type(x).__module__ == '.'.join(('populace_dynamics', 'engine', 'rng'))", + "", + ), + "module-compare", + ), + "module name built by format": ( + _check( + "type(x).__module__ == " + "'{}.{}.{}'.format('populace_dynamics', 'engine', 'rng')", + "", + ), + "module-compare", + ), + "module name as bytes": ( + _check( + "type(x).__module__.encode() == b'populace_dynamics.engine.rng'", + "", + ), + "module-compare", + ), + "probe name as bytes": ( + _check("b'_period_sequence'.decode() in dir(x)", ""), + "private-member-probe", ), } @@ -1507,12 +3431,24 @@ def test_every_spelling_tells_the_two_classes_apart(spelling): false for the registry and true for the direct class.) """ text, _ = SPELLINGS[spelling] + filename = "invented_case.py" namespace = {"__name__": "invented_case"} - exec(compile(text, "invented_case.py", "exec"), namespace) - outcomes = [ - bool(namespace["f"](cls(0, 2))) - for cls in (ProjectionRNGRegistry, DirectRNGStreams) - ] + # Compiled without this module's future imports, as a module of its own + # would be; its text is registered so that source reads find it. + linecache.cache[filename] = ( + len(text), + None, + text.splitlines(True), + filename, + ) + try: + exec(compile(text, filename, "exec", dont_inherit=True), namespace) + outcomes = [ + bool(namespace["f"](cls(0, 2))) + for cls in (ProjectionRNGRegistry, DirectRNGStreams) + ] + finally: + linecache.cache.pop(filename, None) assert outcomes[0] != outcomes[1] @@ -1611,6 +3547,42 @@ def test_scan_follows_re_exports_across_modules(): # Literal tables that name nothing the scan tracks; pruning drops them. "TABLE = {'a': (1, -2.5, None), 'b': [b'x', {3}]}\n" "def f(x):\n return isinstance(x, (int, str)) and x in TABLE\n", + # Trusted decorators, and a class with no base, leave hints unread. + "import functools\n" + "from dataclasses import dataclass\n" + "class Holder(object):\n" + " registry: ProjectionRNGRegistry\n" + " @property\n" + " def current(self) -> ProjectionRNGRegistry:\n" + " return ProjectionRNGRegistry(draw_index=0, n_periods=1)\n" + " @current.setter\n" + " def current(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n" + "@functools.lru_cache(maxsize=None)\n" + "def g(n: int) -> 'ProjectionRNGRegistry':\n" + " return ProjectionRNGRegistry(draw_index=0, n_periods=n)\n" + "@dataclass(frozen=True)\n" + "class Config:\n" + " registry: ProjectionRNGRegistry | None = None\n", + # An instance bound, tested for truth or None, passed on, read and + # returned. + "def f(n, sink):\n" + " registry = ProjectionRNGRegistry(draw_index=0, n_periods=n)\n" + " if registry is None or not registry:\n" + " return None\n" + " sink(registry, label=registry.n_periods)\n" + " other = registry if n else None\n" + " return registry.person_generator(1, 'aging', 0) or other\n", + "def f(registry=ProjectionRNGRegistry(draw_index=0, n_periods=1)):\n" + " return registry.n_periods\n", + # Folds that would build large text are refused, not computed. + "def f(x):\n" + " a = 'ab' * 10 ** 12\n" + " b = 'ab' * 4096 * 4096\n" + " c = '{:99999}'.format(x) + '%99999s' % 'x'\n" + " d = 'x'.center(10 ** 9) + 'x'.center(99999)\n" + " e = 'abc'.replace('', 'x' * 4096) * 4096\n" + " return type(x).__name__ in (a, b, c, d, e)\n", ) @@ -1629,7 +3601,25 @@ def test_scan_ignores_unrelated_checks_and_ordinary_registry_use(body): st.none(), st.binary(max_size=2), st.sampled_from( - ["a", REGISTRY_NAME, REGISTRY_MODULE, "_period_sequence", "RNG"] + [ + "a", + "RNG", + "Projection", + "RNGRegistry", + REGISTRY_NAME, + REGISTRY_NAME.lower(), + REGISTRY_MODULE, + DIRECT_NAME, + OWN_MODULE, + "_period_sequence", + "__all__", + "__doc__", + "__annotations__", + REGISTRY_NAME.encode(), + DIRECT_NAME.encode(), + REGISTRY_MODULE.encode(), + b"_period_sequence", + ] ), ) _LITERALS = st.recursive( @@ -1649,7 +3639,10 @@ def test_scan_ignores_unrelated_checks_and_ordinary_registry_use(body): + "def f(x, default={0}):\n" + " for item in {0}:\n" + " pass\n" - + " return type(x).__name__ in {0} or isinstance(x, {0}) or g({0})\n" + + " a = type(x).__name__ in {0} or isinstance(x, {0}) or g({0})\n" + + " b = ''.join({0}) == type(x).__name__ or ({0},)[0] == x\n" + + " c = '%s' % ({0},) == '{{}}'.format({0}) == str({0}).lower()\n" + + " return a or b or c or g(key={0})\n" ) @@ -1714,6 +3707,191 @@ def test_scan_messages_never_show_source_text(): assert refused.value.__suppress_context__ +def test_every_kind_has_a_spelling(): + """Each kind of hit is pinned by at least one invented spelling.""" + kinds = {kind for _, kind in SPELLINGS.values()} | set(MARKERS.values()) + assert not SPECIFIC_KINDS & CATCH_ALL_KINDS + assert kinds == SPECIFIC_KINDS | CATCH_ALL_KINDS + + +_HIT = re.compile(r"invented/[a-z_]+\.py:[1-9][0-9]*: [a-z_-]+") + + +def test_every_hit_names_only_a_path_a_line_and_a_kind(): + for text, _ in SPELLINGS.values(): + for hit in scan_sources([_invented(text)]): + assert hit.kind in SPECIFIC_KINDS | CATCH_ALL_KINDS + assert _HIT.fullmatch(str(hit)), hit + + +# Modules that track nothing: their reads of namespaces, docstrings, +# annotations and source cannot reach either class. +UNTRACKED_READS = ( + '"""A module that tracks nothing."""\n' + "__all__ = ['f']\n" + "def f():\n" + " 'Return the namespace.'\n" + " return globals(), locals(), vars(), dir(), __doc__, f.__doc__\n", + "import inspect\n" + "def f(x: int) -> int:\n" + " return inspect.signature(f), f.__annotations__, inspect.getdoc(f)\n", +) + + +@pytest.mark.parametrize("text", UNTRACKED_READS) +def test_reads_count_only_where_something_is_tracked(text): + hits = scan_sources([_invented(text)]) + assert hits == (), describe(hits) + + +# Each expression would build text, a tuple or an integer past the +# folder's limits, so the folder must refuse it before building it. Long +# text is built from integers within the integer limit, so that each case +# reaches the check it is listed under. +FOLD_LIMIT_CASES = ( + # Integers. + "'ab' * 10 ** 12", + "'x'.center(10 ** 9)", + "'x'.center(99999)", + "4096 * 4096", + # Repetition, concatenation, replacement and joining. + "'ab' * 4096 * 4096", + "('ab',) * 4096", + "'abc'.replace('', 'x' * 4096) * 4096", + "('x' * 4096).replace('', 'y' * 1024)", + "'x' * 4096 + 'y' * 4096 * 16", + "('x' * 4096 * 15).join(('a',) * 200)", + # Widths, and text formatted field by field. + "'{:9999999}'.format(1)", + "'%9999999s' % 'x'", + "f'{1:9999999}'", + "'{:{}}'.format('x', '9' * 7)", + "('{0}' * 64).format('x' * 4096 * 15)", + "f\"{'x' * 4096 * 10}{'y' * 4096 * 10}\"", + # Tuples, and text built from their items. + "('x' * 4096 * 10, 'y' * 4096 * 10)", + "str(('x' * 4096 * 15, 'y' * 4096 * 15))", + "('x' * 4096 * 4).join(('a' * 4096 * 4, 'b' * 4096 * 4, 'c'))", +) + + +@pytest.mark.parametrize("expression", FOLD_LIMIT_CASES) +def test_folder_refuses_results_past_its_limits(expression): + """The folder gives no value, and never holds much memory on the way. + + Built and then refused, the width cases would take about 10 MB, the + join 12 MB, and the replacement and the repeated field about 4 MB + each; the bound is 1 MB. + """ + node = ast.parse(expression, mode="eval").body + tracing = tracemalloc.is_tracing() + if not tracing: + tracemalloc.start() + try: + tracemalloc.reset_peak() + start = tracemalloc.get_traced_memory()[0] + folded = _fold(node, {}) + peak = tracemalloc.get_traced_memory()[1] - start + finally: + if not tracing: + tracemalloc.stop() + assert folded is _UNKNOWN + assert peak < 16 * _FOLD_TEXT_LIMIT + + +# The folder's operations, over small literals. Python evaluates each +# expression too; where it succeeds within the limits the folder must give +# the same value, and where it fails the folder must give none. +_FOLD_BUILTINS = { + name: getattr(builtins, name) + for name in ("str", "repr", "ascii", "tuple", "reversed", "sorted") +} +_FOLD_LEAVES = st.one_of( + st.text("aZß-{}%s", max_size=3).map(repr), + st.binary(max_size=3).map(repr), + st.integers(-3, 12).map(str), +) +_FOLD_METHOD_NAMES = ( + "lower", + "upper", + "casefold", + "strip", + "title", + "swapcase", + "split", + "encode", + "decode", +) + + +def _fold_operations(inner): + pairs = st.tuples(inner, inner) + return st.one_of( + pairs.map(lambda p: f"({p[0]} + {p[1]})"), + st.tuples(inner, st.integers(-1, 4)).map( + lambda p: f"({p[0]} * {p[1]})" + ), + pairs.map(lambda p: f"({p[0]} % ({p[1]},))"), + st.tuples(inner, st.integers(-3, 3)).map( + lambda p: f"({p[0]})[{p[1]}]" + ), + st.tuples(inner, st.integers(-3, 3), st.integers(-3, 3)).map( + lambda p: f"({p[0]})[{p[1]}:{p[2]}]" + ), + st.tuples(inner, st.sampled_from(_FOLD_METHOD_NAMES)).map( + lambda p: f"({p[0]}).{p[1]}()" + ), + st.tuples(inner, inner, inner).map( + lambda p: f"({p[0]}).replace({p[1]}, {p[2]})" + ), + st.tuples(inner, inner, inner).map( + lambda p: f"({p[0]}).join(({p[1]}, {p[2]}))" + ), + pairs.map(lambda p: f"'{{}}-{{!r}}'.format({p[0]}, {p[1]})"), + pairs.map(lambda p: f"'{{0}}{{1!a:>3}}{{0}}'.format({p[0]}, {p[1]})"), + pairs.map(lambda p: f"'{{:{{}}}}'.format({p[0]}, {p[1]})"), + pairs.map(lambda p: f"f'{{{p[0]}}}{{{p[1]}!a:>3}}'"), + pairs.map(lambda p: f"({p[0]}, {p[1]})"), + pairs.map(lambda p: f"[{p[0]}, *{p[1]}]"), + inner.map(lambda e: f"tuple(reversed({e}))"), + inner.map(lambda e: f"''.join(reversed({e}))"), + inner.map(lambda e: f"sorted({e})"), + inner.map(lambda e: f"str({e})"), + inner.map(lambda e: f"repr({e})"), + ) + + +_FOLD_EXPRESSIONS = st.recursive(_FOLD_LEAVES, _fold_operations, max_leaves=6) + + +@settings(max_examples=300, deadline=None) +@given(expression=_FOLD_EXPRESSIONS) +def test_folder_agrees_with_python_on_literal_expressions(expression): + folded = _fold(ast.parse(expression, mode="eval").body, {}) + with warnings.catch_warnings(): + # Python warns of subscripting an integer literal; it then fails. + warnings.simplefilter("ignore", SyntaxWarning) + code = compile(expression, "", "eval", dont_inherit=True) + try: + expected = eval(code, {"__builtins__": _FOLD_BUILTINS}) + except Exception: + assert folded is _UNKNOWN + return + if _sized(expected) is _UNKNOWN: + assert folded is _UNKNOWN + return + if folded is _UNKNOWN: + # The folder refuses by design any template whose width or + # precision has four digits, which Python may still format within + # the limits; without that rule, the folder must agree. + with pytest.MonkeyPatch.context() as patch: + for kind in _WIDE_FIELDS: + patch.setitem(_WIDE_FIELDS, kind, re.compile(r"(?!)")) + folded = _fold(ast.parse(expression, mode="eval").body, {}) + assert type(folded) is type(expected) + assert folded == expected + + def test_scan_walk_skips_excluded_modules_without_opening_them( tmp_path, monkeypatch ): @@ -2407,3 +4585,381 @@ def test_projection_identity_holds_on_generated_runs( direct = _run(size, draw, horizon, "direct", **rates) registry = _run(size, draw, horizon, "registry", **rates) _assert_identical(direct, registry) + + +# --------------------------------------------------------------------------- +# Condition 4, by reader: each sealed reader of a context's streams gives the +# same output whichever class serves it. +# --------------------------------------------------------------------------- + +# The names through which sealed code reaches a context's streams. The census +# finds each use of one as an attribute, a name, a keyword or a string. A +# function handed the registry under another name is found through the call +# that hands it over, which is why the composition helper is listed. Like the +# scan, the census only says where to look; the runs below are the check. +STREAM_NAMES = frozenset( + {"rng_registry", "person_generator", "composition_rngs_from_registry"} +) +_RUN = "run below with both classes" +_OWN = "builds its own registry and reads only that one" +_CERTIFIED = ( + "needs certified fitted inputs; reads only generator, child_generator, " + "n_periods and composition_rngs_from_registry, each checked at every " + "address by test_streams.py and the composition run below" +) +# Every reader the census finds, by path under the package and qualified +# name, with the run that checks it or the reason none does. A new reader, a +# moved one or a removed one fails the census and comes back for a decision. +STREAM_READERS = { + ("assembled_history_observer.py", "FertilityCapture._call"): _RUN, + ("baselines/fertility.py", "draw_asfr_births"): _RUN, + ("cola_track_a/adapters.py", "di_aware_claiming"): _RUN, + ("engine/__init__.py", ""): "names the helper in __all__", + ( + "engine/assembly.py", + "assemble_period_modules.disability_step", + ): _CERTIFIED, + ( + "engine/assembly.py", + "assemble_period_modules.household_step", + ): _CERTIFIED, + ("engine/assembly.py", "assemble_period_modules.marital_step"): _CERTIFIED, + ("engine/claiming.py", "apply_claiming"): _RUN, + ( + "engine/composition.py", + "simulate_candidate9_internal_reference", + ): _OWN, + ("engine/di_entitlement.py", "_person_uniforms"): _RUN, + ("engine/forward_earnings_p2_v1.py", "resume_p2_projection"): _OWN, + ("engine/loop.py", "PeriodContext"): "declares the field", + ("engine/loop.py", "PeriodContext.person_generator"): _RUN, + ("engine/loop.py", "ProjectionEngine.project"): _OWN, + ("engine/steps.py", "apply_claiming"): _RUN, + ("engine/steps.py", "apply_earnings"): _RUN, + ("engine/steps.py", "apply_mortality"): _RUN, + ( + "graph/runtime.py", + "_GraphPeriodContext.__init__", + ): "binds its own adapter, never either class", + ("harness/m6_preflight.py", "run_candidate9_recertification"): _OWN, + ( + "harness/m6_projection.py", + "project_earnings_on_realized_support", + ): _OWN, + ("mortality_observer.py", "observe_mortality"): ( + "the pinned check, which refuses the direct class loudly" + ), + ("mortality_ses/step.py", "apply_differential_mortality"): _RUN, + ("track_b/replay.py", "replay_earnings_on_realized_support"): _OWN, +} + + +def _stream_name_use(node): + if isinstance(node, ast.Attribute): + return node.attr + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.keyword): + return node.arg + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def stream_reader_census(sources): + """Each function, class or module that uses a stream name, by path + under the package and qualified name. Names only, never source text.""" + places = set() + for source in sources: + path = source.path.removeprefix("src/populace_dynamics/") + stack = [(ast.parse(source.text), "")] + while stack: + node, qualified = stack.pop() + for child in ast.iter_child_nodes(node): + name = qualified + if isinstance( + child, + ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, + ): + name = ( + f"{qualified}.{child.name}" + if qualified + else child.name + ) + stack.append((child, name)) + if _stream_name_use(child) in STREAM_NAMES: + places.add((path, qualified or "")) + return places + + +def test_every_stream_reader_is_run_or_named(): + """The census finds exactly the pinned readers, and each one marked to + run has a run below.""" + found = stream_reader_census(repository_sources()) + pinned = set(STREAM_READERS) + assert found == pinned, sorted(found ^ pinned) + assert set(READER_RUNS) == { + place for place, reason in STREAM_READERS.items() if reason == _RUN + } + + +_READER_PERIODS = 3 +_READER_MORTALITY = AgeSexMortalityModel( + ((0, 64), (65, 120)), + { + ("0-64", "female"): 0.3, + ("0-64", "male"): 0.3, + ("65+", "female"): 0.6, + ("65+", "male"): 0.6, + }, +) +_READER_SCHEDULE = ClaimingSchedule( + { + ("female", START + 1): {62: 0.2, 65: 0.3, 70: 0.5}, + ("male", START + 1): {62: 0.4, 67: 0.6}, + } +) +_READER_RATES = {age: 0.5 for age in range(15, 50)} + + +def _reader_roster(size, shift): + """Invented people, in descending ID order: women of child-bearing age, + people past fifty, DI entitlement and conversions.""" + index = np.arange(size) + age = 16 + (index * 13 + shift) % 70 + return pd.DataFrame( + { + "person_id": (100 + 3 * (size - index)).astype("int64"), + "age": age.astype("int64"), + "sex": np.where(index % 2, "male", "female"), + "weight": 0.5 + index / 4, + "di_entitled": index % 4 == 3, + "di_converted": index % 5 == 4, + } + ) + + +class _InventedEarnings: + def generate(self, frame, year, rng): + return rng.gamma(2.0, 1000.0, len(frame)) + + +class _InventedRefreshEarnings: + earnings_frame_update_columns = ("refresh",) + + def generate_with_frame_updates(self, frame, year, rng): + earnings = rng.gamma(2.0, 1000.0, len(frame)) + refresh = (rng.random(len(frame)) < 0.5).astype(np.float64) + return earnings, {"refresh": refresh} + + +class _InventedDomainEarnings(_InventedEarnings): + def validate_domain(self, frame): + return frame["age"].to_numpy() < 70 + + +class _InventedDifferentialMortality: + weight_column = "weight" + + def evaluate(self, frame, context): + probability = np.where(frame["age"].to_numpy() >= 65, 0.6, 0.3) + cells = pd.DataFrame( + {"person_id": frame["person_id"].to_numpy(), "q": probability} + ) + return probability, cells + + +def _invented_fertility(frame, context, marital, rng): + draws = [ + context.person_generator(ProjectionModule.FERTILITY, person).random() + for person in frame["person_id"] + ] + return frame.assign(fertility_draw=np.asarray(draws, dtype=np.float64)) + + +def _read_person_generator(context, frame, rng): + return [ + np.asarray( + [ + context.person_generator(module, person).random(2) + for module in ProjectionModule + for person in frame["person_id"] + ] + ) + ] + + +def _read_mortality(context, frame, rng): + return [apply_mortality(frame, context, rng, model=_READER_MORTALITY)] + + +def _read_differential_mortality(context, frame, rng): + deaths, cells = {}, {} + survivors = apply_differential_mortality( + frame, + context, + rng, + model=_InventedDifferentialMortality(), + weight_column="weight", + death_log=deaths, + cell_log=cells, + ) + return [survivors, *deaths.values(), *cells.values()] + + +def _read_earnings(context, frame, rng): + domain = frame.assign(**{EARNINGS_DOMAIN_COLUMN: True}) + return [ + apply_earnings(frame, context, rng, model=_InventedEarnings()), + apply_earnings(frame, context, rng, model=_InventedRefreshEarnings()), + apply_earnings(domain, context, rng, model=_InventedDomainEarnings()), + ] + + +def _claiming_reader(apply): + def read(context, frame, rng): + return [apply(frame, context, rng, schedule=_READER_SCHEDULE)] + + return read + + +def _read_person_uniforms(context, frame, rng): + person_ids = np.sort(frame["person_id"].to_numpy()) + needed = np.arange(len(person_ids)) % 3 != 1 + return [ + _person_uniforms(person_ids, needed, context, rng, module, tag) + for module in (ProjectionModule.DISABILITY, ProjectionModule.CLAIMING) + for tag in (None, 0xD1) + ] + + +def _read_composition(context, frame, rng): + out = [] + for period in (0, context.period_index): + streams = composition_rngs_from_registry(context.rng_registry, period) + out.append( + np.asarray( + [ + getattr(streams, field.name).random(3) + for field in dataclasses.fields(streams) + ] + ) + ) + return out + + +def _read_asfr_births(context, frame, rng): + return [draw_asfr_births(frame, context, _READER_RATES)] + + +def _read_fertility_capture(context, frame, rng): + """The capture checks period order and its calendar, so it runs every + period of the registry from the first, as a projection does.""" + wrapped, capture = capture_fertility( + replace(_modules(), fertility=_invented_fertility) + ) + for period in range(1, context.rng_registry.n_periods + 1): + step = replace(context, period_index=period, year=2014 + period) + wrapped.fertility(frame, step, None, rng) + return [capture.to_json()] + + +READER_RUNS = { + ("assembled_history_observer.py", "FertilityCapture._call"): ( + _read_fertility_capture + ), + ("baselines/fertility.py", "draw_asfr_births"): _read_asfr_births, + ("cola_track_a/adapters.py", "di_aware_claiming"): _claiming_reader( + di_aware_claiming + ), + ("engine/claiming.py", "apply_claiming"): _claiming_reader( + engine_claiming.apply_claiming + ), + ("engine/di_entitlement.py", "_person_uniforms"): _read_person_uniforms, + ("engine/loop.py", "PeriodContext.person_generator"): ( + _read_person_generator + ), + ("engine/steps.py", "apply_claiming"): _claiming_reader(apply_claiming), + ("engine/steps.py", "apply_earnings"): _read_earnings, + ("engine/steps.py", "apply_mortality"): _read_mortality, + ("mortality_ses/step.py", "apply_differential_mortality"): ( + _read_differential_mortality + ), +} +# The composition helper has no reader place of its own; its callers are +# listed above, and it runs here so that their streams are checked too. +_COMPOSITION = ("engine/composition.py", "composition_rngs_from_registry") + + +def _run_reader(place, streams_class, size, shift, draw, period, seed): + frame = _reader_roster(size, shift) + ordinals = { + person: ordinal + for ordinal, person in enumerate(sorted(frame["person_id"])) + } + context = PeriodContext( + period, + START + period, + draw, + {}, + rng_registry=streams_class(draw, _READER_PERIODS), + person_ordinals=ordinals, + ) + reader = _read_composition if place == _COMPOSITION else READER_RUNS[place] + return reader(context, frame, np.random.default_rng(seed)) + + +def _assert_same_outputs(actual, expected): + for got, want in zip(actual, expected, strict=True): + if isinstance(want, pd.DataFrame): + pd.testing.assert_frame_equal(got, want, check_exact=True) + elif isinstance(want, np.ndarray): + np.testing.assert_array_equal(got, want, strict=True) + else: + assert got == want + + +def _outputs_differ(first, second): + try: + _assert_same_outputs(first, second) + except AssertionError: + return True + return False + + +_READER_PLACES = (*sorted(READER_RUNS), _COMPOSITION) + + +@pytest.mark.parametrize("place", _READER_PLACES) +@settings(max_examples=12, deadline=None) +@given( + size=st.integers(0, 12), + shift=st.integers(0, 69), + draw=st.integers(0, 2**20), + period=st.integers(1, _READER_PERIODS), + seed=st.integers(0, 2**32 - 1), +) +def test_each_stream_reader_gives_the_same_output_from_either_class( + place, size, shift, draw, period, seed +): + """The invariant: each sealed reader the census marks to run gives + exactly the same output from either class, for any invented roster, + draw, period and injected generator seed.""" + registry = _run_reader( + place, ProjectionRNGRegistry, size, shift, draw, period, seed + ) + direct = _run_reader( + place, DirectRNGStreams, size, shift, draw, period, seed + ) + _assert_same_outputs(direct, registry) + + +@pytest.mark.parametrize("place", _READER_PLACES) +def test_each_stream_reader_draws_from_the_streams(place): + """Two draw indices give different output for each reader, so each run + above reads the streams rather than passing them by.""" + first, second = ( + _run_reader(place, DirectRNGStreams, 24, 0, draw, 1, 0) + for draw in (0, 1) + ) + assert _outputs_differ(first, second) From 56fa57ca10df20017991d1a1b7dc3a4734edf3bf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 14:13:23 -0700 Subject: [PATCH 11/15] Address review inject-r3: bound the folder per module, verify accessors, flag deep literal code - The folder's limits now apply to each value it computes; a source literal is kept as written. A module's folded values share a 1 MiB budget, past which the scan stops with an error naming the module. Edge, literal and budget tests pin each limit. - A property's setter, getter or deleter is trusted only on a property defined above it in the same class body; register stays trusted by spelling, and the report says so. - Literal code is parsed five levels deep; a call whose code would be a sixth level is reported as dynamic code. - Each folder method and builtin has a call checked against Python, and the Hypothesis grammar takes the one-argument and width methods. - The class-info, instance and module-path walks keep their own stacks, so chains thousands of links long scan without RecursionError. - Four spellings a mutant sweep found unpinned: class info read by a method and rebuilt, and an instance bound in a conditional and in a boolean. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 119 ++- .../open_population/test_stream_injection.py | 730 +++++++++++++++--- 2 files changed, 744 insertions(+), 105 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index 01bbb498..6750ab56 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -669,8 +669,9 @@ on the build's four open questions. The branch then merged master observer's refusal stays, with a clearer OP1 refusal under the default; the 2100 identity grid is the full product; and the scan skips the depletion-cut modules by path. Review inject-r1 asked for revisions at `8634858c`, and -review inject-r2 for more at `33e383b4`; their subsections, below, give -the findings and the fixes. +review inject-r2 for more at `33e383b4`. Review inject-r3 approved +`5a4fc14c` with one minor finding and two nits, since fixed. Their +subsections, below, give the findings and the fixes. ### Mechanism @@ -848,7 +849,14 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at A decorator is trusted only by the path it resolves to, such as `functools.lru_cache` or `dataclasses.dataclass`; a local function - named `property` is not. A class is trusted only with trusted + named `property` is not. There are two exceptions. A property's + `setter`, `getter` or `deleter` is trusted only where its receiver is a + property defined above it in the same class body: a function whose + outermost decorator is `property` or another such accessor, not bound + again since. And any `register`, as an attribute or as a name bound + from one, is taken for a dispatcher's by its spelling alone: the hint + it dispatches on has its own check, and its other hints are trusted. + A class is trusted only with trusted decorators, no keywords and no base but `object`. A specific check is reported at the outermost node that makes it, and nothing under that node is reported again. So a spelling the specific checks miss still @@ -897,7 +905,9 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at - `dynamic-code`: `eval`, `exec` or `typing.ForwardRef` on text the folder cannot read, in a module that binds anything tracked. Literal code passed to `eval`, `exec`, `compile` or `ForwardRef` is parsed and - scanned with the module, and so is literal code nested in it. + scanned with the module, and so is literal code nested in it, down to + five levels. A call whose literal code would be a sixth level is + reported as `dynamic-code` whatever the module binds. - `member-introspection`: a dunder read on, or an introspection call such as `inspect.getmodule` or `getattr` on, a member of a module through which a class can be reached. @@ -935,19 +945,53 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at the other pure `str` and `bytes` methods it lists, such as `lower`, `strip`, `split`, `encode` and `decode`; and `str`, `bytes`, `repr`, `ascii`, `format`, `chr`, `tuple`, `list`, `sorted` and `reversed`. It - does not follow names. It holds no text over 64 KiB, no integer larger - than 4096 in magnitude and no tuple over 256 items, and it formats no - template over 4096 characters or with a four-digit width or precision. - It runs `str.format` one field at a time, so a template that reads one - argument in many fields stops at the limit. + does not follow names. Each value it computes holds no text over 64 KiB, + no integer larger than 4096 in magnitude and no tuple over 256 items, + and it formats no template over 4096 characters or with a four-digit + width or precision. It runs `str.format` one field at a time, so a + template that reads one argument in many fields stops at the limit. + These limits apply to what the folder builds, not to what the source + holds. A source literal is kept as written, at any length, since the + parser already holds it, and one operation on a long literal can build + text as long as that literal before the folder refuses the result. + Across a module, the values the folder keeps weigh at most 1 MiB in + characters and items, the weight of 16 texts at the limit; past that the + scan stops with an error naming the module, so it never skips one. - `test_folder_refuses_results_past_its_limits` checks 19 expressions past those limits: the folder gives no value, and holds under 1 MiB while refusing. + - `test_folder_limits_hold_at_their_edges` checks nine pairs: each + limit's edge, which folds to Python's value, and one step past it, + which gives none. + - `test_folder_keeps_a_source_literal_of_any_length` folds a + 70,000-character literal to itself, and the scan finds the check in + code passed to `exec` in such a literal. + - `test_folder_budget_bounds_what_one_expression_keeps` folds review + inject-r3's tuple of 512 texts of 60 KiB, which kept about 33.8 MB + before the item limit refused it. It now stops at the budget, with a + peak under 2 MiB. In + `test_scan_stops_at_the_folder_budget_naming_only_the_path`, a module + of twelve built 64 KiB texts scans clean, one of twenty stops with the + error, and one of twenty 64 KiB literals scans clean. + - `test_folder_runs_each_listed_method_and_builtin` calls each of the + 26 methods and 10 builtins the folder runs (`format` both ways) and + compares the result with Python's. + `test_every_listed_method_and_builtin_has_a_call` fails if one is + listed without a call. - `test_folder_agrees_with_python_on_literal_expressions` checks 300 - Hypothesis expressions against Python itself, by value and type. Where + Hypothesis expressions against Python itself, by value and type. The + grammar includes the methods that take another expression, such as + `partition` and `removeprefix`, and those that take a width. Where Python fails or passes a limit, the folder must give no value. The one exception it allows is the four-digit width rule; with that rule off, the folder must agree. +- **Deep expressions.** The walks that follow class info, instances and + module paths keep their own stacks, as the folder and the catch-all do. + `test_scan_reads_long_chains_without_exhausting_recursion` gives seven + working checks inside or beside chains 3000 links long: a sum of + literals, a union, slices, conditionals, attributes, calls and + negations. At `5a4fc14c`, the sum, the union, the slices and the + attributes raised `RecursionError` at 1000 links. - **Cost.** Right after parsing, each literal container of constants is replaced by `None` unless a string in it holds either class's or module's name in any case, a probe name, or a name a read of a trusted @@ -965,13 +1009,15 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at 1.2 GB, mostly while parsing the largest data module. - **Its own tests.** - `test_scan_catches_every_spelling_of_a_registry_type_check` covers - 121 invented spellings. Each gives exactly the hits its + 125 invented spellings. Each gives exactly the hits its markers ask for: one of its kind on its check line, plus one on each line that binds a class or holds its name. All twelve of review inject-r1's misses and all nineteen of review inject-r2's are among - them. + them, and so are four that a mutant sweep found unpinned: class info + read by `KINDS.get(...)` and rebuilt by `tuple(KINDS)`, and an + instance bound through a conditional and through `and`. - `test_every_spelling_tells_the_two_classes_apart` runs - 119 of them and checks that each answers differently for the + 123 of them and checks that each answers differently for the two classes. The other two cannot run: a relative import, and an alias chain written in reverse order. - `test_every_kind_has_a_spelling` checks that each of the 24 kinds, 19 @@ -989,13 +1035,24 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at - `test_reads_count_only_where_something_is_tracked` gives two modules that track nothing; their reads of namespaces, docstrings, annotations and signatures give no hit. + - `test_scan_trusts_an_accessor_only_of_a_property_above_it` gives + seven class bodies. A property's own `setter`, `deleter` and `getter` + give no hit; an accessor of a receiver bound nowhere, of a plain + method, of a property bound again, of a property under another + decorator, outside a class, or of a local `property` leaves the + function's hint to the catch-all. + - `test_scan_parses_five_levels_of_literal_code_and_flags_deeper` nests + a working `isinstance` check in one to seven literal `exec` calls. It + is reported as `isinstance` down to five levels and as `dynamic-code` + at six and seven. - **Its messages.** A hit carries only a module path, line number and node kind. `test_scan_messages_never_show_source_text` plants a distinctive literal in an invented snippet and checks that it is absent from the hits, their `repr` and the assertion message, and from the refusal raised for unparsable source, whose original exception is suppressed. `test_every_hit_names_only_a_path_a_line_and_a_kind` checks that every - spelling's hits print as exactly `path:line: kind`. + spelling's hits print as exactly `path:line: kind`. The budget error's + text is checked exactly: the module's path and a fixed phrase. - **Limits.** The scan is a heuristic. It reads source as written, before it runs, and what it cannot see falls into four classes: 1. **Values made at run time.** Text built from anything but literals @@ -1282,6 +1339,40 @@ and killed each of the author's thirteen mutants. inside a nested tuple, and the pruning property samples those bytes. 3. **Nit:** the slot-binding range is `:393–406`. **Fix:** cited. +### Review inject-r3 + +Review inject-r3, a hard-tier Subfleet review on a Codex lane, read the +branch at `5a4fc14c` and returned APPROVE. It found no blocking or major +finding under the behavioural claim, and r2's three findings fixed: all 38 +of r2's checks give hits, and the real-tree scan gives exactly the pinned +hit. Its one minor finding and two nits concern how the scan's limits were +stated: + +1. **Minor: the folder's 64 KiB limit was stated without its scope.** It + bounds each value the folder computes. A source literal of any length + is kept as written, and nothing bounded what many values add up to: a + tuple of 512 texts of 60 KiB each kept about 33.8 MB before the item + limit refused it. **Fix:** the folder's description states the scope. + A module's folded values now share a 1 MiB budget, past which the scan + stops with an error naming the module. Edge, literal and budget tests + pin each limit (condition 2, the folder). +2. **Nit: two kinds of decorator were trusted by spelling.** Any + `setter`, `getter` or `deleter` was trusted whatever its receiver, and + any `register` still is. **Fix:** an accessor is trusted only on a + property defined above it in the same class body, with seven tests, + and the `register` rule is stated. +3. **Nit: nested literal code was parsed five levels deep, unstated.** At + six levels a working check gave no hit. **Fix:** a call whose code + would be a sixth level is reported as `dynamic-code`, and a test pins + the boundary from one to seven levels. + +It also noted that no test failed when `partition` was dropped from the +folder's list, and that a literal `+` chain of 500 terms raised +`RecursionError` in the scan, as at `33e383b4`. **Fix:** a test calls each +of the folder's 26 methods and 10 builtins, the Hypothesis grammar takes +the methods with an argument or a width, and the walks that recursed keep +their own stacks. + ### Commands and results ```sh diff --git a/tests/open_population/test_stream_injection.py b/tests/open_population/test_stream_injection.py index 72b2142d..9f5872fc 100644 --- a/tests/open_population/test_stream_injection.py +++ b/tests/open_population/test_stream_injection.py @@ -375,10 +375,14 @@ def test_direct_streams_are_not_a_registry_subclass(): "dataclasses.dataclass", } ) -# A property's own decorators, whatever the property is called. +# A property's own decorators; one is trusted only on a property defined +# above it in the same class body (find_accessors). _PROPERTY_METHODS = frozenset({"setter", "getter", "deleter"}) # Calls whose literal first argument is code; the scan parses and checks it. _CODE_CALLS = frozenset({"eval", "exec", "compile", "ForwardRef"}) +# Literal code is parsed this many levels deep: code in a module's literal, +# code in that code's literal, and so on. +_EMBED_DEPTH = 5 def _code_call(func: ast.expr) -> bool: @@ -521,13 +525,19 @@ def _names_probe(text: str) -> bool: # The folder: the text an expression holds before run time. # --------------------------------------------------------------------------- -# The folder builds no text longer than this, takes no larger integer, and -# formats no template longer than the template limit or with a four-digit -# field width, so no literal expression can make it allocate much. +# Each value the folder computes holds no text longer than this, no larger +# integer and no more items, and it formats no template longer than the +# template limit or with a four-digit field width. A source literal is kept +# as written, at any length: the parser already holds it. _FOLD_TEXT_LIMIT = 1 << 16 _FOLD_INT_LIMIT = 4096 _FOLD_TEMPLATE_LIMIT = 4096 _FOLD_ITEMS_LIMIT = 256 +# The values one memo (a module's, or one expression's) keeps may weigh +# this much in all, in characters and items; past it, the scan stops with +# an error rather than hold more, so many values within the limits cannot +# add up to much either. +_FOLD_BUDGET = 16 * _FOLD_TEXT_LIMIT # A field width or precision of 1000 or more, in each kind of template. _WIDE_FIELDS = { "percent": re.compile(r"%[^a-zA-Z%]*\d{4}"), @@ -830,6 +840,7 @@ def _fold_value(node: ast.AST, value): """Fold one node, given ``value`` for each child it reads.""" if isinstance(node, ast.Constant): if isinstance(node.value, str | bytes): + # The source's own text, kept as is: folding allocates nothing. return node.value if node.value is None or _is_int(node.value): return _sized(node.value) @@ -925,7 +936,19 @@ def _fold_call(node: ast.Call, value): return _fold_function(func.id, args, keywords) -def _fold(node: ast.AST, memo: dict): +class _FoldMemo(dict): + """Each folded node's value, by node id, and the weight kept so far. + + ``path`` names the module in the error raised past the budget. + """ + + def __init__(self, path: str = ""): + super().__init__() + self.path = path + self.held = 0 + + +def _fold(node: ast.AST, memo: _FoldMemo): """Return the value an expression holds before run time, or unknown. Literals fold, and so do the pure operations a module might build @@ -933,7 +956,8 @@ def _fold(node: ast.AST, memo: dict): format specs, indexing and slicing, the ``str`` and ``bytes`` methods in ``_FOLD_METHODS`` and the builtins in ``_FOLD_FUNCTIONS``. Names are not followed. The walk keeps its own stack, so a deep expression - cannot exhaust recursion, and ``memo`` keeps each node's value. + cannot exhaust recursion, and ``memo`` keeps each node's value. Every + value but a source literal counts against ``_FOLD_BUDGET``. """ def known(item): @@ -949,24 +973,29 @@ def known(item): child for child in _fold_children(item) if not known(child) ] if ready or not children: - memo[id(item)] = ( - item, - _fold_value(item, lambda child: memo[id(child)][1]), - ) + value = _fold_value(item, lambda child: memo[id(child)][1]) + if not isinstance(item, ast.Constant): + memo.held += _weight(value) + if memo.held > _FOLD_BUDGET: + raise ScanError( + f"{memo.path}: literal expressions past the " + "folder's budget" + ) + memo[id(item)] = (item, value) else: stack.append((item, True)) stack.extend((child, False) for child in children) return memo[id(node)][1] -def _static_text(node: ast.expr, memo: dict | None = None) -> str | None: +def _static_text(node: ast.expr, memo: _FoldMemo | None = None) -> str | None: """Return the text a string expression holds before run time, if any. The parser already joins adjacent literals and decodes escapes; the folder does the rest. Bytes are read as Latin-1, so an ASCII name matches in either. """ - value = _fold(node, {} if memo is None else memo) + value = _fold(node, _FoldMemo() if memo is None else memo) if isinstance(value, str): return value if isinstance(value, bytes): @@ -1068,6 +1097,31 @@ def _statements(body: list[ast.stmt]) -> Iterable[ast.stmt]: stack.extend(reversed(inner)) +def _bound_names(statement: ast.stmt) -> set[str]: + """Name everything a statement may bind, in nested scopes too.""" + names = set() + for node in ast.walk(statement): + if isinstance(node, ast.Name) and isinstance( + node.ctx, ast.Store | ast.Del + ): + names.add(node.id) + elif isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef + ): + names.add(node.name) + elif isinstance(node, ast.Import | ast.ImportFrom): + names.update( + (alias.asname or alias.name).split(".")[0] + for alias in node.names + ) + elif isinstance(node, ast.ExceptHandler | ast.MatchAs | ast.MatchStar): + if node.name: + names.add(node.name) + elif isinstance(node, ast.MatchMapping) and node.rest: + names.add(node.rest) + return names + + def _hints(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]: """List a function's hints in the order Python stores them. @@ -1096,6 +1150,21 @@ def _class_annotations(node: ast.ClassDef) -> list[ast.expr]: ] +def _parts(node: ast.expr) -> list[ast.expr]: + """List the parts an expression holds or picks one of.""" + if isinstance(node, ast.Tuple | ast.List | ast.Set): + return list(node.elts) + if isinstance(node, ast.Dict): + return [item for item in (*node.keys, *node.values) if item] + if isinstance(node, ast.Starred | ast.NamedExpr | ast.Subscript): + return [node.value] + if isinstance(node, ast.IfExp): + return [node.body, node.orelse] + if isinstance(node, ast.BoolOp): + return list(node.values) + return [] + + def _binds_by_name(target: ast.expr) -> bool: """Return whether an assignment target only binds names.""" if isinstance(target, ast.Name | ast.Attribute): @@ -1127,13 +1196,17 @@ def _annotation_nodes(tree: ast.AST) -> set[int]: return {id(item) for hint in hints for item in ast.walk(hint)} -def _embedded(tree: ast.AST, depth: int = 0) -> list[ast.Module]: +def _embedded( + tree: ast.AST, memo: _FoldMemo, unexpanded: set[int], depth: int = 1 +) -> list[ast.Module]: """Parse the literal code a module evaluates, nested code too. A literal passed to ``eval``, ``exec``, ``compile`` or ``ForwardRef`` is code the module runs, so the scan checks it as part of the module; each parsed tree takes its call's location. Inside an annotation the hint checks parse such literals instead, so they are skipped here. + Code nested more than ``_EMBED_DEPTH`` deep is not parsed: its call + goes in ``unexpanded``, and the scan reports it as dynamic code. """ found = [] hinted = _annotation_nodes(tree) @@ -1145,17 +1218,19 @@ def _embedded(tree: ast.AST, depth: int = 0) -> list[ast.Module]: and id(node) not in hinted ): continue - text = _static_text(node.args[0]) + text = _static_text(node.args[0], memo) if text is None: continue try: parsed = ast.parse(text) except (SyntaxError, ValueError): continue + if depth > _EMBED_DEPTH: + unexpanded.add(id(node)) + continue _relocate(parsed, node) found.append(parsed) - if depth < 4: - found += _embedded(parsed, depth + 1) + found += _embedded(parsed, memo, unexpanded, depth + 1) return found @@ -1211,9 +1286,13 @@ def __init__( self.listing_calls: set[int] = set() self.listing_loads: set[int] = set() # Set by scan_sources: the modules whose objects and members count, - # and the modules the scan reads. + # the modules the scan reads, and the code calls nested too deep to + # parse. self.introspected: frozenset[str] = frozenset() self.known_modules: frozenset[str] = frozenset() + self.unexpanded: set[int] = set() + # Set by find_accessors: the decorators that are a property's own. + self.accessors: set[int] = set() self.parents: dict[int, ast.AST] = {} # Each hint's parsed parts, kept so that their nodes stay alive and # their parents stay known. @@ -1251,8 +1330,17 @@ def module_path(self, node: ast.expr) -> str | None: path = self.modules.get(node.id) return None if path is None else self._resolve(path) if isinstance(node, ast.Attribute): - base = self.module_path(node.value) - return self._resolve(f"{base}.{node.attr}") if base else None + # Unwound in a loop, so a long chain cannot exhaust recursion. + attributes = [] + while isinstance(node, ast.Attribute): + attributes.append(node.attr) + node = node.value + path = self.module_path(node) + for attribute in reversed(attributes): + if not path: + return None + path = self._resolve(f"{path}.{attribute}") + return path if isinstance(node, ast.Call): return self._imported(node) if ( @@ -1315,45 +1403,51 @@ def is_tracked(self, node: ast.expr) -> bool: return False def instance_valued(self, node: ast.expr) -> bool: - """Return whether ``node`` may be an instance of a tracked class.""" - if isinstance(node, ast.Call): - return self.is_tracked(node.func) - if isinstance(node, ast.Name): - return node.id in self.instances - if isinstance(node, ast.Attribute): - if node.attr in self.instances: - return True - base = self.module_path(node.value) - return base is not None and (base, node.attr) in ( - self.instance_exports - ) - if isinstance(node, ast.NamedExpr): - return self.instance_valued(node.value) - if isinstance(node, ast.IfExp): - return self.instance_valued(node.body) or self.instance_valued( - node.orelse - ) - if isinstance(node, ast.BoolOp): - return any(map(self.instance_valued, node.values)) + """Return whether ``node`` may be an instance of a tracked class. + + The walk keeps its own stack, so a long chain of conditionals + cannot exhaust recursion. + """ + stack = [node] + while stack: + item = stack.pop() + if isinstance(item, ast.Call): + if self.is_tracked(item.func): + return True + elif isinstance(item, ast.Name): + if item.id in self.instances: + return True + elif isinstance(item, ast.Attribute): + if item.attr in self.instances: + return True + base = self.module_path(item.value) + if base is not None and (base, item.attr) in ( + self.instance_exports + ): + return True + elif isinstance(item, ast.NamedExpr): + stack.append(item.value) + elif isinstance(item, ast.IfExp): + stack += [item.body, item.orelse] + elif isinstance(item, ast.BoolOp): + stack += item.values return False - def _any_part(self, node: ast.expr, test) -> bool: - """Apply ``test`` to ``node`` and to each part it holds or picks.""" - if test(node): - return True - if isinstance(node, ast.Tuple | ast.List | ast.Set): - parts = node.elts - elif isinstance(node, ast.Dict): - parts = [item for item in (*node.keys, *node.values) if item] - elif isinstance(node, ast.Starred | ast.NamedExpr | ast.Subscript): - parts = [node.value] - elif isinstance(node, ast.IfExp): - parts = [node.body, node.orelse] - elif isinstance(node, ast.BoolOp): - parts = node.values - else: - return False - return any(self._any_part(part, test) for part in parts) + def _any_part(self, node: ast.expr, test, more=None) -> bool: + """Apply ``test`` to ``node`` and to each part it holds or picks. + + ``more``, if given, lists further parts of a node to test. The walk + keeps its own stack, so a deep expression cannot exhaust recursion. + """ + stack = [node] + while stack: + item = stack.pop() + if test(item): + return True + stack += _parts(item) + if more is not None: + stack += more(item) + return False def _class_part(self, node: ast.expr) -> bool: if self.is_tracked(node): @@ -1362,30 +1456,32 @@ def _class_part(self, node: ast.expr) -> bool: return node.id in self.containers if isinstance(node, ast.Attribute): return node.attr in self.containers + return False + + @staticmethod + def _class_info_parts(node: ast.expr) -> list[ast.expr]: + """List the class info a node builds from or reads.""" if isinstance(node, ast.BinOp) and isinstance( node.op, ast.BitOr | ast.Add ): # A union, or class-info tuples joined with "+". - return self.contains_tracked(node.left) or self.contains_tracked( - node.right - ) + return [node.left, node.right] if isinstance(node, ast.Subscript): # Union[int, cls] holds the class in its subscript. - return self.contains_tracked(node.slice) + return [node.slice] if isinstance(node, ast.Call): # Class info read or rebuilt: KINDS.get(key), tuple(KINDS). - if isinstance(node.func, ast.Attribute) and self.contains_tracked( - node.func.value - ): - return True - return _call_name(node.func) in _CONTAINER_CALLS and any( - self.contains_tracked(item) for item in node.args - ) - return False + parts = [] + if isinstance(node.func, ast.Attribute): + parts.append(node.func.value) + if _call_name(node.func) in _CONTAINER_CALLS: + parts += node.args + return parts + return [] def contains_tracked(self, node: ast.expr) -> bool: """Return whether a class-info expression may include either class.""" - return self._any_part(node, self._class_part) + return self._any_part(node, self._class_part, self._class_info_parts) def _name_part(self, node: ast.expr) -> bool: texts = self.texts(node) @@ -1654,31 +1750,76 @@ def bind(self, nodes: list[ast.AST]) -> None: if before == self._state(): return + def _decorator_path(self, target: ast.expr) -> str | None: + """Return the path a decorator resolves to, if it can be trusted. + + A name the module binds other than by import resolves to nothing, + and a bare name it does not import is a builtin. + """ + root = target + while isinstance(root, ast.Attribute): + root = root.value + if not isinstance(root, ast.Name) or root.id in self.assigned: + return None + if isinstance(target, ast.Name) and target.id not in self.modules: + return f"builtins.{target.id}" + return self.module_path(target) + + def _accessor_of(self, node: ast.expr, properties: set[str]) -> bool: + return ( + isinstance(node, ast.Attribute) + and node.attr in _PROPERTY_METHODS + and isinstance(node.value, ast.Name) + and node.value.id in properties + ) + + def find_accessors(self, roots: Iterable[ast.AST]) -> None: + """Record the decorators that are a property's own accessors. + + ``@name.setter``, ``.getter`` or ``.deleter`` counts only in a + class body where ``name`` is, at that point, a function whose + outermost decorator is ``property`` or such an accessor. Any other + binding of the name in that body ends it. + """ + for root in roots: + for node in ast.walk(root): + if not isinstance(node, ast.ClassDef): + continue + properties: set[str] = set() + for statement in _statements(node.body): + if not isinstance( + statement, ast.FunctionDef | ast.AsyncFunctionDef + ): + properties -= _bound_names(statement) + continue + decorators = statement.decorator_list + for decorator in decorators: + if self._accessor_of(decorator, properties): + self.accessors.add(id(decorator)) + if decorators and ( + id(decorators[0]) in self.accessors + or self._decorator_path(decorators[0]) + == "builtins.property" + ): + properties.add(statement.name) + else: + properties.discard(statement.name) + def _benign_decorator(self, node: ast.expr) -> bool: """Return whether a decorator leaves annotations unread. A dispatcher's ``register`` reads only the hint it dispatches on, which is checked on its own; called with a class, it reads none. + Any ``register`` is taken for a dispatcher's, by its spelling. """ if self.registrar(node) or ( isinstance(node, ast.Call) and self.registrar(node.func) ): return True - target = node.func if isinstance(node, ast.Call) else node - if isinstance(target, ast.Attribute) and ( - target.attr in _PROPERTY_METHODS - ): + if id(node) in self.accessors: return True - root = target - while isinstance(root, ast.Attribute): - root = root.value - if not isinstance(root, ast.Name) or root.id in self.assigned: - return False - if isinstance(target, ast.Name) and target.id not in self.modules: - path = f"builtins.{target.id}" - else: - path = self.module_path(target) - return path in _BENIGN_DECORATORS + target = node.func if isinstance(node, ast.Call) else node + return self._decorator_path(target) in _BENIGN_DECORATORS def trusts_class(self, node: ast.ClassDef) -> bool: """Return whether a class leaves its annotations unread. @@ -2013,10 +2154,14 @@ def _call_kind(self, node: ast.Call) -> str | None: if ( _code_call(node.func) and node.args - and self.text(node.args[0]) is None - and self.binds_tracked() + and ( + id(node) in self.unexpanded + or (self.text(node.args[0]) is None and self.binds_tracked()) + ) ): - # Code the folder cannot read may name what the module binds. + # Code the folder cannot read may name what the module binds, + # and code nested past the depth the scan parses may name + # anything. return "dynamic-code" if ( name in _INTROSPECTION_CALLS @@ -2345,10 +2490,16 @@ def scan_sources( sources = tuple(sources) trees = {} embedded = {} + memos = {source.path: _FoldMemo(source.path) for source in sources} + unexpanded: dict[str, set[int]] = {} for source in sources: tree = _parse(source) trees[source.path] = _prune(tree) if prune else tree - embedded[source.path] = _embedded(trees[source.path]) + embedded[source.path] = _embedded( + trees[source.path], + memos[source.path], + unexpanded.setdefault(source.path, set()), + ) binders = { path: [ node @@ -2358,7 +2509,6 @@ def scan_sources( ] for path, tree in trees.items() } - memos = {path: {} for path in trees} exports = set(TRACKED_CLASSES) instance_exports: set[tuple[str, str]] = set() # What each module's import statements bind: fixed before the loop, so @@ -2398,6 +2548,8 @@ def scan_sources( known = frozenset(source.module for source in sources) | TRACKED_MODULES for path, scope in scopes.items(): scope.known_modules = known + scope.unexpanded = unexpanded[path] + scope.find_accessors((trees[path], *embedded[path])) scope.facts(trees[path]) introspected = frozenset( {module for module, _ in exports} @@ -2646,6 +2798,21 @@ def _dispatch(hint, imports=_IMPORT): ), "isinstance", ), + "class info read by a method": ( + _check( + "isinstance(x, KINDS.get('registry', int))", + f"{_IMPORT}\nKINDS = {{'registry': ProjectionRNGRegistry}}" + " # alias", + ), + "isinstance", + ), + "class info rebuilt": ( + _check( + "isinstance(x, tuple(KINDS))", + f"{_IMPORT}\nKINDS = [int, ProjectionRNGRegistry] # alias", + ), + "isinstance", + ), "class-info tuples joined with +": ( _check("isinstance(x, (int,) + (ProjectionRNGRegistry,))"), "isinstance", @@ -3124,6 +3291,20 @@ def f(x): _check("type(x) is type(REFERENCE)", _INSTANCE_IMPORT), "type-compare", ), + "type of an instance bound in a conditional": ( + _check( + "type(x) is type(REFERENCE)", + f"{_IMPORT}\nREFERENCE = ProjectionRNGRegistry(0, 2) if 1 else 0", + ), + "type-compare", + ), + "type of an instance bound in a boolean": ( + _check( + "type(x) is type(REFERENCE)", + f"{_IMPORT}\nREFERENCE = 1 and ProjectionRNGRegistry(0, 2)", + ), + "type-compare", + ), "class of an instance bound to a name": ( _check("isinstance(x, REFERENCE.__class__)", _INSTANCE_IMPORT), "isinstance", @@ -3593,6 +3774,199 @@ def test_scan_ignores_unrelated_checks_and_ordinary_registry_use(body): assert hits == (), describe(hits) +# A property's accessor is trusted only where its receiver is a property +# defined above it in the same class body. Each body follows the registry +# import and lists the lines of its hits: a distrusted accessor leaves the +# function's hints to the catch-all. +ACCESSOR_CASES = { + "a property's own accessors": ( + "class C:\n" + " @property\n" + " def p(self) -> ProjectionRNGRegistry:\n" + " return ProjectionRNGRegistry(0, 1)\n" + " @p.setter\n" + " def p(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n" + " @p.deleter\n" + " def p(self) -> None:\n" + " pass\n" + " @p.getter\n" + " def p(self) -> ProjectionRNGRegistry:\n" + " return ProjectionRNGRegistry(0, 1)\n", + [], + ), + "an accessor of a receiver bound nowhere": ( + "class C:\n" + " @unrelated.setter\n" + " def p(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n", + [4], + ), + "an accessor of a plain method": ( + "class C:\n" + " def p(self):\n" + " return 0\n" + " @p.setter\n" + " def p(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n", + [6], + ), + "an accessor of a property rebound": ( + "class C:\n" + " @property\n" + " def p(self):\n" + " return 0\n" + " p = staticmethod(len)\n" + " @p.setter\n" + " def p(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n", + [8], + ), + "an accessor of a property under another decorator": ( + "class C:\n" + " @wrap\n" + " @property\n" + " def p(self):\n" + " return 0\n" + " @p.setter\n" + " def p(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n", + [8], + ), + "an accessor outside a class": ( + "@property\n" + "def p(self):\n" + " return 0\n" + "@p.setter\n" + "def q(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n", + [6], + ), + "an accessor of a local property": ( + "def property(function):\n" + " return function\n" + "class C:\n" + " @property\n" + " def p(self):\n" + " return 0\n" + " @p.setter\n" + " def p(self, value: ProjectionRNGRegistry) -> None:\n" + " pass\n", + [9], + ), +} + + +@pytest.mark.parametrize("case", tuple(ACCESSOR_CASES)) +def test_scan_trusts_an_accessor_only_of_a_property_above_it(case): + body, lines = ACCESSOR_CASES[case] + hits = scan_sources([_invented(_REGISTRY_IMPORT + body)]) + assert [(hit.line, hit.kind) for hit in hits] == [ + (line, "class-reference") for line in lines + ], describe(hits) + + +def _nested_code(depth): + """Invent a module whose check sits in ``depth`` nested literal execs.""" + code = "def f(x):\n return isinstance(x, R)" + for _ in range(depth): + code = f"exec({code!r})" + return f"{_IMPORT} as R\n{code}\n" + + +def _answers(text): + """Run an invented module's ``f`` on the registry and the direct class.""" + namespace = {"__name__": "invented_case"} + exec( + compile(text, "invented_case.py", "exec", dont_inherit=True), namespace + ) + return [ + bool(namespace["f"](cls(0, 2))) + for cls in (ProjectionRNGRegistry, DirectRNGStreams) + ] + + +@pytest.mark.parametrize("depth", range(1, 8)) +def test_scan_parses_five_levels_of_literal_code_and_flags_deeper(depth): + """Code nested past five levels is reported as dynamic code. + + At every depth the module is a working check, so the scan must report + it either way: as the check while it parses the code, and as dynamic + code where it stops. + """ + text = _nested_code(depth) + assert _answers(text) == [True, False] + hits = scan_sources([_invented(text)]) + kind = "isinstance" if depth <= 5 else "dynamic-code" + assert [(hit.line, hit.kind) for hit in hits] == [(2, kind)], describe( + hits + ) + + +_CHAIN = 3000 +_RUN_ANY = "isinstance(x, ProjectionRNGRegistry) if x else x" +# Checks inside, or next to, chains 3000 links long. The recursive walks +# r3 found stopped at about 500 links; the scan walks these with its own +# stacks. Markers are those of SPELLINGS. +LONG_CHAINS = { + "a sum of literals": ( + f"{_IMPORT}\nNAME = 'ProjectionRNG' + 'Registry'" + + " + ''" * _CHAIN + + " # label\ndef f(x):\n return type(x).__name__ == NAME # check\n", + "name-compare", + ), + "a union": ( + f"{_IMPORT}\nKINDS = int" + + " | int" * _CHAIN + + " | ProjectionRNGRegistry # alias\n" + "def f(x):\n return isinstance(x, KINDS) # check\n", + "isinstance", + ), + "a chain of slices": ( + f"{_IMPORT}\nKINDS = (ProjectionRNGRegistry,) # alias\n" + "def f(x):\n return isinstance(x, KINDS" + + "[:]" * _CHAIN + + ") # check\n", + "isinstance", + ), + "a chain of conditionals": ( + f"{_IMPORT}\ndef f(x):\n return isinstance(x, " + + "ProjectionRNGRegistry if x else " * _CHAIN + + "ProjectionRNGRegistry) # check\n", + "isinstance", + ), + "a chain of attributes": ( + f"{_IMPORT}\ndef f(x):\n return {_RUN_ANY}" + + ".real" * _CHAIN + + " # check\n", + "isinstance", + ), + "a chain of calls": ( + f"{_IMPORT}\ndef f(x):\n return {_RUN_ANY[:-1]}(lambda: x)" + + "()" * _CHAIN + + " # check\n", + "isinstance", + ), + "a chain of negations": ( + f"{_IMPORT}\ndef f(x):\n return " + + "not " * _CHAIN + + "isinstance(x, ProjectionRNGRegistry) # check\n", + "isinstance", + ), +} + + +@pytest.mark.parametrize("shape", tuple(LONG_CHAINS)) +def test_scan_reads_long_chains_without_exhausting_recursion(shape): + """Each chain is a working check, and the scan finds it.""" + text, kind = LONG_CHAINS[shape] + assert _answers(text) == [True, False] + hits = scan_sources([_invented(text)]) + assert [(hit.line, hit.kind) for hit in hits] == _expected( + text, kind + ), describe(hits) + + # Inert-literal pruning must not change any result. The leaves include the # class's name, its module's name and a probe name, so some generated # literals are inert and some are not. @@ -3790,7 +4164,7 @@ def test_folder_refuses_results_past_its_limits(expression): try: tracemalloc.reset_peak() start = tracemalloc.get_traced_memory()[0] - folded = _fold(node, {}) + folded = _fold(node, _FoldMemo()) peak = tracemalloc.get_traced_memory()[1] - start finally: if not tracing: @@ -3815,13 +4189,31 @@ def test_folder_refuses_results_past_its_limits(expression): "lower", "upper", "casefold", + "capitalize", "strip", + "lstrip", + "rstrip", "title", "swapcase", "split", + "rsplit", + "splitlines", "encode", "decode", ) +# Methods taking one argument: another expression, or a width. +_FOLD_ARGUMENT_METHODS = ( + "partition", + "rpartition", + "removeprefix", + "removesuffix", + "split", + "rsplit", + "strip", + "lstrip", + "rstrip", +) +_FOLD_WIDTH_METHODS = ("center", "ljust", "rjust", "zfill") def _fold_operations(inner): @@ -3841,6 +4233,12 @@ def _fold_operations(inner): st.tuples(inner, st.sampled_from(_FOLD_METHOD_NAMES)).map( lambda p: f"({p[0]}).{p[1]}()" ), + st.tuples(inner, st.sampled_from(_FOLD_ARGUMENT_METHODS), inner).map( + lambda p: f"({p[0]}).{p[1]}({p[2]})" + ), + st.tuples( + inner, st.sampled_from(_FOLD_WIDTH_METHODS), st.integers(-2, 8) + ).map(lambda p: f"({p[0]}).{p[1]}({p[2]})"), st.tuples(inner, inner, inner).map( lambda p: f"({p[0]}).replace({p[1]}, {p[2]})" ), @@ -3867,7 +4265,7 @@ def _fold_operations(inner): @settings(max_examples=300, deadline=None) @given(expression=_FOLD_EXPRESSIONS) def test_folder_agrees_with_python_on_literal_expressions(expression): - folded = _fold(ast.parse(expression, mode="eval").body, {}) + folded = _fold(ast.parse(expression, mode="eval").body, _FoldMemo()) with warnings.catch_warnings(): # Python warns of subscripting an integer literal; it then fails. warnings.simplefilter("ignore", SyntaxWarning) @@ -3887,7 +4285,157 @@ def test_folder_agrees_with_python_on_literal_expressions(expression): with pytest.MonkeyPatch.context() as patch: for kind in _WIDE_FIELDS: patch.setitem(_WIDE_FIELDS, kind, re.compile(r"(?!)")) - folded = _fold(ast.parse(expression, mode="eval").body, {}) + folded = _fold( + ast.parse(expression, mode="eval").body, _FoldMemo() + ) + assert type(folded) is type(expected) + assert folded == expected + + +# Each limit at its edge: the first expression of a pair is within it and +# folds to Python's value; the second is one step past it and gives none. +FOLD_EDGE_CASES = ( + ("'x' * 4096 * 16", "'x' * 4096 * 16 + 'y'"), + ("tuple('ab' * 128)", "tuple('ab' * 128 + 'c')"), + ("4096", "4097"), + ("-4096", "-4097"), + ("2048 * 2", "2049 * 2"), + ("('{}' + 'x' * 4094).format(1)", "('{}' + 'x' * 4095).format(1)"), + ("'{:999}'.format(1)", "'{:1000}'.format(1)"), + ("'%999s' % 'x'", "'%1000s' % 'x'"), + ("f'{1:999}'", "f'{1:1000}'"), +) + + +@pytest.mark.parametrize(("within", "past"), FOLD_EDGE_CASES) +def test_folder_limits_hold_at_their_edges(within, past): + folded = _fold(ast.parse(within, mode="eval").body, _FoldMemo()) + expected = eval(compile(within, "", "eval", dont_inherit=True), {}) + assert type(folded) is type(expected) + assert folded == expected + assert _fold(ast.parse(past, mode="eval").body, _FoldMemo()) is _UNKNOWN + + +def test_folder_keeps_a_source_literal_of_any_length(): + """A literal past the text limit is the parser's own text, kept as is. + + The limits bound what the folder builds, not what the source holds, + so the scan still reads code in a long literal. + """ + code = "def f(x):\n return isinstance(x, R)\n#" + "x" * 70000 + node = ast.parse(repr(code), mode="eval").body + assert _fold(node, _FoldMemo()) is node.value + hits = scan_sources([_invented(f"{_IMPORT} as R\nexec({code!r})\n")]) + assert [(hit.line, hit.kind) for hit in hits] == [ + (2, "isinstance") + ], describe(hits) + + +def test_folder_budget_bounds_what_one_expression_keeps(): + """Values each within the limits stop at the budget, loudly. + + r3's tuple of 512 texts of 60 KiB each kept about 34 MB before the + item limit refused it; now the fold stops near 1 MiB. + """ + node = ast.parse("(" + "'x' * 4096 * 15, " * 512 + ")", mode="eval").body + tracing = tracemalloc.is_tracing() + if not tracing: + tracemalloc.start() + try: + tracemalloc.reset_peak() + start = tracemalloc.get_traced_memory()[0] + with pytest.raises(ScanError) as raised: + _fold(node, _FoldMemo()) + peak = tracemalloc.get_traced_memory()[1] - start + finally: + if not tracing: + tracemalloc.stop() + assert str(raised.value) == ( + ": literal expressions past the folder's budget" + ) + assert peak < _FOLD_BUDGET + 16 * _FOLD_TEXT_LIMIT + + +# Each built text is 64 KiB, so 16 fill the budget; a literal costs none. +_BUDGET_TEXTS = {"built": "'x' * 4096 * 16", "literal": repr("x" * 65536)} + + +@pytest.mark.parametrize( + ("form", "count", "fits"), + (("built", 12, True), ("built", 20, False), ("literal", 20, True)), +) +def test_scan_stops_at_the_folder_budget_naming_only_the_path( + form, count, fits +): + """A module's values share one budget; past it the scan stops. + + The error names the module's path and nothing of its text. + """ + text = f"{_IMPORT}\n" + "".join( + f"TEXT_{index} = {_BUDGET_TEXTS[form]}\n" for index in range(count) + ) + if fits: + assert scan_sources([_invented(text)]) == () + return + with pytest.raises(ScanError) as raised: + scan_sources([_invented(text)]) + assert str(raised.value) == ( + "invented/invented_case.py: literal expressions past the folder's " + "budget" + ) + + +# One call to each method and builtin the folder runs; a name dropped from +# its lists stops folding here. +FOLD_CALLS = ( + ("capitalize", "'ab'.capitalize()"), + ("casefold", "'Aß'.casefold()"), + ("center", "'ab'.center(6, '*')"), + ("decode", "b'ab'.decode()"), + ("encode", "'ab'.encode()"), + ("format", "'{}-{!r}'.format(1, 'a')"), + ("format", "format(5, '03')"), + ("fromhex", "bytes.fromhex('6162')"), + ("join", "'-'.join(('a', 'b'))"), + ("ljust", "'ab'.ljust(4)"), + ("lower", "'AB'.lower()"), + ("lstrip", "' ab'.lstrip()"), + ("partition", "'a-b-c'.partition('-')"), + ("removeprefix", "'abc'.removeprefix('a')"), + ("removesuffix", "'abc'.removesuffix('c')"), + ("replace", "'abc'.replace('b', 'x')"), + ("rjust", "'ab'.rjust(4)"), + ("rpartition", "'a-b-c'.rpartition('-')"), + ("rsplit", "'a b c'.rsplit(' ', 1)"), + ("rstrip", "'ab '.rstrip()"), + ("split", "'a b'.split()"), + ("splitlines", "'a\\nb'.splitlines()"), + ("strip", "' ab '.strip()"), + ("swapcase", "'aB'.swapcase()"), + ("title", "'ab cd'.title()"), + ("upper", "'ab'.upper()"), + ("zfill", "'7'.zfill(3)"), + ("ascii", "ascii('ß')"), + ("bytes", "bytes('ab', 'ascii')"), + ("chr", "chr(97)"), + ("list", "list('ab')"), + ("repr", "repr('a')"), + ("reversed", "tuple(reversed('ab'))"), + ("sorted", "sorted('ba')"), + ("str", "str(12)"), + ("tuple", "tuple('ab')"), +) + + +def test_every_listed_method_and_builtin_has_a_call(): + assert {name for name, _ in FOLD_CALLS} == _FOLD_METHODS | _FOLD_FUNCTIONS + + +@pytest.mark.parametrize(("name", "expression"), FOLD_CALLS) +def test_folder_runs_each_listed_method_and_builtin(name, expression): + folded = _fold(ast.parse(expression, mode="eval").body, _FoldMemo()) + code = compile(expression, "", "eval", dont_inherit=True) + expected = eval(code, {}) assert type(folded) is type(expected) assert folded == expected From ec888be7490636d5947ead2a44474ee639e4ac8e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 14:14:37 -0700 Subject: [PATCH 12/15] Recount the tier inventories for the stream injection tests Against master 172f690f, tests/open_population adds 457 node ids (432 in test_stream_injection.py, 25 in test_streams.py) and removes none; all collect as unit. unit moves from 18,789 to 19,246 and the total from 25,359 to 25,816. Co-Authored-By: Claude Opus 5.5 --- tests/README-tiers.md | 4 ++-- tests/tier_counts.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/README-tiers.md b/tests/README-tiers.md index 6daa58ab..c5552a1e 100644 --- a/tests/README-tiers.md +++ b/tests/README-tiers.md @@ -39,9 +39,9 @@ pytest --collect-only -q -m oracle_policyengine | tail -1 | Tier | Tests at HEAD | |---|---:| -| `unit` | 18,789 | +| `unit` | 19,246 | | `artifact` | 4,271 | | `integration_psid` | 1,471 | | `reproduction_legacy` | 520 | | `oracle_policyengine` | 308 | -| **Total** | **25,359** | +| **Total** | **25,816** | diff --git a/tests/tier_counts.json b/tests/tier_counts.json index cab2876f..9b0b0782 100644 --- a/tests/tier_counts.json +++ b/tests/tier_counts.json @@ -1,7 +1,7 @@ { "schema_version": 1, "counts": { - "unit": 18789, + "unit": 19246, "artifact": 4271, "integration_psid": 1471, "reproduction_legacy": 520, From 305b171bd4f91fae2627add2da56c2867164cd2b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 14:38:46 -0700 Subject: [PATCH 13/15] Update the stream injection report for the tier-count slot Section 6 now names master 172f690f as the base, lists the two tier files the recount sets, describes the merge and the recount, and records the host run at ec888be7: tests/open_population 694 passed and 1 strict xfail, the seven-path command 1,022 passed, 2 skipped and 1 strict xfail, 24 of 25 scan mutants killed (the survivor makes a path no module has), and the old-vs-new scan differential with only its intended differences. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 112 +++++++++++++++--- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index 6750ab56..33fb265a 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -671,7 +671,9 @@ observer's refusal stays, with a clearer OP1 refusal under the default; the modules by path. Review inject-r1 asked for revisions at `8634858c`, and review inject-r2 for more at `33e383b4`. Review inject-r3 approved `5a4fc14c` with one minor finding and two nits, since fixed. Their -subsections, below, give the findings and the fixes. +subsections, below, give the findings and the fixes. In the coordinator's +tier-count slot the branch then merged master `172f690f` and recounted the +tier inventories (condition 5). ### Mechanism @@ -742,11 +744,13 @@ so `POST_REVIEW_SOURCE_EXCLUSIONS` and its pin are unchanged. ### The coordinator's conditions and their evidence -**1. No sealed code changes.** Against master `9cced8ff`, the master it -merged, the branch changes only `open_population/harness.py` and `gates.py`, -OP1's two stream test modules and three files in this folder: this report, -`op1_run_size.py` and `op1_run_size_injection.json` (§3's commands, with -`9cced8ff` as the base). No engine or baselines attribute is patched, and +**1. No sealed code changes.** Against master `172f690f`, the master it +last merged, the branch changes only `open_population/harness.py` and +`gates.py`, OP1's two stream test modules, three files in this folder (this +report, `op1_run_size.py` and `op1_run_size_injection.json`) and the two +tier files the recount sets, `tests/tier_counts.json` and +`tests/README-tiers.md` (§3's commands, with `172f690f` as the base). No +engine or baselines attribute is patched, and `DirectRNGStreams` is not a registry subclass: - `test_a_direct_run_patches_no_sealed_module_attribute` compares every attribute of `engine/rng.py`, `engine/loop.py`, `ProjectionRNGRegistry` @@ -796,12 +800,12 @@ computes, and the runs listed above check that on the paths they take. `test_sealed_code_holds_only_the_known_registry_type_check` runs an AST scan of `src/populace_dynamics/` outside `open_population/`: 302 modules at -`9cced8ff`, once the five excluded modules below are dropped. +`172f690f`, once the five excluded modules below are dropped. - **What it reads.** The project's data-access record keeps two modules out of automated reads: `estimates/adjusted_poverty.py` and `uniform_cut_track_u/rows.py`. The scan also skips the depletion-cut modules, every path matching `src/populace_dynamics/bridge/depletion_cut*.py`, - so that what it reads does not depend on who runs it. At `9cced8ff` the + so that what it reads does not depend on who runs it. At `172f690f` the pattern matches three: `bridge/depletion_cut.py`, `bridge/depletion_cut_population.py` and `bridge/depletion_cut_population_child.py`. The walk decides from each @@ -999,7 +1003,9 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at anything from such a container. The generated data modules of 18.5, 11.1 and 1.6 MB are almost all such containers: pruning leaves 178, 78 and 7 of their 706,994, 251,826 and 85,417 syntax-tree nodes, and - 934,099 of the 2,007,887 in all 302 modules. + 934,119 of the 2,007,907 in all 302 modules at `172f690f`. At `9cced8ff` + they were 934,099 of 2,007,887; master's Track B change is the only + source change between the two. `test_pruning_leaves_every_invented_scan_unchanged` and `test_pruning_leaves_scans_unchanged_for_any_literal` (200 Hypothesis literals, each placed in fourteen positions, folded ones among them) @@ -1217,7 +1223,7 @@ bounds. The full-size measurement is still due before OP10a. **4, by reader.** `test_every_stream_reader_is_run_or_named` takes a census of sealed code: each function, class or module that uses `rng_registry`, `person_generator` or `composition_rngs_from_registry` as an attribute, a -name, a keyword or a string. At `9cced8ff` it finds 23 places and pins each +name, a keyword or a string. At `172f690f` it finds 23 places and pins each with the run that checks it or the reason none does: - ten run with both classes, below; - six build their own `ProjectionRNGRegistry` and read only that one: @@ -1262,14 +1268,30 @@ by. **5. Seal bookkeeping.** No module was added, so the exclusion tuple and its pin are unchanged. `tests/track_u2/test_u2_isolation.py` was not run, and CI -gives its result. `tests/tier_counts.json` and `tests/README-tiers.md` are -untouched, for the main session's recount. +gives its result. `tests/tier_counts.json` and `tests/README-tiers.md` +change only by the recount in the coordinator's tier-count slot, below. **Tier.** Every new test is in `tests/open_population/test_streams.py` or `test_stream_injection.py`, and `_classify_test_module` classifies both modules as `unit`. The stream differential, the reader runs and the hash identity therefore run on every CI push. +In the slot, `619282e7` merges master `172f690f` (a Track B holdout +registry change and master's own recount) into the fixes for review +inject-r3, `56fa57ca`, without conflicts; its tree is the one +`git merge-tree --write-tree 56fa57ca 172f690f` writes. Master's +`tests/open_population` and `tests/conftest.py` are the same at `9cced8ff` +and `172f690f`. `ec888be7` then sets the counts. Against master, +`tests/open_population` collects 457 more node ids (432 in +`test_stream_injection.py`, 25 in `test_streams.py`) and loses none, and +all 695 collect as `unit`. The branch changes no other test module and no +`conftest.py`, so no other directory's collection changes. `unit` +therefore moves from 18,789 to 19,246 and the total from 25,359 to 25,816; +the other four tiers keep master's counts. CI's +`tests/test_tier_policy.py::test__given_collected_suite__then_tiers_match_policy_manifest`, +which failed at `5a4fc14c` because the counts lagged, compares the whole +collected suite with these files at each push. + ### Review inject-r1 Review inject-r1, a hard-tier Subfleet review on a Codex lane, read the @@ -1380,11 +1402,16 @@ their own stacks. .venv/bin/python -m pytest --collect-only -q -p no:cacheprovider -m unit tests/open_population .venv/bin/black --check -l 79 src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py .venv/bin/ruff check src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py -git diff --check 9cced8ff -git diff --stat 9cced8ff -git diff --quiet 9cced8ff -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b scripts tests/estimates tests/tier_counts.json tests/README-tiers.md +git diff --check 172f690f +git diff --stat 172f690f +git diff --quiet 172f690f -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b gates.yaml runs scripts tests/estimates +git diff --numstat 172f690f -- tests/tier_counts.json tests/README-tiers.md ``` +Rounds before the tier-count slot used master `9cced8ff` as the base and +also held `tests/tier_counts.json` and `tests/README-tiers.md` quiet; the +results below name the base each round used. + The build ran the required command in its sandbox in five chunks of named modules, one process each, because the sandbox could not finish it within one tool call. Together they gave **442 passed and 1 strict xfail, with the @@ -1506,6 +1533,61 @@ person streams; shifting a tagged child's tag changes `_person_uniforms`, generator's tag changes the composition streams. Unmutated, no reader's output differs. +After the fixes for review inject-r3, the merge of master `172f690f` and +the recount, the main session ran the same commands on the host at +`ec888be7` from 21:18 to 21:38 UTC on 26 October. The next commit changes +only this report. Each process ran under the load-check audit hook and +started only at a one-minute load under 100: +- `tests/open_population`: **694 passed and 1 strict xfail** in 67.08 s. + These are OP1's 238 and the 457 tests this follow-up adds: 432 in + `test_stream_injection.py` and 25 in `test_streams.py`. +- The other four modules: 104 passed in 3.41 s. +- The two Track B holdout-registry modules: 224 passed and 2 skipped; + master's Track B change adds nine. The required command plus those two + modules, in one process: **1,022 passed, 2 skipped and 1 strict xfail**. +- Peak RSS was 1.33 GB. +- The slowest tests took 20.2 s (the real-tree scan), 3.8 s (the census), + 2.5 s (the fresh-interpreter import check) and up to 2.1 s for a 2100 + hash pair. +- No file under a bar was opened, the pattern line named only + `baselines/asfr.py` (read by the scan and the census, never displayed), + and a call-count hook on the one-process run counted no call to either + barred function. +- `--collect-only -m unit tests/open_population` collects all 695. Black, + Ruff and `git diff --check 172f690f` are clean. `git diff --stat + 172f690f` lists the nine files of condition 1, and the sealed-path + `git diff --quiet` exits 0. `tests/tier_counts.json` differs from master + only in its `unit` line, and `tests/README-tiers.md` only in its `unit` + and total rows. +- All 38 of review inject-r2's checks give hits. On the real tree the scan + gives exactly the pinned hit, and every module's folded values weigh + under 16,384, a sixty-fourth of the budget; the heaviest outside + `baselines/asfr.py` weighs 10,433. +- The scan at `5a4fc14c` and at `ec888be7` give the same hits on 155 of 163 + invented sources: the 125 spellings, the 20 negatives, the seven accessor + cases, nested depths one to seven and four pruning places. The eight + that differ are the intended ones: the six accessor cases the new rule + distrusts, now hits, and depths six and seven, now `dynamic-code`. On + the real tree both give the same single hit. +- Seven working checks beside chains of 200, 1000 and 3000 links give + their hits with no `RecursionError`. + +Twenty-five mutants of the fixes for review inject-r3 ran at `ec888be7` +against the deterministic scan tests. Twenty-four each fail the tests aimed +at them: accessors trusted by spelling, an unchecked receiver, a rebound +property kept, any decorator taken for `property`, and `_bound_names` +skipping stores; embed depths of four and six, and an unreported sixth +level; a budget that never raises, and source literals counted against it; +a bounded source literal, and text, item, integer and template limits that +refuse a value at the limit; `partition` dropped; the recursive part walk and module path +restored; and the call receiver, call arguments, slice, conditional and +`and` parts the four new spellings pin. The survivor drops `module_path`'s +check for a missing base. It can only make a path that starts `None.`, +which names no module, so it changes no result. The three stream mutants +above, rerun at `ec888be7`, still each change some reader's output, and +unmutated no reader's output differs. None of these ran against the real +tree. + ### Restricted-files statement The build read `RESTRICTED-FILES.md` at its pinned hash and the From ed2c0e906ab204e531380379fa200b8409ab4b28 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 07:50:48 -0400 Subject: [PATCH 14/15] Recount the tier inventories after merging master a747bafa Against master a747bafa, tests/open_population adds 457 node ids (432 in test_stream_injection.py, 25 in test_streams.py) and removes none; all collect as unit. unit moves from 19,047 to 19,504 and the total from 25,617 to 26,074. Co-Authored-By: Claude Opus 5.5 --- tests/README-tiers.md | 4 ++-- tests/tier_counts.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/README-tiers.md b/tests/README-tiers.md index 222873f4..10705e52 100644 --- a/tests/README-tiers.md +++ b/tests/README-tiers.md @@ -39,9 +39,9 @@ pytest --collect-only -q -m oracle_policyengine | tail -1 | Tier | Tests at HEAD | |---|---:| -| `unit` | 19,047 | +| `unit` | 19,504 | | `artifact` | 4,271 | | `integration_psid` | 1,471 | | `reproduction_legacy` | 520 | | `oracle_policyengine` | 308 | -| **Total** | **25,617** | +| **Total** | **26,074** | diff --git a/tests/tier_counts.json b/tests/tier_counts.json index a0d827c6..1c90096a 100644 --- a/tests/tier_counts.json +++ b/tests/tier_counts.json @@ -1,7 +1,7 @@ { "schema_version": 1, "counts": { - "unit": 19047, + "unit": 19504, "artifact": 4271, "integration_psid": 1471, "reproduction_legacy": 520, From 6780f442b7447cdd4fa6105e8918d4fb8635c9e6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:07:11 -0400 Subject: [PATCH 15/15] State what the OP1 scan leaves out, for review inject-r4 Section 6 now says that literal statement suites in annotations stay unparsed (limit class 4), that module_path still recurses through sys.modules subscripts, and that accessor trust reads syntax, and it limits the surviving mutant's explanation to this repository. It names master a747bafa as the base, describes the merge and the recount, adds the review subsection, and records the host run at ed2c0e90: tests/open_population 694 passed and 1 strict xfail, and the seven-path command 1,028 passed, 2 skipped and 1 strict xfail. No code changes. Co-Authored-By: Claude Opus 5.5 --- .../open_population_evidence/op1_report.md | 155 +++++++++++++++--- 1 file changed, 128 insertions(+), 27 deletions(-) diff --git a/docs/design/open_population_evidence/op1_report.md b/docs/design/open_population_evidence/op1_report.md index 33fb265a..950d8194 100644 --- a/docs/design/open_population_evidence/op1_report.md +++ b/docs/design/open_population_evidence/op1_report.md @@ -673,7 +673,10 @@ review inject-r2 for more at `33e383b4`. Review inject-r3 approved `5a4fc14c` with one minor finding and two nits, since fixed. Their subsections, below, give the findings and the fixes. In the coordinator's tier-count slot the branch then merged master `172f690f` and recounted the -tier inventories (condition 5). +tier inventories (condition 5). Review inject-r4 approved `305b171b` with +two minor findings and two nits, which this section answers by stating what +the scan leaves out. In the next slot the branch merged master `a747bafa` +and recounted again. ### Mechanism @@ -744,12 +747,12 @@ so `POST_REVIEW_SOURCE_EXCLUSIONS` and its pin are unchanged. ### The coordinator's conditions and their evidence -**1. No sealed code changes.** Against master `172f690f`, the master it +**1. No sealed code changes.** Against master `a747bafa`, the master it last merged, the branch changes only `open_population/harness.py` and `gates.py`, OP1's two stream test modules, three files in this folder (this report, `op1_run_size.py` and `op1_run_size_injection.json`) and the two tier files the recount sets, `tests/tier_counts.json` and -`tests/README-tiers.md` (§3's commands, with `172f690f` as the base). No +`tests/README-tiers.md` (§3's commands, with `a747bafa` as the base). No engine or baselines attribute is patched, and `DirectRNGStreams` is not a registry subclass: - `test_a_direct_run_patches_no_sealed_module_attribute` compares every @@ -800,12 +803,12 @@ computes, and the runs listed above check that on the paths they take. `test_sealed_code_holds_only_the_known_registry_type_check` runs an AST scan of `src/populace_dynamics/` outside `open_population/`: 302 modules at -`172f690f`, once the five excluded modules below are dropped. +`a747bafa`, once the five excluded modules below are dropped. - **What it reads.** The project's data-access record keeps two modules out of automated reads: `estimates/adjusted_poverty.py` and `uniform_cut_track_u/rows.py`. The scan also skips the depletion-cut modules, every path matching `src/populace_dynamics/bridge/depletion_cut*.py`, - so that what it reads does not depend on who runs it. At `172f690f` the + so that what it reads does not depend on who runs it. At `a747bafa` the pattern matches three: `bridge/depletion_cut.py`, `bridge/depletion_cut_population.py` and `bridge/depletion_cut_population_child.py`. The walk decides from each @@ -857,9 +860,13 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at `setter`, `getter` or `deleter` is trusted only where its receiver is a property defined above it in the same class body: a function whose outermost decorator is `property` or another such accessor, not bound - again since. And any `register`, as an attribute or as a name bound - from one, is taken for a dispatcher's by its spelling alone: the hint - it dispatches on has its own check, and its other hints are trusted. + again since. The scan reads the class body's statements in order and + runs none of them, so a property under `if False` still counts, and a + walrus in a later function's default does not end it. Review inject-r4 + found no check these two gaps hide outside limit classes 2 and 4. And + any `register`, as an attribute or as a name bound from one, is taken + for a dispatcher's by its spelling alone: the hint it dispatches on has + its own check, and its other hints are trusted. A class is trusted only with trusted decorators, no keywords and no base but `object`. A specific check is reported at the outermost node that makes it, and nothing under that @@ -911,7 +918,11 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at code passed to `eval`, `exec`, `compile` or `ForwardRef` is parsed and scanned with the module, and so is literal code nested in it, down to five levels. A call whose literal code would be a sixth level is - reported as `dynamic-code` whatever the module binds. + reported as `dynamic-code` whatever the module binds. Annotations + differ. There only the hint checks parse literals, and they read + expressions, so a statement suite in an annotation, such as + `v: exec("def f(x): ...")`, stays unparsed at every depth and gives no + hit (limit class 4). - `member-introspection`: a dunder read on, or an introspection call such as `inspect.getmodule` or `getattr` on, a member of a module through which a class can be reached. @@ -990,7 +1001,11 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at exception it allows is the four-digit width rule; with that rule off, the folder must agree. - **Deep expressions.** The walks that follow class info, instances and - module paths keep their own stacks, as the folder and the catch-all do. + module paths keep their own stacks, as the folder and the catch-all do, + with one exception: `module_path` still recurses through literal + `sys.modules[...]` subscripts. Beside a working check, such a chain + passes at 500 links and raises `RecursionError` at 1000. The error stops + the scan, so it cannot silently miss a check. `test_scan_reads_long_chains_without_exhausting_recursion` gives seven working checks inside or beside chains 3000 links long: a sum of literals, a union, slices, conditionals, attributes, calls and @@ -1003,9 +1018,9 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at anything from such a container. The generated data modules of 18.5, 11.1 and 1.6 MB are almost all such containers: pruning leaves 178, 78 and 7 of their 706,994, 251,826 and 85,417 syntax-tree nodes, and - 934,119 of the 2,007,907 in all 302 modules at `172f690f`. At `9cced8ff` - they were 934,099 of 2,007,887; master's Track B change is the only - source change between the two. + 934,121 of the 2,007,909 in all 302 modules at `a747bafa`. At `9cced8ff` + they were 934,099 of 2,007,887; master's Track B changes are the only + source changes between the two. `test_pruning_leaves_every_invented_scan_unchanged` and `test_pruning_leaves_scans_unchanged_for_any_literal` (200 Hypothesis literals, each placed in fourteen positions, folded ones among them) @@ -1042,7 +1057,7 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at that track nothing; their reads of namespaces, docstrings, annotations and signatures give no hit. - `test_scan_trusts_an_accessor_only_of_a_property_above_it` gives - seven class bodies. A property's own `setter`, `deleter` and `getter` + seven cases. A property's own `setter`, `deleter` and `getter` give no hit; an accessor of a receiver bound nowhere, of a plain method, of a property bound again, of a property under another decorator, outside a class, or of a local `property` leaves the @@ -1082,7 +1097,8 @@ of `src/populace_dynamics/` outside `open_population/`: 302 modules at 3. **Files read as data**, a module's own source among them, as with `open(__file__)` or `importlib.resources`. 4. **Code it does not read**: the excluded modules, OP1's own package, - scripts, tests and third-party code. + scripts, tests, third-party code and literal statement suites inside + annotations. So a clean scan is evidence, not proof. Behaviour is checked separately, at run time: on the paths the reader runs and §4's identity @@ -1223,7 +1239,7 @@ bounds. The full-size measurement is still due before OP10a. **4, by reader.** `test_every_stream_reader_is_run_or_named` takes a census of sealed code: each function, class or module that uses `rng_registry`, `person_generator` or `composition_rngs_from_registry` as an attribute, a -name, a keyword or a string. At `172f690f` it finds 23 places and pins each +name, a keyword or a string. At `a747bafa` it finds 23 places and pins each with the run that checks it or the reason none does: - ten run with both classes, below; - six build their own `ProjectionRNGRegistry` and read only that one: @@ -1292,6 +1308,16 @@ the other four tiers keep master's counts. CI's which failed at `5a4fc14c` because the counts lagged, compares the whole collected suite with these files at each push. +In the next slot, `174ff3f8` merges master `a747bafa` (Track B's `b0_2` +files, a further holdout-registry change and master's own recount). Only the +two tier files conflict, and the merge takes master's copies of them. +Against `305b171b` it changes exactly master's 31 files, none of them under +`tests/open_population` or in this folder. `ed2c0e90` then recounts. Master +leaves `tests/open_population` and `tests/conftest.py` as they stood at +`172f690f`, so the branch adds the same 457 node ids, all `unit`, and still +changes no other test module. `unit` moves from 19,047 to 19,504 and the +total from 25,617 to 26,074; the other four tiers keep master's counts. + ### Review inject-r1 Review inject-r1, a hard-tier Subfleet review on a Codex lane, read the @@ -1395,6 +1421,39 @@ of the folder's 26 methods and 10 builtins, the Hypothesis grammar takes the methods with an argument or a width, and the walks that recursed keep their own stacks. +### Review inject-r4 + +Review inject-r4, a hard-tier Subfleet review on a Codex lane, read the +branch at `305b171b` and returned APPROVE. It found no blocking or major +finding under the behavioural claim, and r3's findings fixed. It confirmed +the merge of `172f690f` and the recount, and it reran the seven-path +command: 1,022 passed, 2 skipped and 1 strict xfail. Its two minor findings +and two nits all concern how this section described the scan, so this +section changes and the code does not: `tests/open_population` and +`open_population/` stay byte-identical to what it approved. + +1. **Minor: literal statement suites in annotations escape both expansion + paths.** `_embedded` skips annotation nodes, and the hint checks parse + expressions only. So `v: exec('def f(x): ...')` runs a working check + when something reads the annotation, and the scan gives no hit. The + scan at `5a4fc14c` misses it too, and limit class 4 covers it, but the + literal-code claims did not say so. **Fix:** the `dynamic-code` bullet + and limit class 4 now state it. +2. **Minor: `module_path` still recurses through `sys.modules` subscripts.** + Beside a working check, a chain of them passes at 500 links and raises + `RecursionError` at 1000. The arm predates the r3 fixes, and the error + fails loudly, but "keep their own stacks" claimed too much. **Fix:** + "Deep expressions" now states the exception. +3. **Nit: accessor trust reads syntax.** A walrus in a later function's + default rebinds the name without ending the property, and a property + under `if False` still counts. **Fix:** the decorator paragraph now + states both. The accessor test's seven bodies also include one outside a + class, so this section now says seven cases. +4. **Nit: the surviving mutant's explanation claimed too much.** Python can + import a package named `None.R`, so an invented exporter there tells the + mutant apart. **Fix:** the mutant paragraph now limits the claim to this + repository's modules. + ### Commands and results ```sh @@ -1402,14 +1461,14 @@ their own stacks. .venv/bin/python -m pytest --collect-only -q -p no:cacheprovider -m unit tests/open_population .venv/bin/black --check -l 79 src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py .venv/bin/ruff check src/populace_dynamics/open_population tests/open_population docs/design/open_population_evidence/op1_run_size.py -git diff --check 172f690f -git diff --stat 172f690f -git diff --quiet 172f690f -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b gates.yaml runs scripts tests/estimates -git diff --numstat 172f690f -- tests/tier_counts.json tests/README-tiers.md +git diff --check a747bafa +git diff --stat a747bafa +git diff --quiet a747bafa -- src/populace_dynamics/engine src/populace_dynamics/baselines src/populace_dynamics/cola_track_a src/populace_dynamics/track_b gates.yaml runs scripts tests/estimates +git diff --numstat a747bafa -- tests/tier_counts.json tests/README-tiers.md ``` -Rounds before the tier-count slot used master `9cced8ff` as the base and -also held `tests/tier_counts.json` and `tests/README-tiers.md` quiet; the +Rounds before the first tier-count slot used master `9cced8ff` as the base +and also held `tests/tier_counts.json` and `tests/README-tiers.md` quiet; the results below name the base each round used. The build ran the required command in its sandbox in five chunks of named @@ -1579,15 +1638,50 @@ property kept, any decorator taken for `property`, and `_bound_names` skipping stores; embed depths of four and six, and an unreported sixth level; a budget that never raises, and source literals counted against it; a bounded source literal, and text, item, integer and template limits that -refuse a value at the limit; `partition` dropped; the recursive part walk and module path -restored; and the call receiver, call arguments, slice, conditional and -`and` parts the four new spellings pin. The survivor drops `module_path`'s -check for a missing base. It can only make a path that starts `None.`, -which names no module, so it changes no result. The three stream mutants +refuse a value at the limit; `partition` dropped; the recursive part walk +and module path restored; and the call receiver, call arguments, slice, +conditional and `and` parts the four new spellings pin. The survivor drops +`module_path`'s check for a missing base. It can only make a path that +starts `None.`, and no module in this repository has one, so it changes no +result here. The three stream mutants above, rerun at `ec888be7`, still each change some reader's output, and unmutated no reader's output differs. None of these ran against the real tree. +After review inject-r4, the merge of master `a747bafa` and the recount, the +main session ran the same commands on the host at `ed2c0e90` from 11:51 to +12:07 UTC on 31 October. The next commit changes only this report. +`ed2c0e90` holds the same `tests/open_population` and `open_population/` as +`305b171b`, so the mutant, chain and probe results above stand. The tests +and the real-tree scan ran again because the tree they read changed. Each +process ran under the load-check audit hook and started only at a +one-minute load under 100: +- `tests/open_population`: **694 passed and 1 strict xfail** in 47.57 s. +- The other four modules: 104 passed in 2.40 s. +- The two Track B holdout-registry modules: 230 passed and 2 skipped; + master's changes add six. The required command plus those two modules, + in one process: **1,028 passed, 2 skipped and 1 strict xfail**. +- Peak RSS was 1.33 GB. +- The slowest tests took 15.08 s (the real-tree scan), 3.65 s (the census) + and 2.11 s (the fresh-interpreter import check). +- No file under a bar was opened, the pattern line named only + `baselines/asfr.py` (read by the scan and the census, never displayed), + and a call-count hook on the one-process run counted no call to either + barred function. +- `--collect-only -m unit tests/open_population` collects all 695. Black, + Ruff and `git diff --check a747bafa` are clean. `git diff --stat + a747bafa` lists the nine files of condition 1, and the sealed-path + `git diff --quiet` exits 0. `tests/tier_counts.json` differs from master + only in its `unit` line, and `tests/README-tiers.md` only in its `unit` + and total rows. +- On the real tree the scan gives exactly the pinned hit, the census pin + holds, and every module's folded values still weigh under 16,384; the + heaviest outside `baselines/asfr.py` still weighs 10,433. +- All four of review inject-r4's counterexamples reproduce on invented + sources, at `ed2c0e90` and at `5a4fc14c`, as §6 now states them. A + `sys.modules` chain gives its check at 200 and 500 links and raises + `RecursionError` at 1000 and 2000. + ### Restricted-files statement The build read `RESTRICTED-FILES.md` at its pinned hash and the @@ -1622,3 +1716,10 @@ only to run `di_aware_claiming`; no test calls a barred function. The main session's searches over `src/` and `tests/` took their file lists from the helper, and its measurement scripts printed paths, line numbers, node kinds, qualified names and counts. + +The fixes for review inject-r3, the wording for review inject-r4 and both +tier-count merges followed the same rules. Git reads that spanned master's +changes used `--name-only`, `--stat` or `--quiet`, and displayed none of +master's changed files beyond the two tier files. The real-tree scripts +read the scanned modules only through the test module's own walk, under +the audit hook, and printed paths, line numbers, node kinds and counts.