diff --git a/corpus/baseline.json b/corpus/baseline.json index 48e6dfb..19d8424 100644 --- a/corpus/baseline.json +++ b/corpus/baseline.json @@ -913,32 +913,29 @@ "stopped_by": "differential.bitexact", "verdicts": { "differential.bitexact": { - "detail": "2 subprogram(s) could not be compared: dogleg: 10 of 10 trial(s) were compared only after the free extent(s) lr, n were moved off the configured values; the 45 ", + "detail": "3 subprogram(s) could not be compared: chkder: 19 draw(s) were declined (19 error stop) to compare 10 trial(s); the trials compared are a minority of what the c", "metrics": { - "bit_exact": 17270, + "bit_exact": 17110, + "declined": { + "NaN on both sides": 7 + }, "integer_mismatch": 0, "integer_points": 760, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 17270, + "points": 17110, + "redrawn": 7, "subprograms": { "chkder": { - "bit_exact": 160, - "integer_mismatch": 0, - "integer_points": 0, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 160, - "redrawn": 19, - "reshaped": 0 + "error": "19 draw(s) were declined (19 error stop) to compare 10 trial(s); the trials compared are a minority of what the configured draw produces and are not evidence about the rest. Narrow the draw with `ranges`, or pin `dims`, to values the subprogram takes" }, "dogleg": { "error": "10 of 10 trial(s) were compared only after the free extent(s) lr, n were moved off the configured values; the 45 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" }, "enorm": { "bit_exact": 10, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -950,6 +947,7 @@ }, "fdjac1": { "bit_exact": 890, + "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, @@ -961,6 +959,7 @@ }, "fdjac2": { "bit_exact": 810, + "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, @@ -972,6 +971,7 @@ }, "hybrd": { "bit_exact": 1380, + "declined": {}, "integer_mismatch": 0, "integer_points": 20, "max_rel": 0.0, @@ -983,6 +983,7 @@ }, "hybrd1": { "bit_exact": 250, + "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, @@ -994,6 +995,7 @@ }, "hybrj": { "bit_exact": 1390, + "declined": {}, "integer_mismatch": 0, "integer_points": 30, "max_rel": 0.0, @@ -1005,6 +1007,7 @@ }, "hybrj1": { "bit_exact": 890, + "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, @@ -1016,6 +1019,7 @@ }, "lmder": { "bit_exact": 1390, + "declined": {}, "integer_mismatch": 0, "integer_points": 110, "max_rel": 0.0, @@ -1027,6 +1031,7 @@ }, "lmder1": { "bit_exact": 970, + "declined": {}, "integer_mismatch": 0, "integer_points": 90, "max_rel": 0.0, @@ -1038,6 +1043,7 @@ }, "lmdif": { "bit_exact": 1380, + "declined": {}, "integer_mismatch": 0, "integer_points": 100, "max_rel": 0.0, @@ -1049,6 +1055,7 @@ }, "lmdif1": { "bit_exact": 330, + "declined": {}, "integer_mismatch": 0, "integer_points": 90, "max_rel": 0.0, @@ -1060,6 +1067,9 @@ }, "lmpar": { "bit_exact": 970, + "declined": { + "NaN on both sides": 7 + }, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -1071,6 +1081,7 @@ }, "lmstr": { "bit_exact": 1390, + "declined": {}, "integer_mismatch": 0, "integer_points": 110, "max_rel": 0.0, @@ -1082,6 +1093,7 @@ }, "lmstr1": { "bit_exact": 970, + "declined": {}, "integer_mismatch": 0, "integer_points": 90, "max_rel": 0.0, @@ -1093,6 +1105,7 @@ }, "qform": { "bit_exact": 720, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -1104,6 +1117,7 @@ }, "qrfac": { "bit_exact": 960, + "declined": {}, "integer_mismatch": 0, "integer_points": 80, "max_rel": 0.0, @@ -1115,6 +1129,7 @@ }, "qrsolv": { "bit_exact": 880, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -1126,6 +1141,7 @@ }, "r1mpyq": { "bit_exact": 640, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -1140,6 +1156,7 @@ }, "rwupdt": { "bit_exact": 890, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -1318,15 +1335,18 @@ "detail": "1 translated subprogram(s) were never compared: print_msg -- defer them or drop them from the unit; silence is not a pass", "metrics": { "bit_exact": 10, + "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, "points": 10, + "redrawn": 0, "subprograms": { "is_inf": { "bit_exact": 10, + "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, @@ -1914,15 +1934,18 @@ "detail": "80 points across 1 subprogram(s), all bit-exact", "metrics": { "bit_exact": 80, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, "points": 80, + "redrawn": 0, "subprograms": { "sort": { "bit_exact": 80, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, @@ -2061,12 +2084,14 @@ "detail": "11 subprogram(s) could not be compared: dchfdv: oracle raised: ValueError: failed to create intent(cache|hide)|optional array-- must have defined dimensions but", "metrics": { "bit_exact": 0, + "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, "points": 0, + "redrawn": 0, "subprograms": { "dchfdv": { "error": "oracle raised: ValueError: failed to create intent(cache|hide)|optional array-- must have defined dimensions but got (-1,)" diff --git a/src/recast/fortran/interface.py b/src/recast/fortran/interface.py index 98b9234..6716078 100644 --- a/src/recast/fortran/interface.py +++ b/src/recast/fortran/interface.py @@ -1226,7 +1226,7 @@ def is_public(name: str) -> bool: for s in subs ] _infer_write_only_intents(subs, subprograms) - _infer_read_only_intents(subs, subprograms, sub_names) + _infer_read_only_intents(subs, subprograms, sub_names, set(state_names)) # After the inference, not before: an intent this pass just gave a # dummy is one this rule has to see. _mark_buffer_out_arrays(subprograms, every=buffer_out_arrays == "all") @@ -1449,15 +1449,22 @@ def _mark_buffer_out_arrays(records: list[dict[str, Any]], every: bool = False) argument["buffer"] = True -def _written_or_escaping(exec_part: Any, sub_names: set[str]) -> set[str]: +def _written_or_escaping( + exec_part: Any, sub_names: set[str], variables: set[str] | None = None +) -> set[str]: """Names this execution part could change, read conservatively. A name is here if it is assigned to, if it controls a DO, if a READ fills - it, if an ALLOCATE/DEALLOCATE/NULLIFY names it -- or if it is handed to - something that might write it. An intrinsic never writes its argument and - a subscript is not a call, so neither of those escapes; a reference to one - of this file's own subprograms, and anything fparser could not resolve to - either, does. + it or a WRITE takes it as the internal unit, if an ALLOCATE, DEALLOCATE, + NULLIFY or INQUIRE names it, if an ASSOCIATE takes it as a selector -- or + if it is handed to something that might write it: a CALL, a function + reference, or a parenthesised reference whose base is not a variable this + scope declares. An intrinsic never writes its argument and a subscript of + a declared array is not a call, so neither of those escapes. A reference + to a use-associated or external procedure parses exactly like a subscript, + so a base that ``variables`` does not name is taken for a call and its + variable actuals escape; with no ``variables`` given, only this file's + own subprograms are calls, as before. """ escaping: set[str] = set() @@ -1469,6 +1476,25 @@ def leftmost(node: Any) -> str | None: node = children[0] return str(node).lower() if isinstance(node, f03.Name) else None + def actuals(arguments: Any) -> list[Any]: + if arguments is None: + return [] + if type(arguments).__name__.endswith("_List"): + return list(arguments.children) + return [arguments] + + def variable_actual(item: Any) -> str | None: + """The variable an actual argument names, if the callee could write + it: a bare name, a keyword form of one, an element or component of + one. An expression is a temporary and nobody's to write.""" + if isinstance(item, f03.Actual_Arg_Spec): + return variable_actual(item.children[1]) + if isinstance(item, f03.Name): + return str(item).lower() + if isinstance(item, (f03.Part_Ref, f03.Data_Ref)): + return leftmost(item) + return None + for assignment in walk(exec_part, (f03.Assignment_Stmt, f03.Pointer_Assignment_Stmt)): name = leftmost(assignment.children[0]) if name: @@ -1479,27 +1505,62 @@ def leftmost(node: Any) -> str | None: break for statement in walk( exec_part, - (f03.Read_Stmt, f03.Allocate_Stmt, f03.Deallocate_Stmt, f03.Nullify_Stmt), + ( + f03.Read_Stmt, + f03.Allocate_Stmt, + f03.Deallocate_Stmt, + f03.Nullify_Stmt, + f03.Inquire_Stmt, + ), ): for name in walk(statement, f03.Name): escaping.add(str(name).lower()) + for statement in walk(exec_part, f03.Write_Stmt): + # ``write(buf, fmt) ...``: a character variable as the unit is the + # thing written. + for spec in walk(statement.children[0], f03.Io_Control_Spec): + key, value = spec.children + if key in (None, "UNIT") and isinstance(value, f03.Name): + escaping.add(str(value).lower()) + for association in walk(exec_part, f03.Association): + name = leftmost(association.children[2]) + if name: + escaping.add(name) for call in walk(exec_part, f03.Call_Stmt): arguments = call.children[1] for name in walk(arguments, f03.Name) if arguments is not None else []: escaping.add(str(name).lower()) + for function in walk(exec_part, f03.Function_Reference): + for item in actuals(function.children[1]): + name = variable_actual(item) + if name: + escaping.add(name) for reference in walk(exec_part, (f03.Part_Ref, f03.Structure_Constructor)): base = reference.children[0] if not isinstance(base, f03.Name): continue - if isinstance(reference, f03.Part_Ref) and str(base).lower() not in sub_names: - continue # a subscript, or a reference to something with no body here + base_name = str(base).lower() + if isinstance(reference, f03.Part_Ref) and base_name not in sub_names: + if variables is None or base_name in variables: + continue # a subscript + # Not a variable this scope declares and not a subprogram of this + # file: a use-associated or external procedure, parsed as a + # subscript. Its variable actuals are the callee's to write. + for item in actuals(reference.children[1]): + name = variable_actual(item) + if name: + escaping.add(name) + continue for name in walk(reference.children[1], f03.Name): escaping.add(str(name).lower()) return escaping def _infer_read_only_intents( - subs: list[Any], records: list[dict[str, Any]], sub_names: set[str] + subs: list[Any], + records: list[dict[str, Any]], + sub_names: set[str], + state_names: set[str] | None = None, ) -> None: """Give a dummy the body never changes the intent its use says it has. @@ -1509,6 +1570,11 @@ def _infer_read_only_intents( for anything downstream that has to know whether the value after the call is an output -- the differential gate refuses the whole subprogram over it, so one undeclared argument costs the routine its evidence. + + Read-only has to be proved, not assumed: a dummy handed to a function, to + a procedure this file does not define, to an internal WRITE or an + ASSOCIATE stays UNKNOWN, and the gate keeps refusing the routine by name + rather than comparing it with an output missing on both sides. """ by_name = {sub_name_of(s): s for s in subs} for record in records: @@ -1525,7 +1591,13 @@ def _infer_read_only_intents( exec_part = next((c for c in node.children if isinstance(c, f03.Execution_Part)), None) if exec_part is None: continue - escaping = _written_or_escaping(exec_part, sub_names) + variables = ( + {a["name"] for a in record["args"]} + | {local["name"] for local in record.get("locals") or []} + | {p["name"] for p in record.get("local_parameters") or []} + | set(state_names or ()) + ) + escaping = _written_or_escaping(exec_part, sub_names, variables) for argument in candidates: if argument["name"] not in escaping: argument["intent"] = "IN" diff --git a/src/recast/verify/bitexact.py b/src/recast/verify/bitexact.py index 41e4187..0a5095f 100644 --- a/src/recast/verify/bitexact.py +++ b/src/recast/verify/bitexact.py @@ -47,6 +47,25 @@ DEFAULT_RANGE = (-1000.0, 1000.0) DEFAULT_INTEGER_RANGE = (1, 8) + + +def _declined_summary(declined_by: dict[str, int]) -> str: + """``"3 error stop, 1 NaN on both sides"``: the declined draws by kind.""" + return ( + ", ".join(f"{count} {why}" for why, count in sorted(declined_by.items())) + or "no reason recorded" + ) + + +def _redrawn_note(totals: dict[str, Any]) -> str: + """What a passing verdict says about the draws it did not compare on.""" + redrawn = int(totals.get("redrawn") or 0) + if not redrawn: + return "" + reasons = _declined_summary(totals.get("declined") or {}) + return f"; {redrawn} draw(s) declined and drawn again ({reasons})" + + DEFAULT_DIMENSION = 8 SUPPORTED_DTYPES = frozenset({"float32", "float64", "int32", "int64", "bool"}) PROCEDURE_DTYPE = "PROCEDURE" @@ -244,8 +263,13 @@ class BitexactVerifier(Verifier): Bounded, and the bound is the point: a subprogram whose every draw is refused is reported as one that could not be compared, which is what - ``uncovered`` and the coverage gate above are for. The number of redraws - is recorded per subprogram so the count is never silent. + ``uncovered`` and the coverage gate above are for. And a subprogram whose + declined draws outnumber the trials it was compared on fails by name: + the trials that survived are a minority of what the configured draw + produces, and a translation that stops or overruns on inputs the source + accepts would pass on the handful it did not, one survivor at a time. + The number of redraws, and the reason for each, is recorded per + subprogram and repeated on the verdict, so the count is never silent. Two things a redraw is not for. A NaN on one side only is a mismatch between the sides, not a domain draw, and is counted as one; only a trial @@ -441,13 +465,15 @@ def generable(name: str) -> bool: per_subprogram: dict[str, dict[str, Any]] = {} failures: list[str] = [] worst_rel = 0.0 - totals = { + totals: dict[str, Any] = { "points": 0, "bit_exact": 0, "max_ulp": 0, "nan_mismatch": 0, "integer_points": 0, "integer_mismatch": 0, + "redrawn": 0, + "declined": {}, } for name in wanted: sub = table[name] @@ -485,6 +511,9 @@ def generable(name: str) -> bool: totals["nan_mismatch"] += outcome["nan_mismatch"] totals["integer_points"] += outcome["integer_points"] totals["integer_mismatch"] += outcome["integer_mismatch"] + totals["redrawn"] += outcome.get("redrawn", 0) + for why, count in (outcome.get("declined") or {}).items(): + totals["declined"][why] = totals["declined"].get(why, 0) + count worst_rel = max(worst_rel, outcome["max_rel"]) if "max_ulp_dominant" in outcome: totals["max_ulp_dominant"] = max( @@ -603,7 +632,7 @@ def _award( Confidence.BIT_EXACT, metrics, f"{totals['points']} points across {len(per_subprogram)} " - f"subprogram(s), all bit-exact", + f"subprogram(s), all bit-exact" + _redrawn_note(totals), ) if rtol is not None and worst_rel <= float(rtol): return self._verdict( @@ -738,6 +767,8 @@ def _compare_subprogram( dominant_points = 0 max_rel = 0.0 redrawn = 0 + # Why each declined draw was declined, by kind, so the verdict can say. + declined_by: dict[str, int] = {} # Trials that were compared only after a shape refusal moved the free # extents off the configured ones. reshaped = 0 @@ -901,6 +932,8 @@ def _compare_subprogram( # called on this draw; draw again. declined = f"candidate raised: {type(error).__name__}: {error}" reshape = reshape or isinstance(error, IndexError) + why = "subscript past extent" if isinstance(error, IndexError) else "error stop" + declined_by[why] = declined_by.get(why, 0) + 1 redrawn += 1 continue except Exception as error: @@ -1029,6 +1062,7 @@ def _compare_subprogram( staged.append(measured) if reason: declined = reason + declined_by["NaN on both sides"] = declined_by.get("NaN on both sides", 0) + 1 redrawn += 1 continue if reshape: @@ -1064,6 +1098,18 @@ def _compare_subprogram( f"values; the {points} point(s) that fit are not evidence at those extents. " "Pin `dims` to extents the subprogram takes" } + if samples is None and redrawn > len(rounds): + # More draws were declined than trials compared: the survivors + # are a minority of what the configured draw produces, and a + # candidate that stops or overruns where the source does not + # would pass on exactly that minority. Unverified at the + # configured draw, and says so by name rather than passing on it. + return { + "error": f"{redrawn} draw(s) were declined ({_declined_summary(declined_by)}) " + f"to compare {len(rounds)} trial(s); the trials compared are a minority of " + "what the configured draw produces and are not evidence about the rest. " + "Narrow the draw with `ranges`, or pin `dims`, to values the subprogram takes" + } outcome = { "points": points, "bit_exact": bit_exact, @@ -1073,6 +1119,7 @@ def _compare_subprogram( "integer_points": integer_points, "integer_mismatch": integer_mismatch, "redrawn": redrawn, + "declined": declined_by, "reshaped": reshaped, } if dominant_at is not None: diff --git a/tests/test_bitexact_draws.py b/tests/test_bitexact_draws.py index 5109ebc..00e87c8 100644 --- a/tests/test_bitexact_draws.py +++ b/tests/test_bitexact_draws.py @@ -82,8 +82,9 @@ def probe(mode, x): def test_a_draw_the_source_stops_on_is_drawn_again(tmp_path: Path) -> None: - """``mode`` is sampled from the harness's default integer range and only - two of those values are ones the subprogram takes.""" + """``mode`` is drawn from 1 to 3 and only two of those values are ones the + subprogram takes: the third is declined and drawn again, and the verdict + says how many times.""" def w_probe(mode: Any, x: Any) -> Any: # An ERROR STOP on the reference side ends the process; the harness @@ -92,9 +93,29 @@ def w_probe(mode: Any, x: Any) -> Any: assert int(mode) in (1, 2), "the reference was called on a refused draw" return x * 2.0 - verdict = judge(tmp_path, MODE, SimpleNamespace(w_probe=w_probe)) + verdict = judge(tmp_path, MODE, SimpleNamespace(w_probe=w_probe), ranges={"mode": (1, 3)}) assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail - assert verdict.metrics["subprograms"]["probe"]["redrawn"] > 0 + redrawn = verdict.metrics["subprograms"]["probe"]["redrawn"] + assert redrawn > 0 + assert verdict.metrics["subprograms"]["probe"]["declined"] == {"error stop": redrawn} + assert f"{redrawn} draw(s) declined and drawn again ({redrawn} error stop)" in verdict.detail + + +def test_a_subprogram_whose_draws_are_mostly_declined_fails_by_name(tmp_path: Path) -> None: + """``mode`` from the default integer range, 1 to 8, and the subprogram + takes two of them: three draws in four are declined. The survivors are a + minority of the configured draw, and a candidate that stopped on inputs + the source accepts would pass on that minority one survivor at a time -- + so it fails by name, with the count, the reason and the remedy.""" + verdict = judge(tmp_path, MODE, SimpleNamespace(w_probe=lambda mode, x: x * 2.0)) + assert verdict.confidence is Confidence.FAILED + detail = verdict.detail or "" + assert "probe: " in detail and "draw(s) were declined (" in detail + assert "error stop) to compare 10 trial(s)" in detail + assert "Narrow the draw with `ranges`" in detail + assert verdict.metrics["subprograms"]["probe"] == { + "error": verdict.metrics["subprograms"]["probe"]["error"] + } def test_every_draw_refused_is_a_subprogram_that_could_not_be_compared(tmp_path: Path) -> None: @@ -181,7 +202,7 @@ def test_pinned_extents_the_body_takes_are_not_redrawn(tmp_path: Path) -> None: def probe(x): with np.errstate(invalid="ignore"): - return np.sqrt(x) + return np.sqrt(x + 500.0) """ @@ -193,12 +214,34 @@ def test_a_draw_both_sides_take_to_nan_is_drawn_again(tmp_path: Path) -> None: def w_probe(x: Any) -> Any: with np.errstate(invalid="ignore"): - return np.sqrt(x) + return np.sqrt(x + 500.0) verdict = judge(tmp_path, NAN, SimpleNamespace(w_probe=w_probe)) assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail - assert verdict.metrics["subprograms"]["probe"]["redrawn"] > 0 + redrawn = verdict.metrics["subprograms"]["probe"]["redrawn"] + assert redrawn > 0 + assert verdict.metrics["subprograms"]["probe"]["declined"] == {"NaN on both sides": redrawn} assert verdict.metrics["nan_mismatch"] == 0 + assert "NaN on both sides" in verdict.detail + + +def test_a_subprogram_that_mostly_goes_to_nan_on_both_sides_fails_by_name(tmp_path: Path) -> None: + """Both sides agree on the NaN, on three draws in four. A NaN agreeing with + a NaN is not evidence, and the draws that did compare are a minority of + the configured range: the operator has to narrow the range, and the + verdict says so rather than passing on the quarter that fit.""" + + def w_probe(x: Any) -> Any: + with np.errstate(invalid="ignore"): + return np.sqrt(x - 500.0) + + verdict = judge( + tmp_path, NAN.replace("x + 500.0", "x - 500.0"), SimpleNamespace(w_probe=w_probe) + ) + assert verdict.confidence is Confidence.FAILED + detail = verdict.detail or "" + assert "draw(s) were declined (" in detail and "NaN on both sides) to compare" in detail + assert "Narrow the draw with `ranges`" in detail def test_a_nan_on_one_side_only_is_a_mismatch_not_a_redraw(tmp_path: Path) -> None: @@ -208,7 +251,7 @@ def test_a_nan_on_one_side_only_is_a_mismatch_not_a_redraw(tmp_path: Path) -> No exactly the narrowing the bound exists to prevent.""" def w_probe(x: Any) -> Any: - return np.sqrt(x) if x >= 0.0 else np.float64(0.0) + return np.sqrt(x + 500.0) if x >= -500.0 else np.float64(0.0) verdict = judge(tmp_path, NAN, SimpleNamespace(w_probe=w_probe)) assert verdict.confidence is Confidence.FAILED @@ -220,7 +263,7 @@ def test_a_draw_that_needs_no_redrawing_is_the_one_the_seed_names(tmp_path: Path """The first draw of every trial is unchanged -- same seed, same extents -- so a run that never has to redraw compares exactly what it compared before.""" - plain = NAN.replace("return np.sqrt(x)", "return x * 2.0") + plain = NAN.replace("return np.sqrt(x + 500.0)", "return x * 2.0") verdict = judge(tmp_path, plain, SimpleNamespace(w_probe=lambda x: x * 2.0)) assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail assert verdict.metrics["subprograms"]["probe"]["redrawn"] == 0 diff --git a/tests/test_fortran_analysis.py b/tests/test_fortran_analysis.py index a5c048c..9a45dbf 100644 --- a/tests/test_fortran_analysis.py +++ b/tests/test_fortran_analysis.py @@ -1264,6 +1264,43 @@ def test_a_write_only_f77_dummy_is_given_the_intent_its_use_shows(tmp_path: Path assert intents["onward"] == "UNKNOWN" # only passed on; its fate is the callee's +def test_a_dummy_handed_to_what_might_write_it_is_not_read_only(tmp_path: Path) -> None: + """Read-only is proved, not assumed. A dummy passed whole to a function + this file does not define (a use-associated one parses exactly like a + subscript), written as an internal unit, or aliased by an ASSOCIATE stays + UNKNOWN; a subscript of a declared array and an expression actual, which + is a temporary, are reads.""" + source = """ +module escapes + use elsewhere, only: g + implicit none + real, dimension(8) :: tbl +contains + subroutine rates(a, b, c, buf, d, e, out) + real :: a, b, c, d, e + integer :: buf + real :: out + real :: local(4) + local = 0.0 + out = g(a) + tbl(int(b)) + local(int(c)) + g(e + 1.0) + write(buf, '(i5)') 3 + associate (alias => d) + alias = 1.0 + end associate + end subroutine rates +end module escapes +""" + record = interface.extract(_write(tmp_path, "escapes.f90", source), kind_assumptions=KINDS) + intents = {arg["name"]: arg["intent"] for arg in record["subprograms"][0]["args"]} + assert intents["a"] == "UNKNOWN" # g is not this file's; it may write a + assert intents["b"] == "IN" # a subscript of a module array + assert intents["c"] == "IN" # a subscript of a local array + assert intents["buf"] == "UNKNOWN" # the internal unit of a WRITE + assert intents["d"] == "UNKNOWN" # assigned through its ASSOCIATE alias + assert intents["e"] == "IN" # ``e + 1.0`` is a temporary; g cannot write e + assert intents["out"] == "OUT" + + def test_a_module_allocatable_records_the_lower_bound_its_allocate_gave_it( tmp_path: Path, ) -> None: