diff --git a/src/recast/fortran/interface.py b/src/recast/fortran/interface.py index 98b9234..00c4c8c 100644 --- a/src/recast/fortran/interface.py +++ b/src/recast/fortran/interface.py @@ -556,6 +556,54 @@ def apply_intent_override(arg: dict[str, Any], sub_name: str, override: str | No arg["intent_override"] = True +_INT_LITERAL = re.compile(r"^[+-]?\d+(?:_\w+)?$") + + +def _fold_local_parameter_bounds( + args: list[dict[str, Any]], + result_dims: list[dict[str, Any]] | None, + local_parameters: list[dict[str, Any]], +) -> dict[str, str]: + """A dummy's bound that names a *local* integer parameter of the + subprogram, folded to the parameter's literal value. + + CLUBB's ``w_term_ma_zt_lhs`` declares ``integer, parameter :: t_above = + 1, t_below = 2`` and then ``weights_zt2zm(ngrdcol, nzm, t_above:t_below)``. + The name is the subprogram's own and reaches no consumer of the record: + an f2py wrapper that spells it does not compile, and a sampler that + reads it has no table for it. The value does reach every consumer, and + it is the same array. Only a bare name with a literal integer + initializer folds; an expression stays as written and is the wrapper's + to refuse. Returns ``{"arg.bound": "name -> value"}`` for the record. + """ + values: dict[str, str] = {} + for parameter in local_parameters: + init = str(parameter.get("init_expr") or "").strip() + if parameter.get("dims") or not _INT_LITERAL.match(init): + continue + if str(parameter.get("dtype", "")).startswith("int"): + values[str(parameter["name"]).lower()] = init.split("_")[0] + folded: dict[str, str] = {} + if not values: + return folded + targets: list[tuple[str, list[dict[str, Any]]]] = [ + (a["name"], a.get("dims") or []) for a in args + ] + if result_dims: + targets.append(("", result_dims)) + for owner, dims in targets: + for axis, dim in enumerate(dims): + for key in ("lb", "ub"): + bound = dim.get(key) + if bound is None: + continue + name = str(bound).strip().lower() + if name in values: + dim[key] = values[name] + folded[f"{owner}[{axis}].{key}"] = f"{name} -> {values[name]}" + return folded + + def extract_subprogram( sub: Any, kind_map: dict[str, str], @@ -868,6 +916,8 @@ def _record_of( # reads in non-assignment contexts (if conditions, call arguments) state_read |= (used & module_state_names) - state_written + folded = _fold_local_parameter_bounds(args, result_dims, local_parameters) + return { "name": name, "kind": kind, @@ -877,6 +927,7 @@ def _record_of( "result_dims": result_dims, "line_span": list(node_span(sub)), "args": args, + "folded_bounds": folded, "local_parameters": local_parameters, "locals": locals_, "present_calls": sorted(set(present_args)), @@ -1242,8 +1293,22 @@ def is_public(name: str) -> bool: subprograms.insert( 0, extract_program(sub_scope, mod_name, kind_map, state_names, sub_names) ) + generics = _generics(mod_spec) + # A specific of a public generic is reachable through the generic even + # when the module keeps the specific itself private (CLUBB's banded + # solvers: ``public :: tridiag_lu_solve`` over three private specifics). + # It is gate-visible, and ``public_via`` says what to call it through. + via = { + specific: generic + for generic, specifics in generics.items() + if is_public(generic) + for specific in specifics + } for record in subprograms: record["public"] = is_public(record["name"]) + if not record["public"] and record["name"] in via: + record["public"] = True + record["public_via"] = via[record["name"]] parent = submodule_parent(sub_scope) if isinstance(sub_scope, f08.Submodule) else None use_statements = [str(u) for u in walk(sub_scope, f03.Use_Stmt)] @@ -1270,7 +1335,7 @@ def is_public(name: str) -> bool: "module_allocate_bounds": allocated_bounds, "public": sorted(set(public_names)), "types": _derived_types(mod_spec, kind_map, scope=sub_scope, visible=visible | imported), - "generics": _generics(mod_spec), + "generics": generics, "interfaces": _interfaces(mod_spec, kind_map, state_names, sub_names), "buffer_convention": buffer_convention, "subprograms": subprograms, diff --git a/src/recast/oracle/f2py.py b/src/recast/oracle/f2py.py index b04598d..543dd87 100644 --- a/src/recast/oracle/f2py.py +++ b/src/recast/oracle/f2py.py @@ -323,8 +323,12 @@ def _fold(line: str) -> list[str]: def _extent(dim: dict[str, Any]) -> str: + """The axis as the wrapper declares it: ``lb:ub`` when the lower bound is + not one (CLUBB's ``lhs(-2:2, ngrdcol, ndim)``), so the callee sees the + layout it was written for and f2py sizes the axis ``ub - lb + 1``.""" if dim.get("ub"): - return str(dim["ub"]) + lower = str(dim.get("lb") or "1").strip() + return f"{lower}:{dim['ub']}" if lower != "1" else str(dim["ub"]) return "*" if dim.get("assumed_size") else ":" diff --git a/src/recast/oracle/flat.py b/src/recast/oracle/flat.py index e0361c3..0f5b82b 100644 --- a/src/recast/oracle/flat.py +++ b/src/recast/oracle/flat.py @@ -114,13 +114,33 @@ def _declare(argument: dict[str, Any]) -> str: intent = {"IN": "in", "OUT": "out", "INOUT": "inout", "UNKNOWN": "inout"}[argument["intent"]] dims = "" if argument.get("dims"): - dims = "(" + ", ".join(d["ub"] or ":" for d in argument["dims"]) + ")" + dims = "(" + ", ".join(_axis(d) for d in argument["dims"]) + ")" return f" {spelled}, intent({intent}) :: {argument['name']}{dims}" -def fortran_adapter(module: str, plans: list[FlatPlan], reexport: list[str]) -> str: +def _axis(dim: dict[str, Any]) -> str: + """``lb:ub`` when the lower bound is not one, ``ub`` otherwise, ``:`` + for an assumed shape -- the source's own layout, which is what the + original expects to be handed.""" + if not dim.get("ub"): + return ":" + lower = str(dim.get("lb") or "1").strip() + return f"{lower}:{dim['ub']}" if lower != "1" else str(dim["ub"]) + + +def fortran_adapter( + module: str, + plans: list[FlatPlan], + reexport: list[str], + public_via: dict[str, str] | None = None, +) -> str: """The ``_flat`` Fortran module: the adapters, and the module's - own flat subprograms re-exported so one ``use`` line reaches both.""" + own flat subprograms re-exported so one ``use`` line reaches both. + + ``public_via`` maps a private specific to the public generic it is + reached through; the adapter uses, calls and re-exports the generic, + the only name of it the module lets out. + """ used_modules: dict[str, set[str]] = {} for plan in plans: for obj in plan.objects: @@ -129,11 +149,23 @@ def fortran_adapter(module: str, plans: list[FlatPlan], reexport: list[str]) -> for state in plan.states: used_modules.setdefault(state.module, set()).add(state.name) types_used = {obj.type_name: obj.type_module for plan in plans for obj in plan.objects} + # A private specific of a public generic is reached through the generic + # (``public_via``): that is the name the adapter uses and calls. + via = dict(public_via or {}) + via.update( + { + p.subprogram["name"]: p.subprogram["public_via"] + for p in plans + if p.subprogram.get("public_via") + } + ) + reached = { + via.get(name) or name for name in {*reexport, *(p.subprogram["name"] for p in plans)} + } lines = [ "! Machine-generated by RecastEngine (recast.oracle.flat) -- DO NOT EDIT.", f"module {module}_flat", - f" use {module}, only: " - + ", ".join(sorted({*reexport, *(p.subprogram["name"] for p in plans)})), + f" use {module}, only: " + ", ".join(sorted(reached)), ] for type_name, type_module in sorted(types_used.items()): lines.append(f" use {type_module or type_name}, only: {type_name}") @@ -166,7 +198,8 @@ def fortran_adapter(module: str, plans: list[FlatPlan], reexport: list[str]) -> call_args = ", ".join( f"{a['name']}={a['name']}" for a in plan.subprogram["args"] if not a.get("optional") ) - lines.append(f" call {plan.subprogram['name']}({call_args})") + callee = plan.subprogram.get("public_via") or plan.subprogram["name"] + lines.append(f" call {callee}({call_args})") for obj in plan.objects: for comp in obj.components: if comp.written: @@ -250,7 +283,12 @@ def _plan(self, unit: Unit, facts: Facts, config: dict[str, Any]) -> dict[str, A plans = plans_from_facts(facts) module = facts.interface["module"] spellable = self._subprograms(facts, config) - adapter = fortran_adapter(module, plans, spellable) + public_via = { + s["name"]: s["public_via"] + for s in facts.interface["subprograms"] + if s.get("public_via") + } + adapter = fortran_adapter(module, plans, spellable, public_via) digest = hashlib.sha256() for path in library: digest.update(str(path).encode()) @@ -291,11 +329,18 @@ def _handed( module = facts.interface["module"] chosen = set(self._subprograms(facts, config)) flat_entries = [{**signature(p), "name": p.name, "public": True} for p in plan["plans"]] + # A chosen specific of a public generic is re-exported under the + # generic's name, so its wrapper has to call it through that name + # too: the generic table survives for exactly those. + generics = { + generic: [s for s in specifics if s in chosen] + for generic, specifics in (facts.interface.get("generics") or {}).items() + } interface = { **facts.interface, "module": f"{module}_flat", "is_module": True, - "generics": {}, + "generics": {g: names for g, names in generics.items() if names}, "subprograms": [ *[s for s in facts.interface["subprograms"] if s["name"] in chosen], *flat_entries, diff --git a/src/recast/transform/numpy/expressions.py b/src/recast/transform/numpy/expressions.py index 2aec32c..eb53977 100644 --- a/src/recast/transform/numpy/expressions.py +++ b/src/recast/transform/numpy/expressions.py @@ -352,7 +352,9 @@ def binary(self, left: Any, operator: str, right: Any) -> str: return self._comparison(spelling, left, right, rendered_left, rendered_right) if spelling in LOGICAL_OPS: - if self.vector_boolean and spelling in (".AND.", ".OR."): + if spelling in (".AND.", ".OR.") and ( + self.vector_boolean or self._array_valued(left) or self._array_valued(right) + ): return f"({rendered_left} {'&' if spelling == '.AND.' else '|'} {rendered_right})" return f"({rendered_left} {LOGICAL_OPS[spelling]} {rendered_right})" @@ -409,10 +411,21 @@ def _not(self, node: Any) -> str: operator, operand = node.children if str(operator).upper() != ".NOT.": raise NoRule(f"unary logical operator {operator}") - if self.vector_boolean: + if self.vector_boolean or self._array_valued(operand): return f"(~({self.render(operand)}))" return f"(not {self.render(operand)})" + def _array_valued(self, node: Any) -> bool: + """Whether a logical operand is an array, so ``.NOT.`` / ``.AND.`` / + ``.OR.`` have to be elementwise -- ``any( .not. l_valid )`` over + ``logical, dimension(nz) :: l_valid`` (CLUBB's new_pdf), outside any + WHERE. Python's ``not`` on an array raises; ``~`` is the operator. + Unsettled ranks fall back to the scalar spelling, as before.""" + try: + return self.semantics.rank(node) > 0 + except Exception: # rank refuses what it cannot settle + return False + # -- references ----------------------------------------------------------- def reference(self, node: Any) -> str: diff --git a/src/recast/verify/bitexact.py b/src/recast/verify/bitexact.py index 41e4187..1d7df19 100644 --- a/src/recast/verify/bitexact.py +++ b/src/recast/verify/bitexact.py @@ -164,6 +164,16 @@ def call(*values: Any) -> Any: return callback +def _extent(dim: dict[str, Any], dims: dict[str, int]) -> int: + """An axis's extent: ``ub - lb + 1`` when a lower bound is declared + (CLUBB's ``lhs(-2:2, ...)`` has five rows, not two), ``ub`` otherwise.""" + upper = _resolve_extent(dim.get("ub"), dims) + lower = str(dim.get("lb") or "1").strip() + if lower == "1" or dim.get("ub") is None: + return upper + return upper - _resolve_extent(lower, dims) + 1 + + def _resolve_extent(text: str | None, dims: dict[str, int]) -> int: """A declared dimension's extent under the operator's table.""" if text is None: @@ -728,7 +738,9 @@ def _compare_subprogram( token for argument in sub["args"] for dim in argument.get("dims") or [] - for token in re.findall(r"[a-z_]\w*", str(dim.get("ub") or "").lower()) + for token in re.findall( + r"[a-z_]\w*", f"{dim.get('lb') or ''} {dim.get('ub') or ''}".lower() + ) } points = bit_exact = nan_mismatch = 0 @@ -1423,7 +1435,7 @@ def _value( }.get(argument["dtype"], np.float64) shape = None if argument.get("dims"): - shape = tuple(_resolve_extent(d.get("ub"), dims) for d in argument["dims"]) + shape = tuple(_extent(d, dims) for d in argument["dims"]) if dtype in (np.float64, np.float32): low, high = ranges.get(name, DEFAULT_RANGE) if shape is None: diff --git a/src/recast/verify/rwset.py b/src/recast/verify/rwset.py index d1f6962..1b29a8e 100644 --- a/src/recast/verify/rwset.py +++ b/src/recast/verify/rwset.py @@ -52,8 +52,10 @@ a variable read; ``F32_`` marks one written in Fortran's default real kind, which is a different value from the same digits suffixed.""" -DISCARD = re.compile(r"_wm\d*|_|_g") -"""Scaffolding targets: a discarded value, a where-mask, a region label.""" +DISCARD = re.compile(r"_wm\d*|_wn\d*|_we\d+_\d+|_|_g") +"""Scaffolding targets: a discarded value, the where-construct's masks (the +branch mask ``_wm``, what no branch has claimed ``_wn``, a masked +elsewhere's own ``_we_``), a region label.""" PRESENT_SENTINEL = re.compile(r"want_(\w+)") """``want_x`` is how an optional output argument is spelled on the target side; diff --git a/tests/test_f2py_oracle.py b/tests/test_f2py_oracle.py index 0a348f1..13892ef 100644 --- a/tests/test_f2py_oracle.py +++ b/tests/test_f2py_oracle.py @@ -1820,6 +1820,17 @@ def test_the_reference_builds_across_two_files(tmp_path: Path) -> None: assert ref.handle["wrappers"]["scale_all"] == "w_scale_all" +def test_a_lower_bound_is_spelled_in_the_wrapper() -> None: + """``lhs(-2:2, ngrdcol, ndim)`` (CLUBB's pentadiagonal solvers) has five + rows; a wrapper declaring ``lhs(2, ...)`` would hand the callee two.""" + from recast.oracle.f2py import _extent + + assert _extent({"lb": "-2", "ub": "2"}) == "-2:2" + assert _extent({"lb": "1", "ub": "n"}) == "n" + assert _extent({"lb": None, "ub": "n"}) == "n" + assert _extent({"lb": "0", "ub": "nlev"}) == "0:nlev" + + BLOCK_SOURCE = """\ module block_mod use iso_fortran_env, only: wp => real64 diff --git a/tests/test_flatten.py b/tests/test_flatten.py index 5918c71..35c75c0 100644 --- a/tests/test_flatten.py +++ b/tests/test_flatten.py @@ -441,3 +441,46 @@ def test_a_component_written_through_a_companion_call_is_written(tree: Path) -> warm = next(p for p in plans_from_facts(facts) if p.subprogram["name"] == "warm") written = {c.name for c in warm.objects[0].components if c.written} assert written == {"tleaf", "gs", "ncan"} + + +def test_the_adapter_declares_a_lower_bound_and_calls_through_a_generic() -> None: + """A private specific of a public generic is reached through the generic + (``public_via``), and an axis declared ``-2:2`` keeps its five rows.""" + from recast.oracle.flat import _axis, _declare + + assert _axis({"lb": "-2", "ub": "2"}) == "-2:2" + assert _axis({"lb": "1", "ub": "ndim"}) == "ndim" + assert _axis({"lb": None, "ub": None}) == ":" + declared = _declare( + { + "name": "lhs", + "dtype": "float64", + "intent": "INOUT", + "dims": [{"lb": "-2", "ub": "2"}, {"lb": "1", "ub": "ngrdcol"}], + } + ) + assert declared == " real(8), intent(inout) :: lhs(-2:2, ngrdcol)" + + subprogram = { + "name": "solve_one", + "public": True, + "public_via": "solve", + "kind": "subroutine", + "args": [ + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + { + "name": "x", + "dtype": "float64", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": "n"}], + }, + ], + } + plan = FlatPlan(subprogram=subprogram, objects=[]) + text = fortran_adapter( + "solve_mod", [plan], ["solve_one", "solve_many"], {"solve_many": "solve"} + ) + assert "use solve_mod, only: solve\n" in text # both specifics, one generic + assert "call solve(n=n, x=x)" in text + assert "solve_one(" not in text.replace("subroutine solve_one_flat(", "") diff --git a/tests/test_fortran_analysis.py b/tests/test_fortran_analysis.py index e86b611..24e63e0 100644 --- a/tests/test_fortran_analysis.py +++ b/tests/test_fortran_analysis.py @@ -1800,6 +1800,87 @@ def test_a_kind_inquiry_on_the_constant_itself_is_kept(tmp_path: Path) -> None: ] +# --- what a dummy's bound may name ------------------------------------------ + +LOCAL_PARAMETER_BOUNDS = """\ +module weights_mod + implicit none + private + public :: lhs_weights +contains + subroutine lhs_weights( ngrdcol, nzm, weights, lhs ) + integer, parameter :: t_above = 1, t_below = 2 + integer, parameter :: nd = 3 + integer, intent(in) :: ngrdcol, nzm + real, intent(in), dimension(ngrdcol, nzm, t_above:t_below) :: weights + real, intent(out), dimension(-nd:nd, ngrdcol) :: lhs + lhs = 0.0 + lhs(0, :) = weights(:, 1, t_above) + weights(:, 1, t_below) + end subroutine lhs_weights +end module weights_mod +""" + + +def test_a_bound_naming_a_local_parameter_is_folded_to_its_value(tmp_path: Path) -> None: + """CLUBB's ``w_term_ma_zt_lhs`` sizes a dummy with the subroutine's own + ``integer, parameter :: t_above = 1, t_below = 2``. No consumer of the + record can see that name -- the wrapper does not compile, the sampler + has no table for it -- and every one can use the value.""" + record = interface.extract(_write(tmp_path, "weights.f90", LOCAL_PARAMETER_BOUNDS)) + (sub,) = record["subprograms"] + weights = next(a for a in sub["args"] if a["name"] == "weights") + assert weights["dims"][2] == {"lb": "1", "ub": "2"} + assert sub["folded_bounds"] == { + "weights[2].lb": "t_above -> 1", + "weights[2].ub": "t_below -> 2", + "lhs[0].ub": "nd -> 3", + } + # ``-nd`` is an expression over a parameter, not the parameter: left as + # written, and the wrapper's to refuse or spell. + lhs = next(a for a in sub["args"] if a["name"] == "lhs") + assert lhs["dims"][0] == {"lb": "- nd", "ub": "3"} # fparser spaces the unary minus + + +PUBLIC_GENERIC = """\ +module solve_mod + implicit none + private + public :: solve + interface solve + module procedure solve_one, solve_many + end interface +contains + subroutine solve_one( n, x ) + integer, intent(in) :: n + real, intent(inout) :: x(n) + x = 2.0 * x + end subroutine solve_one + subroutine solve_many( n, m, x ) + integer, intent(in) :: n, m + real, intent(inout) :: x(n, m) + x = 2.0 * x + end subroutine solve_many + subroutine helper( n, x ) + integer, intent(in) :: n + real, intent(inout) :: x(n) + x = x + end subroutine helper +end module solve_mod +""" + + +def test_the_specifics_of_a_public_generic_are_public_through_it(tmp_path: Path) -> None: + """CLUBB's banded solvers export one generic over private specifics. + Each specific is reachable, so it is gate-visible, and ``public_via`` + says what the wrapper has to call it through.""" + record = interface.extract(_write(tmp_path, "solve.f90", PUBLIC_GENERIC)) + by_name = {s["name"]: s for s in record["subprograms"]} + assert by_name["solve_one"]["public"] and by_name["solve_one"]["public_via"] == "solve" + assert by_name["solve_many"]["public"] and by_name["solve_many"]["public_via"] == "solve" + assert not by_name["helper"]["public"] and "public_via" not in by_name["helper"] + assert record["generics"] == {"solve": ["solve_one", "solve_many"]} + + CALLBACK = """\ module callback_mod implicit none diff --git a/tests/test_numpy_translate.py b/tests/test_numpy_translate.py index c729e64..4796ccd 100644 --- a/tests/test_numpy_translate.py +++ b/tests/test_numpy_translate.py @@ -254,6 +254,43 @@ def test_an_integer_parameter_divides_the_way_fortran_does(tmp_path: Path) -> No assert namespace["HALF"] == 0.5 +LOGICAL_ARRAYS = """\ +module valid_mod + implicit none + private + public :: check +contains + subroutine check( n, x, l_bad ) + integer, intent(in) :: n + real, dimension(n), intent(in) :: x + logical, intent(out) :: l_bad + logical, dimension(n) :: l_ok + logical :: l_scalar + l_ok = x > 0.0 + l_scalar = .true. + l_bad = any( .not. l_ok .and. x < 1.0 ) .or. .not. l_scalar + end subroutine check +end module valid_mod +""" + + +def test_logical_operators_on_arrays_are_elementwise(tmp_path: Path) -> None: + """``any( .not. l_ok )`` over ``logical, dimension(n) :: l_ok`` (CLUBB's + new_pdf) is elementwise in Fortran; Python's ``not`` on an array raises. + Outside a WHERE, an array operand still gets ``~`` / ``&`` / ``|``, and a + scalar one keeps ``not`` / ``and`` / ``or``.""" + (tmp_path / "valid_mod.f90").write_text(LOGICAL_ARRAYS) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.uid == "fortran:valid_mod") + facts = frontend.analyze(unit, tmp_path) + candidate = NumpyTranslation().apply(unit, facts, {"root": tmp_path}) + module = candidate.files[Path("valid_mod_numpy.py")].decode() + assert "(~(l_ok))" in module + assert " & " in module + assert "not l_scalar" in module + assert "not l_ok" not in module + + CALLBACK = """\ module callback_mod implicit none diff --git a/tests/test_rwset_verifier.py b/tests/test_rwset_verifier.py index eddd467..93fb320 100644 --- a/tests/test_rwset_verifier.py +++ b/tests/test_rwset_verifier.py @@ -375,3 +375,14 @@ def test_a_copy_out_writes_its_target(verify) -> None: candidate.files = {Path("demo_numpy.py"): emitted.encode()} verdict = verify(candidate) assert verdict.confidence is Confidence.SAMPLED, verdict.detail + + +def test_the_where_constructs_masks_are_scaffolding() -> None: + """``_wm``, ``_wn`` and ``_we_`` are the emitter's masks for a + where / masked elsewhere / elsewhere; a real name never looks like one.""" + from recast.verify.rwset import DISCARD + + for name in ("_wm", "_wm2", "_wn", "_wn2", "_we0_1", "_we1_3", "_", "_g"): + assert DISCARD.fullmatch(name), name + for name in ("_wet", "_we", "_wn_x", "wn", "_f_copy_out", "x_we0_1"): + assert not DISCARD.fullmatch(name), name diff --git a/tests/test_security_boundaries.py b/tests/test_security_boundaries.py index 6f01126..df0a6cc 100644 --- a/tests/test_security_boundaries.py +++ b/tests/test_security_boundaries.py @@ -107,3 +107,12 @@ def test_a_character_initializer_is_a_python_literal_not_python_source() -> None tree = ast.parse(emitted, mode="eval") assert isinstance(tree.body, ast.Constant) assert tree.body.value == "x'; import os; os.system('id'); x='y" + + +def test_an_extent_counts_from_the_lower_bound() -> None: + from recast.verify.bitexact import _extent + + assert _extent({"lb": "-2", "ub": "2"}, {}) == 5 + assert _extent({"lb": "1", "ub": "n"}, {"n": 4}) == 4 + assert _extent({"lb": "0", "ub": "nlev"}, {"nlev": 3}) == 4 + assert _extent({"lb": None, "ub": "n*2"}, {"n": 4}) == 8