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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion src/recast/fortran/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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>", 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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a bare name folds, so dimension(-nd:nd, ...) becomes {"lb": "- nd", "ub": "3"}; the new test's lhs assertion documents exactly this and its comment says the leftover is "the wrapper's to refuse", but nothing refuses it: f2py.py::_extent spells - nd:3 into the wrapper verbatim (gfortran undeclared-symbol build failure, not an engine refusal) and bitexact.py harvests nd from the lb text as a free extent to redraw. -n:n is common Fortran (stencils, band matrices). Not a regression against main, but the description's "folded to the value" is half true for symmetric bounds. Suggest folding a negated name too, or refusing when one bound of an axis folded and the other did not.

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],
Expand Down Expand Up @@ -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,
Expand All @@ -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)),
Expand Down Expand Up @@ -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)]
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/recast/oracle/f2py.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ":"


Expand Down
61 changes: 53 additions & 8 deletions src/recast/oracle/flat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<module>_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:
Expand All @@ -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}")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions src/recast/transform/numpy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})"

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing Unanalyzable here emits Python's scalar not/and/or for an operand whose rank the engine could not settle, and semantics.rank returns 0 for an undeclared Name without raising, so a whole-array logical use-imported from another module never reaches this branch at all and is spelled not x. At runtime that raises for len>1 arrays (loud) but not np.array([True]) is silently False. This is main's behavior, so the PR strictly improves coverage, but the rule is not fail-closed and the description does not say so; the docstring's "as before" is the only mention.

return False

# -- references -----------------------------------------------------------

def reference(self, node: Any) -> str:
Expand Down
16 changes: 14 additions & 2 deletions src/recast/verify/bitexact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/recast/verify/rwset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<depth>_<n>``), a region label."""

PRESENT_SENTINEL = re.compile(r"want_(\w+)")
"""``want_x`` is how an optional output argument is spelled on the target side;
Expand Down
11 changes: 11 additions & 0 deletions tests/test_f2py_oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tests/test_flatten.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(", "")
Loading