From 614ea7b162d38f67422646c4e6eb3d4bdb5eacb8 Mon Sep 17 00:00:00 2001 From: Qinrun Dai Date: Thu, 24 Sep 2026 21:18:52 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20the=20translation=20defects=20the=20Lean?= =?UTF-8?q?=20audit=20refuted=20(FNP-D0001=E2=80=93D0050)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Evaluate a FORALL's values before storing any, and honour its mask - Write through an ASSOCIATE name to the scalar variable it selects - Fit a CHARACTER assignment to the variable's declared length - Keep a SAVEd local the body changes from one call to the next - Infer intent(out) only for a dummy every path stores, and for any data type - Round ANINT halves away from zero instead of to even - Convert INT and NINT into the INTEGER kind the source asks for - Report LBOUND and UBOUND as the bounds the subscripts are shifted by - Answer KIND, HUGE, TINY, EPSILON and PRECISION from the declared kind - Spell DIGITS and the model inquiries, and refuse an intrinsic with no rule - Hand the bit intrinsics their operands' declared width - Keep INTEGER ** a negative exponent an integer - Divide integer arrays, and map scalar-only intrinsics, elementwise - Start DOT_PRODUCT's accumulator at the operands' own zero - Spell an implied edge of a stepped section as the bound it stands for - Keep an empty section's stop edge from counting from the end - Translate RESHAPE with PAD, ORDER, and a source larger than the shape - Pack in array element order, and give PACK its VECTOR - Fill EOSHIFT's vacated end with its BOUNDARY - Locate MAXLOC/MINLOC under their MASK, first in array element order - Copy a mismatched OUT array back in storage order - Hand an assumed-size dummy the whole storage tail, partial column and all - Truncate a use-imported constant's integer quotient toward zero - Keep INTEGER / and ** inside a constant expression integer arithmetic - Spell NINT, MOD and SIGN in a constant expression as Fortran defines them - Ask HUGE and DIGITS of an integer literal about the INTEGER kind - Divide a module variable's integer initializer as integers - Start a derived type's components at their default initialization - Divide HUGE of an integer and an integer(8) quotient exactly in constants - Convert a default or saved initializer to the type it initializes - Read an INTEGER's kind from its spelling, not from the int32 it was typed - Keep a default-kind NINT's low 32 bits of the 64-bit lround, as gfortran does - Keep FLOOR's and CEILING's KIND over an array - Map an intrinsic over a zero-size array, and keep a mapped bit result's width - Hand an IN or INOUT dummy the leading storage of a larger whole array - Pass over a NaN when MAXLOC and MINLOC look for the extremum - Clamp a section's stop edge at a bound variable the body can redefine - Count an array constructor's scalars as RESHAPE's literal source size - Hand a kernel that calls a helper the Numba runtime lacks to the NumPy module - Stop a FORALL counting down one past its last index, not one short - Copy a FORALL's gathered sections before storing any - Keep a FORALL's index to its construct - Count a store through an ASSOCIATE name, a WRITE or a function as a store - Read a DO loop that can CYCLE or EXIT as writing only part of an array - Fit a CHARACTER store to its length on the CALL, component, module and array paths - Keep an un-INTENTed assumed-length CHARACTER dummy intent(inout) - Type the stop-edge, kind and constant helpers the review fixes added - Read a callee's extent expression in the caller's names - Allocate a zero-sized axis where the upper bound is below the lower one - Hold an array's lower bound from where the array came into being - Allocate an automatic array of zero size where its bounds cross - Regenerate the corpus baseline and the CI summaries for the audit fixes Signed-off-by: Qinrun Dai Carried-From: 35a8819089579f46f8156017d856b444ed8946f9 --- corpus/band_solve/verification.json | 2 +- corpus/baseline.json | 240 +++-- corpus/elm_leaf/verification.json | 4 +- corpus/toy_physics/verification.json | 2 +- src/recast/fortran/constants.py | 64 +- src/recast/fortran/expr.py | 108 ++- src/recast/fortran/interface.py | 313 ++++++- src/recast/fortran/intrinsics.py | 44 + src/recast/fortran/rwset.py | 520 +++++++++- src/recast/fortran/semantics.py | 34 + src/recast/fortran/tree.py | 15 +- src/recast/transform/numpy/constants.py | 308 +++++- src/recast/transform/numpy/expressions.py | 1042 +++++++++++++++++++-- src/recast/transform/numpy/modules.py | 219 ++++- src/recast/transform/numpy/names.py | 48 +- src/recast/transform/numpy/runtime.py | 560 +++++++++-- src/recast/transform/numpy/statements.py | 556 ++++++++++- src/recast/transform/numpy/subprograms.py | 191 +++- src/recast/transform/numpy/vocabulary.py | 13 +- src/recast/verify/rwset.py | 18 +- tests/test_fnp_assoc_review.py | 189 ++++ tests/test_fnp_bounds_review.py | 302 ++++++ tests/test_fnp_charlen_review.py | 297 ++++++ tests/test_fnp_constants.py | 343 +++++++ tests/test_fnp_forall_review.py | 223 +++++ tests/test_fnp_intrinsics.py | 613 ++++++++++++ tests/test_fnp_intrinsics_review.py | 267 ++++++ tests/test_fnp_statements.py | 722 ++++++++++++++ tests/test_fortran_analysis.py | 5 +- tests/test_numpy_modules.py | 3 +- tests/test_numpy_runtime.py | 81 +- tests/test_numpy_statements.py | 33 +- tests/test_numpy_subprograms.py | 15 +- tests/test_numpy_translate.py | 6 +- tests/test_relayed_defects.py | 6 +- 35 files changed, 6921 insertions(+), 485 deletions(-) create mode 100644 tests/test_fnp_assoc_review.py create mode 100644 tests/test_fnp_bounds_review.py create mode 100644 tests/test_fnp_charlen_review.py create mode 100644 tests/test_fnp_constants.py create mode 100644 tests/test_fnp_forall_review.py create mode 100644 tests/test_fnp_intrinsics.py create mode 100644 tests/test_fnp_intrinsics_review.py create mode 100644 tests/test_fnp_statements.py diff --git a/corpus/band_solve/verification.json b/corpus/band_solve/verification.json index bdd9df1..b493696 100644 --- a/corpus/band_solve/verification.json +++ b/corpus/band_solve/verification.json @@ -3,7 +3,7 @@ "schema": 1, "units": [ { - "candidate": "da697904ce5ebab173bff340559ba07634a068e53c8474f9a12e8fe515c7cad7", + "candidate": "25fc85a621d1a7991fe8f232399c4d4491326c45f27e34090776e8a9c0bb1498", "deferred": 0, "oracle": "f2py-golden", "stopped_by": null, diff --git a/corpus/baseline.json b/corpus/baseline.json index aa3ec01..0509a5f 100644 --- a/corpus/baseline.json +++ b/corpus/baseline.json @@ -2,8 +2,8 @@ "cases": { "bspline": { "bare_files": 0, - "blocks": 685, - "deferred": 4, + "blocks": 684, + "deferred": 5, "refusals": [ [ "size with a dim= or mask= keyword", @@ -13,6 +13,10 @@ "seq-assoc target: w is not rank-N and not at a lower bound", 1 ], + [ + "intrinsic 'X' has no spelling in this translation", + 1 + ], [ "write with IOSTAT= control", 1 @@ -65,7 +69,7 @@ "error": "18 draw(s) were declined (18 subscript past extent) 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" }, "dnrm2": { - "error": "candidate raised: NameError: name 'minexponent' is not defined" + "error": "24 draw(s) were declined (24 subscript past extent) 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" }, "drotm": { "error": "31 draw(s) were declined (31 subscript past extent) 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" @@ -138,17 +142,17 @@ "stopped_by": "differential.bitexact", "verdicts": { "differential.bitexact": { - "detail": "3 subprogram(s) could not be compared: defc: candidate raised: ValueError: cannot reshape array of size 0 into shape (0,newaxis); dfc: candidate raised: ValueEr", + "detail": "2 subprogram(s) could not be compared: dfc: candidate raised: ValueError: can only specify one unknown dimension; dcv: argument(s) w have UNKNOWN intent; this v", "metrics": { - "bit_exact": 0, + "bit_exact": 170, "declined": {}, "input_profile": null, "integer_mismatch": 0, - "integer_points": 0, + "integer_points": 10, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 0, + "points": 170, "redrawn": 0, "reference_isolation": "process", "subprograms": { @@ -156,7 +160,17 @@ "error": "argument(s) w have UNKNOWN intent; this verifier cannot know whether their post-call values are outputs" }, "defc": { - "error": "candidate raised: ValueError: cannot reshape array of size 0 into shape (0,newaxis)" + "bit_exact": 170, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 170, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 }, "dfc": { "error": "candidate raised: ValueError: can only specify one unknown dimension" @@ -207,7 +221,7 @@ } }, "fortran:bspline_sub_module": { - "deferred": 4, + "deferred": 5, "imports": true, "mechanical": false, "parses": true, @@ -221,11 +235,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "313 blocks match", + "detail": "312 blocks match", "metrics": { - "blocks_checked": 313, - "blocks_deferred": 4, - "blocks_matched": 313, + "blocks_checked": 312, + "blocks_deferred": 5, + "blocks_matched": 312, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -853,13 +867,17 @@ }, "fortran-utils": { "bare_files": 0, - "blocks": 544, - "deferred": 50, + "blocks": 538, + "deferred": 56, "refusals": [ [ "call to external subroutine 'X'", 44 ], + [ + "intrinsic 'X' has no spelling in this translation", + 6 + ], [ "cmplx(x, y): the two-part form is not spelled; cmplx(x, kind=k) is", 3 @@ -1376,7 +1394,7 @@ } }, "fortran:special": { - "deferred": 16, + "deferred": 22, "imports": true, "mechanical": false, "parses": true, @@ -1389,11 +1407,11 @@ "stopped_by": "static.rwset", "verdicts": { "static.rwset": { - "detail": "1/95 blocks disagree: sphj/B007", + "detail": "1/89 blocks disagree: sphj/B007", "metrics": { - "blocks_checked": 95, - "blocks_deferred": 16, - "blocks_matched": 94, + "blocks_checked": 89, + "blocks_deferred": 22, + "blocks_matched": 88, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1928,9 +1946,13 @@ }, "numfor": { "bare_files": 85, - "blocks": 898, - "deferred": 54, + "blocks": 856, + "deferred": 96, "refusals": [ + [ + "intrinsic 'X' has no spelling in this translation", + 45 + ], [ "generic 'X': no match", 11 @@ -1941,7 +1963,7 @@ ], [ "intrinsic subroutine 'X' has no rule", - 8 + 6 ], [ "do with a loop control that is neither a count nor a condition", @@ -1959,10 +1981,6 @@ "READ into 'X', which is not declared here", 2 ], - [ - "a keyword argument is not a subscript", - 1 - ], [ "internal READ: the unit is a character variable", 1 @@ -2199,7 +2217,7 @@ } }, "fortran:fitpack": { - "deferred": 8, + "deferred": 9, "imports": true, "mechanical": false, "parses": true, @@ -2213,11 +2231,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "124 blocks match", + "detail": "123 blocks match", "metrics": { - "blocks_checked": 124, - "blocks_deferred": 8, - "blocks_matched": 124, + "blocks_checked": 123, + "blocks_deferred": 9, + "blocks_matched": 123, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -2226,7 +2244,7 @@ } }, "fortran:fstring": { - "deferred": 3, + "deferred": 5, "imports": true, "mechanical": false, "parses": true, @@ -2240,11 +2258,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "28 blocks match", + "detail": "26 blocks match", "metrics": { - "blocks_checked": 28, - "blocks_deferred": 3, - "blocks_matched": 28, + "blocks_checked": 26, + "blocks_deferred": 5, + "blocks_matched": 26, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -2475,7 +2493,7 @@ } }, "fortran:mt95": { - "deferred": 3, + "deferred": 37, "imports": true, "mechanical": false, "parses": true, @@ -2495,184 +2513,158 @@ "bit_exact": 0, "declined": {}, "input_profile": null, - "integer_mismatch": 90, - "integer_points": 90, + "integer_mismatch": 0, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 90, + "points": 0, "redrawn": 0, "reference_isolation": "process", "subprograms": { "genrand_int31_0d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_1d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_2d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_3d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_4d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_5d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_6d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int31_7d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_0d": { - "bit_exact": 0, - "declined": {}, - "integer_mismatch": 10, - "integer_points": 10, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 10, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_1d": { - "bit_exact": 0, - "declined": {}, - "integer_mismatch": 80, - "integer_points": 80, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 80, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_2d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_3d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_4d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_5d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_6d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_int32_7d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_0d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_1d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_2d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_3d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_4d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_5d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_6d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real1_7d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_0d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_1d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_2d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_3d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_4d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_5d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_6d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real2_7d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_0d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_1d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_2d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_3d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_4d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_5d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_6d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_real3_7d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_0d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_1d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_2d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_3d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_4d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_5d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_6d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "genrand_res53_7d": { - "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" - }, - "init_by_array": { - "error": "candidate raised: NameError: name 'ibits' is not defined" - }, - "init_by_scalar": { - "error": "candidate raised: NameError: name 'ibits' is not defined" + "error": "candidate raised: NotImplementedError: intrinsic 'ibits' has no spelling in this translation" }, "init_by_type": { "error": "unsupported declared dtype(s) argument 'put'='UNKNOWN(TYPE(GENRAND_STATE))', argument 'get'='UNKNOWN(TYPE(GENRAND_STATE))'; supported dtypes are bool, complex128, complex64, float32, float64, int32, int64" @@ -2688,11 +2680,11 @@ "passed": false }, "static.rwset": { - "detail": "187 blocks match", + "detail": "153 blocks match", "metrics": { - "blocks_checked": 187, - "blocks_deferred": 3, - "blocks_matched": 187, + "blocks_checked": 153, + "blocks_deferred": 37, + "blocks_matched": 153, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -3137,7 +3129,7 @@ } }, "fortran:strings": { - "deferred": 5, + "deferred": 10, "imports": true, "mechanical": false, "parses": true, @@ -3150,11 +3142,11 @@ "stopped_by": "static.rwset", "verdicts": { "static.rwset": { - "detail": "5/107 blocks disagree: replace/B006, zarr2str/B002, dparr2str/B002, rarr2str/B002, iarr2str/B002", + "detail": "5/102 blocks disagree: replace/B006, zarr2str/B002, dparr2str/B002, rarr2str/B002, iarr2str/B002", "metrics": { - "blocks_checked": 107, - "blocks_deferred": 5, - "blocks_matched": 102, + "blocks_checked": 102, + "blocks_deferred": 10, + "blocks_matched": 97, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, diff --git a/corpus/elm_leaf/verification.json b/corpus/elm_leaf/verification.json index 2e8c521..b51a9aa 100644 --- a/corpus/elm_leaf/verification.json +++ b/corpus/elm_leaf/verification.json @@ -3,7 +3,7 @@ "schema": 1, "units": [ { - "candidate": "f3daf912d664ae2c004ea10f80639dd519d32ba65daef6620fe7626fd615d0e4", + "candidate": "a9bbdc2fe8a715947ed9193a449a2653678aba6eb9da56dce1156894d00b01fa", "deferred": 0, "oracle": "f2py-golden", "stopped_by": null, @@ -53,7 +53,7 @@ ] }, { - "candidate": "c3cf7e8b65df60152caa65016a540bf176ac667ba6945129edb4818256f46e1e", + "candidate": "b225ad755520ca42bdb2ac6ab64e95d17ac89af49690dc9d7af1a5563524b883", "deferred": 0, "oracle": "f2py-golden", "stopped_by": null, diff --git a/corpus/toy_physics/verification.json b/corpus/toy_physics/verification.json index 28ccea7..3415b11 100644 --- a/corpus/toy_physics/verification.json +++ b/corpus/toy_physics/verification.json @@ -3,7 +3,7 @@ "schema": 1, "units": [ { - "candidate": "7f20587be4e5c017cd8d781d8d40990fc87110080d5dbf1104bfae17d0b54e4c", + "candidate": "ac05cd81542b810c4ae8bc00f79bda4d79d71d23475a8b48c416951f631a1d94", "deferred": 0, "oracle": "f2py-golden", "stopped_by": null, diff --git a/src/recast/fortran/constants.py b/src/recast/fortran/constants.py index a8c4f80..9fb6ede 100644 --- a/src/recast/fortran/constants.py +++ b/src/recast/fortran/constants.py @@ -275,7 +275,23 @@ def _argument_tokens( return spelled -_KIND_INQUIRIES = frozenset({"epsilon", "huge", "tiny"}) +_KIND_INQUIRIES = frozenset({"digits", "epsilon", "huge", "tiny"}) +"""Inquiries whose argument contributes its kind and no value. + +``digits`` is one as much as the other three: DIGITS of a real is its +mantissa's width, of an integer its magnitude's, and nothing about the value +passed. It was classified as an ordinary call over its argument, so the +renderer spelled it by the call's promoted dtype and answered the double's +53 for ``digits(0)`` (FNP-D0046) as it did for a single.""" + +_INQUIRY_KINDS = { + "digits": frozenset({"float32", "float64", "int32", "int64"}), + "epsilon": frozenset({"float32", "float64"}), + "huge": frozenset({"float32", "float64", "int32", "int64"}), + "tiny": frozenset({"float32", "float64"}), +} +"""The kinds each inquiry has an answer for: EPSILON and TINY are defined +for reals only; HUGE and DIGITS for integers too.""" def _ref_token(name: str, aliases: dict[str, str] | None, kinds: Kinds) -> dict[str, Any]: @@ -415,6 +431,18 @@ def _kind_selector_dtype(stripped: list[str], kinds: Kinds) -> str | None: return None +def _integer_kind(spelling: str, kinds: Kinds) -> str | None: + """The integer dtype a kind spelling names -- ``4``/``8``, or a kind + parameter the module placed as one -- or ``None``.""" + key = spelling.strip().lower() + if key in ("4", "int32"): + return "int32" + if key in ("8", "int64"): + return "int64" + dtype = kinds.kind_map.get(key) + return dtype if dtype in ("int32", "int64") else None + + def _intrinsic_call( tokens: list[str], at: int, @@ -482,18 +510,31 @@ def _intrinsic_call( # The argument names nothing yet defined -- the constant itself, # legally -- and only its kind was ever asked for. stripped = [token for argument in kept for token in argument if token.strip()] - dtype = None + asked = None if len(stripped) == 1: token = stripped[0] if token.lower() == kinds.self_name: - dtype = kinds.self_dtype + asked = kinds.self_dtype + elif re.fullmatch(r"\d+(?:_\w+)?", token): + # An integer literal asks about an integer kind: its suffix's, + # or the default one. ``literal_kind`` reads every literal as + # a real, and ``huge(0)`` was the single's 3.4e38 where the + # compiler has 2147483647 (FNP-D0026). + asked = _integer_kind(token.split("_", 1)[1], kinds) if "_" in token else "int32" elif re.match(r"\d", token): - dtype = kinds.literal(token) + asked = kinds.literal(token) else: - dtype = kinds.dtypes.get(token.lower()) - if dtype not in ("float32", "float64"): + asked = kinds.dtypes.get(token.lower()) + if asked not in _INQUIRY_KINDS[name]: + # An integer name's record says ``int`` without the kind, and an + # inquiry of a width nobody placed has no answer. return None, end + 1 - return {"t": "call", "v": name, "args": [], "dtype": dtype}, end + 1 + # The call's own dtype is its result's; where that is not the kind + # asked about -- DIGITS is an integer whatever it asks of, HUGE of an + # integer kind an integer -- the record says which kind that was. + result = "int" if name == "digits" or asked.startswith("int") else asked + call = {"t": "call", "v": name, "args": [], "dtype": result} + return ({**call, "asked": asked} if asked != result else call), end + 1 spelled_or_none = [ _argument_tokens(argument, known_names, aliases, kinds, array_names) for argument in kept ] @@ -501,6 +542,7 @@ def _intrinsic_call( return None, end + 1 spelled = [text for text in spelled_or_none if text is not None] if name in CONVERSIONS: + dtype: str | None if saw_kind_selector: if kind_dtype is None: return None, end + 1 @@ -514,6 +556,14 @@ def _intrinsic_call( return {"t": "call", "v": name, "args": spelled, "dtype": dtype}, end + 1 if name == "int": return {"t": "call", "v": name, "args": spelled, "dtype": "int"}, end + 1 + if name == "nint": + # An integer, of the kind a trailing argument names and the default + # one without it: the renderer spells the conversion into it, and a + # kind it cannot place -- or one written as an inquiry -- refuses. + kind = "int32" if kind_argument is None else _integer_kind(kind_argument, kinds) + if kind is None or saw_kind_selector: + return None, end + 1 + return {"t": "call", "v": name, "args": spelled, "dtype": "int", "kind": kind}, end + 1 return { "t": "call", "v": name, diff --git a/src/recast/fortran/expr.py b/src/recast/fortran/expr.py index 36c1cf0..c99c40c 100644 --- a/src/recast/fortran/expr.py +++ b/src/recast/fortran/expr.py @@ -73,11 +73,13 @@ class Expr: """One node of a constant initializer. ``kind`` is ``real``, ``int``, ``str``, ``name``, ``paren``, ``unary``, - ``binary`` or ``call``. ``text`` carries the literal text, the identifier, - the operator, or the intrinsic's lower-case name. A ``str`` node's text is - the character constant's *value*, the Fortran quoting undone (``'it''s'`` - -> ``it's``), so a renderer quotes it for its own language and never - re-parses Fortran's. + ``binary`` or ``call`` -- and ``quotient`` and ``power``, which no parse + produces: ``with_integer_division`` puts them where a ``/`` is Fortran's + integer division and a ``**`` its integer power. ``text`` carries the + literal text, the identifier, the operator, or the intrinsic's lower-case + name. A ``str`` node's text is the character constant's *value*, the + Fortran quoting undone (``'it''s'`` -> ``it's``), so a renderer quotes it + for its own language and never re-parses Fortran's. """ kind: str @@ -335,6 +337,8 @@ def render( call: Callable[[str, list[str], str | None], str] | None = None, real32: Callable[[str], str] | None = None, dtype: Callable[[str], str] | None = None, + quotient: Callable[[str, str], str] | None = None, + power: Callable[[str, str], str] | None = None, ) -> str: """Fold an ``Expr`` to text, given how to spell its four kinds of atom and, optionally, an intrinsic call over already-rendered arguments. @@ -343,14 +347,19 @@ def render( a quoting that never re-parses the Fortran one. ``call`` receives the intrinsic's name, its rendered arguments and the call's own kind (``float32`` for ``real(x)``, the argument's for ``epsilon(x)``); - ``real32`` spells a single-precision literal where the target can, and - ``dtype`` spells the kind an inquiry asks about. + ``real32`` spells a single-precision literal where the target can, + ``dtype`` spells the kind an inquiry asks about, and ``quotient`` and + ``power`` spell Fortran's integer division and integer power over their + two rendered operands -- the nodes ``with_integer_division`` marks, + which a language whose ``/`` does not truncate, and whose ``**`` of a + negative exponent is a fraction, has no operator for. Grouping and spacing are fixed here so that every target language brackets the arithmetic identically. That is the whole point: two renderings of one tree can differ in how a literal is spelled and not in what is multiplied by what. A renderer given no ``call`` refuses a tree with one in it - rather than guessing a spelling. + rather than guessing a spelling, and one given no ``quotient`` or + ``power`` a node of that kind. """ if expr.kind == "real": # A single-precision literal is spelled as one when the renderer @@ -380,6 +389,8 @@ def render( call=call, real32=real32, dtype=dtype, + quotient=quotient, + power=power, ) for a in expr.args ] @@ -387,6 +398,14 @@ def render( if call is None: raise UnsupportedExpression(f"no rendering for intrinsic {expr.text!r} in this target") return call(expr.text, sub, expr.dtype) + if expr.kind == "quotient": + if quotient is None: + raise UnsupportedExpression("no rendering for an integer quotient in this target") + return quotient(sub[0], sub[1]) + if expr.kind == "power": + if power is None: + raise UnsupportedExpression("no rendering for an integer power in this target") + return power(sub[0], sub[1]) if expr.kind == "paren": return f"({sub[0]})" if expr.kind == "unary": @@ -517,13 +536,26 @@ def typed(expr: Expr, env: dict[str, str | None] | None = None) -> str | None: def with_integer_division( expr: Expr, *, default_integer: bool | None = None, env: dict[str, str | None] | None = None ) -> Expr: - """The tree with every integer ``/`` spelled ``//``. + """The tree with every integer ``/`` made a ``quotient`` node, and every + integer ``**`` whose exponent may be negative a ``power`` node. Fortran divides two integers to an integer: ``nrk = runge_kutta_type / 10`` is 4, not 4.1. A quotient whose operands are both known integers is marked; one with a name in it is typed by ``env`` (the declared types of the constants resolved so far) and otherwise falls back to ``default_integer``, which the caller sets from the whole initializer. + + Marked, not respelled: the division truncates toward zero, and Python's + ``//`` -- what this spelled it as -- floors, so ``a / 2`` over ``a = -7`` + was -4 where the compiler has -3 (FNP-D0013). How a target truncates is + the renderer's ``quotient`` to say; ``PYTHON_QUOTIENT`` is the Python + one. + + An integer raised to a negative integer is an integer too, the + reciprocal's integer part: ``2 ** (-1)`` is 0, where Python's ``**`` + answers 0.5 (FNP-D0024). Only a power whose exponent is not a literal + -- which has no sign of its own -- is marked, and only where both + operands are known integers; the rest are the ``**`` they always were. """ if default_integer is None: default_integer = typed(expr, env) != "real" @@ -532,12 +564,64 @@ def with_integer_division( args = tuple( with_integer_division(a, default_integer=default_integer, env=env) for a in expr.args ) - text = expr.text if expr.kind == "binary" and expr.text == "/": kinds = {typed(a, env) for a in args} if kinds == {"int"} or ("real" not in kinds and default_integer): - text = "//" - return Expr(expr.kind, text, args, expr.dtype) + return Expr("quotient", "/", args, expr.dtype) + if expr.kind == "binary" and expr.text == "**" and _signed(args[1]): + if {typed(a, env) for a in args} == {"int"}: + return Expr("power", "**", args, expr.dtype) + return Expr(expr.kind, expr.text, args, expr.dtype) + + +def _signed(exponent: Expr) -> bool: + """Whether an exponent may be negative: anything but a literal, which is + unsigned in the tree (``-1`` is a ``unary`` over it), bracketed or not.""" + while exponent.kind == "paren": + exponent = exponent.args[0] + return exponent.kind != "int" + + +PYTHON_QUOTIENT = "_f_int_div" +"""What a Python rendering of a marked quotient calls: ``_f_int_div(a, b)``, +defined by ``int_div`` below's source wherever the rendering is executed.""" + +PYTHON_POWER = "_f_ipow" +"""What a Python rendering of a marked integer power calls, ``int_pow``.""" + + +def int_pow(a: int, n: int) -> int: + """Fortran's integer power over Python integers, exactly. + + A negative exponent gives the integer part of the reciprocal power + (F2018 10.1.5.2.1): 0 for any base but 1 and -1, which are 1 and + +-1. Python's ``**`` answers a float there -- ``2 ** -1`` is 0.5 -- and + NumPy's integers refuse it outright. Zero to a negative power has no + value in either language, and raises. A non-negative exponent is the + ``**`` it always was. + """ + if n >= 0: + power: int = a**n + return power + if a == 0: + raise ZeroDivisionError("zero raised to a negative integer power") + if a == 1: + return 1 + if a == -1: + return 1 if n % 2 == 0 else -1 + return 0 + + +def int_div(a: int, b: int) -> int: + """Fortran's integer division over Python integers: toward zero, exactly. + + Not ``int(a / b)``: that divides in binary64 and is a different integer + once an operand passes 2**53, where ``//`` -- which this replaces for + its sign, not its magnitude -- was exact. The magnitudes' floor is the + magnitude of the truncated quotient, and the sign is the operands'. + """ + q = abs(a) // abs(b) + return q if (a < 0) == (b < 0) else -q def names_used(expr: Expr) -> list[str]: diff --git a/src/recast/fortran/interface.py b/src/recast/fortran/interface.py index 655d302..1ce84ac 100644 --- a/src/recast/fortran/interface.py +++ b/src/recast/fortran/interface.py @@ -418,23 +418,42 @@ def parse_decl_stmt(decl: Any) -> dict[str, Any]: } ) - char_len_spec: str | None = None - if base_type and base_type.upper() == "CHARACTER": - m = re.search(r"len\s*=\s*([^),]+)", str(type_spec), re.I) - if m: - char_len_spec = m.group(1).strip() - return { "base_type": base_type, "kind": kind, "intent": intent, "attrs": attrs, "entities": entities, - "char_len_spec": char_len_spec, + "char_len_spec": character_length_spec(base_type, type_spec), "line": stmt_line(decl), } +def character_length_spec(base_type: str | None, type_spec: Any) -> str | None: + """The length a CHARACTER type spec declares, as source text, or ``None``. + + Shared by variable declarations and derived-type components: a + ``character(len=8) :: nm`` component holds eight characters exactly as + a variable does, and a length recorded for one but not the other left + ``t%nm = 'ab'`` unfitted (FNP-D0021). + """ + if not base_type or base_type.upper() != "CHARACTER": + return None + m = re.search(r"len\s*=\s*([^),]+)", str(type_spec), re.I) + # ``character*8`` and ``character*(n)``, F77's spellings of the same + # length; a ``character`` with no selector at all is length 1. Left + # unrecorded, both read as "length unknown", and an assignment could + # not be fitted to a length nobody knew. + star = re.fullmatch(r"CHARACTER\s*\*\s*\(?\s*([^()]+?)\s*\)?", str(type_spec).strip(), re.I) + if m: + return m.group(1).strip() + if star: + return star.group(1).strip() + if isinstance(type_spec, f03.Intrinsic_Type_Spec) and type_spec.children[1] is None: + return "1" + return None + + def collect_decls(spec_part: Any) -> list[dict[str, Any]]: """Type declarations that belong to THIS scope: the direct children of the specification part. @@ -1025,6 +1044,7 @@ def _record_of( "dims": e["dims"], "char_len": e["char_len"] or d["char_len_spec"], "init_expr": e["init_expr"], + "save": "SAVE" in d["attrs"], "line": d["line"], } ent_info[e["name"]] = info @@ -1033,6 +1053,7 @@ def _record_of( { "name": e["name"], "dtype": info["dtype"], + "kind": info["kind"], "dims": info.get("dims"), "init_expr": e["init_expr"], "line": d["line"], @@ -1165,10 +1186,12 @@ def _record_of( ) result_dtype: str | None = None + result_kind: str | None = None result_dims: list[dict[str, Any]] | None = None if is_function: if result_name in ent_info: result_dtype = ent_info[result_name]["dtype"] + result_kind = ent_info[result_name].get("kind") result_dims = ent_info[result_name].get("dims") elif prefix is not None: # type in the prefix: `real(r8) function f(...)` for t in walk(prefix, f03.Intrinsic_Type_Spec): @@ -1177,11 +1200,32 @@ def _record_of( k = str(nm) break result_dtype = dtype_of(str(t.children[0]), k, scope_kinds) - + result_kind = k + + saved = _saved_names(spec, execution) + # The internal procedures' bodies too: a host's local they store to by + # host association is changed by the host's call as surely as by its own + # statements. + # + # A parenthesized name is a function reference where it is a procedure + # of the module or of the subprogram, or a name the scope declares + # without dimensions (an F77 ``integer incf``): anything else is an + # array element, whose subscripts are not stored to. + functions = ( + set(module_sub_names) + | {sub_name_of(f) for f in walk(sub, f03.Function_Subprogram)} + | {n for n, i in ent_info.items() if not i.get("dims")} + ) + written, stored_through = _stored_names( + [c for c in sub.children if not isinstance(c, f03.Specification_Part)], functions + ) locals_ = [ { "name": n, "dtype": i["dtype"], + # As spelled, beside the dtype: an INTEGER kind nothing resolved + # still types as ``int32``, and a kind inquiry has to tell. + "kind": i.get("kind"), "array_spec": i["array_spec"], "dims": i.get("dims"), # ``character(len=80) :: line``: the length an A edit descriptor @@ -1190,6 +1234,13 @@ def _record_of( # ``real(r8) :: x = -2._r8``: the declaration's value, which the # prologue emits instead of its UB-guard zero. "init_expr": i.get("init_expr"), + # Whether the local keeps its value from one call to the next -- + # a SAVE attribute or statement, or implied by an initializer or + # a DATA statement (F2018 8.5.16) -- and whether the body may + # change it. The two together are a local the translation has to + # keep between calls (FNP-D0014). + "save": bool(i.get("save") or i.get("init_expr") or n in saved or "" in saved), + "written": n in written, } for n, i in ent_info.items() if n not in arg_names and n != result_name and not i["parameter"] @@ -1266,6 +1317,12 @@ def _record_of( if call.children[1] is not None: state_written |= set(names_in(call.children[1])) & module_state_names other_written |= set(names_in(call.children[1])) & module_state_names + # A store to an ASSOCIATE name is a store to its selector's variable: + # ``associate (t => g); t = t + 1`` writes the module's ``g``, which + # the translation spells ``g`` and has to name on the ``global`` + # line, or the store makes it a function local. + state_written |= stored_through & module_state_names + other_written |= stored_through & module_state_names # reads in non-assignment contexts (if conditions, call arguments) state_read |= (used & module_state_names) - state_written # Overwritten: every occurrence is the target of a top-level @@ -1306,6 +1363,7 @@ def _record_of( "prefixes": prefixes, "result": result_name if is_function else None, "result_dtype": result_dtype, + "result_kind": result_kind, "result_dims": result_dims, "line_span": list(node_span(sub)), "args": args, @@ -1458,7 +1516,8 @@ def _derived_types( mod_spec: Any, kind_map: dict[str, str], scope: Any = None, visible: set[str] | None = None ) -> dict[str, Any]: """``{type_name: {component: {dtype, dims, allocatable, pointer}}}``, - plus ``allocated_dims`` on a component whose ALLOCATE re-bases it.""" + plus ``allocated_dims`` on a component whose ALLOCATE re-bases it and + ``init`` on one the type initializes by default.""" types: dict[str, Any] = {} if mod_spec is None: return types @@ -1491,6 +1550,9 @@ def _derived_types( cname = str(ent.children[0]).lower() comps[cname] = { "dtype": dtype_of(base, kind, kind_map), + # As spelled: an INTEGER kind nothing resolved still types + # as ``int32``, and a kind inquiry has to tell the two. + "kind": kind, # The entity's own shape wins where it has one: Fortran # lets ``dimension(4) :: a, b(7)`` give ``b`` a different # one from the attribute's. @@ -1498,6 +1560,19 @@ def _derived_types( "allocatable": "ALLOCATABLE" in attrs, "pointer": "POINTER" in attrs, } + length = ent.children[2] + length_spec = character_length_spec(base, tspec) + if length is not None or length_spec is not None: + comps[cname]["char_len"] = str(length) if length is not None else length_spec + initialization = ent.children[3] + if initialization is not None: + # The default initialization every object of the type + # starts with (``real(r8) :: tol = 1.0d-6``), as source + # text like a module variable's; ``=> null()`` is a + # pointer's. Without it the factory started ``tol`` at + # 0.0, where Fortran starts it at 1.0e-6 (FNP-D0030). + operator, value = initialization.children + comps[cname]["init"] = "null()" if operator == "=>" else str(value) if cname in allocated: comps[cname]["allocated_dims"] = allocated[cname] if tname: @@ -1713,6 +1788,10 @@ def _extract( # (ELM's ``elm_varsur`` arrays are pointers). "pointer": "POINTER" in d["attrs"], } + # The length a module CHARACTER variable is fitted to, as a + # subprogram's own is (FNP-D0021); only CHARACTER carries one. + if e["char_len"] or d["char_len_spec"]: + rec["char_len"] = e["char_len"] or d["char_len_spec"] if "PARAMETER" in d["attrs"]: module_parameters.append(rec) else: @@ -2081,18 +2160,167 @@ def emit_name(record: dict[str, Any]) -> str: return str(record.get("emit_name") or record["name"]) +def _saved_names(spec: Any, execution: Any) -> set[str]: + """The names a SAVE statement or a DATA statement of this scope names. + + ``""`` stands for a bare ``SAVE``, which saves every local. A DATA object + is saved by being initialized (F2018 8.6.7): the array of an element or + an implied-DO, not the implied-DO's own index. + """ + saved: set[str] = set() + for statement in walk(spec, f03.Save_Stmt) if spec is not None else []: + entities = statement.children[1] + if entities is None: + saved.add("") + else: + saved.update(str(n).lower() for n in walk(entities, f03.Name)) + for part in (spec, execution): + for data in walk(part, f03.Data_Stmt) if part is not None else []: + for objects in walk(data, f03.Data_Stmt_Object_List): + saved.update(str(r.children[0]).lower() for r in walk(objects, f03.Part_Ref)) + saved.update( + str(item).lower() + for item in getattr(objects, "children", ()) + if isinstance(item, f03.Name) + ) + return saved + + +def _stored_names(parts: list[Any], functions: set[str]) -> tuple[set[str], set[str]]: + """What a body may store to, for the locals record (``written``), and + what it stores to through an ASSOCIATE name, for its module state. + + ``recast.fortran.rwset.written_names`` and ``written_through_names``, the + over-approximation that also decides where an ASSOCIATE name is written + through; imported here rather than at the top because that module reads + this one's records. ``functions`` are the names a parenthesized + reference calls: ``z = incf(cnt)`` changes a SAVEd ``cnt`` through an + ``intent(inout)`` dummy as surely as a CALL would, and read as neither, + ``cnt`` was re-initialized at every call. + """ + from recast.fortran.rwset import written_names, written_through_names + + return written_names(parts, functions), written_through_names(parts, functions) + + def _intent_inferable(dtype: Any) -> bool: - """Numeric and logical scalars, the kinds an intent can be inferred for. + """Every kind of data a dummy can be: numeric, logical, character, + complex and derived types -- the kinds an intent can be inferred for. A REAL whose kind name did not resolve is still a REAL scalar: excluding it left exactly the F77 sources this rule exists for -- the ones that - spell their kind through a module parameter -- without an intent. + spell their kind through a module parameter -- without an intent. The + rule once stopped at numeric and logical, and a CHARACTER or derived-type + dummy the body assigned stayed UNKNOWN, which the return convention reads + as "pass it in, never hand it back": ``s = 'hello'`` in a routine with no + declared intent reached no caller (FNP-D0047). A dummy procedure, or a + name no declaration typed, is still not data this can reason about. """ - return dtype in ("float64", "float32", "int32", "int64", "bool") or str(dtype).startswith( - "UNKNOWN_REAL_KIND(" + return str(dtype) not in ("PROCEDURE", "UNDECLARED", "None") and not str(dtype).startswith( + "UNKNOWN(PROCEDURE" ) +JUMPS = ( + f03.Return_Stmt, + f03.Goto_Stmt, + f03.Computed_Goto_Stmt, + f03.Arithmetic_If_Stmt, +) +"""Statements that can leave a body, or skip ahead in it, before a later +statement runs.""" + + +def _internal_unit(write: Any) -> str | None: + """The variable a WRITE names as its unit, which is the variable it writes + when that is a CHARACTER one; ``None`` for ``*`` or a number.""" + unit = _unit_of(write) + return str(unit).lower() if isinstance(unit, f03.Name) else None + + +def _unit_of(write: Any) -> Any: + """The unit a WRITE names, as it is spelled.""" + for spec in walk(write.children[0], f03.Io_Control_Spec): + keyword, value = spec.children + if keyword is None or str(keyword).upper() == "UNIT": + return value + return None + + +def _definitely_written(node: Any, name: str, dims: list[dict[str, Any]], callees: Any) -> bool: + """Whether every path through a body stores the whole of ``name`` before + it can return. + + What ``intent(out)`` needs of a dummy the source left undeclared: the + return convention discards the caller's value and hands back whatever + the body left, so a path that leaves the dummy alone hands back the + prologue's zero where Fortran, passing by reference, left the caller's + value in place. ``if (x < 0d0) ierr = 1`` is only a conditional write + (FNP-D0031). Answered for the body's own statements in order: a store + of the whole dummy at the top level -- an assignment to it, one to its + elements in DO loops over its declared bounds, a CALL handing it whole + to an ``intent(out)`` dummy -- settles it, unless a RETURN or a jump in + that statement or an earlier one can get past it first. Anything + subtler (both arms of an IF writing it) is not a definite write here, + and the dummy stays ``intent(inout)``, which is never wrong. + """ + for exec_part in (c for c in node.children if isinstance(c, f03.Execution_Part)): + for statement in exec_part.children: + if _stores_whole(statement, name, dims, callees) and not walk(statement, JUMPS): + return True + if walk(statement, JUMPS): + return False + return False + + +def _stores_whole(statement: Any, name: str, dims: list[dict[str, Any]], callees: Any) -> bool: + """Whether a top-level statement certainly stores the whole of ``name``.""" + if isinstance(statement, f03.Assignment_Stmt): + target = statement.children[0] + if isinstance(target, f03.Part_Ref): + # ``whole(:) = 1.0``: a section spanning the declared bounds. + return ( + bool(dims) + and str(target.children[0]).lower() == name + and _writes_whole_array(target, dims) + ) + return isinstance(target, f03.Name) and str(target).lower() == name + if isinstance(statement, f03.Write_Stmt): + # Counted as a store only where the unit is a CHARACTER dummy, which + # is the only way a WRITE reaches this question. + return _internal_unit(statement) == name + if isinstance(statement, f03.Call_Stmt): + dummies = callees.get(str(statement.children[0]).lower()) + if dummies is None: + return False + arguments = statement.children[1] + items = ( + list(arguments.children) + if arguments is not None and type(arguments).__name__.endswith("_List") + else ([arguments] if arguments is not None else []) + ) + for position, item in enumerate(items): + if isinstance(item, f03.Actual_Arg_Spec): + keyword = str(item.children[0]).lower() + intent = next((i for d, i in dummies if d == keyword), None) + item = item.children[1] + else: + intent = dummies[position][1] if position < len(dummies) else None + if isinstance(item, f03.Name) and str(item).lower() == name and intent == "OUT": + return True + return False + if dims and isinstance( + statement, (f03.Block_Nonlabel_Do_Construct, f03.Block_Label_Do_Construct) + ): + return any( + isinstance(a.children[0], f03.Part_Ref) + and str(a.children[0].children[0]).lower() == name + and _writes_whole_array(a.children[0], dims) + for a in walk(statement, f03.Assignment_Stmt) + ) + return False + + def _infer_domains(subs: list[Any], records: list[dict[str, Any]]) -> None: """The values an integer IN scalar may take, where the source says so. @@ -2185,6 +2413,9 @@ def _infer_write_only_intents(subs: list[Any], records: list[dict[str, Any]]) -> dummy only *passed on* to a procedure this file does not describe stays UNKNOWN: its fate is the callee's, and is not decidable here. """ + # Imported here: ``recast.fortran.rwset`` reads this module's records. + from recast.fortran.rwset import store_chain + by_name = {sub_name_of(s): s for s in subs} intents_of = _callee_intents(records) for record in records: @@ -2208,6 +2439,7 @@ def _infer_write_only_intents(subs: list[Any], records: list[dict[str, Any]]) -> assigned: dict[str, int] = {} partial: set[str] = set() # arrays some write leaves partly unwritten dims_of = {a["name"]: a.get("dims") or [] for a in candidates} + characters = {a["name"] for a in candidates if a.get("dtype") == "str"} def base_of(node: Any) -> str | None: while node is not None and not isinstance(node, f03.Name): @@ -2217,6 +2449,23 @@ def base_of(node: Any) -> str | None: node = children[0] return str(node).lower() if isinstance(node, f03.Name) else None + def stored(target: Any, partial: set[str] = partial) -> str | None: + """The variable a store to ``target`` changes: the name at its + base, or -- where that is an ASSOCIATE name -- its selector's. + + ``associate (t => x); t = 5`` stores to ``x``. Counted as a store + to ``t``, the dummy ``x`` had none, stayed UNKNOWN, and was + passed in and never handed back (FNP-D0047 through an associate + name). Through a name the store is never a top-level one, and + the name's own reads are not ``x``'s mentions, so it counts as + partial: ``intent(inout)``, which is never wrong. + """ + chain = store_chain(target) + if len(chain) > 1: + partial.add(chain[-1]) + return chain[-1] + return base_of(target) + for exec_part in exec_parts: for name in walk(exec_part, f03.Name): mentioned = str(name).lower() @@ -2225,14 +2474,28 @@ def base_of(node: Any) -> str | None: # ``x = ``, ``buf(1) = ``, ``buf(:) = ``: the name at the base # of the target is what the statement writes. target = assignment.children[0] - written_name = base_of(target) + written_name = stored(target) if written_name is None: continue assigned[written_name] = assigned.get(written_name, 0) + 1 - if written_name in dims_of and not _writes_whole_array( + if isinstance(target, f03.Data_Ref) or ( + isinstance(target, f03.Part_Ref) and not dims_of.get(written_name) + ): + # A component of a structure, a substring of a string: + # the rest of it is still the caller's. + partial.add(written_name) + elif written_name in dims_of and not _writes_whole_array( target, dims_of[written_name] ): partial.add(written_name) + for write in walk(exec_part, f03.Write_Stmt): + # ``write (s, '(i0)') n`` assigns the internal unit, whole -- + # a CHARACTER one; an integer unit is a unit number. + unit = _internal_unit(write) + if unit is not None: + unit = stored(_unit_of(write)) + if unit is not None and unit in characters: + assigned[unit] = assigned.get(unit, 0) + 1 for call in walk(exec_part, f03.Call_Stmt): callee = base_of(call.children[0]) dummies = intents_of.get(callee or "") @@ -2253,7 +2516,7 @@ def base_of(node: Any) -> str | None: intent = dummies[position][1] if position < len(dummies) else None if not isinstance(item, (f03.Name, f03.Part_Ref)): continue - actual = base_of(item) + actual = stored(item) if actual is None or intent not in ("OUT", "INOUT"): continue assigned[actual] = assigned.get(actual, 0) + 1 @@ -2273,6 +2536,18 @@ def base_of(node: Any) -> str | None: # rows; a scalar write is always whole. if argument["name"] in partial: write_only = False + # An assumed-length CHARACTER dummy's length is the actual's, + # and only a dummy passed in carries it: inferred OUT, ``s = + # 'hey'`` stored three characters and ``len(s)`` in the callee + # was 3 where gfortran's is the caller's 8. + if argument.get("dtype") == "str" and str(argument.get("char_len")).strip() == "*": + write_only = False + if write_only and not _definitely_written( + node, argument["name"], dims_of[argument["name"]], intents_of + ): + # Written, never read -- but not on every path: the caller's + # value is what the others leave behind. + write_only = False argument["intent"] = "OUT" if write_only else "INOUT" argument["intent_inferred"] = True @@ -2321,6 +2596,12 @@ def flat(node: Any) -> str: # rule does not read arms, and says the conservative thing.) return False if isinstance(parent, (f03.Block_Nonlabel_Do_Construct, f03.Block_Label_Do_Construct)): + if walk(parent, (f03.Cycle_Stmt, f03.Exit_Stmt)): + # ``if (i == 2) cycle`` skips an element, ``if (i > 2) exit`` + # the rest of them: read as whole, the dummy was inferred + # ``intent(out)`` and the caller's unwritten elements came + # back as the fresh buffer's zeros (FNP-D0031 in a loop). + return False control = walk(parent, f03.Loop_Control) if control and control[0].children[1] is not None: variable, bounds = control[0].children[1] diff --git a/src/recast/fortran/intrinsics.py b/src/recast/fortran/intrinsics.py index ae06c85..72fcfd9 100644 --- a/src/recast/fortran/intrinsics.py +++ b/src/recast/fortran/intrinsics.py @@ -34,6 +34,7 @@ "asin", "atan", "atan2", + "bit_size", "c_loc", "ceiling", "char", @@ -46,6 +47,7 @@ "dble", "dcos", "dexp", + "digits", "dim", "dlog", "dlog10", @@ -82,14 +84,17 @@ "log10", "max", "max0", + "maxexponent", "min", "min0", + "minexponent", "mod", "modulo", "mvbits", "nint", "precision", "radix", + "range", "real", "scan", "shape", @@ -210,5 +215,44 @@ else's missing library. """ +STANDARD_FUNCTIONS = frozenset( + { + # F2018 Table 16.1, the intrinsic functions (not the subroutines). + *"""abs achar acos acosh adjustl adjustr aimag aint all allocated anint + any asin asinh associated atan atan2 atanh bessel_j0 bessel_j1 + bessel_jn bessel_y0 bessel_y1 bessel_yn bge bgt bit_size ble blt btest + ceiling char cmplx command_argument_count conjg cos cosh coshape count + cshift dble digits dim dot_product dprod dshiftl dshiftr eoshift + epsilon erf erfc erfc_scaled exp exponent extends_type_of findloc floor + fraction gamma huge hypot iachar iall iand iany ibclr ibits ibset ichar + ieor image_index image_status index int ior iparity is_contiguous + is_iostat_end is_iostat_eor ishft ishftc kind lbound lcobound leadz len + len_trim lge lgt lle llt log log10 log_gamma logical maskl maskr matmul + max maxexponent maxloc maxval merge merge_bits min minexponent minloc + minval mod modulo nearest new_line nint norm2 not null num_images + out_of_range pack parity popcnt poppar precision present product radix + range rank real reduce repeat reshape rrspacing same_type_as scale scan + selected_char_kind selected_int_kind selected_real_kind set_exponent + shape shifta shiftl shiftr sign sin sinh size spacing spread sqrt + storage_size sum tan tanh team_number this_image tiny trailz transfer + transpose trim ubound ucobound unpack verify""".split(), + # The FORTRAN 77 specific names still in the standard. + *"""alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin + csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog + dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float + iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl""".split(), + } +) +"""Every intrinsic function the standard defines, whether or not anything +here spells it. + +Not part of ``ALL`` and not consulted by the analysis, for the reason the +module docstring gives: ``ALL`` is what the sources call and a translation +exists for. This set is the other question -- is ``name(...)`` the standard's +function at all -- which is what lets the emitter refuse ``digits(x)`` rather +than read a function it has no spelling for as a subscript of an array +nobody declared (FNP-D0027). +""" + ALL = ELEMENTAL | TRANSFORMATIONAL | STATE_QUERY | LOCATION | RESHAPING """Every name this frontend recognises as an intrinsic rather than a symbol.""" diff --git a/src/recast/fortran/rwset.py b/src/recast/fortran/rwset.py index 4fe7385..edc4ccb 100644 --- a/src/recast/fortran/rwset.py +++ b/src/recast/fortran/rwset.py @@ -24,6 +24,7 @@ from __future__ import annotations import re +from collections.abc import Collection from dataclasses import dataclass, field from typing import Any @@ -31,7 +32,12 @@ from recast.fortran.chunk import chunk_subprogram from recast.fortran.intrinsics import ALL as INTRINSICS from recast.fortran.intrinsics import STATE_QUERY, TRANSFORMATIONAL -from recast.fortran.semantics import Semantics, Unanalyzable, for_subprogram +from recast.fortran.semantics import ( + DERIVED_TYPE_MARKER, + Semantics, + Unanalyzable, + for_subprogram, +) KIND_ARG_FNS = frozenset( {"real", "dble", "int", "nint", "aint", "anint", "floor", "ceiling", "cmplx", "float"} @@ -249,6 +255,374 @@ def _bare_section(item: Any) -> bool: return isinstance(item, f03.Subscript_Triplet) and all(c is None for c in item.children) +def _designator_base(designator: Any) -> str | None: + """The variable a designator names: ``a`` of ``a``, ``a(i)``, ``a%b(i)``.""" + while isinstance(designator, (f03.Part_Ref, f03.Data_Ref)): + designator = designator.children[0] + return str(designator).lower() if isinstance(designator, f03.Name) else None + + +def written_names(statements: Any, functions: Collection[str] = ()) -> set[str]: + """Every variable a statement list may store to, by the name at its base. + + Assignment targets, the actuals of every CALL (whatever the callee does + with them) and of every reference to one of ``functions``, the unit of a + WRITE (an internal one assigns it), READ items, DO variables, ALLOCATE + and DEALLOCATE objects. Over-approximate on purpose: it decides where an + ASSOCIATE name has to be written through to its selector, and which + SAVEd locals outlive a call, and a name wrongly left out of it is a + write the translation drops. + + A store to an ASSOCIATE name is a store to its selector's variable as + well, and both are counted: ``associate (t => cnt); t = t + 1`` changes + ``cnt``, and counted as a store to ``t`` alone, the SAVEd ``cnt`` was + re-initialized at every call (FNP-D0014 through an associate name). A + parenthesized name is only a function reference where the caller says + so -- this has no declarations, and ``a(i)`` is an element far more + often -- which is what ``functions`` is for. + """ + return {name for target in _stores(statements, functions) for name in store_chain(target)} + + +def written_through_names(statements: Any, functions: Collection[str] = ()) -> set[str]: + """The variables a statement list stores to through an ASSOCIATE name: + ``g`` of ``associate (t => g); t = t + 1``. + + What a body's own targets do not say. The frontend's module-state + record read ``t`` there, found no module variable, and left ``g`` off + the subprogram's ``global`` line, so the respelled ``g = (g + 1.0)`` + made ``g`` a function local and raised ``UnboundLocalError``. + """ + return {name for target in _stores(statements, functions) for name in store_chain(target)[1:]} + + +def store_chain(designator: Any) -> list[str]: + """The name a store's target is at its base, then -- where that is an + associate name -- its selector's variable, and so on outwards.""" + chain: list[str] = [] + base, node = _designator_base(designator), designator + while base is not None: + chain.append(base) + construct = associated_by(node, base) + if construct is None: + break + base, node = _designator_base(associations(construct)[base]), construct + return chain + + +def _stores(statements: Any, functions: Collection[str]) -> list[Any]: + """The targets ``written_names`` reads, as designators.""" + stores: list[Any] = [] + add = stores.append + for statement in statements: + for assignment in walk(statement, (f03.Assignment_Stmt, f03.Pointer_Assignment_Stmt)): + add(assignment.children[0]) + for call in walk(statement, f03.Call_Stmt): + for item in _subscript_items(call.children[1]): + add(item.children[1] if isinstance(item, f03.Actual_Arg_Spec) else item) + for reference in walk(statement, (f03.Part_Ref, f03.Function_Reference)): + if str(reference.children[0]).lower() not in functions: + continue + for item in _subscript_items(reference.children[1]): + item = item.children[1] if isinstance(item, f03.Actual_Arg_Spec) else item + if isinstance(item, (f03.Name, f03.Part_Ref, f03.Data_Ref)): + add(item) + for write in walk(statement, f03.Write_Stmt): + for spec in walk(write.children[0], f03.Io_Control_Spec): + keyword, value = spec.children + if keyword is None or str(keyword).upper() == "UNIT": + add(value) + break + for read in walk(statement, f03.Read_Stmt): + items = read.children[2] if len(read.children) > 2 else None + for name in walk(items, f03.Name): + add(name) + for control in walk(statement, f03.Loop_Control): + if control.children[1] is not None: + add(control.children[1][0]) + for allocation in walk(statement, (f03.Allocation, f03.Deallocate_Stmt)): + if isinstance(allocation, f03.Allocation): + add(allocation.children[0]) + else: + for name in walk(allocation, f03.Name): + add(name) + return stores + + +def associations(construct: Any) -> dict[str, Any]: + """An ASSOCIATE construct's own associations: name -> selector.""" + return { + str(alias).lower(): selector + for alias, _, selector in (a.children for a in walk(construct.children[0], f03.Association)) + } + + +def associated_by(node: Any, name: str) -> Any | None: + """The innermost ASSOCIATE construct around ``node`` that makes ``name`` + an associate name, or ``None`` where ``name`` is the variable itself. + + A nested construct's name shadows an outer one's; a selector is read in + the scope outside its own construct, so resolving one goes on from the + construct's parent, not from the selector. + """ + parent = getattr(node, "parent", None) + while parent is not None: + if isinstance(parent, f03.Associate_Construct) and name in associations(parent): + return parent + parent = getattr(parent, "parent", None) + return None + + +def written_through(construct: Any, semantics: Semantics | None) -> dict[str, Any]: + """The associations of an ASSOCIATE construct that are spelled as their + selector: associate name -> selector. + + ``associate (t => x)`` makes ``t`` another name for the variable ``x``: + a store to ``t`` is a store to ``x`` and a store to ``x`` changes ``t``. + Bound once as a Python local, ``t = x``, the two drift apart the moment + either is written -- ``t = t + 1`` rebound the local and left ``x`` as it + was (FNP-D0015). So where the body writes the name or its variable, the + translation spells the name as the selector throughout the body instead, + and this function is the one place that decides where: the emitter and + the read/write sets above have to draw the line identically. + + Only a scalar *variable* is spelled that way -- a name, an array element, + a structure component -- and only when nothing the selector's subscripts + read is written in the body, because a selector is evaluated once, at + the ASSOCIATE, and a respelled one is evaluated wherever it is read. An + array selector binds a NumPy view, which writes through already. + """ + if semantics is None or not construct.children: + return {} + body = [ + child + for child in construct.children + if not isinstance(child, (f03.Associate_Stmt, f03.End_Associate_Stmt)) + ] + written = written_names(body) + parameters = {p["name"] for p in semantics.subprogram.get("local_parameters") or ()} | { + p["name"] for p in semantics.module.get("module_parameters") or () + } + found: dict[str, Any] = {} + for association in walk(construct.children[0], f03.Association): + alias, _, selector = association.children + name, base = str(alias).lower(), _designator_base(selector) + if base is None or (name not in written and base not in written): + continue + if base in parameters or not scalar_variable(selector, semantics): + continue + subscripts = [ + part.children[1] + for part in [selector, *walk(selector, f03.Part_Ref)] + if isinstance(part, f03.Part_Ref) + ] + if any(str(n).lower() in written for s in subscripts for n in walk(s, f03.Name)): + continue + found[name] = selector + return found + + +def scalar_variable(designator: Any, semantics: Semantics) -> bool: + """Whether a designator certainly names one scalar variable. + + A declared scalar, an element of a declared array, or a structure + component the type record says is scalar -- or an element of one it + says is an array. ``Semantics.rank`` reads a bare component as scalar + whatever its type declares, which is the right default for an + expression and the wrong one here: ``associate (cp => inst%cp)`` over an + array component is a view, and respelling it would be harmless while + refusing it would not. + """ + base = _designator_base(designator) + declared = semantics.declaration(base) if base is not None else None + if declared is None or declared.get("procedure"): + return False + if isinstance(designator, f03.Name): + return not declared.get("dims") + if isinstance(designator, f03.Part_Ref): + if not semantics.is_array(base or ""): + return False # a function reference, or a substring + elif isinstance(designator, f03.Data_Ref): + last = designator.children[-1] + if len(designator.children) != 2 or declared.get("dims"): + return False + component = str(last.children[0] if isinstance(last, f03.Part_Ref) else last).lower() + match = re.match(r"UNKNOWN\(TYPE\((\w+)\)\)", str(declared.get("dtype", "")), re.I) + record = semantics.types.get(match.group(1).lower(), {}).get(component) if match else None + if not record: + return False + dims = record.get("allocated_dims") or record.get("dims") + if isinstance(last, f03.Name): + return not dims + if not dims: + return False # a substring of a character component + else: + return False + try: + return semantics.rank(designator) == 0 + except Unanalyzable: + return False + + +LENGTH_TEXT = re.compile(r"(?!.*[A-Za-z_]\w*\s*\()[\w\s+\-*/()]+") +"""A declared CHARACTER length made of names, numbers and arithmetic only: +``len=len(s)`` or ``len=max(n, 1)`` is computed where the variable is +created, which nothing redoes.""" + + +def declared_length(name: str, semantics: Semantics | None) -> str | None: + """The length a CHARACTER variable is fitted to on assignment, as source + text: digits, arithmetic over named constants, or ``*`` for an assumed + length the variable carries in; ``None`` for no fitting. + + ``*`` is only an answer for a scalar dummy the caller passes in -- an + ``intent(out)`` one never reaches the callee under the return + convention -- and a deferred length ``len=:`` is whatever was last + stored. The emitter spells the answer and the read/write sets count + what it reads, so both take it from here (FNP-D0021). + """ + declared = semantics.declaration(name) if semantics is not None else None + if declared is None or declared.get("dtype") != "str": + return None + length = str(declared.get("char_len") or "").strip() + if length.isdigit(): + return length + if length == "*": + arguments = semantics.subprogram["args"] if semantics is not None else [] + argument = next((a for a in arguments if a["name"] == name), None) + if argument is None or argument["intent"] == "OUT" or declared.get("dims"): + return None + return length + if not length or length == ":" or not LENGTH_TEXT.fullmatch(length): + return None + depth = 0 + for character in length: + depth += {"(": 1, ")": -1}.get(character, 0) + if depth < 0: + return None + return length if depth == 0 else None + + +def fitted_length(target: Any, value: Any, semantics: Semantics | None) -> str | None: + """``declared_length`` of what a store to ``target`` fits: a CHARACTER + scalar, an element, section or the whole of a CHARACTER array, or a + CHARACTER component of a derived-type object. A substring is none of + those, nor is an internal WRITE (``value`` ``None``) to an array, whose + records are its elements. + + Whether the value is fitted as one string or element by element is the + emitter's, from the value's rank: ``a = b`` of a ``len=2`` ``b`` into a + ``len=4`` ``a`` pads every element, where the whole-array store from a + scalar was the only array store fitted and ``a(1) // '|'`` was ``xy|`` + for gfortran's ``xy |``.""" + if semantics is None: + return None + if isinstance(target, f03.Data_Ref): + return _component_length(target, semantics) + if not isinstance(target, (f03.Name, f03.Part_Ref)): + return None + name = _designator_base(target) + declared = semantics.declaration(name) if name is not None else None + if name is None or declared is None: + return None + dims = declared.get("dims") + if isinstance(target, f03.Part_Ref) and not dims: + return None # a substring of a scalar + try: + rank = semantics.rank(target) + if value is not None: + semantics.rank(value) + except Unanalyzable: + return None + if dims and value is None and rank != 0: + # An internal WRITE to a whole array or a section. One to an + # element is one record, and fills it: ``associate (c => a(2)); + # write (c, '(i3)') 42`` is `` 42 `` in a ``len=6`` ``a``. + return None + return declared_length(name, semantics) + + +def _component_length(target: Any, semantics: Semantics) -> str | None: + """The declared length of the CHARACTER component ``t%nm`` names, or of + an element or the whole of a CHARACTER array component, found through + the types the frontend recorded. + + A component holds its declared length as a variable does: gfortran's + ``len(t%nm)`` of a ``character(len=8) :: nm`` is 8 after ``t%nm = 'ab'``, + where the store was never fitted and the Python said 2 (FNP-D0021). + Only a length of digits or of named constants is one: a type parameter + is not known here. A component reached through an array of objects is + left alone, as is anything whose type this file does not describe. + """ + parts = list(target.children) + base = _designator_base(parts[0]) + if base is None or len(parts) < 2: + return None + declared = semantics.declaration(base) + type_name = semantics.derived_type_of(base) + if declared is None or type_name is None or declared.get("dims"): + return None + record: dict[str, Any] | None = None + for position, part in enumerate(parts[1:], start=1): + name = part.children[0] if isinstance(part, f03.Part_Ref) else part + if not isinstance(name, f03.Name) or type_name is None: + return None + record = semantics.types.get(type_name, {}).get(str(name).lower()) + if record is None: + return None + last = position == len(parts) - 1 + if not last: + if record.get("dims"): + return None + match = DERIVED_TYPE_MARKER.match(str(record.get("dtype") or "")) + type_name = match.group(1).lower() if match else None + elif isinstance(part, f03.Part_Ref) and not record.get("dims"): + return None # a substring of a scalar component + if record is None or record.get("dtype") != "str": + return None + length = str(record.get("char_len") or "").strip() + if length.isdigit(): + return length + names = re.findall(r"[A-Za-z_]\w*", length) + if ( + not names + or not LENGTH_TEXT.fullmatch(length) + or not all(n.lower() in semantics.parameters for n in names) + ): + return None + return length + + +def returned_length(formal: dict[str, Any], actual: Any, semantics: Semantics | None) -> str | None: + """``fitted_length`` of the caller's ``actual`` that what an + ``intent(out)`` ``len=*`` dummy hands back is fitted to, or ``None``. + + The dummy has the actual's length, and the return convention never + hands that length in: the callee's ``s = 'hello'`` came back as five + characters, and a caller's ``character(len=8)`` bound to it had ``len`` + 5 where gfortran has 8 (FNP-D0021). A dummy of any other intent was + passed the caller's value, and fitted to its length in the callee. + """ + if formal.get("intent") != "OUT" or str(formal.get("char_len") or "").strip() != "*": + return None + return fitted_length(actual, None, semantics) + + +def fitted_length_reads(target: Any, value: Any, semantics: Semantics | None) -> set[str]: + """What fitting a store to ``target`` reads: the named constants of its + length, or -- an assumed length -- the variable's own.""" + return length_reads(fitted_length(target, value, semantics), target) + + +def length_reads(length: str | None, target: Any) -> set[str]: + """What fitting to ``length``, a store to ``target``, reads.""" + if length is None: + return set() + if length == "*": + return {str(_designator_base(target))} + return {token.lower() for token in re.findall(r"[A-Za-z_]\w*", length)} + + def bound_reads(dims: Any, subscripts: list[Any] | None = None) -> set[str]: """The names a subscript list reads through the declared lower bounds of the axes it indexes. @@ -281,15 +655,71 @@ def bound_reads(dims: Any, subscripts: list[Any] | None = None) -> set[str]: def lower_bound_reads(name: str, scope: Scope, subscripts: list[Any] | None = None) -> set[str]: - """``bound_reads`` for a plain array or an associate alias, by name.""" + """``bound_reads`` for a plain array or an associate alias, by name. + + A declared lower bound over a variable the body can change is read + once, on entry, into a local of the translation's own (``captured``); + a subscript over it reads that local and not the variable, and so does + the source, whose bounds were fixed on entry (F2018 10.1.11).""" semantics = scope.semantics if semantics is None: return set() - declared = semantics.declaration(name) or {} - dims = scope.alias_dims.get(name) or declared.get("dims") or () + dims = scope.alias_dims.get(name) + if not dims: + declared = (semantics.declaration(name) or {}).get("dims") or () + dims = [{**d, "lb": "1"} if captured(d.get("lb"), semantics) else d for d in declared] return bound_reads(dims, subscripts) +def captured(lower: Any, semantics: Semantics) -> bool: + """Whether an array's lower bound is held in a local of its own. + + An array's bounds are the values its bound expressions had when it came + into being -- on entry for an explicit-shape dummy or an automatic + local (F2018 10.1.11), at its ALLOCATE for an allocatable (F2018 + 9.7.1.2) -- and redefining a variable they name does not move them. + Where the subprogram can redefine one, the translation reads the bound + into a local then, and every subscript and inquiry reads that; where it + cannot, the bound text is as good as its value and is spelled as it + always was. A literal is neither. + """ + text = str(lower or "").strip() + return bool(text) and not re.fullmatch(r"-?\d+", text) and not kept_from_entry(text, semantics) + + +def kept_from_entry(text: str, semantics: Semantics) -> bool: + """Whether every variable a bound expression names keeps its value. + + An array's bounds are the values its bound expressions had on entry, + and redefining a variable they name does not change them (F2018 + 10.1.11). So ``a(1:n)`` of an ``a(n)`` whose body has since set + ``n = n - 4`` can be empty, and its stop edge, below zero, counts + from the end -- ``sum(a(1:n))`` with ``n`` gone from 3 to -1 summed + three elements, not none. A name is kept when this subprogram cannot + have changed it: an intent(in) dummy, declared or inferred read-only; + a local the body never stores; module state the subprogram does not + write; a constant, and anything else the record does not list as a + variable. An array named inside an inquiry keeps its shape. + """ + subprogram = getattr(semantics, "subprogram", None) or {} + dummies = {a["name"].lower(): a for a in subprogram.get("args") or ()} + variables = {v["name"].lower(): v for v in subprogram.get("locals") or ()} + written = {str(n).lower() for n in subprogram.get("module_state_written") or ()} + for match in re.finditer(r"\b([a-z_]\w*)\b(?!\s*\()", text.lower()): + name = match.group(1) + if name in dummies: + if dummies[name].get("dims") or semantics.is_array(name): + continue + if str(dummies[name].get("intent", "")).upper() != "IN": + return False + elif name in variables: + if variables[name].get("written"): + return False + elif name in written: + return False + return True + + def component_bound_reads(data_ref: Any, scope: Scope) -> set[str]: """``root%comp(subs)``: what its subscripts read through the component's allocated -- or declared -- lower bounds, resolved the way the @@ -359,6 +789,11 @@ def expr_reads(node: Any, scope: Scope) -> set[str]: items = _without_kind_argument(fname, items) for item in items: reads |= expr_reads(item, scope) + if fname in ("lbound", "ubound") and items and isinstance(items[0], f03.Name): + # The inquiry answers with the array's declared lower bounds, + # which the translation spells out (``_f_lbound(a, 1, (lo,))``) + # and the source reads through the declaration. + reads |= lower_bound_reads(str(items[0]).lower(), scope) reads |= host_reads(fname, scope) if scope.ranks.get(fname, 0) > 0 or fname in scope.alias_dims: # A declared array shadows an intrinsic name -- the same rule the @@ -507,6 +942,28 @@ def _unit_position(specifiers: list[tuple[str | None, Any]]) -> int: return -1 +def target_rwset(target: Any, scope: Scope) -> tuple[set[str], set[str]]: + """``(reads, writes)`` of storing to ``target``: the root is written, its + subscripts -- and the lower bounds they are shifted by -- are read.""" + reads: set[str] = set() + writes: set[str] = set() + if isinstance(target, f03.Name): + writes.add(str(target).lower()) + elif isinstance(target, f03.Data_Ref): + writes.add(str(target.children[0]).lower()) + for comp in target.children[1:]: + if isinstance(comp, f03.Part_Ref) and comp.children[1] is not None: + reads.update(expr_reads(comp.children[1], scope)) + reads.update(component_bound_reads(target, scope)) + else: + root = str(target.children[0]).lower() + writes.add(root) + reads.update(lower_bound_reads(root, scope, _subscript_items(target.children[1]))) + for child in target.children[1:]: + reads.update(expr_reads(child, scope)) + return reads, writes + + def rwset(node: Any, scope: Scope) -> tuple[set[str], set[str]]: """``(reads, writes)`` for one statement or construct.""" reads: set[str] = set() @@ -546,20 +1003,9 @@ def _write_actual(actual: Any) -> None: def write_target(target: Any) -> None: """Record an assignment target: the root is written, subscripts are read.""" - if isinstance(target, f03.Name): - writes.add(str(target).lower()) - elif isinstance(target, f03.Data_Ref): - writes.add(str(target.children[0]).lower()) - for comp in target.children[1:]: - if isinstance(comp, f03.Part_Ref) and comp.children[1] is not None: - reads.update(expr_reads(comp.children[1], scope)) - reads.update(component_bound_reads(target, scope)) - else: - root = str(target.children[0]).lower() - writes.add(root) - reads.update(lower_bound_reads(root, scope, _subscript_items(target.children[1]))) - for child in target.children[1:]: - reads.update(expr_reads(child, scope)) + target_reads, target_writes = target_rwset(target, scope) + reads.update(target_reads) + writes.update(target_writes) def io_output(value: Any) -> None: """Where an I/O statement puts an answer. ``ERR=``/``END=`` name a @@ -696,6 +1142,8 @@ def _agrees(x: dict[str, Any]) -> bool: reads.update(expr_reads(actual, scope)) if formal["intent"] in ("OUT", "INOUT"): _write_actual(actual) + returned = returned_length(formal, actual, scope.semantics) + reads.update(length_reads(returned, actual)) if formal.get("optional") and formal["intent"] == "OUT": hands_on_presence(actual) @@ -721,6 +1169,8 @@ def function_writes(value: Any) -> None: for formal, actual in zip(callee["args"], _bind_actuals(callee, items), strict=False): if actual is not None and formal["intent"] in ("OUT", "INOUT"): _write_actual(actual) + returned = returned_length(formal, actual, scope.semantics) + reads.update(length_reads(returned, actual)) def visit(stmt: Any) -> None: if isinstance(stmt, f08.Block_Construct): @@ -758,6 +1208,7 @@ def visit(stmt: Any) -> None: return # a statement-function definition, not dataflow write_target(lhs) reads.update(expr_reads(rhs, scope)) + reads.update(fitted_length_reads(lhs, rhs, scope.semantics)) function_writes(rhs) elif isinstance(stmt, f03.If_Stmt): @@ -834,8 +1285,18 @@ def visit(stmt: Any) -> None: # call does not make, and failed the block over it. control, items = stmt.children units = [str(n).lower() for n in walk(control, f03.Name)] - if units and units[0] in scope.chars: + # ``associate (c => s); write (c, ...)`` writes ``s``, a CHARACTER + # variable the associate name is spelled as: the name is the + # unit, and its selector's length is the one the record fills. + construct = associated_by(stmt, units[0]) if units else None + selector = associations(construct)[units[0]] if construct is not None else None + if units and ( + units[0] in scope.chars + or (selector is not None and _designator_base(selector) in scope.chars) + ): writes.add(units[0]) + unit = walk(control, f03.Name)[0] if selector is None else selector + reads.update(fitted_length_reads(unit, None, scope.semantics)) specifiers = _io_specifiers(control) for at, (keyword, value) in enumerate(specifiers): if at == _unit_position(specifiers): @@ -949,15 +1410,36 @@ def visit(stmt: Any) -> None: # through to the fallback below instead reported every name in # the whole construct as a read and none as a write, which failed # every block of a model whose physics is written this way. + # + # A name the body writes through to a scalar variable is spelled + # as that variable instead (``written_through``): no binding, a + # read of the name is a read of the selector and a store to it + # a store to the selector. + through = written_through(stmt, scope.semantics) for child in stmt.children: if isinstance(child, f03.Associate_Stmt): for association in walk(child, f03.Association): alias, _, selector = association.children + if str(alias).lower() in through: + continue writes.add(str(alias).lower()) reads.update(expr_reads(selector, scope)) dims = _selector_dims(selector, scope) if dims: scope.alias_dims[str(alias).lower()] = dims + elif through and not isinstance(child, f03.End_Associate_Stmt): + inner_reads, inner_writes = rwset(child, scope) + for alias, selector in through.items(): + if alias in inner_reads: + inner_reads.discard(alias) + inner_reads.update(expr_reads(selector, scope)) + if alias in inner_writes: + inner_writes.discard(alias) + target_reads, target_writes = target_rwset(selector, scope) + inner_reads.update(target_reads) + inner_writes.update(target_writes) + reads.update(inner_reads) + writes.update(inner_writes) elif not isinstance(child, f03.End_Associate_Stmt): visit(child) diff --git a/src/recast/fortran/semantics.py b/src/recast/fortran/semantics.py index 77eabd9..0bfbcfc 100644 --- a/src/recast/fortran/semantics.py +++ b/src/recast/fortran/semantics.py @@ -282,6 +282,15 @@ class Semantics: parameters: frozenset[str] = frozenset() """Names that are compile-time constants, including companions'.""" + companion_parameters: dict[str, dict[str, Any]] = field(default_factory=dict) + """A companion module's parameters, by name, with their declared dtype. + + Kept out of ``declaration`` -- which types this module's arithmetic and + has never consulted them -- and read only where a question is about the + parameter's *kind*: ``huge(one)`` over SLSQP's ``one`` from + ``slsqp_support`` is the double's HUGE because that is how + ``slsqp_support`` declares it.""" + statement_functions: frozenset[str] = frozenset() """Names defined by a statement function, which is scalar by definition. @@ -674,6 +683,27 @@ def is_scalar_integer_target(self, node: Any) -> bool: ) return False + def scalar_target_dtype(self, node: Any) -> str | None: + """The declared dtype of the scalar ``is_scalar_integer_target`` + answered for, or ``None`` where it had no declaration to read. + + The conversion on assignment is into the *target's* kind: a REAL + stored into an ``integer(8)`` holds values an ``integer`` does not, + and converting it as a default integer saturates it at the int32 + edge (FNP-D0005).""" + if isinstance(node, f03.Name): + name = str(node).lower() + declared = self.declaration(name) + if declared is not None: + return declared.get("dtype") + if self.subprogram.get("result") == name: + return self.subprogram.get("result_dtype") + return None + if isinstance(node, f03.Data_Ref): + component = self._dataref_component(node) + return component.get("dtype") if component is not None else None + return None + def is_logical_or_character(self, node: Any) -> bool: """Whether an expression is LOGICAL or CHARACTER valued. @@ -1053,6 +1083,7 @@ def for_subprogram( types = dict(record["types"]) parameters = {p["name"] for p in record["module_parameters"]} companion_state: dict[str, dict[str, Any]] = {} + companion_parameters: dict[str, dict[str, Any]] = {} for other in companions: procedures.update({s["name"]: s for s in other["subprograms"]}) # A module that declares a procedure through an explicit INTERFACE @@ -1068,6 +1099,8 @@ def for_subprogram( companion_generics.update(other["generics"]) types.update(other["types"]) parameters |= {p["name"] for p in other["module_parameters"]} + for parameter in other["module_parameters"]: + companion_parameters.setdefault(str(parameter["name"]).lower(), parameter) for state in other.get("module_state", ()): companion_state.setdefault(str(state["name"]).lower(), state) @@ -1099,6 +1132,7 @@ def for_subprogram( companion_generics=companion_generics, types=types, companion_state=companion_state, + companion_parameters=companion_parameters, parameters=frozenset(parameters), statement_functions=statement_functions, ) diff --git a/src/recast/fortran/tree.py b/src/recast/fortran/tree.py index d1aa5f0..0f1f54a 100644 --- a/src/recast/fortran/tree.py +++ b/src/recast/fortran/tree.py @@ -223,7 +223,16 @@ def _evaluate( initialize it with something a parameter can be folded from.""" # Lazy, like ``render`` above: ``expr`` parses, and this module is imported # by paths that must stay importable without the ``fortran`` extra. - from recast.fortran.expr import fold_check, python_call, typed, with_integer_division + from recast.fortran.expr import ( + PYTHON_POWER, + PYTHON_QUOTIENT, + fold_check, + int_div, + int_pow, + python_call, + typed, + with_integer_division, + ) try: records = resolve([name], files) @@ -246,6 +255,8 @@ def _evaluate( name=lambda t: t.upper(), call=lambda f, a, k: python_call(f, a, real64="float", result_kind=k), dtype=lambda k: k, + quotient=lambda a, b: f"{PYTHON_QUOTIENT}({a}, {b})", + power=lambda a, b: f"{PYTHON_POWER}({a}, {b})", ) if storage == "single": text = f"_f32({text})" @@ -253,6 +264,8 @@ def _evaluate( text = f"int({text})" # an integer parameter over a real truncates scope = {"__builtins__": {}, "max": max, "min": min, "abs": abs, "int": int} scope.update({"float": float, "math": math, "sys": sys, "_f32": _single}) + scope[PYTHON_QUOTIENT] = int_div + scope[PYTHON_POWER] = int_pow env[entry["name"].upper()] = eval(text, scope, dict(env)) # noqa: S307 kinds[entry["name"]] = entry.get("dtype") or typed(entry["expr"], kinds) except Exception: # an initializer shape the renderer has no rule for diff --git a/src/recast/transform/numpy/constants.py b/src/recast/transform/numpy/constants.py index 97dcb7e..a748b6e 100644 --- a/src/recast/transform/numpy/constants.py +++ b/src/recast/transform/numpy/constants.py @@ -19,14 +19,20 @@ from __future__ import annotations +import ast +import inspect import re from pathlib import PurePath, PurePosixPath from typing import Any from recast.fortran.expr import ( + PYTHON_POWER, + PYTHON_QUOTIENT, Expr, UnsupportedExpression, fold_check, + int_div, + int_pow, python_call, render, typed, @@ -34,6 +40,7 @@ ) __all__ = [ + "constant_expression", "constants_module", "defined_module_parameters", "np_int_literal", @@ -83,7 +90,7 @@ def constants_module(record: dict[str, Any], *, extern: tuple[tuple[str, int], . entry = record["hoisted_literals"][name] lines.append(_hoisted(name, entry)) lines.append("") - return "\n".join(lines) + return "\n".join(_with_helpers(lines, 5)) def defined_module_parameters(record: dict[str, Any]) -> set[str]: @@ -102,6 +109,55 @@ def defined_module_parameters(record: dict[str, Any]) -> set[str]: } +def constant_expression(text: str, record: dict[str, Any], dtype: str) -> str | None: + """A constant expression outside a parameter declaration, spelled the way + the constants file spells a parameter's and converted to ``dtype``, or + ``None``. + + A component's default initialization is one -- ``cnt = n + 1_wi``, the + "not yet seeded" sentinel mt19937's state starts at -- and nothing but + this route knows how to spell an expression over the module's own + parameters. Classified by the same classifier over the parameters the + constants file defines (``record`` is the frontend's constants record), + and typed as a parameter's initializer is (``_typed_expression``); a + literal or a lone name is left to the caller's own forms, and anything + the classifier refuses is ``None``. + + The value is then the thing it initializes, as Fortran's intrinsic + assignment converts it (F2018 10.2.1.3): ``integer :: kk = grav * 2`` + is 19, truncated, and ``real :: sx = c4 * 3`` the single nearest 0.3 -- + the expression's own value, 19.62 and a double, was what the component + held. An INTEGER target of a real expression is its ``int``, and one + whose operands' kinds are not known is ``None``: truncating an integer + is harmless, but not truncating a real is the wrong number. A single + is rounded to single before it is widened, as ``_stored`` rounds a + single parameter; a double given an integer expression is its float64. + """ + from recast.fortran.constants import Kinds, classify_init + + parameters = record.get("module_parameters") or [] + known = {p["name"] for p in parameters if p.get("kind") != "skip"} + dtypes = {p["name"]: p.get("dtype") for p in parameters} + kind, payload = classify_init(text, known, kinds=Kinds(dtypes=dtypes)) + if kind != "expr": + return None + spelled = _typed_expression(payload) + try: + tree = ast.parse(spelled, mode="eval") + except SyntaxError: + return None + valued = _IntegerArithmetic(_token_types(payload)).kind(tree.body) + if dtype.startswith("int"): + if valued is None: + return None + return spelled if valued == "int" else f"int({spelled})" + if dtype == "float32": + return _stored(spelled, {"dtype": "float32", "kind": "expr"}) + if dtype == "float64" and valued == "int": + return f"np.float64({spelled})" + return spelled + + def np_int_literal(value: int) -> str: """An integer constant, in the narrowest of int32/int64 that holds it. @@ -184,7 +240,7 @@ def _module_parameter(parameter: dict[str, Any], source: str) -> str: return f"# SKIPPED {name} = {parameter['init_expr']} ({quotient[1]}) {where}" if quotient is not None: return f"{name} = {quotient[0]} {where}" - return f"{name} = {_stored(_expression(payload), parameter)} {where}" + return f"{name} = {_stored(_typed_expression(payload), parameter)} {where}" return f"# SKIPPED {name} = {parameter['init_expr']} ({payload}) {where}" @@ -208,7 +264,7 @@ def _local_parameter(parameter: dict[str, Any]) -> str: if kind == "str": return f"{constant} = {payload!r} {about}" if kind in ("ref", "expr"): - value = _stored(payload.upper() if kind == "ref" else _expression(payload), parameter) + value = _stored(payload.upper() if kind == "ref" else _typed_expression(payload), parameter) # The F77 PARAMETER-statement form historically emitted these bare; # the declaration form carries its comment. Kept apart because the # emitted files are diffed byte-for-byte against the pipeline's. @@ -270,16 +326,20 @@ def _real(value: str, default_kind: bool) -> str: "log10": "np.log10", "max": "max", "min": "min", - "mod": "np.mod", + "mod": "np.fmod", "modulo": "np.mod", - "nint": "np.rint", "real": "np.float64", - "sign": "np.sign", "sin": "np.sin", "sqrt": "np.sqrt", "tan": "np.tan", } -"""Intrinsic -> how this target spells it in a constant expression.""" +"""Intrinsic -> how this target spells it in a constant expression. + +``mod`` is ``np.fmod``, the remainder with the dividend's sign that +Fortran's MOD is (F2018 16.9, MOD); ``modulo`` is ``np.mod``, the floored +one. Both were ``np.mod``, so ``mod(-7, 3)`` was 2 where gfortran folds -1 +(FNP-D0025). ``nint`` and ``sign`` are not here: neither has a NumPy +function that means it, and ``_call`` spells them.""" INQUIRY_SPELLING = { "digits": "53", @@ -288,7 +348,12 @@ def _real(value: str, default_kind: bool) -> str: "radix": "2", "tiny": "np.finfo(np.float64).tiny", } -"""Type inquiries: the argument only says which type is being asked about.""" +"""Type inquiries: the argument only says which type is being asked about. +Spelled for a double here, and by ``_inquiry`` for the kind the classifier +recorded as asked about.""" + +DIGITS = {"float32": "24", "float64": "53", "int32": "31", "int64": "63"} +"""DIGITS per kind: a real's mantissa bits, an integer's magnitude bits.""" def _expression(tokens: list[dict[str, Any]]) -> str: @@ -321,6 +386,134 @@ def _expression(tokens: list[dict[str, Any]]) -> str: return " ".join(spelled) +def _typed_expression(tokens: list[dict[str, Any]]) -> str: + """``_expression``, with Fortran's integer arithmetic where the operands + are integers. + + The token route passes every operator through, and two of them mean + something else in Python over two integers. ``/`` truncates in Fortran, + so ``1.0_r8 * (7/2)`` is 3.0 and was emitted as 3.5 (FNP-D0023); ``**`` + of a negative exponent is an integer there too, so ``2 ** (-1)`` is 0 + and was 0.5 (FNP-D0024). The rendered text is parsed and typed -- an + integer literal, a reference to a constant declared INTEGER, ``int(...)`` + and arithmetic over those are integers; a real literal or reference, a + conversion or a real-valued call is real; a name of unknown kind is + neither -- and only a quotient or power whose operands are *both* known + integers is respelled, as the helpers ``_with_helpers`` defines. An + expression with neither is returned exactly as ``_expression`` spells + it, so no constant that was right changes its text. + """ + text = _expression(tokens) + if "/" not in text and "**" not in text: + return text + try: + tree = ast.parse(text, mode="eval") + except SyntaxError: + return text + marker = _IntegerArithmetic(_token_types(tokens)) + body = marker.visit(tree.body) + return ast.unparse(body) if marker.changed else text + + +def _token_types(tokens: list[dict[str, Any]]) -> dict[str, str | None]: + """Upper-case name -> ``"int"`` / ``"real"`` / ``None``, for every constant + the tokens refer to, from the declared dtype the classifier recorded.""" + types: dict[str, str | None] = {} + for token in tokens: + if token["t"] in ("ref", "index"): + dtype = str(token.get("dtype") or "") + real = dtype.startswith(("float", "complex")) + types[token["v"].upper()] = "int" if dtype == "int" else "real" if real else None + for nested in (*token.get("args", ()), *token.get("elements", ())): + types.update(_token_types(nested)) + return types + + +_INTEGER_CALLS = frozenset({"int", "np.int32", "np.int64", PYTHON_QUOTIENT, PYTHON_POWER}) +_PROMOTING_CALLS = frozenset({"max", "min", "abs", "np.abs", "np.sign", "np.mod", "np.fmod"}) + + +class _IntegerArithmetic(ast.NodeTransformer): + """Respell ``/`` and ``**`` between two integer operands as Fortran's + integer division and integer power; see ``_typed_expression``.""" + + def __init__(self, types: dict[str, str | None]) -> None: + self.types = types + self.changed = False + + def kind(self, node: ast.AST) -> str | None: + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, int | float): + return None + return "int" if isinstance(node.value, int) else "real" + if isinstance(node, ast.Name): + return self.types.get(node.id) + if isinstance(node, ast.Subscript): + return self.kind(node.value) + if isinstance(node, ast.UnaryOp): + return self.kind(node.operand) + if isinstance(node, ast.BinOp): + return _promote(self.kind(node.left), self.kind(node.right)) + if isinstance(node, ast.IfExp): + return _promote(self.kind(node.body), self.kind(node.orelse)) + if isinstance(node, ast.Attribute): + # ``np.finfo(np.float64).max`` and the like: a real kind's + # constant; ``np.iinfo(np.int32).max`` an integer kind's, which + # ``huge(0)`` is spelled as -- read as unknown, ``huge(0)/2`` in a + # REAL constant stayed Python's true division, 1073741823.5. + spelled = ast.unparse(node) + return "real" if "finfo" in spelled else "int" if "iinfo" in spelled else None + if isinstance(node, ast.Call): + function = ast.unparse(node.func) + if function in _INTEGER_CALLS: + return "int" + if function in _PROMOTING_CALLS: + kinds = [self.kind(a) for a in node.args] + return _promote(*kinds) if kinds else None + if function.startswith("np.") and function != "np.array": + return "real" # a conversion to a real kind, or a real function + return None + + def visit_BinOp(self, node: ast.BinOp) -> ast.AST: + self.generic_visit(node) + if self.kind(node.left) != "int" or self.kind(node.right) != "int": + return node + if isinstance(node.op, ast.Div): + helper = PYTHON_QUOTIENT + elif isinstance(node.op, ast.Pow) and _signed_exponent(node.right): + helper = PYTHON_POWER + else: + return node + self.changed = True + return ast.Call( + func=ast.Name(id=helper, ctx=ast.Load()), args=[node.left, node.right], keywords=[] + ) + + +def _integer_valued(tokens: list[dict[str, Any]]) -> bool: + """Whether an argument's tokens are integer-valued, by the same typing.""" + try: + tree = ast.parse(_expression(tokens), mode="eval") + except SyntaxError: + return False + return _IntegerArithmetic(_token_types(tokens)).kind(tree.body) == "int" + + +def _promote(*kinds: str | None) -> str | None: + """Fortran's promotion, over what ``_IntegerArithmetic.kind`` knows.""" + if "real" in kinds: + return "real" + if kinds and all(k == "int" for k in kinds): + return "int" + return None + + +def _signed_exponent(node: ast.AST) -> bool: + """Whether an exponent may be negative: anything but a literal integer + of its own sign -- the ``**`` it always was is exact there.""" + return not (isinstance(node, ast.Constant) and isinstance(node.value, int) and node.value >= 0) + + def _integer_quotient( tokens: list[dict[str, Any]], parameter: dict[str, Any] ) -> tuple[str | None, str] | None: @@ -328,9 +521,13 @@ def _integer_quotient( token route spelled Python's real division (``HBS = FBS / 2`` is 1.5 where the compiler has 1; ledger #32 row 18). The one shape this flat token list can spell exactly is a single quotient of two integer - operands, ``int(A / B)`` (truncation toward zero, exact below 2**53); - anything richer is refused with the reason rather than folded wrong. - ``None`` when the expression is not an integer quotient at all.""" + operands, ``int(_f_int_div(A, B))``: truncation toward zero, exact for + every integer, and the Python ``int`` it always was. It was + ``int(A / B)``, which divides in binary64 -- ``k8 / 1_i8`` with ``k8 = + 2**53 + 1`` came out 2**53, where the same quotient inside a REAL + constant already went through ``_f_int_div``. Anything richer is refused + with the reason rather than folded wrong. ``None`` when the expression + is not an integer quotient at all.""" declared = str(parameter.get("dtype") or parameter.get("base_type") or "").lower() if not declared.startswith("int"): return None @@ -341,7 +538,8 @@ def _integer_quotient( t["t"] in ("int", "ref", "index") and t.get("dtype", "int") == "int" for t in operands ) if len(tokens) == 3 and tokens[1] == {"t": "op", "v": "/"} and plain: - return f"int({_expression(tokens[:1])} / {_expression(tokens[2:])})", "" + numerator, denominator = _expression(tokens[:1]), _expression(tokens[2:]) + return f"int({PYTHON_QUOTIENT}({numerator}, {denominator}))", "" return None, "integer division inside a larger expression: not spelled by this renderer" @@ -349,12 +547,12 @@ def _call(token: dict[str, Any]) -> str: name = token["v"] dtype = token.get("dtype") if name in INQUIRY_SPELLING: - # Of the kind the argument had, which the classifier settled; the - # 64-bit spelling stands for a record made without kinds. - if dtype == "float32": - return INQUIRY_SPELLING[name].replace("np.float64", "np.float32") - return INQUIRY_SPELLING[name] + return _inquiry(name, token.get("asked") or dtype) arguments = ", ".join(_expression(argument) for argument in token["args"]) + if name == "nint": + return _nint(_expression(token["args"][0]), token.get("kind") or "int32") + if name == "sign": + return _sign(token["args"]) if name in ("real", "float", "dble") and dtype == "float32": # ``real(x)`` is the default real: single, then widened by whatever # arithmetic it meets -- exact, where ``np.float64(x)`` was not. @@ -362,6 +560,54 @@ def _call(token: dict[str, Any]) -> str: return f"{INTRINSIC_SPELLING.get(name, name)}({arguments})" +def _inquiry(name: str, asked: str | None) -> str: + """An inquiry, of the kind the argument had, which the classifier + settled; the 64-bit spelling stands for a record made without kinds. + + An integer kind is a question about an integer: ``huge(0)`` is + ``np.iinfo(np.int32).max`` and ``digits(0)`` 31, where both were the + double's answers (FNP-D0026, FNP-D0046); ``digits`` of a single is 24, + where it was 53 too. + """ + if name == "digits" and asked in DIGITS: + return DIGITS[asked] + if name == "huge" and asked in ("int32", "int64"): + return f"np.iinfo(np.{asked}).max" + if asked == "float32": + return INQUIRY_SPELLING[name].replace("np.float64", "np.float32") + return INQUIRY_SPELLING[name] + + +def _nint(x: str, kind: str) -> str: + """Fortran NINT, as an integer of ``kind``: the nearest integer, a half + away from zero (F2018 16.9, NINT). + + ``np.rint`` -- what this was -- rounds a half to even and answers a + float, so ``nint(2.5_r8)`` was 2.0 where gfortran folds 3 (FNP-D0025). + Nor ``floor(x + 0.5)``: the addition rounds, and 0.49999999999999994 + comes out 1. ``trunc(x)`` and ``x - trunc(x)`` are exact in binary + floating point, doubling the fraction is exact, and its truncation is + -1, 0 or 1 exactly as the fraction reaches a half. + """ + return f"np.{kind}(np.trunc({x}) + np.trunc(2 * (({x}) - np.trunc({x}))))" + + +def _sign(arguments: list[list[dict[str, Any]]]) -> str: + """Fortran SIGN(a, b): ``|a|`` with the sign of ``b`` (F2018 16.9, SIGN). + + ``np.sign`` takes one argument and reads a second as the array to write + into, so ``sign(3.0_r8, -1.0_r8)`` raised when the constants file was + imported. Over reals it is ``copysign`` -- gfortran gives ``-0.0`` the + minus sign, as the translated body's ``_f_sign`` does -- and over + integers a comparison, which keeps the result an integer and makes + ``b = 0`` positive. + """ + a, b = (_expression(argument) for argument in arguments) + if all(_integer_valued(argument) for argument in arguments): + return f"(abs({a}) if ({b}) >= 0 else -abs({a}))" + return f"np.copysign(np.abs({a}), {b})" + + def _stored(value: str, parameter: dict[str, Any]) -> str: """The value as the compiler stores it: a single-precision constant is rounded to single before it is widened (``storage == "single"``, or a @@ -409,15 +655,35 @@ def use_constants_module(resolved: list[dict[str, Any]], module_name: str) -> st env[entry["name"]] = entry.get("dtype") or typed(entry["expr"], env) where = f"{PurePath(entry['source']).name}:{entry['line']}" lines.append(f"{entry['name'].upper()} = {value} # {where}") - return "\n".join(lines) + "\n" + return "\n".join(_with_helpers(lines, 6)) + "\n" + + +_HELPERS = { + name: inspect.getsource(function).replace(f"def {function.__name__}(", f"def {name}(", 1) + for name, function in ((PYTHON_QUOTIENT, int_div), (PYTHON_POWER, int_pow)) +} +"""Fortran's integer division and integer power, as the definitions a +generated constants file carries: ``expr.int_div`` and ``expr.int_pow`` +under the names a rendered quotient and power call, read out of the live +functions so the tested helpers and the emitted ones cannot drift apart.""" + + +def _with_helpers(lines: list[str], at: int) -> list[str]: + """``lines`` with the definition of each helper they call inserted at + ``at``. The file stands alone -- the translated module's runtime is not + in it -- so a quotient it spells has to be defined in it; and only where + one is called, so a file without one is the text it always was.""" + used = [name for name in _HELPERS if any(f"{name}(" in line for line in lines[at:])] + return [*lines[:at], *(_HELPERS[name] for name in used), *lines[at:]] def _python(expr: Expr, env: dict[str, str | None] | None = None) -> str: if expr.kind == "str": return repr(expr.text) # no arithmetic to fold; a slash in it is text # Fortran divides two integers to an integer; ``with_integer_division`` - # spells those quotients ``//`` from the tree's own types and the - # declared types of the constants before it. + # marks those quotients from the tree's own types and the declared types + # of the constants before it, and they truncate toward zero -- ``//`` + # floors, which is a different integer for operands of opposite sign. return render( with_integer_division(expr, env=env), real=lambda text: f"np.float64('{text}')", @@ -426,4 +692,6 @@ def _python(expr: Expr, env: dict[str, str | None] | None = None) -> str: name=lambda text: text.upper(), call=lambda fname, args, kind: python_call(fname, args, result_kind=kind), dtype=lambda kind: f"np.{kind}", + quotient=lambda a, b: f"{PYTHON_QUOTIENT}({a}, {b})", + power=lambda a, b: f"{PYTHON_POWER}({a}, {b})", ) diff --git a/src/recast/transform/numpy/expressions.py b/src/recast/transform/numpy/expressions.py index daab40e..d0f5de0 100644 --- a/src/recast/transform/numpy/expressions.py +++ b/src/recast/transform/numpy/expressions.py @@ -34,9 +34,12 @@ from typing import Any from recast.fortran._parse import f03, walk +from recast.fortran.frontend import INTRINSIC_MODULES from recast.fortran.interface import CONFLICTING_BOUNDS, emit_name +from recast.fortran.intrinsics import STANDARD_FUNCTIONS +from recast.fortran.rwset import captured, kept_from_entry from recast.fortran.semantics import F77_SPECIFIC_TO_GENERIC, Semantics, Unanalyzable -from recast.transform.numpy.names import Names +from recast.transform.numpy.names import USE_STATEMENT, Names from recast.transform.numpy.vocabulary import ( ARITH_OPS, ARRAY_TRANSFORM, @@ -124,6 +127,77 @@ and a ``kind=`` keyword drops the tail whichever member it is. """ +BIT_INTRINSICS = frozenset({"iand", "ior", "ieor", "ishft"}) +"""Bit operations whose result depends on the operand's width, which the +emitter passes (``Expressions._bit_arguments``).""" + +MAPPED_OVER_ARRAYS = frozenset( + {"acos", "asin", "atan", "atan2", "cosh", "dim", "gamma", "mod", "sinh", "tan"} +) +"""Elemental intrinsics whose only spelling is a scalar one that an array +breaks -- a ``math`` function, or a runtime helper that calls ``int`` or +``max`` on its operands -- so over arrays they are mapped per element. The +others without an array spelling either take arrays as they are (``np.imag``, +``_f_modulo``), convert elementwise in the runtime (``_f_int``, ``_f_nint``), +or are character and inquiry functions a substring or a whole array reaches +with scalar meaning.""" + +INTEGER_CONVERSIONS = frozenset({"int", "nint", "floor", "ceiling"}) +"""Conversions whose KIND decides the result's *range*, not its precision, +so dropping it changes the number (``_integer_conversion``).""" + +KIND_BYTES = {"int32": 4, "int64": 8, "float32": 4, "float64": 8} +"""A resolved kind's dtype -> the kind number gfortran gives it: its width in +bytes, whichever type the kind parameter was written for.""" + +_INTEGER_KIND_DTYPES = {4: "int32", 8: "int64"} +"""The INTEGER kinds a dtype here spells. gfortran has 1, 2 and 16 as well, +and none of them is an ``int32``.""" + + +def _selected_int_kind(digits: int) -> int: + """gfortran's ``selected_int_kind(r)``: the smallest kind holding every + integer of ``r`` decimal digits, -1 where none does. Not "4 unless it + needs 8": ``selected_int_kind(4)`` is the 2-byte kind, whose HUGE is + 32767.""" + for kind, most in ((1, 2), (2, 4), (4, 9), (8, 18), (16, 38)): + if digits <= most: + return kind + return -1 + + +INQUIRY_DTYPES = frozenset( + {"int32", "int64", "float32", "float64", "complex64", "complex128", "bool", "str"} +) +"""The dtypes a kind inquiry is answered for: every kind the frontend +resolves a declaration to.""" + +_NUMERIC = frozenset({"int32", "int64", "float32", "float64"}) +_REAL = frozenset({"float32", "float64"}) + +KIND_INQUIRIES: dict[str, frozenset[str]] = { + "kind": INQUIRY_DTYPES, + "huge": _NUMERIC, + "tiny": _REAL, + "epsilon": _REAL, + "precision": _REAL | {"complex64", "complex128"}, + "range": _NUMERIC | {"complex64", "complex128"}, + "digits": _NUMERIC, + "maxexponent": _REAL, + "minexponent": _REAL, + "bit_size": frozenset({"int32", "int64"}), +} +"""Inquiries about a kind rather than a value, and the dtypes each is defined +for (F2018 16.9: HUGE takes an integer or a real, TINY and EPSILON a real, +PRECISION a real or a complex, RANGE any of the three, DIGITS an integer or +a real, MAXEXPONENT and MINEXPONENT a real, BIT_SIZE an integer). Answered from the +declaration (``Expressions._kind_inquiry``).""" + + +NON_NEGATIVE_INQUIRIES = frozenset({"count", "index", "len", "len_trim", "scan", "size", "verify"}) +"""Intrinsics whose answer is never below zero: a section's stop edge that is +one of them cannot count from the end of the axis.""" + MAX_EXPANDED_POWER = 16 """Beyond this, expanding ``x**n`` to multiplications stops being worth reading and starts being a place for a transcription error. Refused instead.""" @@ -528,6 +602,15 @@ def _implied_do(self, node: Any) -> str: def _power(self, left: str, right: str, left_node: Any, right_node: Any) -> str | None: """``x**n``, which the reference compiler may have lowered two ways.""" exponent = self.semantics.integer_literal(right_node) + if self._integer_power(left_node, right_node) and (exponent is None or exponent < 0): + # INTEGER ** INTEGER is integer arithmetic throughout: ``2**(-1)`` + # is 1/2 in integer division, zero (F2018 10.1.5.2.2). Python's + # ``2 ** -1`` is 0.5, and so is gfortran's expansion ``1.0 / + # (x*x)`` that ``expand_power`` writes for a REAL base + # (FNP-D0012). A non-negative literal exponent is exact in + # Python already and keeps its spelling; a negative one, or one + # whose sign only the run knows, goes through the runtime. + return f"_f_ipow({left}, {right if exponent is None else exponent})" if exponent is None: exponent = self._folded_real_exponent(right_node) if exponent is not None and exponent != 0: @@ -571,12 +654,19 @@ def _folded_real_exponent(self, node: Any) -> int | None: value = self.semantics.integral_real_literal(node) return value if value in FOLDED_REAL_EXPONENTS else None + def _integer_power(self, left_node: Any, right_node: Any) -> bool: + """An INTEGER raised to an INTEGER, which is not a ``pow`` at all.""" + try: + return self.semantics.is_integer(left_node) and self.semantics.is_integer(right_node) + except Unanalyzable: + return False + def _integer_exponent(self, left_node: Any, right_node: Any) -> bool: """A real raised to an integer, which is the case ``powi`` covers. An integer base is left alone: Fortran's integer power is its own - arithmetic (``2**(-1)`` is zero, not a half), and nothing here has - asked what the reference does with it. + arithmetic (``2**(-1)`` is zero, not a half), which ``_f_ipow`` + spells above. """ try: return self.semantics.is_integer(right_node) and not self.semantics.is_integer( @@ -674,6 +764,12 @@ def reference(self, node: Any) -> str: # which is not a refusal but runnable, wrong code -- with the # zero-based shift applied to what are arguments. return f"{bound}({', '.join(arguments)})" + if self._unspelled_intrinsic(name, items): + # The standard's function, not an array: nothing in scope + # declares or imports the name, or its arguments are ones no + # subscript takes. ``digits(x)`` came out ``digits[x - 1]``, + # a NameError at best (FNP-D0027). + raise NoRule(f"intrinsic {name!r} has no spelling in this translation") from None # Neither a procedure this file declares, nor an intrinsic, nor # a name a USE statement bound: what is left is a subscript of # something it use-imports without the dimensions. Reading it as @@ -696,6 +792,43 @@ def reference(self, node: Any) -> str: raise NoRule(f"unknown function or array {name!r}") from None return self.subscript(name, node.children[1]) + def _unspelled_intrinsic(self, name: str, items: list[Any]) -> bool: + """Whether an unresolved ``name(...)`` is a standard intrinsic this + translation has no rule for, rather than an array it cannot see. + + The subscript fallback exists for arrays a module use-imports + without their dimensions, so a name only a USE statement could have + brought in keeps it: an ONLY list that names it binds it, and a bare + USE of a module that is not intrinsic may. Even then, a keyword or a + non-integer argument is no subscript. + """ + if name not in STANDARD_FUNCTIONS: + return False + if ( + name in self.names.use_bindings + or name in self.names.use_parameters + or name in self.names.companion_globals + ): + return False + bare_use = False + for statement in self.semantics.module.get("use_statements", ()): + match = USE_STATEMENT.match(statement) + if match and not match.group(2) and match.group(1).lower() not in INTRINSIC_MODULES: + bare_use = True + if not bare_use: + return True + + def no_subscript(item: Any) -> bool: + if isinstance(item, (f03.Actual_Arg_Spec, f03.Component_Spec)): + return True + if isinstance(item, f03.Subscript_Triplet): + return False + # Definitely not integer, not merely untyped here: an index + # the scope cannot type is still an index. + return self.semantics._integral_or_unknown(item) is False + + return any(no_subscript(item) for item in items) + def _is_procedure_dummy(self, name: str, arglist: Any) -> bool: """Whether ``name(...)`` calls a callable this subprogram was passed. @@ -713,21 +846,33 @@ def _is_procedure_dummy(self, name: str, arglist: Any) -> bool: argument = next((a for a in self.semantics.subprogram["args"] if a["name"] == name), None) return argument is not None and not argument.get("dims") and argument.get("dtype") != "str" - def bound(self, text: str) -> str: + def bound(self, text: str, substitutions: dict[str, str] | None = None) -> str: """Declared bound text -> Python. Bound texts are simple -- names, integers, ``+ - * /``, parentheses, ``size(a, n)`` -- by construction; - anything else refuses the statement that needed the bound.""" + anything else refuses the statement that needed the bound. + + ``substitutions`` is for a *callee's* bound read in its caller + (``extent``): a name that is one of the callee's dummies is the + actual the call binds to it, parenthesised, and one the call leaves + unbound refuses, as it does when it is the whole bound.""" # An automatic array sized off another argument. UBOUND is the same - # question with unit lower bounds, which every translated array has, - # and the dimension may be written with or without ``dim=``. + # question on an axis based at one, and the dimension may be written + # with or without ``dim=``. On an axis declared from another lower + # bound, UBOUND is that bound plus the extent, less one (FNP-D0006). def extent(match: re.Match[str]) -> str: name = self.names.symbol(match.group(1).lower()) dimension = match.group(2) if dimension is None: return self.extent_of(name) axis = int(DIM_KEYWORD.sub("", dimension)) - 1 - return self.extent_along(name, axis) + along = self.extent_along(name, axis) + if match.group(0).lower().startswith("ubound"): + origins = self._bound_origins(match.group(1).lower()) + if origins is not None and axis < len(origins): + if origins[axis] != indexing.UNIT_ORIGIN: + return f"(({self._origin(origins[axis])}) + {along} - 1)" + return along if EXTENT.fullmatch(text): return EXTENT.sub(extent, text) @@ -761,7 +906,13 @@ def extent(match: re.Match[str]) -> str: rendered.append(piece.lower()) opens_intrinsic = True elif re.match(r"[A-Za-z_]", piece): - rendered.append(self.names.symbol(piece)) + if substitutions is not None and piece.lower() in substitutions: + actual = substitutions[piece.lower()] + if not actual: + raise NoRule(f"dummy dimension {text!r} is not bound by this call") + rendered.append(f"({actual})") + else: + rendered.append(self.names.symbol(piece)) elif piece.isdigit() and piece not in ("0", "1", "2"): hoisted = self.names.literals.get(piece) if hoisted is None: @@ -829,10 +980,25 @@ def subscript(self, name: str, arglist: Any) -> str: "agree, or with one this subprogram cannot evaluate" ) positions = indexing.describe(arglist, dims, rank_of=self.semantics.rank) - parts = [self._position(p) for p in positions] - return f"{self.names.symbol(name)}[{', '.join(parts)}]" + symbol = self.names.symbol(name) + parts = [self._position(p, symbol, axis, dims) for axis, p in enumerate(positions)] + return f"{symbol}[{', '.join(parts)}]" + + def _position( + self, + position: indexing.Position, + array: str | None = None, + axis: int | None = None, + dims: list[dict[str, Any]] | None = None, + ) -> str: + """One subscript position of ``array`` along zero-based ``axis``. - def _position(self, position: indexing.Position) -> str: + The array, axis and declared ``dims`` are only read by a section: one + whose edges the runtime works out, where an implied edge has to be + spelled as the bound it stands for, and one whose stop edge may have + to be kept from going negative. ``None`` for a caller with no array + to name. + """ if position.kind is Kind.VECTOR: return f"(({self.render(position.index)}) - 1)" if position.kind is Kind.INDEX: @@ -840,17 +1006,23 @@ def _position(self, position: indexing.Position) -> str: if folded is not None: return str(folded) return self._shift(self.render(position.index), position.origin) - return self._range(position) - - def _range(self, position: indexing.Position) -> str: + dim = dims[axis] if dims and axis is not None and axis < len(dims) else None + return self._range(position, array, axis, dim) + + def _range( + self, + position: indexing.Position, + array: str | None = None, + axis: int | None = None, + dim: dict[str, Any] | None = None, + ) -> str: """``lo:hi`` inclusive becomes ``lo':hi'+1`` exclusive.""" step = self.render(position.step) if position.step is not None else None if step is not None and step.lstrip("(").startswith("-"): # A descending section: the runtime works the edges out, because - # either may be implied and the stop edge underflows at the first - # element. The declared lower bound goes with them. - lower = self.render(position.lower) if position.lower is not None else "None" - upper = self.render(position.upper) if position.upper is not None else "None" + # the stop edge underflows at the first element. The declared + # lower bound goes with them. + lower, upper = self._stepped_edges(position, array, axis) return f"_f_rstep_lb({lower}, {upper}, {step}, {self._origin(position.origin)})" if step is not None and not step.strip("()").isdigit(): # A step whose sign this cannot read -- a dummy, a variable @@ -858,8 +1030,7 @@ def _range(self, position: indexing.Position) -> str: # the ascending spelling ``lo:hi+1:step`` stops one short when the # step turns out negative, and is empty at the first element. The # runtime reads the sign and builds the slice (#75). - lower = self.render(position.lower) if position.lower is not None else "None" - upper = self.render(position.upper) if position.upper is not None else "None" + lower, upper = self._stepped_edges(position, array, axis) return f"_f_rstep_any({lower}, {upper}, {step}, {self._origin(position.origin)})" start = "" if position.lower is not None: @@ -869,16 +1040,121 @@ def _range(self, position: indexing.Position) -> str: if folded is not None else self._shift(self.render(position.lower), position.origin) ) - stop = "" - if position.upper is not None: - rendered = self.render(position.upper) - stop = ( - rendered - if position.shifts_by_one - else f"({rendered}) - ({self._origin(position.origin)}) + 1" - ) + stop = self._stop(position, dim) if position.upper is not None else "" return f"{start}:{stop}" + (f":{step}" if step is not None else "") + def _stop(self, position: indexing.Position, dim: dict[str, Any] | None) -> str: + """The exclusive stop edge of an ascending section, kept off zero's + far side. + + A section whose upper subscript is below its lower one is empty, and + its subscripts need not be within the bounds (F2018 9.5.3.3.2): + ``a(1:-1)`` selects nothing, as does ``s(1:-1)`` of a string. Rendered + as it stood, the stop edge was ``-1`` and CPython counted it from the + end -- ``a[0:-1]`` is every element but the last, ``s[0:(-1)]`` the + string less its last character (FNP-D0037, FNP-D0043). A stop edge at + zero or above means what Fortran means, so the one that could go + below it is clamped there. Not where it cannot: a non-negative + literal (a negative one is folded to the ``0`` it clamps to), the + axis's own declared upper bound (below the lower one only on a + zero-size axis, where every slice is empty) while the variables it + names still hold the values the bounds took on entry, the section's + own lower edge (``a(i:i)`` is never empty), and an inquiry that + cannot answer below zero. + """ + rendered = self.render(position.upper) + literal = self.semantics.integer_literal(position.upper) + low = _integer_text(position.origin) + if literal is not None and low is not None: + if literal - low + 1 < 0: + return "0" + if position.shifts_by_one: + return rendered + stop = ( + rendered + if position.shifts_by_one + else f"({rendered}) - ({self._origin(position.origin)}) + 1" + ) + if literal is not None and low is not None: + return stop + upper = _bound_text(position.upper) + if ( + upper is not None + and upper == _bound_text((dim or {}).get("ub")) + and self._kept_from_entry(upper) + ) or (position.lower is not None and upper == _bound_text(position.lower)): + return stop + if ( + position.shifts_by_one + and isinstance(position.upper, f03.Intrinsic_Function_Reference) + and str(position.upper.children[0]).lower() in NON_NEGATIVE_INQUIRIES + ): + return stop + return f"max(0, {stop})" + + def _kept_from_entry(self, text: str) -> bool: + """Whether every variable a declared bound names keeps its value + (``rwset.kept_from_entry``, which the read/write sets draw on too).""" + return kept_from_entry(text, self.semantics) + + def capture_bounds( + self, name: str, dims: list[dict[str, Any]], spelled: list[str] + ) -> tuple[list[dict[str, Any]], list[str]]: + """``dims`` with each lower bound that can move held in a local, and + the assignments that fill those locals. + + An array's bounds are fixed when it comes into being -- on entry for + an explicit-shape dummy or an automatic local (F2018 10.1.11), at + its ALLOCATE for an allocatable (F2018 9.7.1.2) -- but a subscript + was shifted by the bound *text*, re-read at every use. ``allocate( + a(-n:n))``, then ``n = n + 1``, then ``a(0)``: the shift was + ``(0) - (- n)`` with the new ``n``, and the element read was + ``a(1)``; LBOUND and UBOUND answered -3 and 1 where gfortran says + -2 and 2 (FNP-D0049). Where the subprogram can redefine a name the + bound reads (``rwset.captured``), the bound's value is read once, + into ``_lb__``, and the returned dims name that local, + so the subscripts, LBOUND/UBOUND and section edges read it through + ``subscript`` and ``_bound_origins`` as they read a bound text. The + caller emits the assignments where the array comes into being. + ``spelled`` is each axis's lower bound as Python. A bound that + cannot move is left as it is, and so is its emitted text. + """ + kept: list[dict[str, Any]] = [] + assignments: list[str] = [] + for axis, (dim, lower) in enumerate(zip(dims, spelled, strict=True)): + if not captured(dim.get("lb"), self.semantics): + kept.append(dim) + continue + hidden = f"_lb_{name.lower()}_{axis + 1}" + assignments.append(f"{hidden} = {lower}") + kept.append({**dim, "lb": hidden, "source_lb": dim["lb"]}) + return kept, assignments + + def _stepped_edges( + self, position: indexing.Position, array: str | None, axis: int | None + ) -> tuple[str, str]: + """Both edges of a section the runtime slices, implied ones spelled. + + An omitted first subscript is the axis's lower bound and an omitted + second one its upper bound, whatever the sign of the step (F2018 + 9.5.3.3.2): ``a(::-1)`` is empty, and ``a(:1:-1)`` is ``a(1)`` alone. + Handed to the runtime as ``None``, an implied edge took Python's + meaning instead -- the end the step walks *from* -- so all three of + ``a(:1:-1)``, ``a(n::-1)`` and ``a(::-1)`` summed the whole array + reversed (FNP-D0001). The lower bound is the one the subscript + already shifts by; the upper bound is the lower one plus the axis's + extent less one, asked of the array the way every other extent is. + """ + origin = self._origin(position.origin) + lower = self.render(position.lower) if position.lower is not None else origin + if position.upper is not None: + return lower, self.render(position.upper) + if array is None or axis is None: + raise NoRule("an implied upper edge of a stepped section, with no array to size it") + extent = self.extent_along(array, axis) + upper = extent if position.shifts_by_one else f"({origin}) + {extent} - 1" + return lower, upper + def _origin(self, origin: str) -> str: """A declared lower bound, as Python. @@ -919,7 +1195,11 @@ def _data_ref(self, node: Any) -> str: positions = indexing.describe( component.children[1], dims, rank_of=self.semantics.rank ) - parts.append(f"{head}[{', '.join(self._position(p) for p in positions)}]") + array = ".".join([*parts, head]) + subscripts = ", ".join( + self._position(p, array, axis, dims) for axis, p in enumerate(positions) + ) + parts.append(f"{head}[{subscripts}]") elif isinstance(component, f03.Data_Ref): # fparser nests them when the chain is long enough. parts.append(self._data_ref(component)) @@ -1006,9 +1286,15 @@ def actual_argument( # ``vl(ldvl, *)`` handed a rank-1 ``vl``: the leading axes have # the extents the call binds, and the assumed-size last axis # takes whatever the actual's storage has left, in column-major - # order -- which is what ``-1`` asks NumPy for. + # order. ``-1`` asks NumPy for exactly that only when the + # storage fills whole columns, which a leading extent of 1 + # always does; any other raised on the partial last column + # Fortran allows (``v(2, *)`` over five elements, FNP-D0045), + # and the runtime keeps it. leading = [self.extent(d, substitutions) for d in formal_dims[:-1]] - return f"np.reshape({rendered}, ({', '.join(leading)}, -1), order='F')" + if all(axis == "1" for axis in leading): + return f"np.reshape({rendered}, ({', '.join(leading)}, -1), order='F')" + return f"_f_seq_tail({', '.join([rendered, '0', *leading])})" if rank is not None and rank > len(formal_dims): # A whole matrix handed to ``c(*)`` -- ``h12(..., a, mda, 1, # i-1)``: the dummy spans all of its storage in column-major @@ -1031,8 +1317,75 @@ def actual_argument( return f"np.reshape({flat}[:{span}], ({', '.join(extents)},), order='F')" if element: return self.sequence_association(actual, formal_dims, substitutions) + if rank == len(formal_dims) and rank > 1 and isinstance(actual, f03.Name): + # A whole array of the dummy's rank. Where the dummy declares + # other extents -- ``y(3, 2)`` for ``x(2, 2)`` -- Fortran + # associates the leading part of the storage, and the array + # handed over in its own shape had the callee index the caller's + # axes: ``x(1, 2)`` read ``y(1, 2)``, not ``y(3, 1)`` (FNP-D0016). + # The run time folds the storage onto the dummy's extents, and an + # OUT or INOUT result goes back through ``_f_copy_out`` in the + # same order. Where the declarations show the extents agree -- + # ``fjac(ldfjac, n)`` for ``a(lda, n)`` bound ``lda = ldfjac`` -- + # the array goes over as it is, as it always did. One axis has + # no fold to get wrong: ``x(i)`` is the actual's ``i``-th element + # either way, and a rank-1 actual goes over as it is. + # A dummy extent the call does not bind is refused by ``extent``: + # neither side can then say where the dummy ends. + if not self._same_extents(str(actual).lower(), formal_dims, substitutions): + extents = [self._dummy_extent(d, substitutions) for d in formal_dims] + return f"_f_seq_shape({', '.join([rendered, *extents])})" return rendered + def _dummy_extent(self, dim: dict[str, Any], substitutions: dict[str, str]) -> str: + """One axis's extent of a callee's explicit-shape dummy, in the caller's names. + + An integer literal is written as it is: the extent is a count the + runtime reads, not a value in the arithmetic, and the caller's module + need not have hoisted the callee's ``x(4)``. + """ + + def side(text: str) -> str: + return text if text.isdigit() else self.extent({"ub": text}, substitutions) + + upper = side(str(dim["ub"])) + lower = str(dim.get("lb") or "1") + if lower == "1": + return upper + return f"({upper}) - ({side(lower)}) + 1" + + def _same_extents( + self, name: str, formal_dims: list[dict[str, Any]], substitutions: dict[str, str] + ) -> bool: + """Whether the actual ``name`` is declared with the dummy's extents. + + Read off the text, both sides in the caller's names: the same lower + and upper bound spelled the same way. Anything the text cannot show + -- an allocatable's or an assumed-shape array's axes, bounds spelled + differently -- is left to the run time, where an equal shape costs a + comparison and nothing else. So are bounds over a variable the body + can change: the actual's extent is the one it had on entry, and the + same text now may name another (FNP-D0049). + """ + declared = (self.semantics.declaration(name) or {}).get("dims") or [] + if name in self.allocated_bounds or len(declared) != len(formal_dims): + return False + try: + for mine, theirs in zip(declared, formal_dims, strict=True): + if not mine.get("ub") or str(mine["ub"]) in ("*", ":"): + return False + if not self._kept_from_entry(f"{mine.get('lb') or 1} {mine['ub']}".lower()): + return False + lower = self.bound(str(mine.get("lb") or "1")) + wanted = self.extent({"ub": str(theirs.get("lb") or "1")}, substitutions) + if lower != wanted or self.bound(str(mine["ub"])) != self.extent( + theirs, substitutions + ): + return False + except REFUSED: + return False + return True + @staticmethod def _assumed_size(formal_dims: list[dict[str, Any]]) -> bool: """``x(*)`` or ``x(n, *)``: the last axis has no extent of its own.""" @@ -1049,8 +1402,9 @@ def _association_tail( vector to ``x(2, *)`` -- goes through the runtime's ``_f_seq_tail``: the storage from the element on in column-major order, a view when the actual is Fortran-contiguous (the gate's inputs and every - reshaped window are), with a rank-2 dummy's leading extents folded - onto it the way Fortran lays it out. SLSQP's ``dcopy(n, a(i, 1), + reshaped window are) and the tail fills whole columns, with a rank-2 + dummy's leading extents folded onto it the way Fortran lays it out, + partial last column included. SLSQP's ``dcopy(n, a(i, 1), la, ...)`` and ``h12(..., c(i, 1), lc, ..., c(j, 1), ...)`` were refused here, which deferred every block that recovers a matrix row. """ @@ -1263,9 +1617,13 @@ def extent(self, dim: dict[str, Any], substitutions: dict[str, str]) -> str: caller does not have. An axis the call did not bind is refused rather than guessed at, for - the same reason. Anything that is not one of the callee's arguments -- - a module parameter, an arithmetic expression over them -- means the - same thing on both sides and goes through ``bound``. + the same reason. So is an arithmetic expression over the callee's + arguments, name by name: ``dbint4``'s ``w(5, ndata + 2)`` bound + ``ndata = nx`` is ``(nx) + 2`` in the caller, where it had been + spelled ``ndata + 2`` -- a name the caller does not have, which the + read/write gate reported and the call would have raised on. Anything + that is not one of the callee's arguments -- a module parameter -- + means the same thing on both sides and goes through ``bound`` as it is. """ text = str(dim["ub"]) if text.lower() in substitutions: @@ -1273,7 +1631,7 @@ def extent(self, dim: dict[str, Any], substitutions: dict[str, str]) -> str: if not substituted: raise NoRule(f"dummy dimension {text!r} is not bound by this call") return substituted - return self.bound(text) + return self.bound(text, substitutions) def _arguments(self, items: list[Any]) -> list[str]: rendered = [] @@ -1396,6 +1754,10 @@ def _intrinsic(self, name: str, items: list[Any], arguments: list[str]) -> str: # refused and deferred to a human. Stubbing it here instead once # turned that whole IF construct into ``if False:`` -- emitted, dead, # and wrong in a way nothing downstream would notice. + if name in ("lbound", "ubound"): + return self._bound_inquiry(name, items) + if name in KIND_INQUIRIES: + return self._kind_inquiry(name, items) if name in ARRAY_TRANSFORM: return self._array_transform(name, items) if name in REDUCTIONS: @@ -1415,10 +1777,330 @@ def _intrinsic(self, name: str, items: list[Any], arguments: list[str]) -> str: # with source one module over never got the chance to resolve. rank = 0 + if name in INTEGER_CONVERSIONS: + return self._integer_conversion(name, items, arguments, rank) + mapped = rank > 0 and not self._substring_rank(items) + if name in BIT_INTRINSICS: + arguments = self._bit_arguments(name, items, arguments) + if mapped: + return f"_f_ecall({self.scalar_table[name]}, {', '.join(arguments)})" + return f"{self.scalar_table[name]}({', '.join(arguments)})" + if mapped and name in MAPPED_OVER_ARRAYS and name not in self.array_table: + # A scalar-only spelling handed an array: ``math.tan`` of one + # raises, ``_f_mod`` of one calls ``int`` on it (FNP-D0036). The + # scalar translation runs per element instead, which keeps the + # libm call the reference makes, the way an ELEMENTAL + # procedure's body is mapped over its actuals. + values = _without_kind(name, arguments) + return f"_f_ecall({self.scalar_table[name]}, {', '.join(values)})" if rank > 0: return self._over_arrays(name, arguments) return self._over_scalars(name, arguments) + def _substring_rank(self, items: list[Any]) -> bool: + """Whether the rank an argument has is only a substring's. + + ``s(i:i)`` of a CHARACTER scalar ranks as a section here, so + ``index(letters, s(i:i))`` and ``ichar(s(i:i))`` reach the array + path with scalar strings. Their scalar spelling is the right one, + and mapping it would hand back a 0-d array where a number was. + """ + for item in items: + for reference in walk(item, f03.Part_Ref): + declared = self.semantics.declaration(str(reference.children[0])) + if declared and declared.get("dtype") == "str" and not declared.get("dims"): + return True + return False + + def _integer_conversion( + self, name: str, items: list[Any], arguments: list[str], rank: int + ) -> str: + """``INT``/``NINT`` into the INTEGER kind the source asked for. + + ``_without_kind`` drops a conversion's KIND, which for a REAL result + is the double this translation computes in anyway. For an INTEGER + one it is the *range*: ``k8 = int(x, kind=8)`` of 3.0d10 is + 30000000000 in Fortran, and ``_f_int(x)`` -- a default-kind + conversion -- answered the int32 edge, -2147483648 (FNP-D0005). So + the kind is read, and a 64-bit one handed on; the default kind + keeps the spelling it always had. A kind this cannot resolve to 4 + or 8 is refused rather than guessed at, because a guess is exactly + the defect. + + ``FLOOR`` and ``CEILING`` the same over an array, whose spelling + converted into int32 whatever the KIND. A scalar one is + ``math.floor``, an unbounded Python int that holds an 8-byte result + already, so it keeps its spelling. + """ + kind = self._conversion_kind(name, items) + values = _without_kind(name, arguments) + table = self.array_table if rank > 0 and name in self.array_table else self.scalar_table + if kind == 8 and table[name].startswith("_f_"): + values = [*values, "8"] + return f"{table[name]}({', '.join(values)})" + + def _bit_arguments(self, name: str, items: list[Any], arguments: list[str]) -> list[str]: + """``IAND``/``IOR``/``IEOR``/``ISHFT`` within the operands' KIND. + + The runtime reads the width off a NumPy dtype, and a literal or a + Python int has none, so the operation went unbounded: + ``ishft(1, 31)`` is -2147483648 in a default INTEGER and came out + 2147483648 (FNP-D0009). The width is the declared kind's, known + here, so it is passed; an operand whose kind this cannot read + leaves the runtime's dtype reading as it was. + """ + values = [ + item.children[1] + if isinstance(item, (f03.Actual_Arg_Spec, f03.Component_Spec)) + else item + for item in items + ] + operands = values[:1] if name == "ishft" else values + widths = [self._integer_width(v) for v in operands] + known = [w for w in widths if w is not None] + if not known: + return arguments + keywords = any(isinstance(i, (f03.Actual_Arg_Spec, f03.Component_Spec)) for i in items) + return [*arguments, f"bits={max(known)}" if keywords else str(max(known))] + + def _integer_width(self, node: Any) -> int | None: + """An integer expression's KIND in bits, where its declaration or its + literal spelling says -- a default one is 32 -- or ``None``. + + A BOZ constant answers ``None`` on purpose: in a bit intrinsic it + takes the kind of the other operand. + """ + while isinstance(node, f03.Parenthesis) or _signed(node): + node = node.children[1] + if isinstance(node, (f03.Hex_Constant, f03.Octal_Constant, f03.Binary_Constant)): + return None + if isinstance(node, (f03.Intrinsic_Function_Reference, f03.Part_Ref)) and not ( + self.semantics.is_array(str(node.children[0])) + ): + called = str(node.children[0]).lower() + items = _items(node.children[1]) + if called in INTEGER_CONVERSIONS: + return 64 if self._conversion_kind(called, items) == 8 else 32 + if called in BIT_INTRINSICS: + inner = [self._integer_width(i) for i in items[: 1 if called == "ishft" else 2]] + known = [w for w in inner if w is not None] + return max(known) if known else None + return None + children = getattr(node, "children", None) + if children and len(children) == 3 and _is_arithmetic(children[1]): + left, right = self._integer_width(children[0]), self._integer_width(children[2]) + return max(left, right) if left is not None and right is not None else None + return {"int32": 32, "int64": 64}.get(self.inquiry_dtype(node) or "") + + def _conversion_kind(self, name: str, items: list[Any]) -> int | None: + """The KIND an ``INT``/``NINT`` asks for, as the number it is. + + ``None`` for no KIND, which is the default. 4 or 8 otherwise -- the + two INTEGER kinds ``_f_int`` spells -- and a refusal for a kind that + is neither, or that nothing here can evaluate. + """ + node = None + for at, item in enumerate(items): + if isinstance(item, (f03.Actual_Arg_Spec, f03.Component_Spec)): + if str(item.children[0]).lower() == "kind": + node = item.children[1] + elif at == 1: + node = item + if node is None: + return None + value = self._kind_value(node) + if value not in (4, 8): + raise NoRule( + f"{name} with KIND {node}: " + + ("not a kind this translation can evaluate" if value is None else f"kind {value}") + + "; only the 4- and 8-byte INTEGER kinds are spelled" + ) + return value + + def _kind_value(self, node: Any) -> int | None: + """A KIND expression's value, or ``None`` where this cannot tell. + + A literal is its value, a kind parameter what ``_kind_named`` reads + it as, and ``selected_int_kind(r)`` with a literal range the kind + gfortran gives -- 2 for ``r = 4``, which the frontend's "int32 + unless it needs int64" is not. + """ + while isinstance(node, f03.Parenthesis): + node = node.children[1] + if isinstance(node, f03.Int_Literal_Constant): + return int(str(node).split("_")[0]) + if isinstance(node, f03.Name): + return self._kind_named(str(node).lower()) + if isinstance(node, (f03.Intrinsic_Function_Reference, f03.Part_Ref)): + called = str(node.children[0]).lower() + items = _items(node.children[1]) + if called == "selected_int_kind" and len(items) == 1: + digits = self.semantics.integer_literal(items[0]) + if digits is not None: + return _selected_int_kind(digits) + return None + + def _kind_named(self, name: str, depth: int = 4) -> int | None: + """The kind a kind parameter names, or ``None`` where this cannot tell. + + Its initializer first, where this scope can see it: a bare number + is itself, ``selected_int_kind(r)`` and ``kind(1_8)`` what gfortran + makes of them, and another kind parameter what *that* names. The + frontend's kind map is the fallback, as the byte width of the dtype + it resolved the name to -- ``r8 = selected_real_kind(12)`` is 8 -- + because gfortran numbers kinds by bytes, which is why ``int(x, r8)`` + is a 64-bit integer. The map comes second because it reads an + integer kind as "int32 unless it needs int64": ``selected_int_kind(4)`` + is ``int32`` there and kind 2 in gfortran. + """ + declared = ( + self.semantics.declaration(name) or self.semantics.companion_parameters.get(name) or {} + ) + initializer = str(declared.get("init_expr") or "").lower().replace(" ", "") + if initializer.isdigit(): + return int(initializer) + spelled = re.fullmatch(r"selected_int_kind\((?:r=)?(\d+)\)", initializer) + if spelled: + return _selected_int_kind(int(spelled.group(1))) + spelled = re.fullmatch(r"kind\([-+]?\d+(?:_(\w+))?\)", initializer) + if spelled: + suffix = spelled.group(1) + if suffix is None: + return 4 + if suffix.isdigit(): + return int(suffix) + return self._kind_named(suffix, depth - 1) if depth else None + if depth and initializer != name and re.fullmatch(r"[a-z]\w*", initializer): + aliased = self._kind_named(initializer, depth - 1) + if aliased is not None: + return aliased + return KIND_BYTES.get(self.kind_map.get(name, "")) + + def _kind_inquiry(self, name: str, items: list[Any]) -> str: + """``KIND``, ``HUGE``, ``TINY``, ``EPSILON``, ``PRECISION``: facts + about the argument's *declared* kind, answered from the declaration. + + The runtime used to decide by the Python type of the value it was + handed, and that is not the kind: ``kind(x4)`` of a ``real(4)`` + answered 8 and ``huge(k8)`` of an ``integer(8)`` the int32 maximum, + because a float is a float and an int an int (FNP-D0007, FNP-D0008); + and a ``real(8)`` assigned the integer ``0`` holds a Python ``0``, + so ``huge(x)`` answered 2147483647 for it (FNP-D0039). The dtype is + a translation-time fact, so it is passed -- ``_f_huge(k8, + 'int64')`` -- and the argument stays in the call because the source + reads it. A kind this cannot resolve, and an argument the inquiry + does not take, are refused. + """ + values = [ + item.children[1] + if isinstance(item, (f03.Actual_Arg_Spec, f03.Component_Spec)) + else item + for item in items + ] + if len(values) != 1: + raise NoRule(f"{name} takes one argument") + dtype = self.inquiry_dtype(values[0]) + if dtype is None: + raise NoRule(f"{name}({values[0]}): the argument's kind is not one this resolves") + if dtype not in KIND_INQUIRIES[name]: + raise NoRule(f"{name} of a {dtype}") + return f"{self.scalar_table[name]}({self.render(values[0])}, {dtype!r})" + + def inquiry_dtype(self, node: Any) -> str | None: + """The dtype of an inquiry's argument, from its declaration or its + literal spelling, or ``None`` where that is not settled. + + Only what a declaration or a literal says: a computed expression's + kind is Fortran's promotion rules, and nothing here needs them. + ``real(4)`` reaches here as ``UNKNOWN_REAL_KIND(4)``, a kind spelled + by its number, which is the number; a kind *name* left unresolved + where it was declared is read through this unit's kind map. + """ + while isinstance(node, f03.Parenthesis) or _signed(node): + node = node.children[1] + if isinstance(node, f03.Int_Literal_Constant): + kind = self._literal_kind(str(node)) + return {None: "int32", 4: "int32", 8: "int64"}.get(kind) + if isinstance(node, f03.Real_Literal_Constant): + kind = self._literal_kind(str(node)) + if kind is None: + kind = 8 if "d" in str(node).lower() else 4 + return {4: "float32", 8: "float64"}.get(kind) + if isinstance(node, f03.Logical_Literal_Constant): + return "bool" + if isinstance(node, f03.Char_Literal_Constant): + return "str" + dtype: Any = None + record: dict[str, Any] | None = None + if isinstance(node, f03.Name): + dtype = self.semantics.scalar_target_dtype(node) + record = self.semantics.declaration(str(node)) + subprogram = self.semantics.subprogram + if record is None and subprogram.get("result") == str(node).lower(): + # A function's result is typed on the subprogram, not as a local. + record = ( + {"kind": subprogram.get("result_kind")} if "result_kind" in subprogram else None + ) + companion = self.semantics.companion_parameters.get(str(node).lower()) + if dtype is None and companion is not None: + # A parameter a sibling module declares: its kind is that + # module's declaration of it. + dtype, record = companion.get("dtype"), companion + elif isinstance(node, f03.Part_Ref) and self.semantics.is_array(str(node.children[0])): + dtype = self.semantics.declared_dtype(node) + record = self.semantics.declaration(str(node.children[0])) + elif isinstance(node, f03.Data_Ref) and len(node.children) == 2: + root, last = node.children + if isinstance(root, f03.Name) and isinstance(last, (f03.Name, f03.Part_Ref)): + component = last if isinstance(last, f03.Name) else last.children[0] + record = self.semantics.component(str(root), str(component)) + dtype = record.get("dtype") if record else None + dtype = str(dtype or "") + if dtype in ("int32", "int64"): + return self._declared_integer_dtype(record) + spelled = re.fullmatch(r"UNKNOWN_REAL_KIND\((\w+)\)", dtype) + if spelled: + # A kind the declaring scope left unresolved: a number is itself, + # and a name is what it names here -- a companion analysed on + # its own does not see the kinds module its reader does + # (SLSQP's ``one`` is ``real(wp)`` in ``slsqp_support``). + name = spelled.group(1).lower() + resolved = {"4": "float32", "8": "float64"}.get(name) or self.kind_map.get(name) + return resolved if resolved in _REAL else None + return dtype if dtype in INQUIRY_DTYPES else None + + def _declared_integer_dtype(self, record: dict[str, Any] | None) -> str | None: + """An INTEGER declaration's dtype, from the kind it spells. + + Not the dtype the frontend typed it with: that is ``int32`` for + every INTEGER kind it could not resolve, with no marker, so + ``integer(kind(1_8))``, ``integer(2)`` and ``integer(1)`` all arrive + as ``int32``. Taken at its word, ``huge(a)`` of the first answered + 2147483647 and ``ishft(a, 40)`` of 1 shifted within 32 bits to 0, + where Fortran says 9223372036854775807 and 1099511627776. So the + kind is read again: none, or one that is 4, is the default + ``int32``; one that is 8 is ``int64``; anything else, and a record + that does not say, is ``None``. + """ + if record is None or "kind" not in record: + return None + kind = str(record.get("kind") or "").strip().lower() + if not kind: + return "int32" + number = int(kind) if kind.isdigit() else self._kind_named(kind) + return None if number is None else _INTEGER_KIND_DTYPES.get(number) + + def _literal_kind(self, text: str) -> int | None: + """A literal's kind suffix as its number: ``1_8`` is 8, ``1.0_r8`` is + what ``r8`` names. ``None`` for no suffix; a suffix nothing here can + evaluate is 0, which no table answers for.""" + if "_" not in text: + return None + suffix = text.rsplit("_", 1)[1].strip().lower() + if suffix.isdigit(): + return int(suffix) + return self._kind_named(suffix) or 0 + def _present(self, argument: str) -> str: """``present(x)``: a sentinel for an optional output, ``is not None`` for everything else. @@ -1433,6 +2115,80 @@ def _present(self, argument: str) -> str: return f"want_{name}" return f"({argument} is not None)" + def _bound_inquiry(self, name: str, items: list[Any]) -> str: + """``LBOUND``/``UBOUND``: the bounds the subscripts are shifted by. + + The runtime used to answer 1 for every LBOUND and the vocabulary + spelled UBOUND as the extent, which is right only on an axis based + at one: ``real(8) :: a(0:5)`` gave ``lbound(a, 1) = 1`` and + ``ubound(a, 1) = 6`` where Fortran says 0 and 5 (FNP-D0006). The + bounds reported are the ones ``subscript`` shifts by -- the + declaration's, or an ``allocate``'s -- so a loop from ``lbound`` to + ``ubound`` indexes exactly the elements it did in the source. An + array expression, a section, or an array this scope has no shape + for is based at one, as Fortran's own answer for them is. + + The array is still handed to the runtime rather than folded away: + the inquiry reads it on both sides of the read/write check, and the + runtime needs its shape anyway -- an axis of zero extent answers 1 + and 0 whatever its declaration says. + """ + positional = [ + i for i in items if not isinstance(i, (f03.Actual_Arg_Spec, f03.Component_Spec)) + ] + keyword = { + str(i.children[0]).lower(): i.children[1] + for i in items + if isinstance(i, (f03.Actual_Arg_Spec, f03.Component_Spec)) + } + array = keyword.get("array", positional[0] if positional else None) + dim = keyword.get("dim", positional[1] if len(positional) > 1 else None) + if array is None: + raise NoRule(f"{name} without an array") + rendered = self.render(array) + dimension = self.render(dim) if dim is not None else None + origins = self._bound_origins(array) + if origins is not None and any(o != indexing.UNIT_ORIGIN for o in origins): + lows = [self._origin(o) for o in origins] + lower = f"({lows[0]},)" if len(lows) == 1 else f"({', '.join(lows)})" + spelled = "_f_lbound" if name == "lbound" else "_f_ubound" + return f"{spelled}({rendered}, {dimension or 'None'}, {lower})" + if name == "lbound": + called = self.scalar_table["lbound"] + return f"{called}({rendered}, {dimension})" if dimension else f"{called}({rendered})" + if dimension is not None: + return self.axis_reduction(REDUCTIONS["ubound"], rendered, dimension) + # Without DIM the answer is a vector, one upper bound per axis; + # ``np.size`` of a rank-2 array is the element count. + return f"_f_ubound({rendered})" + + def _bound_origins(self, array: Any) -> list[str] | None: + """The lower bound of each axis ``subscript`` shifts ``array`` by, + as source text, or ``None`` where it shifts by one throughout.""" + if isinstance(array, str): + name = array + elif isinstance(array, f03.Name): + name = str(array).lower() + elif ( + isinstance(array, f03.Data_Ref) + and len(array.children) == 2 + and isinstance(array.children[1], f03.Name) + ): + dims = self._component_dims(array, 1, str(array.children[1]).lower()) + return [_axis_origin(dims, axis) for axis in range(len(dims))] if dims else None + else: + return None + if not self.semantics.is_array(name): + return None + declaration = self.semantics.declaration(name) or {} + dims = self.allocated_bounds.get(name, declaration.get("dims")) + if dims == CONFLICTING_BOUNDS: + raise NoRule( + f"module allocatable {name!r} is allocated with lower bounds that do not " + "agree, or with one this subprogram cannot evaluate" + ) + return [_axis_origin(dims, axis) for axis in range(len(dims))] if dims else None + def _reduction(self, name: str, arguments: list[str]) -> str: if len(arguments) == 2 and arguments[1].startswith("dim="): arguments = [arguments[0], arguments[1][len("dim=") :]] @@ -1566,25 +2322,40 @@ def argument(index: int, key: str) -> str | None: return keyword[key] return positional[index] if len(positional) > index else None + def node(index: int, key: str) -> Any: + """The source node ``argument`` rendered, for asking its rank.""" + for item in items: + if isinstance(item, f03.Actual_Arg_Spec) and str(item.children[0]).lower() == key: + return item.children[1] + values = [item for item in items if not isinstance(item, f03.Actual_Arg_Spec)] + return values[index] if len(values) > index else None + if name == "transpose": return f"np.asfortranarray({positional[0]}.T)" if name == "matmul": return f"np.matmul({positional[0]}, {positional[1]})" if name == "reshape": - shape = argument(1, "shape") - if shape is None: - raise NoRule("reshape without a shape") - return f"np.reshape({positional[0]}, {shape}, order='F')" + return self._reshape(items, argument) if name == "spread": source, dim, copies = argument(0, "source"), argument(1, "dim"), argument(2, "ncopies") if source is None or dim is None or copies is None: raise NoRule("spread with missing arguments") return f"np.repeat(np.expand_dims({source}, ({dim}) - 1), {copies}, axis=({dim}) - 1)" if name == "pack": - mask = argument(1, "mask") - if mask is None: - raise NoRule("pack without a mask") - return f"({positional[0]})[({mask})]" + array, mask, vector = argument(0, "array"), argument(1, "mask"), argument(2, "vector") + if array is None or mask is None: + raise NoRule("pack without an array or a mask") + if vector is None and self._ranked(node(0, "array"), node(1, "mask")) == 1: + # Boolean indexing is PACK only here: it walks the selected + # elements in row-major order, which is array element order + # at rank 1 alone. + return f"({array})[({mask})]" + # A rank-2 array packed by boolean indexing came out by rows -- + # 1 3 2 4 where Fortran packs 1 2 3 4 -- and VECTOR, which + # sizes the result and supplies its tail, was dropped + # (FNP-D0041, FNP-D0018). The runtime gathers column-major. + tail = f", vector={vector}" if vector is not None else "" + return f"_f_pack({array}, {mask}{tail})" if name == "unpack": mask, field_ = argument(1, "mask"), argument(2, "field") if mask is None or field_ is None: @@ -1601,20 +2372,132 @@ def argument(index: int, key: str) -> str | None: if shift is None: raise NoRule("eoshift without a shift") dim = argument(3, "dim") or "1" - return f"_f_eoshift({positional[0]}, {shift}, axis=({dim}) - 1)" + # EOSHIFT(ARRAY, SHIFT [, BOUNDARY, DIM]): the third argument is + # what fills the vacated end. Dropped, ``eoshift(a, 1, -1d0)`` + # filled it with zero (FNP-D0019). + boundary = argument(2, "boundary") + fill = f", boundary={boundary}" if boundary is not None else "" + return f"_f_eoshift({positional[0]}, {shift}, axis=({dim}) - 1{fill})" if name in ("maxloc", "minloc"): - return self._locate(name, positional, keyword) + return self._locate(name, items, positional, keyword) raise NoRule(f"unhandled array transform {name!r}") - @staticmethod - def _locate(name: str, positional: list[str], keyword: dict[str, str]) -> str: - """``MAXLOC``/``MINLOC`` return 1-based positions, not 0-based ones.""" + def _ranked(self, *nodes: Any) -> int | None: + """The one rank every node has, or ``None`` when they differ or one + of them the semantics cannot rank.""" + try: + ranks = {self.semantics.rank(n) for n in nodes if n is not None} + except REFUSED: + return None + return ranks.pop() if len(ranks) == 1 and None not in nodes else None + + def _reshape(self, items: list[Any], argument: Any) -> str: + """``RESHAPE(source, shape [, pad] [, order])``. + + ``np.reshape`` is RESHAPE only when the source has exactly as many + elements as the shape asks for and neither PAD nor ORDER is given: + a larger source is legal Fortran (the result takes its leading + elements) and raised, and PAD and ORDER were dropped -- ``reshape(a, + [2, 3], order=[2, 1])`` came out column by column, and a short + source with a PAD raised (FNP-D0017, FNP-D0048). The runtime's + ``_f_reshape`` is the standard's definition; the NumPy call stays + where the two sizes are literals and agree, which is where it was + right. + """ + source, shape = argument(0, "source"), argument(1, "shape") + if source is None or shape is None: + raise NoRule("reshape without a source or a shape") + pad, order = argument(2, "pad"), argument(3, "order") + values = [a for a in items if not isinstance(a, f03.Actual_Arg_Spec)] + wanted = _literal_product(values[1]) if len(values) > 1 else None + if pad is None and order is None and wanted is not None: + if wanted == self._literal_size(values[0]): + return f"np.reshape({source}, {shape}, order='F')" + keywords = [f"pad={pad}"] * (pad is not None) + [f"order={order}"] * (order is not None) + return f"_f_reshape({', '.join([source, shape, *keywords])})" + + def _literal_size(self, node: Any) -> int | None: + """How many elements a whole array has, when its declared bounds are + integer literals, or an array constructor has, when every item is a + scalar -- ``[1d0, 2d0, 3d0, 4d0]``, the commonest RESHAPE source, + whose text ``np.reshape`` spelled before ``_f_reshape`` existed; + ``None`` for anything else.""" + if isinstance(node, f03.Array_Constructor): + listed = node.children[1] + values = list(listed.children) if isinstance(listed, f03.Ac_Value_List) else [listed] + try: + scalar = all( + not isinstance(v, (f03.Ac_Implied_Do, f03.Ac_Spec)) + and self.semantics.rank(v) == 0 + for v in values + ) + except REFUSED: + return None + return len(values) if scalar else None + if not isinstance(node, f03.Name): + return None + name = str(node).lower() + dims = self.allocated_bounds.get(name, (self.semantics.declaration(name) or {}).get("dims")) + if not dims or dims == CONFLICTING_BOUNDS: + return None + size = 1 + for dim in dims: + low = _integer_text(str(dim.get("lb") or "1")) + high = _integer_text(str(dim.get("ub") or "")) + if low is None or high is None: + return None + size *= max(high - low + 1, 0) + return size + + def _locate( + self, name: str, items: list[Any], positional: list[str], keyword: dict[str, str] + ) -> str: + """``MAXLOC``/``MINLOC`` return 1-based positions, not 0-based ones. + + Two argument lists share the name: ``(ARRAY, DIM [, MASK, KIND, + BACK])`` and ``(ARRAY [, MASK, KIND, BACK])``, told apart by the + second argument's type. MASK was read as a DIM when positional and + dropped when a keyword -- ``maxloc(a, mask=m)`` answered the whole + array's maximum (FNP-D0020) -- and the whole-array search took + NumPy's row-major first maximum where Fortran's is the first in + array element order, so a rank-2 tie came out at the other element + (FNP-D0042). The runtime's ``_f_loc`` searches column-major under + the mask. BACK, which asks for the last extremum, is refused; + KIND sizes the result's integers and changes no value. + """ + values = [item for item in items if not isinstance(item, f03.Actual_Arg_Spec)] + array = positional[0] if values else keyword.get("array") + if array is None: + raise NoRule(f"{name} without an array") + dim, mask = keyword.get("dim"), keyword.get("mask") + back = keyword.get("back") + if len(values) > 1: + if self.semantics.is_logical_or_character(values[1]): + mask, back = positional[1], (positional[3] if len(values) > 3 else back) + elif self.semantics.is_integer(values[1]): + dim = positional[1] + mask = positional[2] if len(values) > 2 else mask + back = positional[4] if len(values) > 4 else back + else: + raise NoRule(f"{name}: cannot tell whether its second argument is DIM or MASK") + if back is not None and back != "False": + raise NoRule(f"{name} with BACK=, the last extremum rather than the first") find = "np.argmax" if name == "maxloc" else "np.argmin" - array = positional[0] - dim = keyword.get("dim", positional[1] if len(positional) > 1 else None) + if dim is not None and mask is not None: + raise NoRule(f"{name} with both DIM= and MASK=") + extremum = "max" if name == "maxloc" else "min" + # NumPy's search answers the first NaN, gfortran's skips them; only + # an INTEGER array, which holds none, keeps NumPy's spelling. + integer = bool(values) and self.semantics.is_integer(values[0]) if dim is not None: + if not integer: + return f"_f_loc({array}, '{extremum}', dim={dim})" return f"({find}({array}, axis=({dim}) - 1) + 1)" - return f"(np.array(np.unravel_index({find}({array}), np.shape({array}))) + 1)" + if mask is None and integer and self._ranked(values[0]) == 1: + # One axis: row-major and array element order are the same order. + return f"(np.array(np.unravel_index({find}({array}), np.shape({array}))) + 1)" + tail = f", mask={mask}" if mask is not None else "" + return f"_f_loc({array}, '{extremum}'{tail})" # -- structure constructors ----------------------------------------------- @@ -1683,6 +2566,57 @@ def _structure_constructor(self, node: Any) -> str: return f"{self.names.symbol(name)}({', '.join(arguments)})" +def _is_arithmetic(operator: Any) -> bool: + """``+ - * /`` between two operands, whose KIND is the wider one's.""" + return isinstance(operator, str) and operator in ("+", "-", "*", "/") + + +def _signed(node: Any) -> bool: + """``-x`` or ``+x``: fparser's unary form, a sign and an operand.""" + children: Any = getattr(node, "children", None) + return ( + bool(children) + and len(children) == 2 + and isinstance(children[0], str) + and children[0] in ("+", "-") + ) + + +def _axis_origin(dims: list[dict[str, Any]], axis: int) -> str: + """One axis's declared lower bound as ``indexing.describe`` reads it.""" + lower = dims[axis].get("lb") + return indexing.UNIT_ORIGIN if lower in (None, "", ":") else str(lower) + + +def _integer_text(text: str) -> int | None: + """A declared bound that is an integer literal, as its value.""" + try: + return int(text) + except ValueError: + return None + + +def _literal_product(node: Any) -> int | None: + """The product of an array constructor of integer literals -- a shape + written out, ``[2, 3]`` -- or ``None``.""" + if not isinstance(node, f03.Array_Constructor): + return None + values = _items(node.children[1]) + if not values or not all(isinstance(v, f03.Int_Literal_Constant) for v in values): + return None + product = 1 + for value in values: + product *= int(value.children[0]) + return product + + +def _bound_text(node: Any) -> str | None: + """Fortran text compared as text: case and blanks do not matter.""" + if node is None: + return None + return str(node).replace(" ", "").lower() + + def _items(arglist: Any) -> list[Any]: if arglist is None: return [] diff --git a/src/recast/transform/numpy/modules.py b/src/recast/transform/numpy/modules.py index 8fa8255..f317314 100644 --- a/src/recast/transform/numpy/modules.py +++ b/src/recast/transform/numpy/modules.py @@ -38,8 +38,10 @@ from recast import references from recast.errors import ConfigError from recast.fortran._parse import f03, parse, walk +from recast.fortran.expr import int_div from recast.fortran.interface import emit_name, subprogram_key from recast.transform.numpy import runtime +from recast.transform.numpy.constants import constant_expression from recast.transform.numpy.subprograms import Subprograms from recast.transform.numpy.vocabulary import pysafe @@ -57,6 +59,10 @@ a gate says otherwise.""" INTEGER_TEXT = re.compile(r"-?\s*\d+") +INTEGER_KIND_TEXT = re.compile(r"(-?\s*\d+)_\w+") +"""An integer literal with a kind suffix, ``2_i4``. Apart from ``INTEGER_TEXT`` +because the spelling is the digits without it, and ahead of ``REAL_TEXT``, +which matches it too -- a mantissa with no point -- and made it a float.""" REAL_TEXT = re.compile(r"-?\s*(?:\d+\.?\d*|\.\d+)(?:[ed][+-]?\d+)?(?:_\w+)?", re.I) CHARACTER_TEXT = re.compile(r"'[^']*'|\"[^\"]*\"") @@ -349,7 +355,20 @@ def _factory(self, type_name: str, components: dict[str, Any]) -> list[str]: safe = pysafe(name) dims = component.get("dims") shape = self.subprograms.component_shape(component) if dims else None - if shape is not None: + # A CHARACTER component starts at its length in blanks, or at a + # literal initializer fitted to it, as a module variable does. + character = ( + self._character_value(component, str(component.get("init") or "").strip()) + if component["dtype"] == "str" and (shape is not None or not dims) + else None + ) + if character is not None and shape is not None: + lines.append(f" o.{safe} = np.full(({shape},), {character}, dtype=object)") + elif character is not None: + lines.append(f" o.{safe} = {character}") + elif component.get("init"): + lines.append(f" o.{safe} = {self._component_default(component, shape)}") + elif shape is not None: lines.append(f" o.{safe} = np.zeros(({shape},))") elif dims: lines.append(f" o.{safe} = None") @@ -365,6 +384,76 @@ def _factory(self, type_name: str, components: dict[str, Any]) -> list[str]: lines.append("") return lines + def _component_default(self, component: dict[str, Any], shape: str | None) -> str: + """A component's default initialization, as the factory's value. + + Every object of the type starts with it (F2018 7.5.4.6), and the + factory started every component at a zero of its type whatever the + type said -- ``real(r8) :: tol = 1.0d-6`` came out ``o.tol = 0.0`` + (FNP-D0030). The initializer is rendered by the forms a module + variable's is, since it is the same kind of constant text: a scalar + through ``_state_value``, an array's broadcast through + ``_broadcast_fill``. A bare integer given to a REAL component is + that real, as Fortran's conversion makes it. What neither renders + -- an expression, an array whose shape is not static -- is ``None`` + with the text beside it rather than the zero that was not the + source's: a ``None`` read before it is written raises, where a + zero is a wrong number nothing reports. + """ + initializer = str(component["init"]).strip() + dtype = str(component.get("dtype") or "") + parameters = {p["name"] for p in self.subprograms.record["module_parameters"]} + unrendered = f"None # default initialization not rendered: {initializer!r}" + if component.get("dims"): + if shape is None or initializer.lower() == "null()": + return "None" if initializer.lower() == "null()" else unrendered + fill = self._broadcast_fill(initializer.lower(), parameters) + if fill is not None: + return f"np.full(({shape},), {fill}, dtype={STATE_DTYPES.get(dtype, 'np.float64')})" + if dtype in ("int32", "int64") and ARRAY_TEXT.fullmatch(initializer): + # An integer constructor stays integer: ``_state_value``'s + # constructor rule reads ``1`` as a real before an integer. + items = [i.strip() for i in initializer[2:-2].split(",")] + if all(INTEGER_TEXT.fullmatch(i) for i in items): + values = ", ".join(i.replace(" ", "") for i in items) + return f"np.array([{values}], dtype=np.{dtype})" + value = self._state_value({"init_expr": initializer, "dtype": dtype}, parameters) + return unrendered if value.startswith("None") else value + whole = INTEGER_KIND_TEXT.fullmatch(initializer) + if dtype.startswith("float") and (whole or INTEGER_TEXT.fullmatch(initializer)): + digits = (whole.group(1) if whole else initializer).replace(" ", "") + return ( + f"np.float64(np.float32({digits}))" + if dtype == "float32" + else f"np.float64({digits})" + ) + value = self._state_value({"init_expr": initializer, "dtype": dtype}, parameters) + if value.startswith("None # pointer"): + return "None" + if value.startswith("None"): + # An expression over the module's parameters -- ``n + 1_wi`` -- + # spelled as the constants file spells a parameter's, and + # converted to the component's type: ``integer :: kk = grav * 2`` + # is 19, where the expression's own value was 19.62. + spelled = constant_expression(initializer, self.subprograms.constants, dtype) + return unrendered if spelled is None else spelled + # A lone real given to an INTEGER component is converted the same + # way, and a double parameter given to a single one is rounded. + lowered = initializer.lower() + declared = { + p["name"]: str(p.get("dtype") or "") + for p in self.subprograms.constants.get("module_parameters") or [] + } + literal = not whole and not INTEGER_TEXT.fullmatch(initializer) + real = (literal and REAL_TEXT.fullmatch(initializer) is not None) or declared.get( + lowered, "" + ).startswith("float") + if dtype.startswith("int") and real: + return f"int({value})" + if dtype == "float32" and declared.get(lowered) == "float64": + return f"np.float64(np.float32({value}))" + return value + # -- module state --------------------------------------------------------- def _state( @@ -441,9 +530,11 @@ def _refuse(reason: str) -> list[str]: if dtype == "object": # A character array: np.zeros of dtype object is # an array of the integer 0, and the first thing - # done to it is a string comparison. + # done to it is a string comparison. Its elements + # are blanks of its length where that is known. + blank = self._character_value(state) or "''" return [ - f"{state['name']} = np.full(({shape},), '', dtype=object)" + f"{state['name']} = np.full(({shape},), {blank}, dtype=object)" " # module array state (str)" ] return [ @@ -459,6 +550,11 @@ def _refuse(reason: str) -> list[str]: return [ f"{pysafe(state['name'])} = None # allocatable/assumed module array, set by init" ] + if state["dtype"] == "str" and not state.get("dims"): + blanks = self._character_value(state, initializer) + if blanks is not None: + note = "Fortran save-init" if initializer else "set by init" + return [f"{pysafe(state['name'])} = {blanks} # module state (str), {note}"] if state["init_expr"]: value = self._state_value(state, parameters) if value.startswith("None # TODO"): @@ -477,6 +573,31 @@ def _refuse(reason: str) -> list[str]: ] return [f"{pysafe(state['name'])} = None # module state ({state['dtype']}), set by init"] + def _character_value(self, entity: dict[str, Any], initializer: str = "") -> str | None: + """What a CHARACTER module variable or component starts as: its + declared length in blanks, or a character-literal ``initializer`` + fitted to it; ``None`` for a length not known at module scope. + + A ``character(len=6) :: ms`` holds six characters before any store + as after one, and ``ms = 'ab'`` leaves ``'ab '``. The module + bound it to ``None`` and started a component at ``None`` too, and a + literal initializer kept its own length (FNP-D0021). A length is + known here when it is digits or one of the module's parameters, + which the module spells in capitals. + """ + value = "" + if CHARACTER_TEXT.fullmatch(initializer): + value = initializer[1:-1].replace(initializer[0] * 2, initializer[0]) + elif initializer: + return None + length = str(entity.get("char_len") or "").strip() + parameters = {p["name"] for p in self.subprograms.record["module_parameters"]} + if length.isdigit(): + return repr(value.ljust(int(length))[: int(length)]) + if length.lower() in parameters: + return f"({value!r}).ljust({length.upper()})[:{length.upper()}]" + return None + def _broadcast_fill(self, expression: str, parameters: set[str]) -> str | None: """A scalar initializer simple enough to broadcast, or ``None``. @@ -510,15 +631,32 @@ def _state_value(self, state: dict[str, Any], parameters: set[str]) -> str: return expression.upper() if INTEGER_TEXT.fullmatch(expression): return expression.replace(" ", "") + whole = INTEGER_KIND_TEXT.fullmatch(expression) + if whole: + # ``integer(i4) :: j = 2_i4`` is the integer 2. ``REAL_TEXT`` read + # it as a real, ``np.float64('2')``, and ``x(j)`` then raised -- + # a float is no subscript. + return whole.group(1).replace(" ", "") if expression in (".true.", ".false."): return "True" if expression == ".true." else "False" if REAL_TEXT.fullmatch(expression): base = expression.replace(" ", "").split("_")[0].replace("d", "e") + if state.get("dtype") == "float32": + single = self._single(expression) + return ( + single or f"None # TODO: real(4) init of unknown kind {state['init_expr']!r}" + ) return f"np.float64('{base}')" if CHARACTER_TEXT.fullmatch(str(state["init_expr"]).strip()): return _character_literal(str(state["init_expr"]).strip()) if expression == "null()": return "None # pointer, null-init" + logical = re.fullmatch(r"\.(true|false)\.(?:_\w+)?", expression) + if logical: + # A LOGICAL literal of a named kind, ``.false._wi``: the value is + # the same whatever the kind, and the plain spelling above only + # knew the default one. + return "True" if logical.group(1) == "true" else "False" if re.fullmatch(r"huge\(1\)", expression): return "np.int32(2147483647) # HUGE(default int)" if re.fullmatch(r"huge\(1\.0?_?\w*\)", expression): @@ -546,13 +684,78 @@ def _state_value(self, state: dict[str, Any], parameters: set[str]) -> str: return f"np.array([{', '.join(item.strip() for item in items)}], dtype=np.int32)" return f"None # TODO: array init {state['init_expr']!r}" if DIVISION_TEXT.fullmatch(expression): - # An arithmetic constant expression: 2.0_r8 / 7.0_r8. - numerator, denominator = expression.split("/") - top = numerator.strip().split("_")[0].replace("d", "e") - bottom = denominator.strip().split("_")[0].replace("d", "e") - return f"np.float64({float(top) / float(bottom)!r})" + return self._quotient_value(expression, str(state.get("dtype") or "")) return f"None # TODO: init {state['init_expr']!r}" + def _real_kind(self, literal: str) -> str | None: + """The dtype of a real literal: a ``d`` exponent is a double, no + suffix the default REAL, a single, and a suffix what the module's + kind map says it is -- ``None`` for one it does not name.""" + mantissa, _, suffix = literal.replace(" ", "").lower().partition("_") + if not suffix: + return "float64" if "d" in mantissa else "float32" + kinds = {"4": "float32", "8": "float64", **(self.subprograms.record.get("kind_map") or {})} + found = kinds.get(suffix) + return found if found in ("float32", "float64") else None + + def _single(self, literal: str) -> str | None: + """A real literal as a REAL(4) variable holds it, or ``None``. + + The value is the single nearest the literal, widened: ``real :: s = + 0.1`` holds 0.10000000149011612, where the ``np.float64('0.1')`` this + was holds 0.1. A double literal is rounded to double first, as the + compiler converts it; the default kind's is read as a single, as + the constants file's ``_real`` reads it. + """ + base = literal.replace(" ", "").lower().split("_")[0].replace("d", "e") + kind = self._real_kind(literal) + if kind == "float32": + return f"np.float64(np.float32('{base}'))" + if kind == "float64": + return f"np.float64(np.float32(np.float64('{base}')))" + return None + + def _quotient_value(self, expression: str, dtype: str) -> str: + """``a / b`` over two literals, as the variable it initializes holds it. + + Fortran divides first, by the operands' types, and converts the + quotient to the variable's type after: ``real(r8) :: third = 1/3`` + is an integer division, 0, and then 0.0 -- this folded every such + quotient as a real one, 0.333... (FNP-D0029). Two integer literals + truncate toward zero (``expr.int_div``, exact); a quotient with a + real operand is the real one it was; an INTEGER variable takes the + quotient truncated, as the assignment converts it. The sign is + read off the numerator with its spaces gone -- fparser writes + ``-7/2`` as ``- 7 / 2``, which ``float`` refused, and the whole unit + with it. + + A REAL(4) variable holds the quotient rounded to single: ``real :: + ms = 1.0/3.0`` is 0.3333333432674408, and was the double third. Its + real quotient is spelled for NumPy to divide at the operands' kind -- + two singles in single, a double operand in double, an integer at the + other side's -- and then rounded; an operand of a kind the module + does not name is not guessed at. + """ + sides = [side.replace(" ", "") for side in expression.split("/")] + numerator, denominator = (side.split("_")[0].replace("d", "e") for side in sides) + integers = [bool(INTEGER_TEXT.fullmatch(side)) for side in (numerator, denominator)] + if all(integers): + value: float = int_div(int(numerator), int(denominator)) + else: + value = float(numerator) / float(denominator) + if dtype.startswith("int"): + return str(int(value)) # the conversion truncates, as ``int`` does + if dtype == "float32": + if all(integers): + return f"np.float64(np.float32({int(value)}))" + kinds = {self._real_kind(s) for s, i in zip(sides, integers, strict=True) if not i} + if None in kinds: + return f"None # TODO: real(4) init of unknown kind {expression!r}" + ctor = "np.float64" if "float64" in kinds else "np.float32" + quotient = f"{ctor}('{numerator}') / {ctor}('{denominator}')" + return f"np.float64(np.float32({quotient}))" + return f"np.float64({float(value)!r})" + # -- the signature table -------------------------------------------------- def _signatures(self) -> dict[str, dict[str, Any]]: diff --git a/src/recast/transform/numpy/names.py b/src/recast/transform/numpy/names.py index 5c56bb2..ebaa293 100644 --- a/src/recast/transform/numpy/names.py +++ b/src/recast/transform/numpy/names.py @@ -22,6 +22,7 @@ from typing import Any from recast.fortran.frontend import INTRINSIC_MODULES +from recast.fortran.interface import emit_name from recast.fortran.semantics import Semantics from recast.transform.numpy.vocabulary import ( WHITELIST_INT, @@ -30,7 +31,7 @@ ) from recast.transform.rules import NoRule -__all__ = ["Names", "bind_use_statements", "for_subprogram"] +__all__ = ["Names", "bind_use_statements", "for_subprogram", "kept_between_calls", "saved_global"] @dataclass @@ -72,6 +73,20 @@ class Names: checking its own arithmetic rather than the translation. """ + associations: dict[str, str] = field(default_factory=dict) + """ASSOCIATE name -> its selector as emitted, while the construct's body + is rendered: a name the body writes through to a scalar variable is that + variable (``recast.fortran.rwset.written_through``). A FORALL's index is + here too, as ``_fi_``: it has the construct's scope, and the + variable of its name outside keeps its value. Innermost of all, because + either shadows every other meaning of its spelling for the construct's + duration.""" + + saved: dict[str, str] = field(default_factory=dict) + """A local kept from one call to the next -> the module global that keeps + it (``saved_global``). A SAVEd local the body changes is not a Python + local at all, which forgets it on return; see ``Subprograms``.""" + def symbol(self, name: str) -> str: """The emitted name for a Fortran symbol. @@ -81,6 +96,10 @@ def symbol(self, name: str) -> str: through to a constant the rest of the program shares. """ lowered = name.lower() + if lowered in self.associations: + return self.associations[lowered] + if lowered in self.saved: + return self.saved[lowered] subprogram = self.semantics.subprogram if any(p["name"] == lowered for p in subprogram["local_parameters"]): return pysafe(lowered) @@ -139,6 +158,7 @@ def as_protocol_table(self) -> dict[str, str]: table = {emitted: source for source, emitted in self.module_parameters.items()} table.update({emitted: source for source, emitted in self.use_parameters.items()}) table.update({emitted: source for source, emitted in self.local_constants.items()}) + table.update({emitted: source for source, emitted in self.saved.items()}) return table @@ -177,9 +197,35 @@ def for_subprogram( for p in record.get("local_parameters", ()) if p["subprogram"] == name }, + saved={ + local["name"]: saved_global(semantics.subprogram, local["name"]) + for local in semantics.subprogram.get("locals") or () + if kept_between_calls(local) + }, ) +def kept_between_calls(local: dict[str, Any]) -> bool: + """Whether a local has to outlive the call: SAVEd, and changed by the body. + + A SAVE the body never changes holds its initial value on every call, + which re-initializing it at every entry reproduces exactly; only a local + the body can change needs the value it was left with (FNP-D0014). + """ + return bool(local.get("save") and local.get("written")) + + +def saved_global(subprogram: dict[str, Any], local: str | None = None) -> str: + """The module global that keeps a SAVEd local of ``subprogram``, or -- + with no local -- the marker that says the subprogram has run before. + + A leading underscore no Fortran name can have, so it cannot collide + with module state or with another subprogram's local. + """ + stem = f"_saved_{emit_name(subprogram)}" + return stem if local is None else f"{stem}__{local}" + + INTRINSIC_MODULE_NAMES: dict[str, tuple[str, ...]] = { "iso_fortran_env": ( "real32", diff --git a/src/recast/transform/numpy/runtime.py b/src/recast/transform/numpy/runtime.py index 0eca463..75db8bf 100644 --- a/src/recast/transform/numpy/runtime.py +++ b/src/recast/transform/numpy/runtime.py @@ -111,6 +111,29 @@ def _f_powi(x: Any, n: Any) -> Any: return 1.0 / result if count < 0 else result +def _f_ipow(x: Any, n: Any) -> Any: + """Fortran INTEGER ** INTEGER, which stays an integer for any exponent. + + A negative exponent is ``1 / x**|n|`` in integer division + (F2018 10.1.5.2.2): zero for every base but 1 and -1, which give 1 and + +-1, and a division by zero for 0 -- the reference traps, and so does + this. Python's ``2 ** -1`` is the float 0.5 (FNP-D0012). A non-negative + exponent is ``x ** n`` exactly as it was, types included; arrays take + the same answer elementwise, in the base's dtype. + """ + if np.ndim(x) == 0 and np.ndim(n) == 0: + if n >= 0: + return x**n + if x == 0: + raise ZeroDivisionError("0 ** a negative integer") + return x ** (-n % 2) if x in (1, -1) else x * 0 + base, power = np.asarray(x), np.asarray(n) + if np.any((base == 0) & (power < 0)): + raise ZeroDivisionError("0 ** a negative integer") + exponent = np.where(power >= 0, power, -power % 2).astype(base.dtype) + return np.where((power >= 0) | (np.abs(base) == 1), base**exponent, 0) + + def _f_cfold(fn: Any, *args: Any) -> Any: """gfortran evaluates constant-argument intrinsics at COMPILE time with MPFR (correctly rounded) — that value matches no runtime libm @@ -131,14 +154,17 @@ def _f_vachar(x: Any) -> Any: return np.array([chr(int(v)) for v in np.ravel(x)], dtype=object).reshape(np.shape(x)) -def _f_vceil(x: Any) -> Any: - """Fortran CEILING returns default INTEGER.""" - return np.ceil(x).astype(np.int32) +def _f_vceil(x: Any, kind: Any = None) -> Any: + """Fortran CEILING returns an INTEGER of ``kind``, the default one + unless the source asks for 8.""" + return np.ceil(x).astype(np.int64 if int(kind or 4) == 8 else np.int32) -def _f_vfloor(x: Any) -> Any: - """Fortran FLOOR returns default INTEGER; np.floor returns float.""" - return np.floor(x).astype(np.int32) +def _f_vfloor(x: Any, kind: Any = None) -> Any: + """Fortran FLOOR returns an INTEGER of ``kind``; np.floor returns float, + and ``astype(np.int32)`` of 3.0d10 is the int32 edge where + ``floor(x, kind=8)`` holds 30000000000 (FNP-D0005).""" + return np.floor(x).astype(np.int64 if int(kind or 4) == 8 else np.int32) class _FLoopExit(Exception): @@ -179,18 +205,45 @@ def _f_ecall(fn: Any, *args: Any, **kw: Any) -> Any: """ELEMENTAL procedure broadcast over array actuals: run the scalar translation per element (keeps the strict-libm scalar paths and the scalar control flow intact). Keywords (optional/want_ sentinels) - broadcast alongside.""" + broadcast alongside. + + ``np.vectorize`` settles its result type by calling ``fn`` on the first + element, and a zero-size section -- a zero-trip ``a(1:n)``, ordinary + Fortran -- has none: it raised where gfortran does nothing. The result + is as empty as the actuals, of their type. + + The bit intrinsics are mapped here too, and ``np.vectorize`` hands them + Python ints, which carry no dtype for ``_int_bits`` to read. So the + width they were not given is read off the arrays, and the result kept + in it -- their Python ints came back int64 whatever the operands were. + """ + arrays = [a for a in (*args, *kw.values()) if isinstance(a, np.ndarray)] + if any(a.size == 0 for a in arrays): + shape = np.broadcast_shapes(*(a.shape for a in arrays)) + return np.empty(shape, dtype=np.result_type(*arrays)) + if fn in (_f_iand, _f_ior, _f_ieor, _f_ishft) and len(args) >= 2: + operands = args[:1] if fn is _f_ishft else args[:2] + bits = (args[2] if len(args) > 2 else kw.get("bits")) or _int_bits(*operands) + mapped = np.vectorize(fn, excluded={"bits"})(args[0], args[1], bits=bits) + return mapped.astype(f"int{bits}") if bits in (8, 16, 32, 64) else mapped return np.vectorize(fn)(*args, **kw) def _f_copy_out(dst: Any, src: Any) -> None: """Copy a callee's returned OUT array into the caller's buffer. - ``dst[...] = src`` when the shapes agree; when they do not -- a - ``pcols``-wide buffer receiving an ``ncol``-wide result, or a rank-1 - buffer receiving a section -- the overlap is copied and the rest left as - it was, which is what Fortran's by-reference OUT did. ``None`` is an - unsupplied optional, and nothing is written.""" + ``dst[...] = src`` when the shapes agree. When they do not -- a + ``pcols``-wide buffer receiving an ``ncol``-wide result, a rank-1 + buffer receiving a section, a ``y(3, 2)`` handed to a dummy declared + ``x(2, 2)`` -- the dummy was the leading part of the buffer's storage + (F2018 15.5.2.11): the callee's elements land on the buffer's first + column-major positions, and the rest is left as it was, which is what + Fortran's by-reference OUT did. Not a box of the leading rows and + columns, which is where a rank-2 mismatch used to put them, and not in + C order, which is how a rank mismatch used to walk them -- through a + ``ravel`` that is a copy of any buffer but a C-contiguous one, so the + write went nowhere. ``None`` is an unsupplied optional, and nothing is + written.""" if dst is None: return if not isinstance(src, np.ndarray): @@ -199,12 +252,47 @@ def _f_copy_out(dst: Any, src: Any) -> None: if src.shape == dst.shape: dst[...] = src return - if src.ndim == dst.ndim and src.ndim > 1: - slices = tuple(slice(0, min(s, d)) for s, d in zip(src.shape, dst.shape, strict=True)) - dst[slices] = src[slices] - else: - n = min(src.size, dst.size) - dst.ravel()[:n] = src.ravel()[:n] + flat = np.ravel(dst, order="F") + n = min(src.size, flat.size) + flat[:n] = np.ravel(src, order="F")[:n] + if not np.may_share_memory(flat, dst): + dst[...] = np.reshape(flat, np.shape(dst), order="F") + + +def _f_seq_shape(arr: Any, *extents: Any) -> Any: + """A whole array actual as the explicit-shape dummy it is handed to sees it. + + The dummy is the leading part of the actual's storage, in array element + order, whatever extents it declares (F2018 15.5.2.11): ``z(3, 2)`` to + ``x(2, 2)`` makes ``x(1, 2)`` the actual's third element, ``z(3, 1)``, + where the array itself, handed over in its own shape, answers ``z(1, 2)`` + -- and a whole-array operation in the callee spans all six. So the + caller's first elements in column-major order, folded onto the dummy's + extents, and a view wherever one exists, which the callee's in-place + writes reach: the leading columns when the leading extents agree -- + ``z(3, 4)`` to ``x(3, 2)`` -- whatever the actual's layout, and the fold + of a Fortran-contiguous actual. Otherwise a copy, which the call's + copy-out (``_f_copy_out``, storage order) puts back. The array itself + when the shapes agree, which is every call a caller can see the extents + of, and when it is too short to hold the dummy, which Fortran does not + allow.""" + shape = tuple(max(int(e), 0) for e in extents) + if not isinstance(arr, np.ndarray) or arr.shape == shape: + return arr + span = math.prod(shape) + if span > arr.size or len(shape) != arr.ndim: + return arr + if shape[:-1] == arr.shape[:-1]: + return arr[..., : shape[-1]] + return np.reshape(np.ravel(arr, order="F")[:span], shape, order="F") + + +class _FSeqCopy(np.ndarray): + """A storage tail ``_f_seq_tail`` could not hand out as a view, and so + copied: ``_f_taken`` is what it was copied from, which is how + ``_f_seq_tail_out`` tells what the callee changed in it.""" + + _f_taken: Any = None def _f_seq_tail(arr: Any, start: Any, *leading: Any) -> Any: @@ -212,30 +300,49 @@ def _f_seq_tail(arr: Any, start: Any, *leading: Any) -> Any: The caller's storage from 0-based column-major position ``start`` to the end of ``arr``, which is what ``x(*)`` spans when ``a(i, j)`` is passed - for it. A view when ``arr`` is Fortran-contiguous -- every array the gate - draws and every reshaped window is -- so the callee's in-place writes - land in the caller's array; otherwise a copy, which ``_f_seq_tail_out`` - writes back. - - ``leading`` are the extents of a rank-2 dummy's leading axes, ``u(iue, - *)``. The BLAS idiom ``h12(..., a(i, 1), mda, ...)`` -- a row of the - matrix walked with the matrix's own leading extent -- is the matrix from - that row and column on, ``a[i-1:, :]``: a view whatever the memory order, - and its last column is the partial one Fortran's storage has. Any other - leading extent folds the tail in column-major order onto as many whole - columns as the storage holds.""" + for it. ``leading`` are the extents of the dummy's leading axes, ``u(iue, + *)``: the storage folds onto them in column-major order, and its last + column is the partial one Fortran's storage has, padded out here to a + whole one nobody may read. + + A view wherever one is exact -- a Fortran-contiguous actual whose tail + fills whole columns -- so the callee's in-place writes land in the + caller's array. Anything else is a copy: a C-ordered actual, and a tail + that ends in a partial column, which is the BLAS row walk ``h12(..., + a(i, 1), mda, ...)`` whenever ``i > 1``. A view of rows ``i`` onwards + answered ``u(1, j)`` but lost every element past the bottom of a column, + which Fortran finds at the top of the next one, and a view of whole + columns lost the partial last one. The copy remembers what it was made + from, and ``_f_seq_tail_out`` puts back only what the callee changed in + it: the same call may hand the callee another view of the same storage + -- ``h12``'s ``c(i, 1)`` and ``c(j, 1)`` -- whose writes a wholesale copy + back would undo.""" at = int(start) extents = [int(e) for e in leading] - if len(extents) == 1 and np.ndim(arr) == 2 and extents[0] == np.shape(arr)[0] > 0: - return arr[at % extents[0] :, at // extents[0] :] - flat = np.ravel(arr, order="F")[at:] - if not extents: - return flat - block = 1 - for extent in extents: - block *= extent - whole = (flat.size // block) * block if block else 0 - return np.reshape(flat[:whole], (*extents, -1), order="F") + tail = np.ravel(arr, order="F")[at:] + if extents: + block = math.prod(extents) + columns = -(-tail.size // block) if block else 0 + short = columns * block - tail.size + if short > 0: + tail = np.concatenate([tail, np.zeros(short, dtype=tail.dtype)]) + tail = np.reshape(tail[: columns * block], (*extents, columns), order="F") + if np.may_share_memory(tail, arr): + return tail + copy = tail.view(_FSeqCopy) + copy._f_taken = np.array(tail, copy=True) + return copy + + +def _f_changed(after: Any, before: Any) -> Any: + """Which elements of ``after`` differ from ``before``, to the bit: a NaN + left alone is unchanged, and ``-0.0`` written over ``0.0`` is not.""" + after, before = np.ascontiguousarray(after), np.ascontiguousarray(before) + if after.dtype != before.dtype or after.dtype.hasobject: + return np.asarray(after != before, dtype=bool) + width = after.dtype.itemsize + bits = after.view(np.uint8).reshape(-1, width) != before.view(np.uint8).reshape(-1, width) + return np.any(bits, axis=1) def _f_seq_tail_out(dst: Any, start: Any, src: Any) -> None: @@ -243,9 +350,13 @@ def _f_seq_tail_out(dst: Any, start: Any, src: Any) -> None: ``_f_seq_tail(dst, start, ...)`` handed it. Where the tail was a view the callee wrote the caller's array in place - and there is nothing to do; where ``dst`` is not Fortran-contiguous the - tail was a copy, and the callee's writes reach ``dst`` only through - here, at the same column-major positions.""" + and there is nothing to do. Where it was a copy the callee's writes + reach ``dst`` only through here, at the same column-major positions -- + those it changed, when the copy says what it was made from, so a write + made meanwhile through another view of the same storage stands; all of + them when it does not, as for the array a callee builds for a whole + actual of another rank. Padding past the end of the storage is + dropped.""" if dst is None or not isinstance(src, np.ndarray): return if np.may_share_memory(src, dst): @@ -256,7 +367,12 @@ def _f_seq_tail_out(dst: Any, start: Any, src: Any) -> None: n = min(values.size, max(flat.size - at, 0)) if n <= 0: return - flat[at : at + n] = values[:n] + taken = getattr(src, "_f_taken", None) + if taken is not None and np.shape(taken) == np.shape(src): + changed = _f_changed(values[:n], np.ravel(taken, order="F")[:n]) + flat[at : at + n][changed] = values[:n][changed] + else: + flat[at : at + n] = values[:n] if not np.may_share_memory(flat, dst): dst[...] = np.reshape(flat, np.shape(dst), order="F") @@ -270,7 +386,16 @@ def _f_rstep(lo: Any, hi: Any, st: Any) -> Any: def _f_rstep_lb(lo: Any, hi: Any, st: Any, lb: Any) -> Any: """Fortran lo:hi:st (st<0, inclusive) with declared lower bound lb. - Either edge may be None: Fortran lets a section leave one implied.""" + The emitter spells both edges: an implied one is the axis's bound, not + the end a negative step walks from, so ``a(::-1)`` is empty. ``None`` + still means Python's open edge, for a caller that means that. + + Counting down from below where it stops, the section is empty, and its + edges need not be within the bounds: ``a(0:3:-1)`` is nothing, where + the slice it spells starts at ``-1`` -- the last element -- and walks + down to the third.""" + if lo is not None and hi is not None and lo < hi: + return slice(0, 0, st) start = None if lo is None else lo - lb stop = None if hi is not None: @@ -281,11 +406,16 @@ def _f_rstep_lb(lo: Any, hi: Any, st: Any, lb: Any) -> Any: def _f_rstep_any(lo: Any, hi: Any, st: Any, lb: Any) -> Any: """Fortran lo:hi:st with a step of either sign (a variable the source - reads at run time), inclusive edges, declared lower bound lb; either - edge may be implied. Ascending, the stop edge is one past ``hi``; - descending, ``_f_rstep_lb`` works it out (#75).""" + reads at run time), inclusive edges, declared lower bound lb; the + emitter spells an implied edge as the bound it stands for. Ascending, + the stop edge is one past ``hi``; descending, ``_f_rstep_lb`` works it + out (#75).""" if st < 0: return _f_rstep_lb(lo, hi, st, lb) + if lo is not None and hi is not None and hi < lo: + # Empty, and its stop edge may be below zero, where Python counts + # from the end of the axis. + return slice(0, 0, st) start = None if lo is None else lo - lb stop = None if hi is None else hi - lb + 1 return slice(start, stop, st) @@ -293,16 +423,26 @@ def _f_rstep_any(lo: Any, hi: Any, st: Any, lb: Any) -> Any: def _f_vdot(a: Any, b: Any) -> Any: """Fortran DOT_PRODUCT accumulates in order; np.dot (BLAS/pairwise) - rounds differently.""" + rounds differently. + + The accumulator starts at the operands' own zero. It started at + ``0.0``, so the DOT_PRODUCT of two INTEGER arrays was a float, and + wherever the emitter's typing did not wrap it -- a subscript, + ``c(dot_product(a, b))`` -- NumPy refused it as an index (FNP-D0040). + For REAL operands ``float64(0) + x*y`` is ``0.0 + x*y`` to the bit. A + COMPLEX first operand is conjugated, as F2018 16.9.66 has it.""" + x_, y_ = np.asarray(a), np.asarray(b) + if np.iscomplexobj(x_): + x_ = np.conj(x_) if _LIBM_STRICT: - s = 0.0 + s = np.result_type(x_, y_).type(0) # Unchecked on purpose: this is the emitted runtime, and it has # been through bit-exact gates in this form. A length mismatch is # invalid Fortran that never reaches here. - for x, y in zip(np.ravel(a), np.ravel(b)): # noqa: B905 + for x, y in zip(np.ravel(x_), np.ravel(y_)): # noqa: B905 s += x * y return s - return np.dot(a, b) + return np.dot(x_, y_) def _f_vsum(a: Any, axis: Any = None) -> Any: @@ -412,6 +552,15 @@ def _f_int(x: Any, kind: Any = None) -> Any: limit = _INT32_LIMIT * (2**32 if dtype is np.int64 else 1) if isinstance(x, (int, np.integer)): return dtype(x) + if isinstance(x, np.ndarray): + # ``int`` of an array elementwise, the same conversion per element; + # ``float(x)`` below raised on any array of more than one (FNP-D0036). + if np.issubdtype(x.dtype, np.integer): + return x.astype(dtype) + values = x.astype(np.float64) + with np.errstate(invalid="ignore"): + inside = (values >= -limit) & (values < limit) + return np.where(inside, np.trunc(np.where(inside, values, 0.0)), -limit).astype(dtype) value = float(x) if not -limit <= value < limit: # NaN compares false and lands here too return dtype(-limit) @@ -461,13 +610,53 @@ def _f_vmax(a: Any, b: Any) -> Any: return np.fmax(a, b) -def _f_nint(x: Any) -> Any: +def _f_anint(x: Any) -> Any: + """Fortran ANINT: the nearest whole number, halves away from zero, as a + REAL of the argument's kind -- C's ``round``, which is what gfortran + calls. + + ``np.round`` -- the spelling this replaced -- rounds halves to *even*: + ``anint(2.5)`` came out 2.0 and ``anint(-2.5)`` -2.0 where Fortran says + 3.0 and -3.0 (FNP-D0004). ``floor(x + 0.5)`` is not the fix either: the + addition rounds, so 0.49999999999999994 + 0.5 is 1.0 and the "nearest" + whole number of something below a half would be one. The fractional + part ``x - trunc(x)`` is exact in binary floating point, so comparing + *it* with a half decides without rounding anything. A NaN compares + false and stays NaN; an infinity's fraction is NaN and it stays itself; + ``-0.3`` keeps its sign, as ``round`` does. + + Scalars and arrays alike, so the one name serves the scalar and the + array spelling and the JAX shim can answer for it. + """ + t = np.trunc(x) + if np.ndim(x) == 0: + return t + math.copysign(1.0, x) if math.isfinite(x) and abs(x - t) >= 0.5 else t + with np.errstate(invalid="ignore"): # inf - inf, whose NaN picks ``t`` + return np.where(np.abs(x - t) >= 0.5, t + np.copysign(1.0, x), t) + + +def _f_nint(x: Any, kind: Any = None) -> Any: """Fortran NINT: round half away from zero (not banker's rounding). Python round() uses banker's rounding: round(0.5)=0, round(2.5)=2. - Fortran NINT: NINT(0.5)=1, NINT(2.5)=3.""" - if isinstance(x, (float, np.floating)): - return np.int32(math.floor(x + 0.5)) if x >= 0 else np.int32(math.ceil(x - 0.5)) - return np.int32(x) + Fortran NINT: NINT(0.5)=1, NINT(2.5)=3. + + Rounded by ``_f_anint`` and converted by ``_f_int``: ``floor(x + 0.5)``, + which this used to be, rounds 0.49999999999999994 up to 1 and an odd + integer above 2**52 up to the next even one, and ``math.floor`` of a NaN + raised where the compiled ``lround`` answers INT64_MIN. ``kind`` is the + result's, 4 or 8, as for ``_f_int``. + + gfortran computes even a default-kind NINT as that 64-bit ``lround`` + and keeps its low 32 bits, so the default kind is not ``_f_int``'s + saturating conversion: ``nint(3.0d10)`` is -64771072, not -2147483648, + and ``nint`` of a NaN -- INT64_MIN, whose low half is zero -- is 0.""" + if isinstance(x, (float, np.floating)) or ( + isinstance(x, np.ndarray) and not np.issubdtype(x.dtype, np.integer) + ): + # An array too: ``np.int32`` of a REAL array truncates, silently. + wide = _f_int(_f_anint(x), 8) + return wide if int(kind or 4) == 8 else wide.astype(np.int32) + return np.int64(x) if int(kind or 4) == 8 else np.int32(x) def _f_sign(a: Any, b: Any) -> Any: @@ -486,7 +675,27 @@ def _f_mod(a: Any, p: Any) -> Any: def _f_int_div(a: Any, b: Any) -> Any: - """Fortran integer division truncates toward zero; Python // floors.""" + """Fortran integer division truncates toward zero; Python // floors. + + In integers throughout: ``int(a / b)`` rounds the quotient to a double + first, and past 2**53 that is a different integer -- ``(2**53 + 1) / 1`` + came back 2**53 (FNP-D0035). The magnitude's floor is the truncated + quotient, negated when the signs differ. Arrays elementwise, where + ``int`` of one raised (FNP-D0036). A scalar result stays a Python int, as + it always was; an operand that is not an integer at all -- which Fortran + typing says cannot happen here -- keeps the old spelling.""" + if isinstance(a, np.ndarray) or isinstance(b, np.ndarray): + x, y = np.asarray(a), np.asarray(b) + if not (np.issubdtype(x.dtype, np.integer) and np.issubdtype(y.dtype, np.integer)): + return np.trunc(x / y) + if np.any(y == 0): + raise ZeroDivisionError("integer division by zero") + quotients = np.abs(x.astype(np.int64)) // np.abs(y.astype(np.int64)) + signed = np.where((x < 0) != (y < 0), -quotients, quotients) + return signed.astype(np.result_type(x, y)) + if isinstance(a, (int, np.integer)) and isinstance(b, (int, np.integer)): + q = abs(int(a)) // abs(int(b)) + return -q if (a < 0) != (b < 0) else q return int(a / b) @@ -687,6 +896,80 @@ def fit(s: str, w: int) -> str: raise ValueError(f"_f_fmt_write: descriptor {u}") +def _f_reshape(source: Any, shape: Any, pad: Any = None, order: Any = None) -> Any: + """Fortran RESHAPE (F2018 16.9.163), where ``np.reshape`` is not it. + + The result takes the source's elements in array element order -- + column-major -- and only as many as it has room for: a source larger + than ``PRODUCT(SHAPE)`` is legal, and ``np.reshape`` raises on it. A + shorter one is filled out with copies of PAD, taken in its own array + element order and repeated as often as needed. ORDER permutes the + subscripts the elements are laid down along: ``order=[2, 1]`` varies + the second subscript fastest, so a 2-by-3 result is filled row by row. + """ + extents = [int(e) for e in np.ravel(shape)] + size = math.prod(extents) + flat = np.ravel(source, order="F") + if flat.size < size: + if pad is None or np.size(pad) == 0: + raise ValueError(f"RESHAPE: {flat.size} source elements for {size}, and no PAD") + fill = np.resize(np.ravel(pad, order="F"), size - flat.size) + flat = np.concatenate([flat, fill]) + flat = flat[:size] + if order is None: + return np.reshape(flat, extents, order="F") + axes = [int(o) - 1 for o in np.ravel(order)] + laid = np.reshape(flat, [extents[a] for a in axes], order="F") + return np.asfortranarray(np.transpose(laid, np.argsort(axes))) + + +def _f_pack(array: Any, mask: Any, vector: Any = None) -> Any: + """Fortran PACK (F2018 16.9.145): the elements MASK selects, in array + element order -- column-major, where boolean indexing walks a rank-2 + array by rows -- and, given VECTOR, a result as long as VECTOR whose + tail past the packed elements is VECTOR's own. A scalar MASK selects + every element or none.""" + values = np.asarray(array) + chosen = np.ravel(np.broadcast_to(mask, values.shape), order="F") + packed = np.ravel(values, order="F")[chosen] + if vector is None: + return packed + result = np.array(np.ravel(vector, order="F"), dtype=values.dtype) + result[: packed.size] = packed + return result + + +def _f_loc(array: Any, extremum: str, mask: Any = None, dim: Any = None) -> Any: + """Fortran MAXLOC/MINLOC of a whole array: the 1-based subscripts of the + first maximum (minimum) in array element order -- column-major, where + ``np.argmax`` breaks a rank-2 tie by rows -- among the elements MASK + selects; zeros when it selects none or the array is empty. + + A NaN is no element's extremum: ``np.argmax`` answers the first NaN it + meets, where gfortran skips them and answers the first number that is + the extremum -- ``maxloc([NaN, 2, 7, 7])`` is 3, not 1 -- and answers + the first selected element only when every selected one is a NaN. With + DIM, the same search along each line of that axis.""" + values = np.asarray(array) + if dim is not None: + axis = int(dim) - 1 + if values.ndim == 1: + return _f_loc(values, extremum)[0] + if values.size == 0: + return np.zeros(np.delete(values.shape, axis), dtype=np.int64) + return np.apply_along_axis(lambda line: _f_loc(line, extremum)[0], axis, values) + chosen = np.ones(values.shape, dtype=bool) if mask is None else mask + where = np.flatnonzero(np.ravel(np.broadcast_to(chosen, values.shape), order="F")) + if where.size == 0: + return np.zeros(values.ndim, dtype=np.int64) + picked = np.ravel(values, order="F")[where] + numbers = ~np.isnan(picked) if picked.dtype.kind == "f" else None + if numbers is not None and numbers.any() and not numbers.all(): + where, picked = where[numbers], picked[numbers] + first = np.argmax(picked) if extremum == "max" else np.argmin(picked) + return np.array(np.unravel_index(where[first], values.shape, order="F")) + 1 + + def _f_unpack(vector: Any, mask: Any, field: Any) -> Any: """Fortran UNPACK: scatter vector elements into field where mask is True.""" result = field.copy() @@ -694,11 +977,16 @@ def _f_unpack(vector: Any, mask: Any, field: Any) -> Any: return result -def _f_eoshift(array: Any, shift: Any, axis: Any = 0) -> Any: - """Fortran EOSHIFT: shift and fill with zeros (no wrap-around).""" +def _f_eoshift(array: Any, shift: Any, axis: Any = 0, boundary: Any = None) -> Any: + """Fortran EOSHIFT: shift, no wrap-around, and fill the vacated end with + BOUNDARY -- a scalar, or for rank > 1 an array of one value per line + along ``axis`` -- or with zero when it is absent. A shift longer than + the axis vacates all of it.""" result = np.zeros_like(array) + if boundary is not None: + result[...] = np.expand_dims(boundary, axis) if np.ndim(boundary) else boundary n = array.shape[axis] - s = int(shift) + s = max(-n, min(n, int(shift))) if s > 0: slc_src = [slice(None)] * array.ndim slc_dst = [slice(None)] * array.ndim @@ -722,21 +1010,35 @@ def _f_index(string: str, substring: str) -> int: return p + 1 if p >= 0 else 0 -def _f_huge(x: Any) -> Any: - """Fortran HUGE: largest representable value of same type.""" - if isinstance(x, (float, np.floating)): - return np.finfo(np.float64).max - return np.iinfo(np.int32).max +def _f_huge(x: Any, dtype: Any = None) -> Any: + """Fortran HUGE: largest representable value of the argument's kind. + ``dtype`` is the kind as the declaration gives it, which the emitter + passes: the Python type of ``x`` is not the kind -- an ``integer(8)`` + holds a Python int, and so does a ``real(8)`` assigned ``0`` -- and + guessing from it answered the int32 maximum for both (FNP-D0008, + FNP-D0039). Without one, the old guess. A REAL answer is a float64, + the precision this translation computes in, holding the kind's value + exactly.""" + if dtype is None: + if isinstance(x, (float, np.floating)): + return np.finfo(np.float64).max + return np.iinfo(np.int32).max + if str(dtype).startswith("int"): + return int(np.iinfo(dtype).max) + return np.float64(np.finfo(dtype).max) -def _f_tiny(x: Any) -> Any: - """Fortran TINY: smallest positive normalized value.""" - return np.finfo(np.float64).tiny +def _f_tiny(x: Any, dtype: Any = None) -> Any: + """Fortran TINY: smallest positive normalized value of the argument's + kind (``dtype``, as for ``_f_huge``; float64 without one).""" + return np.float64(np.finfo(dtype or np.float64).tiny) -def _f_epsilon(x: Any) -> Any: - """Fortran EPSILON: smallest difference from 1.0 of same type.""" - return np.finfo(np.float64).eps + +def _f_epsilon(x: Any, dtype: Any = None) -> Any: + """Fortran EPSILON: smallest difference from 1.0 of the argument's kind + (``dtype``, as for ``_f_huge``; float64 without one).""" + return np.float64(np.finfo(dtype or np.float64).eps) def _f_modulo(a: Any, p: Any) -> Any: @@ -770,29 +1072,34 @@ def _wrap_signed(v: Any, bits: int | None) -> Any: return v -def _f_iand(a: Any, b: Any) -> Any: - bits = _int_bits(a, b) +def _f_iand(a: Any, b: Any, bits: Any = None) -> Any: + """``bits`` is the operands' declared KIND in bits, which the emitter + passes where it knows it (FNP-D0009); otherwise it is read off their + dtypes, as ``_int_bits`` says.""" + bits = bits or _int_bits(a, b) m = (1 << bits) - 1 if bits is not None else -1 return _wrap_signed((int(a) & m) & (int(b) & m), bits) -def _f_ior(a: Any, b: Any) -> Any: - bits = _int_bits(a, b) +def _f_ior(a: Any, b: Any, bits: Any = None) -> Any: + bits = bits or _int_bits(a, b) m = (1 << bits) - 1 if bits is not None else -1 return _wrap_signed((int(a) & m) | (int(b) & m), bits) -def _f_ieor(a: Any, b: Any) -> Any: - bits = _int_bits(a, b) +def _f_ieor(a: Any, b: Any, bits: Any = None) -> Any: + bits = bits or _int_bits(a, b) m = (1 << bits) - 1 if bits is not None else -1 return _wrap_signed((int(a) & m) ^ (int(b) & m), bits) -def _f_ishft(i: Any, shift: Any) -> Any: +def _f_ishft(i: Any, shift: Any, bits: Any = None) -> Any: """Fortran ISHFT: a LOGICAL shift (zero-fill), positive = left, negative - = right, within the operand's bit width (#15).""" + = right, within the operand's bit width (#15) -- ``bits`` where the + emitter knows the declared kind, which a literal ``1`` has and a dtype + does not say (FNP-D0009).""" s = int(shift) - bits = _int_bits(i) + bits = bits or _int_bits(i) v = int(i) if bits is not None: v &= (1 << bits) - 1 # the unsigned view, for a logical shift @@ -808,7 +1115,25 @@ def _f_scan(string: str, set_chars: str) -> int: return 0 -def _f_kind(x: Any) -> Any: +_F_KINDS = { + "int32": 4, + "int64": 8, + "float32": 4, + "float64": 8, + "complex64": 4, + "complex128": 8, + "bool": 4, + "str": 1, +} +"""gfortran's kind numbers: the byte width, of a complex's parts.""" + + +def _f_kind(x: Any, dtype: Any = None) -> Any: + """Fortran KIND of the argument's declared ``dtype``, which the emitter + passes (``real(4)`` is 4 whatever float holds it, FNP-D0007); without + one, the old guess from the Python type.""" + if dtype is not None: + return _F_KINDS[str(dtype)] if isinstance(x, (float, np.floating)): return 8 if isinstance(x, (int, np.integer)): @@ -816,8 +1141,46 @@ def _f_kind(x: Any) -> Any: return 1 -def _f_precision(x: Any) -> Any: - return 15 +def _f_precision(x: Any, dtype: Any = None) -> Any: + """Fortran PRECISION: decimal digits of the argument's kind -- 6 for a + single, 15 for a double, a complex's that of its parts (``dtype``, as + for ``_f_kind``; a double without one).""" + return int(np.finfo(dtype or np.float64).precision) + + +def _f_digits(x: Any, dtype: Any) -> Any: + """Fortran DIGITS of the argument's declared kind (the emitter passes + it, FNP-D0027): the model's significant binary digits -- 31 and 63 for + the integers, 24 and 53 for the reals.""" + if str(dtype).startswith("int"): + return int(np.iinfo(dtype).bits) - 1 + return int(np.finfo(dtype).nmant) + 1 + + +def _f_range(x: Any, dtype: Any) -> Any: + """Fortran RANGE: the decimal exponent range of the declared kind -- + 9 and 18 for the integers, 37 and 307 for the reals and their complexes.""" + if str(dtype).startswith("int"): + return int(math.log10(np.iinfo(dtype).max)) + info = np.finfo(dtype) + return min(int(math.log10(info.max)), int(-math.log10(info.tiny))) + + +def _f_maxexponent(x: Any, dtype: Any) -> Any: + """Fortran MAXEXPONENT of the declared real kind: 128, 1024.""" + return int(np.finfo(dtype).maxexp) + + +def _f_minexponent(x: Any, dtype: Any) -> Any: + """Fortran MINEXPONENT of the declared real kind: -125, -1021. One above + NumPy's ``minexp``: Fortran's model puts the significand in [1/2, 1), + IEEE's in [1, 2).""" + return int(np.finfo(dtype).minexp) + 1 + + +def _f_bit_size(x: Any, dtype: Any) -> Any: + """Fortran BIT_SIZE of the declared integer kind: 32, 64.""" + return int(np.iinfo(dtype).bits) def _f_radix(x: Any) -> Any: @@ -841,11 +1204,32 @@ def _f_is_iostat_end(stat: int) -> bool: return stat < 0 -def _f_lbound(arr: Any, dim: Any = None) -> Any: - """Fortran LBOUND is always 1 for standard arrays.""" +def _f_lbound(arr: Any, dim: Any = None, lower: Any = None) -> Any: + """Fortran LBOUND: the lower bound of each axis, or of axis ``dim``. + + ``lower`` is the declared (or allocated) lower bound of every axis, as + the emitter shifts subscripts by; absent, every axis is based at one, + which is what an array expression, a section and an undeclared-bound + array are. An axis of zero extent answers 1 whatever it was declared + with (F2018 16.9.109).""" + shape = np.shape(arr) + lows = [1] * len(shape) if lower is None else [int(b) for b in lower] + lows = [low if extent else 1 for low, extent in zip(lows, shape, strict=False)] + if dim is not None: + return lows[int(dim) - 1] + return np.array(lows, dtype=np.int32) + + +def _f_ubound(arr: Any, dim: Any = None, lower: Any = None) -> Any: + """Fortran UBOUND: the lower bound plus the extent, less one, per axis + or along ``dim`` -- the extent itself only on an axis based at one. An + axis of zero extent answers 0 (F2018 16.9.196).""" + shape = np.shape(arr) + lows = [1] * len(shape) if lower is None else [int(b) for b in lower] + highs = [low + extent - 1 if extent else 0 for low, extent in zip(lows, shape, strict=False)] if dim is not None: - return 1 - return np.ones(arr.ndim, dtype=np.int32) + return highs[int(dim) - 1] + return np.array(highs, dtype=np.int32) def _f_c_loc(x: Any) -> Any: diff --git a/src/recast/transform/numpy/statements.py b/src/recast/transform/numpy/statements.py index 99ab08d..c8c370c 100644 --- a/src/recast/transform/numpy/statements.py +++ b/src/recast/transform/numpy/statements.py @@ -41,6 +41,7 @@ from __future__ import annotations +import ast import re from dataclasses import dataclass, field from typing import Any @@ -49,9 +50,24 @@ from recast.fortran import intrinsics from recast.fortran._parse import f03, f08, walk from recast.fortran.interface import CONFLICTING_BOUNDS, emit_name +from recast.fortran.rwset import ( + associated_by, + associations, + declared_length, + fitted_length, + returned_length, + scalar_variable, + written_names, + written_through, +) from recast.fortran.semantics import Semantics, Unanalyzable from recast.transform.numpy.calls import CallSite -from recast.transform.numpy.expressions import REFUSED, Expressions, function_outputs +from recast.transform.numpy.expressions import ( + NON_NEGATIVE_INQUIRIES, + REFUSED, + Expressions, + function_outputs, +) from recast.transform.numpy.names import Names from recast.transform.numpy.vocabulary import pysafe from recast.transform.rules import NoRule @@ -345,6 +361,32 @@ def arm_end(node: Any) -> int | None: return marked +def _base_name(designator: Any) -> str | None: + """The variable a designator names: ``a`` of ``a``, ``a(i)``, ``a%b(i)``.""" + while isinstance(designator, (f03.Part_Ref, f03.Data_Ref)): + designator = designator.children[0] + return str(designator).lower() if isinstance(designator, f03.Name) else None + + +def _write_unit(write: Any) -> Any: + """The unit a WRITE names, as it is spelled.""" + for spec in walk(write.children[0], f03.Io_Control_Spec): + keyword, value = spec.children + if keyword is None or str(keyword).upper() == "UNIT": + return value + return None + + +def _subscripts_of(designator: Any) -> Any: + """A ``Part_Ref``'s subscript list, or ``None`` for anything else.""" + return designator.children[1] if isinstance(designator, f03.Part_Ref) else None + + +def _spelled(node: Any) -> str | None: + """Source text compared as text: blanks and case do not make it differ.""" + return None if node is None else str(node).lower().replace(" ", "") + + @dataclass class Statements: """Render Fortran statements for one subprogram. @@ -734,6 +776,11 @@ def _assignment(self, node: Any, indent: int) -> list[str]: # OUT/INOUT dummies back beside its result, so the statement is # a call whose first output is the target (``_call``). return self._call(value, indent, result=target) + return [f"{pad}{self.target(target)} = {self._assigned_value(target, value)}"] + + def _assigned_value(self, target: Any, value: Any) -> str: + """The right-hand side of ``target = value``, converted the way + Fortran converts on assignment.""" rendered = self.expressions.render(value) if ( self.semantics.is_scalar_integer_target(target) @@ -750,8 +797,73 @@ def _assignment(self, node: Any, indent: int) -> list[str]: except Unanalyzable: rank = 0 if rank == 0 and not self.semantics.is_logical_or_character(value): - rendered = f"_f_int({rendered})" - return [f"{pad}{self.target(target)} = {rendered}"] + # Into the target's kind: an ``integer(8)`` holds what the + # default-kind conversion would saturate (FNP-D0005). + wide = self.semantics.scalar_target_dtype(target) == "int64" + rendered = f"_f_int({rendered}, 8)" if wide else f"_f_int({rendered})" + return self.fitted(target, rendered, value) + + def fitted(self, target: Any, rendered: str, value: Any = None) -> str: + """A value stored into a fixed-length CHARACTER, padded or cut to it. + + A ``character(len=10)`` variable always holds ten characters: Fortran + pads a shorter value with blanks and truncates a longer one on + assignment (F2018 10.2.1.3). The Python string kept whatever it was + given, so ``s = 'ab'`` then ``len(s)`` answered 2 where gfortran + answers 10, and ``t = 'abcdef'`` into a ``len=3`` kept all six + (FNP-D0021). Which stores are fitted, and to what, is + ``fitted_length``'s: the read/write sets count what the length reads. + + An array value is fitted element by element -- ``a = b`` and + ``a = ['ab', 'cd']`` into a ``len=4`` ``a`` stored the two-character + strings as they were -- through ``np.frompyfunc``, which keeps the + value's shape and evaluates it once. + """ + length = self._spelled_length(fitted_length(target, value, self.semantics), target) + if length is None: + return rendered + try: + elementwise = value is not None and self.semantics.rank(value) > 0 + except Unanalyzable: + elementwise = False + if elementwise: + return f"np.frompyfunc(lambda _fe: _fe.ljust({length})[:{length}], 1, 1)({rendered})" + return f"({rendered}).ljust({length})[:{length}]" + + def _returned_length(self, formal: dict[str, Any], actual: Any) -> str | None: + """``returned_length``, spelled: the length of the caller's CHARACTER + ``actual`` that what an ``intent(out)`` ``len=*`` dummy hands back is + fitted to, or ``None``. + + ``s = 'hello'`` in the callee is stored and returned as five + characters, and the caller's ``character(len=8) :: s`` bound to it + had ``len(s)`` 5 and ``s // '|'`` ``hello|`` where gfortran has 8 + and ``hello |`` (FNP-D0021). + """ + return self._spelled_length(returned_length(formal, actual, self.semantics), actual) + + def character_length(self, name: str) -> str | None: + """The declared length of a CHARACTER variable, as Python, or ``None``. + + ``declared_length``, spelled: digits as they are, a named constant + as this subprogram names it, an assumed length ``len=*`` as the + length of the value the dummy was passed. + """ + return self._spelled_length(declared_length(name, self.semantics), name) + + def _spelled_length(self, length: str | None, variable: Any) -> str | None: + if length is None: + return None + if length.isdigit(): + return length + if length == "*": + return f"len({self.names.symbol(str(_base_name(variable) or variable))})" + spelled = re.sub(r"[A-Za-z_]\w*", lambda m: self.names.symbol(m.group(0)), length) + try: + ast.parse(spelled, mode="eval") + except SyntaxError: + return None + return spelled def _function_with_outputs(self, node: Any) -> dict[str, Any] | None: """The record of a function with source whose OUT/INOUT dummies the @@ -928,33 +1040,206 @@ def _where_construct(self, node: Any, indent: int) -> list[str]: return lines def _forall(self, node: Any, indent: int) -> list[str]: - """FORALL, as the nested loops it abbreviates.""" + """FORALL: every value computed before any is stored. + + A FORALL is not the nested loops it looks like. For each assignment + of its body, Fortran evaluates the right-hand side -- and the target's + subscripts -- for every active combination of the index values + first, and only then assigns (F2018 11.1.7.4.3); each assignment of + a construct completes over the whole index set before the next one + starts; and the scalar mask picks the active combinations once, up + front. Written as loops, ``forall (i = 2:n) a(i) = a(i-1)`` read the + ``a(i-1)`` the previous trip had just stored and smeared ``a(1)`` + across the array, and the mask of ``forall (i = 1:n, a(i) > 0)`` was + dropped outright (FNP-D0002, FNP-D0003). + + The loops are still what is emitted wherever they mean the same + thing -- no reference in the construct reads an element some + assignment of it stores, other than the very element the same trip + stores -- with the mask as an ``if`` around the body. Everywhere else + the active combinations are listed first (``_fa``), each assignment's + values gathered over them beside the combination they belong to + (``_fv``) and then stored; a body that is not all assignments has no + such form and is refused. + """ + header = walk(node, f03.Forall_Header)[0] if isinstance(node, f03.Forall_Stmt): - header = walk(node, f03.Forall_Header)[0] - body = [c for c in node.children if isinstance(c, f03.Assignment_Stmt)] + body = [c for c in node.children if c is not None and c is not header] else: - header = walk(node, f03.Forall_Header)[0] body = [ c for c in node.children if not isinstance(c, (f03.Forall_Construct_Stmt, f03.End_Forall_Stmt)) ] + mask = header.children[1] if len(header.children) > 1 else None triplets = walk(header, f03.Forall_Triplet_Spec) - lines = [] - for depth, triplet in enumerate(triplets): - parts = [str(c).strip() for c in triplet.children if c is not None] - variable = parts[0].lower() + # An index has the construct's scope (F2018 19.4): a variable of its + # name outside is neither read nor changed by it. As the Python local + # of that name, ``i = 100; forall (i = 2:n) ...; k = i`` left ``k`` at + # the last index, 4, where gfortran gives 100. Within the construct + # the name is spelled as an index of its own, the way an ASSOCIATE + # name is spelled as its selector; the bounds are rendered first, and + # may not name the construct's own indices anyway. + indices = { + lowered: f"_fi_{lowered}" for lowered in (str(t.children[0]).lower() for t in triplets) + } + loops = [] + for triplet in triplets: + variable = indices[str(triplet.children[0]).lower()] low = self.expressions.render(triplet.children[1]) if triplet.children[1] else "1" high = self.expressions.render(triplet.children[2]) step = "" + stop = f"({high}) + 1" if triplet.children[3] is not None: - step = f", {self.expressions.render(triplet.children[3])}" - pad = " " * (indent + depth) - lines.append(f"{pad}for {pysafe(variable)} in range({low}, ({high}) + 1{step}):") + rendered = self.expressions.render(triplet.children[3]) + step = f", {rendered}" + # ``range`` stops short of its stop edge, which lies past + # ``high`` in the direction of the step: ``forall (i = n:1:-1)`` + # as ``range(n, 1 + 1, -1)`` never visited ``i = 1``. A step + # spelled as an integer literal settles the direction here; + # any other waits for its value. + literal = re.fullmatch(r"([+-]?)\d+", str(triplet.children[3]).replace(" ", "")) + if literal is None: + stop = f"({high}) + (1 if ({rendered}) > 0 else -1)" + elif literal.group(1) == "-": + stop = f"({high}) - 1" + loops.append((variable, f"range({low}, {stop}{step})")) + associations = self.names.associations + shadowed = {name: associations.get(name) for name in indices} + associations.update(indices) + try: + return self._forall_lines(body, mask, loops, indent) + finally: + for name, outer in shadowed.items(): + if outer is None: + associations.pop(name, None) + else: + associations[name] = outer + + def _forall_lines( + self, body: list[Any], mask: Any, loops: list[tuple[str, str]], indent: int + ) -> list[str]: + """A FORALL's loops, or its gathered values, over ``loops``' indices.""" + if not self._forall_depends(body, mask): + lines = [] + for depth, (variable, span) in enumerate(loops): + lines.append(f"{' ' * (indent + depth)}for {variable} in {span}:") + inner = indent + len(loops) + if mask is not None: + lines.append(f"{' ' * inner}if {self.expressions.render(mask)}:") + inner += 1 + for statement in body: + lines.extend(self.render(statement, inner)) + return lines + + if not all(isinstance(s, f03.Assignment_Stmt) for s in body): + kinds = sorted( + {type(s).__name__ for s in body if not isinstance(s, f03.Assignment_Stmt)} + ) + raise NoRule( + f"FORALL whose values depend on its own stores, with a body of {', '.join(kinds)}" + ) + pad = " " * indent + names = ", ".join(variable for variable, _ in loops) + combination = names if len(loops) == 1 else f"({names})" + generators = " ".join(f"for {variable} in {span}" for variable, span in loops) + condition = f" if {self.expressions.render(mask)}" if mask is not None else "" + lines = [f"{pad}_fa = [{combination} {generators}{condition}]"] for statement in body: - lines.extend(self.render(statement, indent + len(triplets))) + target, _, value = statement.children + if self._function_with_outputs(value) is not None or ( + isinstance(target, f03.Name) and self.semantics.derived_type_of(str(target)) + ): + raise NoRule("FORALL assigning a derived type or through a function's outputs") + base = _base_name(target) + if base is not None and any( + str(name).lower() == base for name in walk(_subscripts_of(target), f03.Name) + ): + raise NoRule(f"FORALL whose target {base!r} is subscripted by its own elements") + value_text = self._assigned_value(target, value) + if self._forall_stores_sections(target, value): + # A section of an array is a view of it, not its values: + # ``a(i, :) = a(i-1, :)`` gathered views of the rows it then + # stored into, and each store changed what the next trip's + # "value" held (1 1 1 1 where gfortran gives 1 1 2 3). + value_text = f"np.array({value_text})" + lines.append(f"{pad}_fv = [({combination}, {value_text}) for {combination} in _fa]") + lines.append(f"{pad}for {combination}, _fe in _fv:") + lines.append(f"{pad} {self.target(target)} = _fe") return lines + def _forall_stores_sections(self, target: Any, value: Any) -> bool: + """Whether a gathered FORALL assignment stores arrays, not elements. + + The target settles it: Fortran lets only a scalar be stored into an + element, and indexing an element out of an array is already a + value. A target whose rank cannot be settled falls back on the + value's, and when neither can be the assignment is refused rather + than gathered as what may be a view. + """ + for node in (target, value): + try: + return self.semantics.rank(node) > 0 + except Unanalyzable: + continue + raise NoRule(f"FORALL assigning {target}, whose rank cannot be settled") + + @staticmethod + def _forall_depends(body: list[Any], mask: Any) -> bool: + """Whether a FORALL's loops would read something its own stores changed. + + The loops agree with FORALL when every reference to a stored array -- + in a right-hand side, a target's subscripts, the mask -- names the + element the same trip stores: the same subscripts, spelled the same, + as the one assignment that stores that array. ``a(i) = 2*a(i)`` reads + only what this trip is about to overwrite; ``a(i) = a(i-1)`` reads + what the previous one wrote. Anything this cannot see that way -- a + whole-array or component reference to a stored name, two assignments + storing one array under different subscripts -- is a dependence. + """ + assignments = [a for statement in body for a in walk(statement, f03.Assignment_Stmt)] + stored: dict[str, str | None] = {} + for assignment in assignments: + target = assignment.children[0] + base = _base_name(target) + if base is None: + return True + spelled = _spelled(_subscripts_of(target)) if isinstance(target, f03.Part_Ref) else None + if base in stored and stored[base] != spelled: + stored[base] = None + else: + stored.setdefault(base, spelled) + if not stored: + return False + read = [mask] if mask is not None else [] + for assignment in assignments: + read.append(assignment.children[2]) + read.append(_subscripts_of(assignment.children[0])) + for expression in read: + if expression is None: + continue + references = walk(expression, (f03.Name, f03.Part_Ref, f03.Data_Ref)) + for reference in [expression, *references]: + if isinstance(reference, f03.Part_Ref): + base = str(reference.children[0]).lower() + if base in stored and ( + stored[base] is None or _spelled(_subscripts_of(reference)) != stored[base] + ): + return True + elif isinstance(reference, f03.Data_Ref): + if _base_name(reference) in stored: + return True + elif isinstance(reference, f03.Name): + parent = getattr(reference, "parent", None) + heads = isinstance(parent, f03.Part_Ref) and parent.children[0] is reference + if ( + str(reference).lower() in stored + and not heads + and not isinstance(parent, f03.Data_Ref) + ): + return True + return False + def _note_dropped_reads(self, items: list[Any]) -> None: """Every name in a stubbed call's actual arguments, keywords aside.""" keywords = {str(a.children[0]).lower() for a in items if isinstance(a, f03.Actual_Arg_Spec)} @@ -998,27 +1283,35 @@ def _allocate(self, node: Any, indent: int) -> list[str]: target, shape = allocation.children[0], allocation.children[1] extents = [] bounds = [] + lows = [] for spec in walk(shape, f03.Allocate_Shape_Spec): low, high = spec.children if low is not None and str(low) != "1": bounds.append({"lb": str(low), "ub": str(high)}) - extents.append( - f"({self.expressions.render(high)}) - ({self.expressions.render(low)}) + 1" - ) + lows.append(self.expressions.render(low)) + extent = f"({self.expressions.render(high)}) - ({lows[-1]}) + 1" else: bounds.append({"lb": "1", "ub": str(high)}) - extents.append(self.expressions.render(high)) + lows.append("1") + extent = self.expressions.render(high) + extents.append(self._allocated_extent(extent, low, high)) + captures: list[str] = [] if isinstance(target, f03.Name) and any(d["lb"] != "1" for d in bounds): key = str(target).lower() previous = self.expressions.allocated_bounds.get(key) if previous is not None and ( previous == CONFLICTING_BOUNDS # type: ignore[comparison-overlap] - or [d["lb"] for d in previous] != [d["lb"] for d in bounds] + or [d.get("source_lb", d["lb"]) for d in previous] != [d["lb"] for d in bounds] ): # The module-wide record already found two ALLOCATEs # that disagree, or one no other subprogram can # evaluate; this allocation cannot settle it either. raise NoRule(f"conflicting allocate lower bounds for {key}") + # The bounds are the values the expressions have here, at + # the ALLOCATE (F2018 9.7.1.2): one the body can change later + # is held in a local, set again by every ALLOCATE of the + # array, a loop's included (FNP-D0049). + bounds, captures = self.expressions.capture_bounds(key, bounds, lows) self.expressions.allocated_bounds[key] = bounds if isinstance(target, f03.Name): rendered = self.names.symbol(str(target)) @@ -1046,12 +1339,14 @@ def _allocate(self, node: Any, indent: int) -> list[str]: ) if filled is not None: lines.append(f"{pad}{rendered} = {filled}") + lines.extend(pad + capture for capture in captures) continue # A component (``obj%arr``) has no declaration here and keeps the # float64 the pipeline gave it; a declared name is built as it # was declared or refused. dtype = allocated_dtype(declaration["dtype"]) if declaration else "np.float64" lines.append(f"{pad}{rendered} = {undefined_array(self, shape_text, dtype)}") + lines.extend(pad + capture for capture in captures) if not lines: # `allocate(x)` with no shape: a scalar allocatable, which for a # derived type is the object coming into existence. @@ -1064,6 +1359,31 @@ def _allocate(self, node: Any, indent: int) -> list[str]: raise NoRule("allocate without shape specs") return lines + def _allocated_extent(self, extent: str, low: Any, high: Any) -> str: + """One axis's extent in an ALLOCATE shape, kept off zero's far side. + + ``allocate(a(lo:hi))`` with ``hi < lo`` allocates an array of zero + size on that axis (F2018 9.7.1.2), as ``allocate(a(n))`` does with + ``n < 0``. The shape was the bare ``(hi) - (lo) + 1``, and NumPy + refuses a negative dimension outright: ``allocate(a(-n:n))`` with + ``n = -1`` raised ValueError where gfortran has an empty ``a`` + (FNP-D0050). So the extent is clamped at zero. Not where it cannot + go below it: integer literals that bound a non-negative extent, and + an upper bound on an axis based at one that is an inquiry never + below zero (``allocate(b(size(a)))``). + """ + upper = self.semantics.integer_literal(high) + lower = 1 if low is None or str(low) == "1" else self.semantics.integer_literal(low) + if upper is not None and lower is not None and upper - lower + 1 >= 0: + return extent + if ( + lower == 1 + and isinstance(high, f03.Intrinsic_Function_Reference) + and str(high.children[0]).lower() in NON_NEGATIVE_INQUIRIES + ): + return extent + return f"max(0, {extent})" + def _deallocate(self, node: Any, pad: str) -> list[str]: # Allocatable tracking: only DIRECT variables go back to None (the # ``allocated()`` mirror); deallocating a derived-type COMPONENT is a @@ -1142,6 +1462,15 @@ def _write(self, node: Any, indent: int) -> list[str]: format_ = value position += 1 name = str(unit).strip().lower() if unit is not None else "*" + if isinstance(unit, f03.Name) and name in self.names.associations: + # ``associate (c => s); write (c, '(i4)') n`` writes ``s``: the + # name is spelled as its selector (``written_through``), and it + # is the selector's declaration that says the unit is internal. + # Read by the name's, which is none, the write was a log and + # the store was dropped. + through = self._write_through(node, unit, name, items, format_, record_bound, pad) + if through is not None: + return through declaration = self.semantics.declaration(name) if UNIT_NAME.fullmatch(name) else None subprogram = self.semantics.subprogram # A character function's result is an internal unit too: ``write(str, @@ -1154,21 +1483,8 @@ def _write(self, node: Any, indent: int) -> list[str]: if internal: if not record_bound: raise NoRule("internal write with ADVANCE= control") - arguments = ", ".join( - self.expressions.render(item) - for item in (items.children if hasattr(items, "children") else [items]) - ) - spelled = str(format_).strip() if format_ is not None else "*" - if spelled == "*": - return [f"{pad}{pysafe(name)} = _f_list_write({arguments})"] - # A formatted internal write: the FMT decides the layout, so the - # list-directed shim would be a silently wrong string (#16). The - # format need not be a literal -- ``_f_fmt_write`` parses it where - # it stands, which is where a dummy argument carrying one is - # known -- but a literal is checked here, while the descriptors - # are still in front of the emitter. - fmt = self._io_format(format_, "formatted internal write") - return [f"{pad}{pysafe(name)} = _f_fmt_write({fmt}, {arguments})"] + record = self._internal_record(unit, items, format_) + return [f"{pad}{pysafe(name)} = {record}"] if name not in set(subprogram.get("file_units") or ()): return [f"{pad}pass # write({name},...) log — no dataflow"] rendered_items = [ @@ -1182,6 +1498,61 @@ def _write(self, node: Any, indent: int) -> list[str]: f"{self._io_format(format_, 'WRITE')}, [{', '.join(rendered_items)}]{settings})" ] + def _internal_record(self, unit: Any, items: Any, format_: Any) -> str: + """The record an internal write puts in ``unit``, blank-padded to its + length.""" + arguments = ", ".join( + self.expressions.render(item) + for item in (items.children if hasattr(items, "children") else [items]) + ) + spelled = str(format_).strip() if format_ is not None else "*" + record = f"_f_list_write({arguments})" + if spelled != "*": + # A formatted internal write: the FMT decides the layout, so the + # list-directed shim would be a silently wrong string (#16). The + # format need not be a literal -- ``_f_fmt_write`` parses it where + # it stands, which is where a dummy argument carrying one is + # known -- but a literal is checked here, while the descriptors + # are still in front of the emitter. + fmt = self._io_format(format_, "formatted internal write") + record = f"_f_fmt_write({fmt}, {arguments})" + # The record fills the variable, blank-padded to its length. + length = self._spelled_length(fitted_length(unit, None, self.semantics), unit) + if length is not None: + record = f"({record}).ljust({length})[:{length}]" + return record + + def _write_through( + self, + node: Any, + unit: Any, + name: str, + items: Any, + format_: Any, + record_bound: bool, + pad: str, + ) -> list[str] | None: + """A WRITE whose unit is an ASSOCIATE name spelled as its selector: + an internal write to the selector's variable, ``None`` for a unit + number, or refused. + + A CHARACTER variable or an element of a CHARACTER array is an + internal unit, and gets the record. An integer variable is a unit + number, and the write the log it always was. A component could be + either, and is refused rather than guessed at. + """ + construct = associated_by(node, name) + selector = associations(construct)[name] if construct is not None else None + declaration = self.semantics.declaration(_base_name(selector) or "") or {} + if isinstance(selector, f03.Name) and not self.semantics.is_character(selector): + return None + if not isinstance(selector, (f03.Name, f03.Part_Ref)) or declaration.get("dtype") != "str": + raise NoRule(f"WRITE to ASSOCIATE name {name}, whose selector {selector} is not typed") + if not record_bound: + raise NoRule("internal write with ADVANCE= control") + record = self._internal_record(selector, items, format_) + return [f"{pad}{self.expressions.render(unit)} = {record}"] + def _print(self, node: Any, pad: str) -> list[str]: """PRINT writes a record to standard output and *reads* its item list. The stub this replaces told a read/write gate that nothing was @@ -2051,41 +2422,108 @@ def _associate(self, node: Any, indent: int) -> list[str]: A plain assignment is right for the reads and for writes through a whole-array or component target, which is what the physics corpora use it - for -- the body sees the same object. It is *not* right for a scalar - target written through the association, where Fortran writes back to - the selector and Python would rebind the local; nothing in the corpus - does that, and it is a refusal worth having when something does. + for -- the body sees the same object, a NumPy view or the structure + itself. It is *not* right for a scalar variable written through the + association: Fortran's ``t`` is another name for ``x``, so ``t = t + + 1`` stores to ``x``, and the local ``t = x`` it was bound as was + rebound instead, leaving ``x`` as it was (FNP-D0015). Such a name is + spelled as its selector for the body's duration and bound to nothing + (``written_through`` decides where, for this and for the read/write + sets). A write the translation still cannot carry to the selector -- + through the name of an expression, which Fortran does not allow + either, of an element whose subscripts the body changes, or a + whole-array store that would rebind a view -- is refused, as is a + body that writes a scalar variable its snapshot would then disagree + with. """ pad = " " * indent lines = [] inherited: dict[str, Any] = {} - for association in walk(node, f03.Association): + body_nodes = [ + child + for child in node.children + if not isinstance(child, (f03.Associate_Stmt, f03.End_Associate_Stmt)) + ] + through = written_through(node, self.semantics) + written = written_names(body_nodes) + spelled: dict[str, str] = {} + for association in walk(node.children[0], f03.Association): alias, _, selector = association.children - name = pysafe(str(alias).lower()) + lowered = str(alias).lower() + if lowered in through: + spelled[lowered] = self.expressions.render(selector) + continue + self._refuse_lost_association(lowered, selector, body_nodes, written) + name = pysafe(lowered) lines.append(f"{pad}{name} = {self.expressions.render(selector)}") dims = self.expressions.selector_dims(selector) if dims: - inherited[str(alias).lower()] = dims + inherited[lowered] = dims # The alias subscripts like its selector for the body's duration: a # component allocated from zero keeps its zero through the alias. bounds = self.expressions.allocated_bounds previous = {name: bounds.get(name) for name in inherited} bounds.update(inherited) + associations = self.names.associations + shadowed = {name: associations.get(name) for name in spelled} + associations.update(spelled) try: - body = [ - line - for child in node.children - if not isinstance(child, (f03.Associate_Stmt, f03.End_Associate_Stmt)) - for line in self.render(child, indent) - ] + body = [line for child in body_nodes for line in self.render(child, indent)] finally: for name, before in previous.items(): if before is None: bounds.pop(name, None) else: bounds[name] = before + for name, outer in shadowed.items(): + if outer is None: + associations.pop(name, None) + else: + associations[name] = outer return lines + (body or [f"{pad}pass"]) + def _refuse_lost_association( + self, alias: str, selector: Any, body: list[Any], written: set[str] + ) -> None: + """Refuse an association bound as a local that the body's writes + would make disagree with its selector. + + Two ways that happens once ``written_through`` has respelled what it + can. A store to the bare name rebinds the local -- whether the + selector is an expression, which Fortran does not let be defined + through its name at all, a component whose shape this cannot read, or + an array whose view the store would replace. And a scalar variable + the body changes, through the name or around it, while a subscript + of the selector changes too, has no one spelling that is the element + Fortran associated. + """ + if (alias in written or _base_name(selector) in written) and scalar_variable( + selector, self.semantics + ): + raise NoRule( + f"ASSOCIATE name {alias} selects {selector}, whose subscripts the body changes" + ) + if any( + isinstance(a.children[0], f03.Name) and str(a.children[0]).lower() == alias + for statement in body + for a in walk(statement, f03.Assignment_Stmt) + ): + raise NoRule( + f"assignment to ASSOCIATE name {alias}, whose selector {selector} is not a " + "scalar variable the store can be carried to" + ) + # An internal WRITE to the name is the same store; one to an integer + # is to a unit number, a log that stores nothing. + if not self.semantics.is_integer(selector) and any( + isinstance(unit, f03.Name) and str(unit).lower() == alias + for statement in body + for unit in (_write_unit(w) for w in walk(statement, f03.Write_Stmt)) + ): + raise NoRule( + f"WRITE to ASSOCIATE name {alias}, whose selector {selector} is not a " + "scalar variable the store can be carried to" + ) + def _block(self, node: Any, indent: int) -> list[str]: """``block ... end block``: its declarations, then its statements. @@ -2337,6 +2775,9 @@ def _call(self, node: Any, indent: int, result: Any = None) -> list[str]: # flat window onto it, so the value is laid out in Fortran's order # before it is copied back. flattened: set[int] = set() + # Output positions stored into a CHARACTER variable of the caller's + # that what comes back has to be fitted to (``_returned_length``). + fits: dict[int, str] = {} for formal, actual in zip(record["args"], actuals, strict=True): if actual is None: # an unsupplied optional if not formal["optional"]: @@ -2362,6 +2803,9 @@ def _call(self, node: Any, indent: int, result: Any = None) -> list[str]: target, flat = self._output_target(formal, actual, substitutions) if flat: flattened.add(len(outputs)) + length = self._returned_length(formal, actual) + if length is not None and not flat and "[...]" not in target and "{}" not in target: + fits[len(outputs)] = length outputs.append(target) elemental = any("ELEMENTAL" in str(p).upper() for p in (record.get("prefixes") or [])) @@ -2398,6 +2842,12 @@ def _call(self, node: Any, indent: int, result: Any = None) -> list[str]: # bound above follow it, so their positions move up by one. outputs.insert(0, self.target(result)) flattened = {position + 1 for position in flattened} + fits = {position + 1: length for position, length in fits.items()} + # A scalar result is stored as ``result = f(...)`` is anywhere + # else: fitted to the target's length (``fitted``). + length = self._spelled_length(fitted_length(result, node, self.semantics), result) + if length is not None and scalar_variable(result, self.semantics): + fits[0] = length # Python takes no positional argument after a keyword one, and # Fortran's optionals can leave a gap anywhere in the list. inputs = [a for a in inputs if "=" not in a] + [a for a in inputs if "=" in a] @@ -2424,17 +2874,23 @@ def copy_out(target: str, text: str) -> str: def copied(index: int, target: str) -> bool: return "[...]" in target or "{}" in target or index in flattened + def fit(index: int, text: str) -> str: + length = fits.get(index) + return text if length is None else f"({text}).ljust({length})[:{length}]" + has_array = any(copied(i, target) for i, target in enumerate(outputs)) if has_array and len(outputs) == 1: return [copy_out(outputs[0], value(0, call))] - if has_array: + if has_array or (fits and len(outputs) > 1): lines = [f"{pad}_out = {call}"] for i, target in enumerate(outputs): if copied(i, target): lines.append(copy_out(target, value(i, f"_out[{i}]"))) else: - lines.append(f"{pad}{target} = _out[{i}]") + lines.append(f"{pad}{target} = {fit(i, f'_out[{i}]')}") return lines + if fits: + return [f"{pad}{outputs[0]} = {fit(0, call)}"] return [f"{pad}{', '.join(outputs)} = {call}"] return [f"{pad}{call}"] diff --git a/src/recast/transform/numpy/subprograms.py b/src/recast/transform/numpy/subprograms.py index 37971a2..0ab9505 100644 --- a/src/recast/transform/numpy/subprograms.py +++ b/src/recast/transform/numpy/subprograms.py @@ -37,10 +37,11 @@ from recast.fortran.chunk import chunk_subprogram from recast.fortran.constants import is_default_real from recast.fortran.interface import CONFLICTING_BOUNDS, emit_name, node_span, subprogram_key +from recast.fortran.rwset import captured from recast.fortran.semantics import Semantics, for_subprogram from recast.transform.numpy.agentic import DeferredHandler, DeferredSite -from recast.transform.numpy.expressions import Expressions, Remote, _integer_divisions -from recast.transform.numpy.names import bind_use_statements +from recast.transform.numpy.expressions import EXTENT, Expressions, Remote, _integer_divisions +from recast.transform.numpy.names import bind_use_statements, kept_between_calls, saved_global from recast.transform.numpy.names import for_subprogram as names_for from recast.transform.numpy.statements import ( REFUSED, @@ -70,6 +71,7 @@ IDENTIFIER = re.compile(r"[a-zA-Z_]\w*") REAL_TEXT = re.compile(r"-?\s*(?:\d+\.?\d*|\.\d+)(?:[ed][+-]?\d+)?(?:_\w+)?", re.I) NEWFORM_ARRAY = re.compile(r"\[\s*(.*?)\s*\]", re.S) +CHARACTER_LITERAL = re.compile(r"'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"") SINGLE_PRECISION = frozenset({"float32", "complex64"}) @@ -460,6 +462,7 @@ def render(self, node: Any, name: str) -> tuple[list[str], list[dict[str, Any]]] span = subprogram["line_span"] lines.append(f' """L{span[0]}-L{span[1]} {subprogram["kind"]} (machine-translated)."""') + written: list[str] = [] if subprogram["module_state_written"]: shadowed = {a["name"] for a in subprogram["args"]} | { local["name"] for local in subprogram.get("locals") or [] @@ -467,8 +470,13 @@ def render(self, node: Any, name: str) -> tuple[list[str], list[dict[str, Any]]] written = sorted( pysafe(n) for n in subprogram["module_state_written"] if n not in shadowed ) - if written: - lines.append(" global " + ", ".join(written)) + # A SAVEd local the body changes lives at module scope, beside the + # marker that says the subprogram has run (``_saved_locals``). + kept = statements.names.saved + if kept: + written += [saved_global(subprogram), *sorted(kept.values())] + if written: + lines.append(" global " + ", ".join(written)) lines.extend(self._result_initializer(subprogram, semantics, statements)) @@ -516,7 +524,7 @@ def render(self, node: Any, name: str) -> tuple[list[str], list[dict[str, Any]]] } before = len(lines) try: - lines.extend(statements.data_statement(statement, 1)) + lines.extend(self._data_block(statement, statements)) data_entry["status"] = "mechanical" except REFUSED as refusal: reason = f"DATA deferred: {refusal}" @@ -830,6 +838,10 @@ def _emit(emitted: list[str], pending: list[str]) -> None: ) return lines + # First, because an extent below may be asked of an array whose + # lower bound it holds (``ubound(a, 1)`` sizing an automatic local). + lines.extend(self._entry_bounds(subprogram, statements)) + # intent(out)-only arguments are NOT parameters (return convention): # the function owns their buffers. Arrays go through # ``undefined_array`` and scalars get the UB-guard zero. @@ -907,15 +919,110 @@ def _emit(emitted: list[str], pending: list[str]) -> None: continue lines.append(f" {name} = {value}") parameter_names = {p["name"] for p in subprogram["local_parameters"]} + kept: list[str] = [] for local in subprogram["locals"]: if local["name"] in parameter_names: continue if self._types_an_intrinsic(local, statements): continue pending = [] - _emit(self._local(local, semantics, statements, pending), pending) + emitted = self._local(local, semantics, statements, pending) + if kept_between_calls(local) and not pending: + kept.extend(emitted) + continue + _emit(emitted, pending) + if statements.names.saved: + lines.extend(self._saved_locals(subprogram, kept)) + return lines + + @staticmethod + def _entry_bounds(subprogram: dict[str, Any], statements: Statements) -> list[str]: + """The lower bounds of the explicit-shape dummies and automatic + locals that the body could move, read into locals on entry. + + ``real(8), intent(in) :: a(-n:n)`` of an ``intent(inout) :: n``: the + bounds are n's value on entry, whatever the body later stores in n + (F2018 10.1.11), and ``a(0)`` after ``n = n + 1`` is still the middle + element. Spelled from the text at each use, it was ``a(1)`` + (FNP-D0049). ``Expressions.capture_bounds`` says which, and makes + every later subscript and inquiry read the local. A bound the + emitter cannot spell is left to refuse where it is used. + """ + expressions = statements.expressions + semantics = statements.semantics + lines: list[str] = [] + for entity in [*subprogram["args"], *(subprogram.get("locals") or ())]: + name = str(entity["name"]).lower() + dims = entity.get("dims") + if not dims or name in expressions.allocated_bounds: + continue + if not any(captured(d.get("lb"), semantics) for d in dims): + continue + try: + spelled = [ + expressions.bound(str(d["lb"])) if captured(d.get("lb"), semantics) else "" + for d in dims + ] + except REFUSED: + continue + bounds, assignments = expressions.capture_bounds(name, dims, spelled) + expressions.allocated_bounds[name] = bounds + lines.extend(f" {assignment}" for assignment in assignments) return lines + @staticmethod + def _saved_locals(subprogram: dict[str, Any], initializers: list[str]) -> list[str]: + """The initialization of the locals kept between calls, on the first + call only. + + A local with an initializer, or named by SAVE or DATA, keeps its + value from one call to the next (F2018 8.5.16); ``integer :: calls + = 0`` counts the calls. Re-initialized at every entry, as every + other local is, ``calls = calls + 1`` answered 1 on the second call + where gfortran answers 2 (FNP-D0014). Such a local is a module + global instead (``Names.saved``), and its initializer -- the + declaration's value, or the UB-guard one -- runs when the marker + global says the subprogram has not run before. ``_fresh`` is that + answer, which a DATA block of such a local asks as well. + """ + marker = saved_global(subprogram) + return [ + f" _fresh = {marker!r} not in globals()", + " if _fresh:", + f" {marker} = True", + *(f" {line}" for line in initializers), + ] + + @staticmethod + def _data_block(statement: Any, statements: Statements) -> list[str]: + """A DATA statement's assignments: every call, or the first only. + + DATA initializes what it names once, before the first call, and the + objects are SAVEd by it. For objects the body never changes, running + the assignments at every entry is the same thing; for ones it does + change, they run under ``_fresh`` with the rest of the kept locals' + initialization. A statement naming both kinds would need splitting, + and is refused. + """ + kept = statements.names.saved + objects: set[str] = set() + for group in walk(statement, f03.Data_Stmt_Object_List): + objects.update(str(r.children[0]).lower() for r in walk(group, f03.Part_Ref)) + objects.update( + str(item).lower() + for item in getattr(group, "children", ()) + if isinstance(item, f03.Name) + ) + changed = objects & set(kept) + if not changed: + return statements.data_statement(statement, 1) + if changed != objects: + raise NoRule( + f"DATA initializes {', '.join(sorted(changed))}, which the body changes, " + f"beside {', '.join(sorted(objects - changed))}, which it does not" + ) + return [" if _fresh:", *statements.data_statement(statement, 2)] + @staticmethod def _types_an_intrinsic(local: dict[str, Any], statements: Statements) -> bool: """Whether ``real(8) :: abs`` types an INTRINSIC rather than declaring @@ -1047,6 +1154,12 @@ def _out_argument( return [f" {name} = {SCALAR_ZEROS[argument['dtype']]}"] if not dims and argument["dtype"] == "bool": return [f" {name} = False"] + if not dims and argument["dtype"] == "str": + # Undefined on entry, and still of its declared length: blanks. + # A ``len=*`` one has the caller's length, which the return + # convention never hands in; it is left unbound as before. + blank = self._blank({"name": argument["name"]}, statements) + return [f" {name} = {blank}"] if blank != "''" else [] derived = DERIVED.match(str(argument["dtype"])) if not dims and derived is not None: # An INTENT(OUT) derived-type dummy is a fresh object at entry: @@ -1096,14 +1209,16 @@ def _local( ) -> list[str]: if refusals is None: refusals = [] - name = pysafe(local["name"]) + # The local's emitted name: its own, or the module global that keeps + # it between calls. + name = statements.names.symbol(local["name"]) dims = local.get("dims") if dims: if any(d["ub"] is None for d in dims): # Allocatable: None until its Allocate_Stmt (allocated() rule). return [f" {name} = None"] try: - shape = ", ".join(self._extent(d, statements) for d in dims) + extents = [self._automatic_extent(d, statements) for d in dims] except REFUSED as refusal: reason = f"local array {local['name']}: extent not resolvable ({refusal})" refusals.append(reason) @@ -1111,16 +1226,13 @@ def _local( f" # AGENT_QUEUE: {reason}", f" raise NotImplementedError({reason!r})", ] + shape = ", ".join(extents) derived = DERIVED.match(str(local["dtype"])) if derived is not None: # An array of a derived type: the elements exist the moment # the array does, so they are constructed here rather than # left as the ``None``s an object ``np.empty`` would hold. - filled = derived_array( - derived.group(1).lower(), - [self._extent(d, statements) for d in dims], - semantics.types, - ) + filled = derived_array(derived.group(1).lower(), extents, semantics.types) if filled is not None: return [f" {name} = {filled}"] initializer = local.get("init_expr") @@ -1149,6 +1261,12 @@ def _local( f" # AGENT_QUEUE: {reason}", f" raise NotImplementedError({reason!r})", ] + if local["dtype"] == "str": + # A CHARACTER array of a known length: its elements are that + # many blanks, not the zeros an object ``np.zeros`` holds. + blank = self._blank({"name": local["name"]}, statements) + if blank != "''": + return [f" {name} = np.full(({shape},), {blank}, dtype=object)"] return [f" {name} = {undefined_array(self, f'({shape},)', dtype)}"] if local.get("array_spec"): return [] @@ -1186,9 +1304,30 @@ def _local( if local["dtype"] == "bool": return [f" {name} = False{note}"] if local["dtype"] == "str": - return [f" {name} = ''"] + return [f" {name} = {self._blank(local, statements)}"] return [] + @staticmethod + def _blank(entity: dict[str, Any], statements: Statements) -> str: + """The UB-guard value of a CHARACTER scalar: its length in blanks. + + A ``character(len=10)`` variable holds ten characters whether or not + it has been assigned, and ``len`` of it is 10 before the first store + as after it (FNP-D0021). A declared initializer that is a character + literal is that literal, fitted to the length; a length nothing + here can evaluate keeps the empty string it always had. + """ + length = statements.character_length(entity["name"]) + initializer = str(entity.get("init_expr") or "").strip() + value = "" + if CHARACTER_LITERAL.fullmatch(initializer): + value = initializer[1:-1].replace(initializer[0] * 2, initializer[0]) + if length is None: + return repr(value) + if length.isdigit(): + return repr(value.ljust(int(length))[: int(length)]) + return f"({value!r}).ljust({length})[:{length}]" + def _parameter_value( self, text: str, @@ -1378,6 +1517,30 @@ def _extent(dim: dict[str, Any], statements: Statements) -> str: return upper return f"({upper}) - ({statements.bound(lower)}) + 1" + @classmethod + def _automatic_extent(cls, dim: dict[str, Any], statements: Statements) -> str: + """A local array's declared extent, kept off zero's far side. + + ``real(8) :: w(lo:hi)`` with ``hi < lo`` is an automatic array of + zero size on that axis (F2018 8.5.8.2), as ``w(m)`` is with + ``m < 0``. The prologue allocated it with the bare extent, and NumPy + refuses a negative dimension: ``w(-n:n)`` entered with ``n = -1`` + raised ValueError where gfortran runs on with an empty ``w`` + (FNP-D0050). The rule is ALLOCATE's (``_allocated_extent``): clamp + at zero, except where the extent cannot go below it -- integer + literals that bound a non-negative extent, and an upper bound on an + axis based at one that is ``size(...)``. + """ + extent = cls._extent(dim, statements) + upper = str(dim["ub"]).strip() + lower = str(dim.get("lb") or "1").strip() + if INTEGER_TEXT.fullmatch(upper) and INTEGER_TEXT.fullmatch(lower): + if int(upper.replace(" ", "")) - int(lower.replace(" ", "")) + 1 >= 0: + return extent + if lower == "1" and EXTENT.fullmatch(upper) and upper.lower().startswith("size"): + return extent + return f"max(0, {extent})" + def _specification(node: Any) -> Any: """A subprogram's specification part, or an empty list if it has none.""" diff --git a/src/recast/transform/numpy/vocabulary.py b/src/recast/transform/numpy/vocabulary.py index 43b29a0..4ab486f 100644 --- a/src/recast/transform/numpy/vocabulary.py +++ b/src/recast/transform/numpy/vocabulary.py @@ -47,10 +47,11 @@ "alog10": "_f_log10", "amax0": "max", "amin0": "min", - "anint": "np.round", + "anint": "_f_anint", "asin": "math.asin", "atan": "math.atan", "atan2": "math.atan2", + "bit_size": "_f_bit_size", "c_loc": "_f_c_loc", "ceiling": "math.ceil", "char": "chr", @@ -63,6 +64,7 @@ "dble": "np.float64", "dcos": "math.cos", "dexp": "math.exp", + "digits": "_f_digits", "dim": "_f_dim", "dlog": "_f_log", "dlog10": "_f_log10", @@ -99,14 +101,17 @@ "log10": "_f_log10", "max": "_f_max", "max0": "max", + "maxexponent": "_f_maxexponent", "min": "_f_min", "min0": "min", + "minexponent": "_f_minexponent", "mod": "_f_mod", "modulo": "_f_modulo", "mvbits": "_f_mvbits", "nint": "_f_nint", "precision": "_f_precision", "radix": "_f_radix", + "range": "_f_range", "real": "np.float64", "scan": "_f_scan", "shape": "np.shape", @@ -131,7 +136,7 @@ "abs": "np.abs", "achar": "_f_vachar", "aint": "np.trunc", - "anint": "np.round", + "anint": "_f_anint", "ceiling": "_f_vceil", "cos": "np.cos", "dble": "np.float64", @@ -170,8 +175,8 @@ "product": "np.prod", "size": "np.size", "sum": "_f_vsum", - # With unit lower bounds -- which every translated array has -- the upper - # bound and the extent are the same number. + # On an axis based at one the upper bound and the extent are the same + # number; ``Expressions._bound_inquiry`` spells any other axis. "ubound": "np.size", } """Intrinsics that collapse an array. ``dot_product`` and ``sum`` are runtime diff --git a/src/recast/verify/rwset.py b/src/recast/verify/rwset.py index f810263..09320e1 100644 --- a/src/recast/verify/rwset.py +++ b/src/recast/verify/rwset.py @@ -52,12 +52,21 @@ 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*|_wn\d*|_we\d+_\d+|_do(?:lo|hi|st)_\w+|_|_g") +DISCARD = re.compile(r"_wm\d*|_wn\d*|_we\d+_\d+|_do(?:lo|hi|st)_\w+|_lb_\w+|_f[ave]|_fresh|_|_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_``), the bounds a DO loop holds for its -index's completion value (``_dolo_i``, ``_dohi_i``, ``_dost_i``), a region -label.""" +index's completion value (``_dolo_i``, ``_dohi_i``, ``_dost_i``), an array's +lower bound held from where it came into being (``_lb_a_1``, whose +expression is read there, as the source reads it), a FORALL's active +combinations, gathered values and the one being stored (``_fa``, ``_fv``, +``_fe``), whether a subprogram with SAVEd locals is on its first call +(``_fresh``), a region label.""" + +FORALL_INDEX = re.compile(r"_fi_(\w+)") +"""A FORALL's index, spelled apart from the variable of its name outside +(``_fi_i``) because it has the construct's scope. It is the source's ``i``, +which is what the source side reads and writes for it.""" PRESENT_SENTINEL = re.compile(r"want_(\w+)") """``want_x`` is how an optional output argument is spelled on the target side; @@ -179,6 +188,9 @@ def back(self, name: str, *, store: bool) -> str | None: """The source symbol an emitted name stands for, or ``None`` for none.""" if DISCARD.fullmatch(name): return None + index = FORALL_INDEX.fullmatch(name) + if index: + return index.group(1) if name.endswith("_") and ( keyword.iskeyword(name[:-1]) or name[:-1] in self.protocol.reserved ): diff --git a/tests/test_fnp_assoc_review.py b/tests/test_fnp_assoc_review.py new file mode 100644 index 0000000..5ccd2c6 --- /dev/null +++ b/tests/test_fnp_assoc_review.py @@ -0,0 +1,189 @@ +"""Stores the ASSOCIATE, SAVE and intent fixes still lost, found by review. + +The fixes for FNP-D0015 (a store through an ASSOCIATE name), FNP-D0014 (a +SAVEd local kept between calls) and FNP-D0031 / FNP-D0047 (the intent an +un-INTENTed dummy is given) each read the body's stores through one list of +what a statement can store to. An independent review ran gfortran against +the translation and found the stores that list left out: one made through +an associate name, an internal WRITE, a function's ``intent(inout)`` actual, +and the elements a CYCLE or an EXIT skips. Every test translates a small +module, runs the emitted Python, and asserts what gfortran prints for the +same source. +""" + +from __future__ import annotations + +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +np = pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +from tests.test_fnp_statements import body_of, rwset_verdict, translate # noqa: E402 + +REVIEWED = """\ +module rv_mod + implicit none + real(8) :: g = 1.0d0 +contains + subroutine noint(x) + real(8) :: x + associate (t => x) + t = 5.0d0 + end associate + end subroutine noint + + subroutine counted(c) + integer, intent(out) :: c + integer :: cnt = 0 + associate (t => cnt) + t = t + 1 + end associate + c = cnt + end subroutine counted + + subroutine bumpg() + associate (t => g) + t = t + 1.0d0 + end associate + end subroutine bumpg + + subroutine fmtw(s, n) + character(len=8), intent(inout) :: s + integer, intent(in) :: n + associate (c => s) + write (c, '(i4)') n + end associate + end subroutine fmtw + + subroutine logged(u, n) + integer, intent(in) :: u, n + associate (k => u) + write (k, *) n + end associate + end subroutine logged + + subroutine cyc(a, n) + integer :: n + real(8) :: a(n) + integer :: i + do i = 1, n + if (i == 2) cycle + a(i) = 1.0d0 + end do + end subroutine cyc + + subroutine ext(a, n) + integer :: n + real(8) :: a(n) + integer :: i + do i = 1, n + if (i > 2) exit + a(i) = 1.0d0 + end do + end subroutine ext + + subroutine full(a, n) + integer :: n + real(8) :: a(n) + integer :: i + do i = 1, n + a(i) = 1.0d0 + end do + end subroutine full + + integer function incf(j) + integer, intent(inout) :: j + j = j + 1 + incf = j + end function incf + + subroutine viaf(k) + integer, intent(out) :: k + integer :: cnt = 0 + integer :: z + z = incf(cnt) + k = cnt + end subroutine viaf +end module rv_mod +""" + + +@pytest.fixture(scope="module") +def reviewed(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("reviewed"), REVIEWED, "rv_mod") + + +def test_a_dummy_written_only_through_an_associate_name_is_handed_back(reviewed: Any) -> None: + """``associate (t => x); t = 5`` stores to ``x``. Counted as a store to + ``t``, the un-INTENTed ``x`` had none and stayed UNKNOWN: passed in, + never returned, and the caller kept 1.0 where gfortran gives 5.0.""" + module, text, _ = reviewed + assert module.noint(1.0) == 5.0 + assert "return x" in body_of(text, "noint") + + +def test_a_saved_local_changed_through_an_associate_name_is_kept(reviewed: Any) -> None: + """gfortran counts 1, 2; with ``cnt`` read as never changed, its + initializer ran at every entry and the translation counted 1, 1.""" + module, _, _ = reviewed + assert [module.counted(), module.counted()] == [1, 2] + + +def test_a_module_variable_written_through_an_associate_name_is_global(reviewed: Any) -> None: + """The store is spelled ``g = (g + 1.0)``; without ``g`` on the ``global`` + line that made ``g`` a local and raised ``UnboundLocalError``.""" + module, text, _ = reviewed + module.bumpg() + assert module.g == 2.0 + assert "global g" in body_of(text, "bumpg") + + +def test_an_internal_write_through_an_associate_name_fills_its_variable(reviewed: Any) -> None: + """gfortran: ``[ 42 ]``. The WRITE to ``c`` was a log that stored + nothing, and the caller kept ``xxxxxxxx``.""" + module, text, _ = reviewed + assert module.fmtw("xxxxxxxx", 42) == " 42 " + assert "log" not in body_of(text, "fmtw") + + +def test_a_write_to_a_unit_number_through_an_associate_name_is_still_a_log( + reviewed: Any, +) -> None: + _, text, candidate = reviewed + assert not any(entry.startswith("logged/") for entry in candidate.deferred) + assert "log" in body_of(text, "logged") + + +def test_a_loop_that_cycles_or_exits_leaves_the_callers_elements(reviewed: Any) -> None: + """The array was inferred ``intent(out)``, so the caller's ``7 7 7 7`` + was never passed and the skipped elements came back as zeros. gfortran: + ``1 7 1 1`` and ``1 1 7 7``.""" + module, _, _ = reviewed + assert list(module.cyc(np.full(4, 7.0), 4)) == [1.0, 7.0, 1.0, 1.0] + assert list(module.ext(np.full(4, 7.0), 4)) == [1.0, 1.0, 7.0, 7.0] + + +def test_a_loop_over_every_element_is_still_out(reviewed: Any) -> None: + module, text, _ = reviewed + assert "def full(n):" in text + assert list(module.full(3)) == [1.0, 1.0, 1.0] + + +def test_a_saved_local_changed_through_a_function_argument_is_kept(reviewed: Any) -> None: + """``z = incf(cnt)`` changes ``cnt`` through an ``intent(inout)`` dummy. + gfortran counts 1, 2; the translation re-initialized ``cnt`` and + counted 1, 1.""" + module, _, _ = reviewed + assert [module.viaf(), module.viaf()] == [1, 2] + + +def test_the_read_write_gate_agrees_with_every_reviewed_block( + reviewed: Any, tmp_path: Path +) -> None: + _, _, candidate = reviewed + verdict = rwset_verdict(candidate, tmp_path) + assert not verdict.metrics["failures"], verdict.metrics["failures"] diff --git a/tests/test_fnp_bounds_review.py b/tests/test_fnp_bounds_review.py new file mode 100644 index 0000000..4aa1ff2 --- /dev/null +++ b/tests/test_fnp_bounds_review.py @@ -0,0 +1,302 @@ +"""An array's bounds, as they were when the array came into being. + +Two defects the RecastEngine-Pro-Lean audit's review found in how the +translation spells an array's bounds: an ALLOCATE of a zero-sized axis, and +bounds re-read from variables the body has since redefined. Each case is +translated whole, the emitted module imported and run, and the value asserted +is the one gfortran (``-O0 -fcheck=all``) prints for the same program. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +import numpy as np + +from tests.test_fnp_intrinsics import dataflow_agrees, translate + +# --- FNP-D0050: an ALLOCATE whose upper bound is below its lower one --------- + +EMPTY = """\ +module zero_extent_mod + implicit none +contains + subroutine basic(n, x1, x2, lb, ub, sz, s) + integer, intent(in) :: n + real(8), intent(out) :: x1, x2, s + integer, intent(out) :: lb, ub, sz + real(8), allocatable :: a(:) + integer :: i + allocate(a(-n:n)) + do i = -n, n + a(i) = 10d0 * i + end do + x1 = 0d0; x2 = 0d0 + if (n >= 0) then + x1 = a(-n) + x2 = a(0) + end if + lb = lbound(a, 1); ub = ubound(a, 1); sz = size(a); s = sum(a(-n:0)) + deallocate(a) + end subroutine basic + subroutine one(n, sz, ub) + integer, intent(in) :: n + integer, intent(out) :: sz, ub + real(8), allocatable :: b(:), c(:) + allocate(b(n), c(4)) + sz = size(b) + size(c); ub = ubound(b, 1) + deallocate(b, c) + end subroutine one +end module zero_extent_mod +""" + + +def test_an_allocate_below_its_lower_bound_is_empty(tmp_path: Path) -> None: + """FNP-D0050 (rule FNP-R0160). ``allocate(a(-n:n))`` with n = -1 is a + zero-sized ``a`` (F2018 9.7.1.2); gfortran prints 0 0 1 0 0 0.0 for + ``basic(-1)`` -- LBOUND 1 and UBOUND 0 on the empty axis -- where the + shape ``((n) - ((-n)) + 1,)``, -1, was a ValueError from NumPy.""" + module, text, deferred = translate(tmp_path, EMPTY) + assert not deferred + assert "np.zeros((max(0, (n) - ((-n)) + 1),)" in text + assert module.basic(-1) == (0.0, 0.0, 1, 0, 0, 0.0) + + +def test_an_allocate_that_is_not_empty_keeps_its_values(tmp_path: Path) -> None: + """``basic(2)``: gfortran prints -20.0 0.0 -2 2 5 -30.0.""" + module, _, _ = translate(tmp_path, EMPTY) + assert module.basic(2) == (-20.0, 0.0, -2, 2, 5, -30.0) + + +def test_an_allocate_based_at_one_is_empty_below_one(tmp_path: Path) -> None: + """``allocate(b(n))`` with n = -3 is empty too: gfortran's ``one(-3)`` + is 4 0. A literal extent cannot go negative and is written as it was.""" + module, text, _ = translate(tmp_path, EMPTY) + assert "np.zeros((max(0, n),)" in text + assert "np.zeros((I_4,)" in text + assert module.one(-3) == (4, 0) + assert module.one(3) == (7, 3) + + +# --- FNP-D0049: bounds over a variable the body redefines --------------------- + +MOVED = """\ +module moved_bounds_mod + implicit none +contains + subroutine redef(n0, x, lb, ub) + integer, intent(in) :: n0 + real(8), intent(out) :: x + integer, intent(out) :: lb, ub + real(8), allocatable :: a(:) + integer :: n, i + n = n0 + allocate(a(-n:n)) + do i = -n, n + a(i) = 10d0 * i + end do + n = n + 1 + x = a(0); lb = lbound(a, 1); ub = ubound(a, 1) + deallocate(a) + end subroutine redef + subroutine d(n, a, x, lb, ub, s) + integer, intent(inout) :: n + real(8), intent(in) :: a(-n:n) + real(8), intent(out) :: x, s + integer, intent(out) :: lb, ub + n = n + 1 + x = a(0); lb = lbound(a, 1); ub = ubound(a, 1); s = sum(a(-1:)) + end subroutine d + subroutine auto(n, x, lb, ub) + integer, intent(inout) :: n + real(8), intent(out) :: x + integer, intent(out) :: lb, ub + real(8) :: w(-n:n) + integer :: i + do i = -n, n + w(i) = 10d0 * i + end do + n = n + 1 + x = w(0); lb = lbound(w, 1); ub = ubound(w, 1) + end subroutine auto + subroutine twice(n0, x, lb, ub) + integer, intent(in) :: n0 + real(8), intent(out) :: x + integer, intent(out) :: lb, ub + real(8), allocatable :: a(:) + integer :: n, k, i + x = 0d0 + n = n0 + do k = 1, 2 + allocate(a(-n:n)) + do i = -n, n + a(i) = 10d0 * i + k + end do + n = n + 1 + x = x + a(1 - n + 1) + lb = lbound(a, 1); ub = ubound(a, 1) + deallocate(a) + end do + end subroutine twice + subroutine kept(n, a, x, lb, ub) + integer, intent(in) :: n + real(8), intent(in) :: a(-n:n) + real(8), intent(out) :: x + integer, intent(out) :: lb, ub + x = a(0); lb = lbound(a, 1); ub = ubound(a, 1) + end subroutine kept +end module moved_bounds_mod +""" + +TEN_I = np.arange(-20.0, 21.0, 10.0) # a(i) = 10 i over -2:2 + + +def test_an_allocated_bound_is_the_value_at_the_allocate(tmp_path: Path) -> None: + """FNP-D0049 (rules FNP-R0040, FNP-R0043, FNP-R0049). ``allocate(a(-n:n))`` + with n = 2, then ``n = n + 1``: ``a`` is still ``a(-2:2)`` (F2018 + 9.7.1.2), and gfortran's ``redef(2)`` is 0.0 -2 2. The shift re-read + ``n`` -- ``a[(0) - (- n)]`` -- and answered 10.0 -3 1.""" + module, text, deferred = translate(tmp_path, MOVED) + assert not deferred + assert "_lb_a_1 = (-n)" in text + assert "x = a[(0) - (_lb_a_1)]" in text + assert module.redef(2) == (0.0, -2, 2) + + +def test_a_dummys_bound_is_its_value_on_entry(tmp_path: Path) -> None: + """``a(-n:n)`` of an intent(inout) n, then ``n = n + 1``: the bounds + stay -2:2 (F2018 10.1.11). gfortran's ``d`` over a(i) = 10 i is n = 3, + x = 0.0, -2 2, and ``sum(a(-1:))`` = 20.0.""" + module, _, _ = translate(tmp_path, MOVED) + assert module.d(2, TEN_I.copy()) == (3, 0.0, -2, 2, 20.0) + + +def test_an_automatic_locals_bound_is_its_value_on_entry(tmp_path: Path) -> None: + """``real(8) :: w(-n:n)`` sized on entry: gfortran's ``auto(2)`` is + n = 3, 0.0 -2 2.""" + module, _, _ = translate(tmp_path, MOVED) + assert module.auto(2) == (3, 0.0, -2, 2) + + +def test_every_allocate_of_the_array_sets_its_bound_again(tmp_path: Path) -> None: + """An ALLOCATE in a loop, its bound variable moving between passes: + ``a(-2:2)`` then ``a(-3:3)``, reading ``a(-1)`` then ``a(-2)``. + gfortran's ``twice(2)`` is -27.0 -3 3.""" + module, _, _ = translate(tmp_path, MOVED) + assert module.twice(2) == (-27.0, -3, 3) + + +def test_the_held_bounds_pass_the_read_write_gate(tmp_path: Path) -> None: + """The local a bound is held in is the translation's own: the source + reads the bound's variables where the array comes into being, and a + subscript over the bound reads none of them.""" + dataflow_agrees(tmp_path, MOVED) + + +def test_a_bound_nothing_redefines_is_spelled_as_it_was(tmp_path: Path) -> None: + """``kept``: n is intent(in), so its bound text is its value, and the + text is what it was before the fix. gfortran: 0.0 -2 2.""" + module, text, _ = translate(tmp_path, MOVED) + kept = text[text.index("def kept(") :] + assert "_lb_" not in kept + assert "x = a[(0) - (- n)]" in kept + assert "lb = _f_lbound(a, 1, (- n,))" in kept + assert module.kept(2, TEN_I.copy()) == (0.0, -2, 2) + + +# --- FNP-D0050: an automatic array whose upper bound is below its lower one -- + +AUTOMATIC_EMPTY = """\ +module auto_zero_mod + implicit none + type pt + real(8) :: v + end type pt +contains + subroutine centred(n, lb, ub, sz, s) + integer, intent(in) :: n + integer, intent(out) :: lb, ub, sz + real(8), intent(out) :: s + real(8) :: w(-n:n) + integer :: i + do i = -n, n + w(i) = 10d0 * i + 1d0 + end do + lb = lbound(w, 1); ub = ubound(w, 1); sz = size(w); s = sum(w) + end subroutine centred + subroutine based(m, lb, ub, sz, sk) + integer, intent(in) :: m + integer, intent(out) :: lb, ub, sz, sk + real(8) :: w(m) + integer :: k(m, 3), c(3) + w = 1d0; k = 2; c = 3 + lb = lbound(w, 1); ub = ubound(w, 1); sz = size(w) + sk = size(k) + sum(k) + sum(c) + end subroutine based + subroutine sized(n, x, sz, ub, s) + integer, intent(in) :: n + real(8), intent(in) :: x(n) + integer, intent(out) :: sz, ub + real(8), intent(out) :: s + real(8) :: w(size(x)), v(n) + w = x; v = 2d0 * x + sz = size(w) + size(v); ub = ubound(v, 1); s = sum(w) + sum(v) + end subroutine sized + subroutine objects(n, sz) + integer, intent(in) :: n + integer, intent(out) :: sz + type(pt) :: p(n) + sz = size(p) + end subroutine objects +end module auto_zero_mod +""" + + +def test_an_automatic_array_below_its_lower_bound_is_empty(tmp_path: Path) -> None: + """FNP-D0050 (rule FNP-R0160 and the automatic-array prologue). + ``real(8) :: w(-n:n)`` entered with n = -1 is a zero-sized ``w`` + (F2018 8.5.8.2); gfortran's ``centred(-1)`` is 1 0 0 0.0 -- LBOUND 1 + and UBOUND 0 on the empty axis -- where the prologue's + ``np.zeros(((n) - (- n) + 1,), ...)``, -1, was a ValueError.""" + module, text, deferred = translate(tmp_path, AUTOMATIC_EMPTY) + assert not deferred + assert "w = np.zeros((max(0, (n) - (- n) + 1),), dtype=np.float64)" in text + assert module.centred(-1) == (1, 0, 0, 0.0) + assert module.centred(2) == (-2, 2, 5, 5.0) + + +def test_an_automatic_array_based_at_one_is_empty_below_one(tmp_path: Path) -> None: + """``w(m)`` and ``k(m, 3)`` with m = -2 are empty: gfortran's + ``based(-2)`` is 1 0 0 9, and ``based(2)`` 1 2 2 27. The literal + extents, 3 on ``k``'s second axis and ``c(3)``, are written as they + were.""" + module, text, _ = translate(tmp_path, AUTOMATIC_EMPTY) + assert "w = np.zeros((max(0, m),), dtype=np.float64)" in text + assert "k = np.zeros((max(0, m), I_3,), dtype=np.int32)" in text + assert "c = np.zeros((I_3,), dtype=np.int32)" in text + assert module.based(-2) == (1, 0, 0, 9) + assert module.based(2) == (1, 2, 2, 27) + + +def test_an_automatic_array_sized_off_a_dummy_is_empty_with_it(tmp_path: Path) -> None: + """``v(n)`` beside a dummy ``x(n)``, n = -2: both empty, and gfortran's + ``sized(-2, x)`` is 0 0 0.0; ``sized(3, [1, 2, 3])`` is 6 3 18.0. + ``w(size(x))`` cannot go negative and is written as it was.""" + module, text, _ = translate(tmp_path, AUTOMATIC_EMPTY) + assert "w = np.zeros((np.size(x),), dtype=np.float64)" in text + assert "v = np.zeros((max(0, n),), dtype=np.float64)" in text + assert module.sized(-2, np.zeros(0)) == (0, 0, 0.0) + assert module.sized(3, np.array([1.0, 2.0, 3.0])) == (6, 3, 18.0) + + +def test_an_automatic_array_of_a_derived_type_is_empty_below_one(tmp_path: Path) -> None: + """``type(pt) :: p(n)``: gfortran's ``objects(-1)`` is 0, ``objects(2)`` + 2.""" + module, _, _ = translate(tmp_path, AUTOMATIC_EMPTY) + assert module.objects(-1) == 0 + assert module.objects(2) == 2 diff --git a/tests/test_fnp_charlen_review.py b/tests/test_fnp_charlen_review.py new file mode 100644 index 0000000..0359701 --- /dev/null +++ b/tests/test_fnp_charlen_review.py @@ -0,0 +1,297 @@ +"""CHARACTER lengths on the store paths the first fix of FNP-D0021 missed. + +The RecastEngine-Pro-Lean audit's FNP-D0021 is a ``character(len=n)`` +variable that did not hold ``n`` characters. Fitting a store to a local +scalar or element fixed the audit's probe; an independent review then ran +gfortran against four more ways a CHARACTER variable is stored -- the +caller's variable after a CALL to an ``intent(out)`` ``len=*`` dummy, a +derived-type component, a module variable, and an array stored from an +array -- and each still held the value's own length. Every test here +translates a small module, runs the emitted Python and asserts what +gfortran prints for the same source. +""" + +from __future__ import annotations + +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +from tests.test_fnp_statements import body_of, rwset_verdict, translate + +STORES = """\ +module cs_mod + implicit none + integer, parameter :: nl = 5 + type named + character(len=8) :: nm + end type named + type rec + character(len=nl) :: tag + character(len=3) :: codes(2) + character(len=4) :: init = 'xy' + end type rec + character(len=6) :: ms + character(len=nl) :: mp + character(len=4) :: mi = 'q' + character(len=3) :: ma(2) +contains + subroutine setstar(s) + character(len=*), intent(out) :: s + s = 'hello' + end subroutine setstar + + subroutine setnoint(s) + character(len=*) :: s + s = 'hey' + end subroutine setnoint + + subroutine two(s, n) + character(len=*), intent(out) :: s + integer, intent(out) :: n + s = 'ab' + n = 7 + end subroutine two + + subroutine callstar(n, r) + integer, intent(out) :: n + character(len=9), intent(out) :: r + character(len=8) :: s + call setstar(s) + n = len(s) + r = s // '|' + end subroutine callstar + + subroutine callnoint(n, r) + integer, intent(out) :: n + character(len=9), intent(out) :: r + character(len=8) :: s + call setnoint(s) + n = len(s) + r = s // '|' + end subroutine callnoint + + subroutine multi(n, r, k) + integer, intent(out) :: n, k + character(len=9), intent(out) :: r + character(len=nl) :: s + call two(s, k) + n = len(s) + r = s // '|' + end subroutine multi + + subroutine relay(s, n) + character(len=*), intent(inout) :: s + integer, intent(out) :: n + call two(s, n) + end subroutine relay + + subroutine comp(n, r) + integer, intent(out) :: n + character(len=9), intent(out) :: r + type(named) :: t + t%nm = 'ab' + n = len(t%nm) + r = t%nm // '|' + end subroutine comp + + subroutine comps(r1, r2, r3, n) + character(len=9), intent(out) :: r1, r2, r3 + integer, intent(out) :: n + type(rec) :: t + r3 = t%init // '|' + t%tag = 'x' + t%codes(2) = 'abcdef' + t%codes(1) = 'z' + r1 = t%tag // '|' + r2 = t%codes(1) // t%codes(2) // '|' + n = len(t%tag) + end subroutine comps + + subroutine modvar(n, r) + integer, intent(out) :: n + character(len=7), intent(out) :: r + ms = 'ab' + n = len(ms) + r = ms // '|' + end subroutine modvar + + subroutine mods(r1, r2, r3) + character(len=9), intent(out) :: r1, r2, r3 + r1 = mi // '|' + r3 = ma(1) // '|' + mp = 'abcdefgh' + r2 = mp // '|' + end subroutine mods + + subroutine arrs(r1, r2) + character(len=5), intent(out) :: r1, r2 + character(len=4) :: a(2) + character(len=2) :: b(2) + b(1) = 'xy' + b(2) = 'zw' + a = b + r1 = a(1) // '|' + a = ['ab', 'cd'] + r2 = a(2) // '|' + end subroutine arrs + + subroutine sect(r1, r2) + character(len=9), intent(out) :: r1, r2 + character(len=4) :: a(3) + character(len=2) :: b(2) + a = 'qqqqqq' + b = ['xy', 'zw'] + a(2:3) = b + r1 = a(1) // a(2) // '|' + a(1:2) = 'k' + r2 = a(1) // a(3) // '|' + end subroutine sect + + subroutine lennoint(s, n) + character(len=*) :: s + integer, intent(out) :: n + s = 'hey' + n = len(s) + end subroutine lennoint + + subroutine padnoint(s, r) + character(len=*) :: s + character(len=12), intent(out) :: r + s = 'hey' + r = s // '|' + end subroutine padnoint + + subroutine calllen(n, m, r) + integer, intent(out) :: n, m + character(len=12), intent(out) :: r + character(len=8) :: s + call lennoint(s, m) + n = len(s) + call padnoint(s, r) + end subroutine calllen + + subroutine wel(r) + character(len=9), intent(out) :: r + character(len=6) :: a(2) + a = 'zzzzzz' + associate (c => a(2)) + write (c, '(i3)') 42 + end associate + r = a(2) // '|' + end subroutine wel +end module cs_mod +""" + + +@pytest.fixture(scope="module") +def stores(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("stores"), STORES, "cs_mod") + + +def test_the_callers_variable_keeps_its_length_after_an_out_assumed_length_dummy( + stores: Any, +) -> None: + """gfortran: ``8 [hello |]``. The callee stores five characters into + a dummy whose length the return convention never hands in, and the + caller bound them unfitted: 5 and ``hello|``.""" + module, _, _ = stores + assert module.callstar() == (8, "hello |") + + +def test_an_assumed_length_dummy_without_intent_keeps_the_callers_length(stores: Any) -> None: + """``setnoint`` declares no intent; every path stores ``s``. It is kept + ``intent(inout)``, so the caller's length reaches the store. + gfortran: ``8 [hey |]``.""" + module, _, _ = stores + assert module.callnoint() == (8, "hey |") + + +def test_the_fitted_output_among_several_and_a_named_length(stores: Any) -> None: + """gfortran: ``multi 5 [ab | ] 7``: the CHARACTER output is fitted to + ``nl`` and the INTEGER one beside it is bound as it was.""" + module, _, _ = stores + assert module.multi() == (5, "ab | ", 7) + + +def test_a_relayed_assumed_length_is_the_callers_callers(stores: Any) -> None: + """gfortran: ``relay [ab ] 7`` for a six-character actual.""" + module, _, _ = stores + assert module.relay("zzzzzz") == ("ab ", 7) + + +def test_a_character_component_holds_its_declared_length(stores: Any) -> None: + """gfortran: ``8 [ab |]``; the ``Data_Ref`` store was never fitted + and the Python said 2 and ``ab|``.""" + module, _, _ = stores + assert module.comp() == (8, "ab |") + + +def test_component_elements_named_lengths_and_default_initialization(stores: Any) -> None: + """gfortran: ``[x | ] [z abc| ] [xy | ] 5`` -- a component of + a named-constant length, elements of a CHARACTER array component, and a + component's literal default initialization at its length.""" + module, _, _ = stores + assert module.comps() == ("x | ", "z abc| ", "xy | ", 5) + + +def test_a_module_variable_holds_its_declared_length(stores: Any) -> None: + """gfortran: ``6 [ab |]``. The store was not fitted, and the module + bound the variable to ``None`` rather than six blanks.""" + module, _, _ = stores + assert module.modvar() == (6, "ab |") + + +def test_module_initial_values_are_at_the_declared_length(stores: Any) -> None: + """gfortran: ``[q | ] [abcde| ] [ | ]`` -- a literal + initializer, a store cut to a named length, and an array's blanks.""" + module, text, _ = stores + assert module.mods() == ("q | ", "abcde| ", " | ") + assert "ms = ' '" in text + + +def test_an_array_stored_from_an_array_is_fitted_element_by_element(stores: Any) -> None: + """gfortran: ``[xy |] [cd |]`` for ``a = b`` and ``a = ['ab', 'cd']`` + into a ``len=4`` ``a``; the elements kept their two characters.""" + module, text, _ = stores + assert module.arrs() == ("xy |", "cd |") + assert "np.frompyfunc" in body_of(text, "arrs") + + +def test_a_section_is_fitted_from_an_array_and_from_a_scalar(stores: Any) -> None: + """gfortran: ``[qqqqxy |] [k zw |]``.""" + module, _, _ = stores + assert module.sect() == ("qqqqxy |", "k zw |") + + +def test_an_assumed_length_dummy_without_intent_has_the_actuals_length_inside( + stores: Any, +) -> None: + """gfortran: ``8 8 [hey | ]``. Inferred ``intent(out)``, the dummy + was never passed in, so its length never reached the callee: ``len(s)`` + there was 3 and ``s // '|'`` ``hey|``. Kept ``intent(inout)``, it is.""" + module, text, _ = stores + assert module.calllen() == (8, 8, "hey | ") + assert "def lennoint(s):" in text + + +def test_an_internal_write_to_an_element_still_fills_it(stores: Any) -> None: + """gfortran: ``[ 42 | ]``. An internal WRITE through an associate + name for an element is one record, padded to the array's length; read + as a write to an array, whose records are its elements, it was left + unfitted, `` 42|``.""" + module, _, _ = stores + assert module.wel() == " 42 | " + + +def test_the_read_write_gate_counts_what_the_new_fittings_read(stores: Any, tmp_path: Path) -> None: + """A fitted component, section or CALL output reads its length's named + constants, or -- an assumed length -- the variable's own.""" + _, _, candidate = stores + verdict = rwset_verdict(candidate, tmp_path) + assert not verdict.metrics["failures"], verdict.metrics["failures"] diff --git a/tests/test_fnp_constants.py b/tests/test_fnp_constants.py new file mode 100644 index 0000000..037544f --- /dev/null +++ b/tests/test_fnp_constants.py @@ -0,0 +1,343 @@ +"""The constants pipelines against the defects the Lean audit refuted them on. + +Each test names its record in the RecastEngine-Pro-Lean audit (``FNP-Dxxxx``) +and asserts the value gfortran gives the constant, not the spelling that +produced it: the defects were all spellings that ran and were a different +number. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("fparser") +pytest.importorskip("numpy") + +from recast.fortran import constants, interface +from recast.fortran.tree import integer_parameters +from recast.fortran.use import resolve +from recast.transform.numpy.constants import constants_module, use_constants_module +from recast.transform.numpy.modules import Modules +from recast.transform.numpy.subprograms import Subprograms +from recast.transform.profiles import PROFILES + +QUOTIENTS = """\ +module quotients_mod + implicit none + integer, parameter :: a = -7 + integer, parameter :: m = a / 2 + integer, parameter :: m2 = (-7) / 2 + integer, parameter :: m3 = 7 / (-2) + integer, parameter :: m4 = (-7) / (-2) + integer(8), parameter :: big = 4611686018427387903_8 + integer(8), parameter :: third = big / 3_8 +end module quotients_mod +""" + + +def _use_constants(tmp_path: Path, source: str, names: list[str]) -> dict[str, object]: + """Render the use-constants module for ``names`` and import it.""" + path = tmp_path / "use_mod.f90" + path.write_text(source) + text = use_constants_module(resolve(names, [path]), "use_mod") + scope: dict[str, object] = {} + exec(compile(text, "use_constants.py", "exec"), scope) + return scope + + +def _constants(tmp_path: Path, source: str) -> dict[str, object]: + """Render a module's own constants file and import it.""" + path = tmp_path / "own_mod.f90" + path.write_text(source) + text = constants_module(constants.extract(path)) + scope: dict[str, object] = {} + exec(compile(text, "constants.py", "exec"), scope) + return scope + + +def test_a_use_constant_quotient_truncates_toward_zero(tmp_path: Path) -> None: + """FNP-D0013: ``a / 2`` over ``a = -7`` is -3 in Fortran; the fold spelled + it ``//``, which floors, and the module said -4. Every sign combination + truncates, and a quotient past 2**53 stays the exact integer ``//`` was + -- ``int(a / b)`` would have rounded it through binary64.""" + scope = _use_constants(tmp_path, QUOTIENTS, ["m", "m2", "m3", "m4", "third"]) + assert (scope["M"], scope["M2"], scope["M3"], scope["M4"]) == (-3, -3, -3, 3) + assert scope["THIRD"] == 4611686018427387903 // 3 + + +def test_a_file_without_a_quotient_carries_no_helper(tmp_path: Path) -> None: + """The truncating helper is emitted only into a file that calls it.""" + path = tmp_path / "plain_mod.f90" + path.write_text("module plain_mod\n integer, parameter :: n = 3 * 4\nend module plain_mod\n") + text = use_constants_module(resolve(["n"], [path]), "plain_mod") + assert "_f_int_div" not in text + + +def test_an_extent_folded_from_a_quotient_truncates(tmp_path: Path) -> None: + """The tree's integer evaluator -- what sizes an extent written over a + module parameter -- folds the same tree and had the same ``//``.""" + (tmp_path / "quotients_mod.f90").write_text(QUOTIENTS) + values = integer_parameters(["m", "m3", "m4"], tmp_path, frozenset({"quotients_mod"})) + assert values == {"m": -3, "m3": -3, "m4": 3} + + +ARITHMETIC = """\ +module arithmetic_mod + implicit none + integer, parameter :: r8 = selected_real_kind(12) + integer, parameter :: seven = 7 + integer, parameter :: minus = -1 + real(r8), parameter :: x = 1.0_r8 * (7/2) + real(r8), parameter :: y = real(7/2, r8) + real(r8), parameter :: z = 2.0_r8 * (seven / 2) + 1.0_r8 / 4.0_r8 + real(r8), parameter :: w = 1.0_r8 * ((-7) / 2) + integer, parameter :: n = 2 ** (-1) + integer, parameter :: n1 = (-1) ** (-3) + integer, parameter :: n2 = seven ** minus + integer, parameter :: n3 = 2 ** 3 + real(r8), parameter :: v = 1.0_r8 * 2 ** (-1) +contains + subroutine noop(k) + integer, intent(out) :: k + integer, parameter :: half = seven / 2 + real(r8), parameter :: part = 0.5_r8 * (seven / 2) + k = half + end subroutine noop +end module arithmetic_mod +""" + + +def test_an_integer_quotient_inside_a_real_constant_truncates(tmp_path: Path) -> None: + """FNP-D0023: ``1.0_r8 * (7/2)`` is 3.0 -- ``7/2`` is an integer + quotient whatever it is multiplied by -- and the token route emitted + ``np.float64('1.0') * ( 7 / 2 )``, 3.5. A quotient of integer + constants, one under a conversion, and one with a negative operand + truncate; a quotient with a real operand stays real division; and a + subprogram's own parameters, which have no integer-quotient rule of + their own, are typed the same way.""" + scope = _constants(tmp_path, ARITHMETIC) + assert scope["X"] == 3.0 and scope["Y"] == 3.0 and scope["W"] == -3.0 + assert scope["Z"] == 6.25 + assert scope["NOOP__HALF"] == 3 and scope["NOOP__PART"] == 1.5 + + +def test_an_integer_power_with_a_negative_exponent_is_an_integer(tmp_path: Path) -> None: + """FNP-D0024: ``2 ** (-1)`` is 0 in Fortran and was emitted as Python's + ``2 ** ( - 1 )``, 0.5; an INTEGER constant raised to one raised + outright, NumPy refusing a negative integer power. ``(-1) ** (-3)`` is + -1, and a power with a literal non-negative exponent is spelled as it + always was.""" + path = tmp_path / "own_mod.f90" + path.write_text(ARITHMETIC) + text = constants_module(constants.extract(path)) + assert "N3 = 2 ** 3" in text + scope = _constants(tmp_path, ARITHMETIC) + assert (scope["N"], scope["N1"], scope["N2"], scope["N3"]) == (0, -1, 0, 8) + assert scope["V"] == 0.0 + + +def test_an_expression_without_integer_arithmetic_keeps_its_text(tmp_path: Path) -> None: + """Only a quotient or power over two integers is respelled: a constant + without one is the text the token route always gave it, and the file + defines no helper it does not call.""" + path = tmp_path / "own_mod.f90" + path.write_text( + "module plain_mod\n integer, parameter :: r8 = selected_real_kind(12)\n" + " real(r8), parameter :: a = 2.0_r8 / 3.0_r8 + 1.0_r8\nend module plain_mod\n" + ) + text = constants_module(constants.extract(path)) + assert "A = np.float64('2.0') / np.float64('3.0') + np.float64('1.0')" in text + assert "_f_int_div" not in text and "_f_ipow" not in text + + +def test_a_use_constant_integer_power_is_an_integer(tmp_path: Path) -> None: + """FNP-D0024 on the use-constants route: the same ``2 ** (-1)``.""" + source = ( + "module pow_mod\n integer, parameter :: p = 2 ** (-1), q = 3 ** 2\nend module pow_mod\n" + ) + scope = _use_constants(tmp_path, source, ["p", "q"]) + assert (scope["P"], scope["Q"]) == (0, 9) + + +ROUNDING = """\ +module rounding_mod + implicit none + integer, parameter :: r8 = selected_real_kind(12) + integer, parameter :: i8 = selected_int_kind(18) + integer, parameter :: up = nint(2.5_r8), down = nint(-2.5_r8) + integer, parameter :: under = nint(0.49999999999999994_r8), odd = nint(-3.5_r8) + integer(i8), parameter :: wide = nint(3.0e10_r8, i8) + integer, parameter :: m = mod(-7, 3), mo = modulo(-7, 3) + real(r8), parameter :: rm = mod(-7.5_r8, 2.0_r8), rmo = modulo(-7.5_r8, 2.0_r8) + integer, parameter :: s1 = sign(3, -2), s2 = sign(-3, 0) + real(r8), parameter :: s3 = sign(3.0_r8, -1.0_r8) +end module rounding_mod +""" + + +def test_nint_mod_and_sign_in_a_constant_are_fortrans(tmp_path: Path) -> None: + """FNP-D0025: ``nint`` was ``np.rint`` -- half to even, and a float -- + and ``mod`` was ``np.mod``, the floored remainder MODULO is. gfortran + folds ``nint(2.5)`` to 3, ``nint(0.49999999999999994)`` to 0 (where + ``floor(x + 0.5)`` says 1), ``mod(-7, 3)`` to -1 and ``mod(-7.5, 2.0)`` + to -1.5; ``modulo`` stays floored. A kind on ``nint`` is the result's. + ``sign`` was ``np.sign``, which reads its second argument as an output + array and raised on import.""" + scope = _constants(tmp_path, ROUNDING) + assert (scope["UP"], scope["DOWN"], scope["UNDER"], scope["ODD"]) == (3, -3, 0, -4) + assert scope["UP"].dtype.name == "int32" and scope["WIDE"].dtype.name == "int64" + assert scope["WIDE"] == 30000000000 + assert (scope["M"], scope["MO"], scope["RM"], scope["RMO"]) == (-1, 2, -1.5, 0.5) + assert (scope["S1"], scope["S2"], scope["S3"]) == (-3, 3, -3.0) + + +INQUIRIES = """\ +module inquiries_mod + implicit none + integer, parameter :: r8 = selected_real_kind(12) + integer, parameter :: i8 = selected_int_kind(18) + integer, parameter :: big = huge(0), d = digits(0), d8 = digits(0_i8) + integer, parameter :: dr = digits(1.0_r8), ds = digits(1.0), dx = digits(x0) + integer(i8), parameter :: big8 = huge(0_i8) + real(r8), parameter :: hr = huge(1.0_r8), er = epsilon(1.0_r8) + real(r8), parameter :: x0 = 1.0_r8 + integer, parameter :: unplaced = huge(big) + real(r8), parameter :: invalid = epsilon(0) +end module inquiries_mod +""" + + +def test_an_inquiry_about_an_integer_literal_answers_for_the_integer(tmp_path: Path) -> None: + """FNP-D0026 and FNP-D0046: ``huge(0)`` and ``digits(0)`` ask about the + default INTEGER, and the classifier read the literal as a default REAL + -- ``huge(0)`` was ``np.finfo(np.float32).max``, ``digits(0)`` the + double's 53. gfortran: 2147483647, 31, 63 for ``digits(0_i8)``, and 24 + for a single's DIGITS. An integer name, whose record has no kind, and + EPSILON of an integer, which Fortran does not define, refuse.""" + import numpy as np + + scope = _constants(tmp_path, INQUIRIES) + assert (scope["BIG"], scope["D"], scope["D8"]) == (2147483647, 31, 63) + assert (scope["DR"], scope["DS"], scope["DX"]) == (53, 24, 53) + assert scope["BIG8"] == 9223372036854775807 + assert scope["HR"] == np.finfo(np.float64).max and scope["ER"] == np.finfo(np.float64).eps + path = tmp_path / "own_mod.f90" + text = constants_module(constants.extract(path)) + assert "# SKIPPED UNPLACED" in text and "# SKIPPED INVALID" in text + + +STATE = """\ +module state_mod + implicit none + integer, parameter :: r8 = selected_real_kind(12) + real(r8) :: third = 1/3 + real(r8) :: neg = -7/2 + real(r8) :: half = 1.0_r8/2.0_r8 + integer :: k = 7/2 + integer :: kr = 7.0_r8/2.0_r8 + integer(8) :: k8 = -7_8/2_8 +contains + subroutine noop() + end subroutine noop +end module state_mod +""" + + +def _renderer(tmp_path: Path, source: str) -> tuple[Modules, Path]: + path = tmp_path / "mod.f90" + path.write_text(source) + renderer = Modules( + subprograms=Subprograms( + record=interface.extract(path), + constants=constants.extract(path), + profile=PROFILES["ifx"], + ) + ) + return renderer, path + + +def test_a_module_variables_integer_quotient_initializer_truncates(tmp_path: Path) -> None: + """FNP-D0029: ``real(r8) :: third = 1/3`` divides two integers, 0, and + converts the 0 -- gfortran starts ``third`` at 0.0, and the state was + ``np.float64(0.3333333333333333)``. The quotient truncates toward zero, + a real quotient stays real, an INTEGER variable takes the quotient + truncated, and ``-7/2`` -- which fparser writes ``- 7 / 2`` -- is -3 + rather than a ``float('- 7')`` that took the translation down.""" + import numpy as np + + renderer, path = _renderer(tmp_path, STATE) + body, _report = renderer.body(renderer._subprogram_nodes(path)) + state = {} + for line in body: + if "# module state" in line: + name, value = line.split(" #")[0].split(" = ", 1) + state[name] = eval(value, {"np": np}) + assert (state["third"], state["neg"], state["half"]) == (0.0, -3.0, 0.5) + assert (state["k"], state["kr"], state["k8"]) == (3, 3, -3) + + +DEFAULTS = """\ +module defaults_mod + implicit none + integer, parameter :: r8 = selected_real_kind(12) + integer, parameter :: nlev = 3 + real(r8), parameter :: grav = 9.81_r8 + type t + real(r8) :: tol = 1.0d-6 + integer :: k = -3 + real(r8) :: v + real(r8) :: w = 2 + logical :: on = .true. + real(r8) :: a(nlev) = 0.5_r8 + integer :: b(3) = (/1, 2, 3/) + real(r8) :: g = grav + real(r8) :: third = 1/3 + real(r8), pointer :: p(:) => null() + real(r8) :: e = 2.0_r8 * grav + logical(kind=4) :: ini = .false._4 + integer :: cnt = nlev + 1 + real(r8) :: elsewhere = not_a_parameter + end type t +contains + subroutine noop() + end subroutine noop +end module defaults_mod +""" + + +def test_a_derived_type_factory_starts_components_at_their_defaults(tmp_path: Path) -> None: + """FNP-D0030: ``real(r8) :: tol = 1.0d-6`` in a type definition is where + every object of the type starts, and the factory started it at 0.0 -- + the frontend's record carried no initializer. Scalars, a broadcast + array, an integer constructor (integer), a parameter name, an integer + quotient, a REAL component given an integer, a LOGICAL of a named kind + and an expression over the module's parameters (mt19937's ``cnt = n + + 1_wi`` sentinel) are what gfortran starts them at; a pointer's ``=> + null()`` is ``None`` as before; and an initializer the factory cannot + render is ``None``, never the zero it used to guess.""" + import numpy as np + + renderer, path = _renderer(tmp_path, DEFAULTS) + body, _report = renderer.body(renderer._subprogram_nodes(path)) + at = body.index("def _make_t():") + factory = body[at : body.index(" return o", at)] + assert " o.tol = np.float64('1.0e-6')" in factory + assert " o.k = -3" in factory and " o.v = 0.0" in factory + assert " o.w = np.float64(2)" in factory and " o.on = True" in factory + assert " o.a = np.full((NLEV,), np.float64('0.5'), dtype=np.float64)" in factory + assert " o.b = np.array([1, 2, 3], dtype=np.int32)" in factory + assert " o.g = GRAV" in factory and " o.third = np.float64(0.0)" in factory + assert " o.p = None" in factory and " o.e = np.float64('2.0') * GRAV" in factory + assert " o.ini = False" in factory and " o.cnt = NLEV + 1" in factory + assert any( + line.startswith(" o.elsewhere = None # default initialization") for line in factory + ) + scope = {"np": np, "NLEV": 3, "GRAV": 9.81} + values = { + line.split(" = ", 1)[0].strip()[2:]: eval(line.split(" = ", 1)[1], scope) + for line in factory + if line.startswith(" o.") and " = " in line + } + assert values["b"].tolist() == [1, 2, 3] and values["a"].tolist() == [0.5, 0.5, 0.5] diff --git a/tests/test_fnp_forall_review.py b/tests/test_fnp_forall_review.py new file mode 100644 index 0000000..a9dca6d --- /dev/null +++ b/tests/test_fnp_forall_review.py @@ -0,0 +1,223 @@ +"""FORALL shapes the review of the FNP-D0002/FNP-D0003 fix found still wrong. + +``tests/test_fnp_statements.py`` covers the FORALL the audit refuted: an +element-wise right-hand side, a positive stride. The review ran gfortran on +the shapes around it -- a stride counting down, a row or column shifted as a +section, an index of the same name as a variable outside -- and each came +out of the translation with another number. Every test here translates a +small module, runs the emitted Python and asserts what gfortran prints for +the same source. +""" + +from __future__ import annotations + +from types import ModuleType +from typing import Any + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +np = pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +from tests.test_fnp_statements import body_of, translate # noqa: E402 + +STRIDES = """\ +module fs_mod + implicit none +contains + subroutine down(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer :: i + forall (i = n:1:-1) a(i) = 10.0d0 * a(i) + end subroutine down + + subroutine downshift(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer :: i + forall (i = n:2:-1) a(i) = a(i-1) + end subroutine downshift + + subroutine stepped(a, n, s) + integer, intent(in) :: n, s + real(8), intent(inout) :: a(n) + integer :: i + forall (i = n:1:s) a(i) = a(i) + 1.0d0 + end subroutine stepped +end module fs_mod +""" + + +@pytest.fixture(scope="module") +def strides(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("strides"), STRIDES, "fs_mod") + + +def test_a_forall_counting_down_reaches_its_last_index(strides: Any) -> None: + """``forall (i = n:1:-1)`` visits ``i = 1``; the stop edge ``1 + 1`` + left it out. gfortran: 10 20 30 40.""" + module, _, _ = strides + assert list(module.down(np.array([1.0, 2.0, 3.0, 4.0]), 4)) == [10.0, 20.0, 30.0, 40.0] + + +def test_a_dependent_forall_counting_down_reaches_its_last_index(strides: Any) -> None: + """The gathered path listed its combinations from the same range. + gfortran: 1 1 2 3.""" + module, _, _ = strides + assert list(module.downshift(np.array([1.0, 2.0, 3.0, 4.0]), 4)) == [1.0, 1.0, 2.0, 3.0] + + +def test_a_forall_step_known_only_at_run_time(strides: Any) -> None: + """``s = -2`` visits 4 and 2 (gfortran: 1 3 3 5); ``s = 1`` from 4 to 1 + visits nothing.""" + module, _, _ = strides + assert list(module.stepped(np.array([1.0, 2.0, 3.0, 4.0]), 4, -2)) == [1.0, 3.0, 3.0, 5.0] + assert list(module.stepped(np.array([1.0, 2.0, 3.0, 4.0]), 4, 1)) == [1.0, 2.0, 3.0, 4.0] + + +SECTIONS = """\ +module fc_mod + implicit none +contains + subroutine rows(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n, 4) + integer :: i + forall (i = 2:n) a(i, :) = a(i-1, :) + end subroutine rows + + subroutine cols(a, m) + integer, intent(in) :: m + real(8), intent(inout) :: a(2, m) + integer :: j + forall (j = 2:m) a(:, j) = a(:, j-1) + end subroutine cols + + subroutine spread(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n, 4) + integer :: i + forall (i = 2:n) a(i, :) = a(i-1, 1) + end subroutine spread +end module fc_mod +""" + + +@pytest.fixture(scope="module") +def sections(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("sections"), SECTIONS, "fc_mod") + + +def rows_of_tens() -> Any: + """``a(r, k) = 10 r + k``, four by four.""" + return np.array([[10.0 * r + k for k in range(1, 5)] for r in range(1, 5)], order="F") + + +def test_a_forall_shifting_rows_gathers_their_values(sections: Any) -> None: + """``a(i, :) = a(i-1, :)`` gathered views of the rows it went on to + store into, and copied row 1 everywhere (FNP-D0002 at rank 2).""" + module, _, _ = sections + got = module.rows(rows_of_tens(), 4) + assert got.tolist() == [[11.0, 12.0, 13.0, 14.0]] * 2 + [ + [21.0, 22.0, 23.0, 24.0], + [31.0, 32.0, 33.0, 34.0], + ] + + +def test_a_forall_shifting_columns_gathers_their_values(sections: Any) -> None: + """gfortran: 1 5 | 1 5 | 2 6 | 3 7, column by column.""" + module, _, _ = sections + got = module.cols(np.array([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], order="F"), 4) + assert got.tolist() == [[1.0, 1.0, 2.0, 3.0], [5.0, 5.0, 6.0, 7.0]] + + +def test_a_forall_spreading_an_element_over_a_section(sections: Any) -> None: + """An element stored into a section is broadcast, gathered or not. + gfortran: row 1 as it was, then 11, 21 and 31 across.""" + module, _, _ = sections + got = module.spread(rows_of_tens(), 4) + assert got.tolist() == [[11.0, 12.0, 13.0, 14.0], [11.0] * 4, [21.0] * 4, [31.0] * 4] + + +SCOPES = """\ +module fi_mod + implicit none +contains + subroutine gathered(a, n, k) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer, intent(out) :: k + integer :: i + i = 100 + forall (i = 2:n) a(i) = a(i-1) + k = i + end subroutine gathered + + subroutine looped(a, n, k) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer, intent(out) :: k + integer :: i + i = 100 + forall (i = 1:n) a(i) = 2.0d0 * a(i) + k = i + end subroutine looped + + subroutine inner(c, n, m, k) + integer, intent(in) :: n, m + real(8), intent(inout) :: c(n, m) + integer, intent(out) :: k + integer :: i, j + j = 7 + forall (i = 1:n) + forall (j = 1:m) c(i, j) = dble(i + j) + end forall + k = j + end subroutine inner +end module fi_mod +""" + + +@pytest.fixture(scope="module") +def scopes(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("scopes"), SCOPES, "fi_mod") + + +def test_a_gathered_forall_leaves_the_variable_of_its_index_alone(scopes: Any) -> None: + """The store loop rebound the Python local ``i``, so ``k = i`` read the + last index, 4, where gfortran gives 100.""" + module, _, _ = scopes + a, k = module.gathered(np.array([1.0, 2.0, 3.0, 4.0]), 4) + assert list(a) == [1.0, 1.0, 2.0, 3.0] + assert k == 100 + + +def test_a_looped_forall_leaves_the_variable_of_its_index_alone(scopes: Any) -> None: + module, _, _ = scopes + a, k = module.looped(np.array([1.0, 2.0, 3.0, 4.0]), 4) + assert list(a) == [2.0, 4.0, 6.0, 8.0] + assert k == 100 + + +def test_a_nested_forall_index_is_scoped_to_its_own_construct(scopes: Any) -> None: + """gfortran: c(i, j) = i + j, and ``j`` still 7 after both.""" + module, _, _ = scopes + c, k = module.inner(np.zeros((2, 3), order="F"), 2, 3) + assert c.tolist() == [[2.0, 3.0, 4.0], [3.0, 4.0, 5.0]] + assert k == 7 + + +def test_the_read_write_gate_reads_a_forall_index_as_its_source_name(scopes: Any) -> None: + """``_fi_i`` is the source's ``i`` spelled apart, not a name of its own: + the gate sees the loop read and write what it did before the rename.""" + import ast + + from recast.verify.rwset import Protocol, span_rwset + + _, text, _ = scopes + code = body_of(text, "looped").lstrip("\n") + assert "for _fi_i in" in code + reads, writes = span_rwset(ast.parse(code), 2, code.count("\n") + 1, Protocol()) + assert "i" in writes and "i" in reads + assert not any(name.startswith("_fi_") for name in reads | writes) diff --git a/tests/test_fnp_intrinsics.py b/tests/test_fnp_intrinsics.py new file mode 100644 index 0000000..68a2f87 --- /dev/null +++ b/tests/test_fnp_intrinsics.py @@ -0,0 +1,613 @@ +"""Intrinsics and arithmetic the formal audit refuted, held to Fortran's value. + +Each test here is one defect of the RecastEngine-Pro-Lean audit (``FNP-Dxxxx``, +against the rule ``FNP-Rxxxx`` it refutes): a construct the translation +accepted and emitted as runnable Python that computes a different number from +the Fortran. They translate a small module through the real frontend and +Transform and run what comes out, because the emitted spelling is the claim +-- a runtime helper that is right on its own proves nothing if the emitter +never calls it with what it needs. +""" + +from __future__ import annotations + +import importlib +import itertools +import math +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +import numpy as np + +from recast.fortran.frontend import FortranFrontend +from recast.transform.numpy import runtime +from recast.transform.numpy.translate import NumpyTranslation + +_serial = itertools.count() + + +def candidate_for(tmp_path: Path, source: str, *siblings: str) -> tuple[Any, Any, Path]: + """``source`` (one module) through the real frontend and Transform, with + ``siblings`` beside it in the tree: the unit, the candidate, and the + directory it was written to.""" + name = source.split()[1].lower() + tree = tmp_path / f"src{next(_serial)}" + tree.mkdir() + for text in (source, *siblings): + (tree / f"{text.split()[1].lower()}.f90").write_text(text) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tree) if u.uid == f"fortran:{name}") + candidate = NumpyTranslation().apply(unit, frontend.analyze(unit, tree), {"root": tree}) + out = tree / "emitted" + out.mkdir() + for path, content in candidate.files.items(): + (out / path.name).write_bytes(content) + return unit, candidate, out + + +def translate(tmp_path: Path, source: str, *siblings: str) -> tuple[ModuleType, str, list[str]]: + """``source`` translated and imported, with its text and deferred list: + what the Transform actually hands on.""" + name = source.split()[1].lower() + _, candidate, out = candidate_for(tmp_path, source, *siblings) + sys.path.insert(0, str(out)) + try: + module = importlib.import_module(f"{name}_numpy") + finally: + sys.path.remove(str(out)) + sys.modules.pop(f"{name}_numpy", None) + return module, (out / f"{name}_numpy.py").read_text(), list(candidate.deferred) + + +def dataflow_agrees(tmp_path: Path, source: str) -> None: + """The static read/write gate over the translation of ``source``: a fix + that reads a name the source does not -- a lower bound, say -- stops a + unit at the gate however right its numbers are.""" + from recast.executors.local import LocalExecutor + from recast.model import Confidence + from recast.verify.rwset import ReadWriteSetVerifier + + unit, candidate, _ = candidate_for(tmp_path, source) + verdict = ReadWriteSetVerifier().check(unit, candidate, Path("."), LocalExecutor(), {}) + assert verdict.confidence is Confidence.SAMPLED, verdict.detail + + +def same(a: Any, b: Any) -> bool: + """Equal, and equal in sign for zeros and in NaN-ness: ``anint(-0.3)`` is + ``-0.0``, which ``==`` cannot tell from ``0.0``.""" + if isinstance(b, float) and math.isnan(b): + return math.isnan(a) + return bool(a == b) and math.copysign(1.0, a) == math.copysign(1.0, b) + + +# --- FNP-D0004: ANINT rounds halves away from zero ---------------------------- + +ANINT = """\ +module anint_mod + implicit none +contains + subroutine rounded(x, v, r, w) + real(8), intent(in) :: x, v(4) + real(8), intent(out) :: r, w(4) + r = anint(x) + w = anint(v) + end subroutine rounded +end module anint_mod +""" + +ANINT_CASES = [ + (2.5, 3.0), + (-2.5, -3.0), + (0.5, 1.0), + (-0.5, -1.0), + (1.5, 2.0), + (0.49999999999999994, 0.0), + (-0.3, -0.0), + (2.0**52 + 1, 2.0**52 + 1), + (1e300, 1e300), + (math.inf, math.inf), + (math.nan, math.nan), +] + + +@pytest.mark.parametrize(("x", "expected"), ANINT_CASES) +def test_anint_is_round_not_round_half_even(x: float, expected: float) -> None: + """``np.round`` rounds a half to even; C's ``round``, which gfortran + calls for ANINT, rounds it away from zero. ``floor(x + 0.5)`` is wrong + too, just less often: the addition rounds 0.49999999999999994 up.""" + assert same(runtime._f_anint(x), expected) + assert same(runtime._f_anint(np.array([x]))[0], expected) + + +def test_anint_is_emitted_as_fortran_rounds_it(tmp_path: Path) -> None: + module, text, deferred = translate(tmp_path, ANINT) + assert not deferred + assert "r = _f_anint(x)" in text and "w[...] = _f_anint(v)" in text + r, w = module.rounded(2.5, np.array([2.5, -2.5, 0.5, -0.49999999999999994])) + assert r == 3.0 + assert list(w) == [3.0, -3.0, 1.0, -0.0] + + +def test_nint_does_not_round_a_value_below_a_half_up() -> None: + """The same ``floor(x + 0.5)`` in NINT: 0.49999999999999994 is below a + half, and gfortran's ``lround`` answers 0.""" + assert runtime._f_nint(0.49999999999999994) == 0 + assert runtime._f_nint(-0.49999999999999994) == 0 + assert runtime._f_nint(2.5) == 3 and runtime._f_nint(-2.5) == -3 + + +# --- FNP-D0005: INT and NINT keep their KIND ---------------------------------- + +INT_KIND = """\ +module intkind_mod + implicit none + integer, parameter :: i8 = selected_int_kind(18) + integer, parameter :: r8 = selected_real_kind(12) +contains + subroutine widths(x, k8, k4, kn, kp, kr, ka) + real(8), intent(in) :: x + integer(8), intent(out) :: k8, kn, kp, kr, ka + integer, intent(out) :: k4 + k8 = int(x, kind=8) + k4 = int(x) + kn = nint(x, 8) + kp = int(x, i8) + kr = int(x, kind=r8) + ka = x + end subroutine widths +end module intkind_mod +""" + + +def test_a_64_bit_int_holds_what_a_default_one_cannot(tmp_path: Path) -> None: + """``int(x, kind=8)`` of 3.0d10 is 30000000000; the kind was dropped and + the default conversion saturated it at the int32 edge. A REAL stored + into an ``integer(8)`` converts into that kind as well.""" + module, text, deferred = translate(tmp_path, INT_KIND) + assert not deferred + assert "k4 = _f_int(x)\n" in text # the default kind keeps its spelling + k8, k4, kn, kp, kr, ka = module.widths(3.0e10) + assert (k8, kn, kp, kr, ka) == (30000000000,) * 5 + assert k4 == -(2**31) # the hardware conversion's answer, as before + assert module.widths(2.5)[2] == 3 # nint rounds, whatever the kind + + +def test_a_kind_nothing_here_can_evaluate_is_refused(tmp_path: Path) -> None: + source = INT_KIND.replace("k4 = int(x)", "k4 = int(x, kind=2)") + _, _, deferred = translate(tmp_path, source) + assert len(deferred) == 1 and "kind 2" in deferred[0] + + +# --- FNP-D0006: LBOUND and UBOUND report the declared bounds ------------------- + +BOUNDS = """\ +module bounds_mod + implicit none +contains + subroutine bounds(a, c, n, lo, hi, cl, cu, sz, lov, hiv, w, total) + integer, intent(in) :: n + real(8), intent(in) :: a(0:5), c(-1:n, 2:3) + integer, intent(out) :: lo, hi, cl, cu, sz, lov(2), hiv(2) + real(8), intent(out) :: w(0:n), total + integer :: i + lo = lbound(a, 1) + hi = ubound(a, 1) + cl = lbound(c, dim=2) + cu = ubound(c, 1) + sz = size(a) + lov = lbound(c) + hiv = ubound(c) + total = 0.0d0 + do i = lbound(w, 1), ubound(w, 1) + w(i) = i + total = total + w(i) + end do + end subroutine bounds +end module bounds_mod +""" + + +def test_bounds_are_the_declared_ones_not_one_and_the_extent(tmp_path: Path) -> None: + """``a(0:5)``: Fortran says LBOUND 0 and UBOUND 5, the translation said 1 + and 6. A loop over ``lbound:ubound`` has to index what it did in the + source, so the bounds are the ones the subscripts are shifted by.""" + module, text, deferred = translate(tmp_path, BOUNDS) + assert not deferred + assert "lo = _f_lbound(a, 1, (0,))" in text + lo, hi, cl, cu, sz, lov, hiv, w, total = module.bounds(np.zeros(6), np.zeros((5, 2)), 3) + assert (lo, hi, cl, cu, sz) == (0, 5, 2, 3, 6) + assert list(lov) == [-1, 2] and list(hiv) == [3, 3] + assert list(w) == [0.0, 1.0, 2.0, 3.0] and total == 6.0 + dataflow_agrees(tmp_path, BOUNDS.replace("-1:n", "-n:n")) # a bound that reads a name + + +def test_a_bound_based_at_one_keeps_its_spelling(tmp_path: Path) -> None: + source = """\ +module plain_mod + implicit none +contains + subroutine plain(a, m, lo, hi, both) + real(8), intent(in) :: a(:), m(3, 4) + integer, intent(out) :: lo, hi, both(2) + lo = lbound(a, 1) + hi = ubound(a, 1) + both = ubound(m) + end subroutine plain +end module plain_mod +""" + module, text, deferred = translate(tmp_path, source) + assert not deferred + assert "lo = _f_lbound(a, 1)" in text and "hi = np.size(a, axis=(1) - 1)" in text + lo, hi, both = module.plain(np.zeros(7), np.zeros((3, 4))) + # UBOUND without DIM is a vector of bounds; it used to be the element count. + assert (lo, hi, list(both)) == (1, 7, [3, 4]) + + +def test_an_axis_of_zero_extent_has_bounds_one_and_zero() -> None: + empty = np.zeros((0, 3)) + assert list(runtime._f_lbound(empty, None, (5, 2))) == [1, 2] + assert list(runtime._f_ubound(empty, None, (5, 2))) == [0, 4] + + +# --- FNP-D0007, FNP-D0008, FNP-D0039: kind inquiries read the declaration ----- + +INQUIRIES = """\ +module inquire_mod + implicit none + integer, parameter :: r8 = selected_real_kind(12) +contains + subroutine inquiries(x4, x8, k8, kx4, kx8, kk8, hk8, hk4, px4, px8, ex4, tx8, hx, hl) + real(4), intent(in) :: x4 + real(r8), intent(in) :: x8 + integer(8), intent(in) :: k8 + integer, intent(out) :: kx4, kx8, kk8, px4, px8 + integer(8), intent(out) :: hk8 + integer, intent(out) :: hk4 + real(8), intent(out) :: ex4, tx8, hx, hl + real(8) :: x + x = 0 + kx4 = kind(x4) + kx8 = kind(x8) + kk8 = kind(k8) + hk8 = huge(k8) + hk4 = huge(0) + px4 = precision(x4) + px8 = precision(x8) + ex4 = epsilon(1.0) + tx8 = tiny(x8) + hx = huge(x) + hl = huge(1.0_r8) + end subroutine inquiries +end module inquire_mod +""" + + +def test_kind_inquiries_answer_for_the_declared_kind(tmp_path: Path) -> None: + """The runtime guessed the kind from the Python type: ``kind(x4)`` was 8, + ``huge(k8)`` the int32 maximum, and a ``real(8)`` assigned the integer 0 + -- a Python ``0`` -- answered HUGE with 2147483647 (the audit's + ``hugeint`` probe). The arguments are passed as the drivers of the audit + passed them: ``k8`` a NumPy int64, the reals plain floats.""" + module, text, deferred = translate(tmp_path, INQUIRIES) + assert not deferred + assert "hk8 = _f_huge(k8, 'int64')" in text + kx4, kx8, kk8, hk8, hk4, px4, px8, ex4, tx8, hx, hl = module.inquiries(1.0, 1.0, 1) + assert (kx4, kx8, kk8) == (4, 8, 8) + assert hk8 == 2**63 - 1 and hk4 == 2**31 - 1 + assert (px4, px8) == (6, 15) + assert ex4 == 2.0**-23 and tx8 == 2.0**-1022 + assert hx == hl == np.finfo(np.float64).max + + +def test_a_companion_parameter_answers_for_its_declared_kind(tmp_path: Path) -> None: + """SLSQP's ``huge(one)``, ``one`` a parameter of ``slsqp_support``.""" + kinds = """\ +module kinds_mod + use, intrinsic :: iso_fortran_env, only: real64 + implicit none + integer, parameter :: wp = real64 +end module kinds_mod +""" + support = """\ +module support_mod + use kinds_mod, only: wp + implicit none + real(wp), parameter :: one = 1.0_wp +end module support_mod +""" + user = """\ +module user_mod + use kinds_mod, only: wp + use support_mod, only: one + implicit none +contains + subroutine bound(h, e) + real(8), intent(out) :: h, e + h = huge(one) + e = epsilon(one) + end subroutine bound +end module user_mod +""" + _, text, deferred = translate(tmp_path, user, support, kinds) + assert not deferred, deferred + assert "'float64'" in text + + +def test_a_kind_inquiry_on_an_unresolved_kind_is_refused(tmp_path: Path) -> None: + source = """\ +module unknown_mod + use elsewhere, only: wp + implicit none +contains + subroutine eps(x, e) + real(wp), intent(in) :: x + real(wp), intent(out) :: e + e = epsilon(x) + end subroutine eps +end module unknown_mod +""" + _, _, deferred = translate(tmp_path, source) + assert len(deferred) == 1 and "epsilon" in deferred[0] + + +# --- FNP-D0027: DIGITS and the other model inquiries, and no subscript -------- + +MODEL = """\ +module model_mod + implicit none +contains + subroutine model(x, k, d8, dk, r8, rk, mx, mn, bs) + real(8), intent(in) :: x + integer(8), intent(in) :: k + integer, intent(out) :: d8, dk, r8, rk, mx, mn, bs + d8 = digits(x) + dk = digits(k) + r8 = range(x) + rk = range(k) + mx = maxexponent(x) + mn = minexponent(x) + bs = bit_size(k) + end subroutine model +end module model_mod +""" + + +def test_digits_and_the_model_inquiries_are_spelled(tmp_path: Path) -> None: + """``digits(x)`` had no spelling and fell through to the subscript + fallback: ``n = digits[x - 1]``, a NameError where Fortran says 53.""" + module, text, deferred = translate(tmp_path, MODEL) + assert not deferred + assert "digits[" not in text + assert module.model(1.0, 1) == (53, 63, 307, 18, 1024, -1021, 64) + dataflow_agrees(tmp_path, MODEL) + + +def test_a_standard_intrinsic_without_a_spelling_is_refused(tmp_path: Path) -> None: + source = """\ +module frac_mod + implicit none +contains + subroutine frac(x, f) + real(8), intent(in) :: x + real(8), intent(out) :: f + f = fraction(x) + end subroutine frac +end module frac_mod +""" + _, text, deferred = translate(tmp_path, source) + assert len(deferred) == 1 and "intrinsic 'fraction'" in deferred[0] + assert "fraction[" not in text + + +def test_a_name_a_bare_use_may_import_is_still_read_as_an_array(tmp_path: Path) -> None: + """``use grid_mod`` may bring an array ``scale`` in, and an integer + subscript of it is what the fallback exists for.""" + source = """\ +module scaled_mod + use grid_mod + implicit none +contains + subroutine scaled(i, v) + integer, intent(in) :: i + real(8), intent(out) :: v + v = scale(i) + end subroutine scaled +end module scaled_mod +""" + _, candidate, out = candidate_for(tmp_path, source) + assert not candidate.deferred + assert "scale[i - 1]" in (out / "scaled_mod_numpy.py").read_text() + + +# --- FNP-D0009: bit intrinsics work within the declared kind ------------------- + +BITS = """\ +module bits_mod + implicit none +contains + subroutine bits(i, k, s1, s2, s3, s4, s5) + integer, intent(in) :: i + integer(8), intent(in) :: k + integer, intent(out) :: s1, s2, s4, s5 + integer(8), intent(out) :: s3 + s1 = ishft(1, 31) + s2 = ishft(i, 31) + s3 = ishft(k, 40) + s4 = ior(i, ishft(1, 31)) + s5 = ieor(-1, ishft(i, shift=30)) + end subroutine bits +end module bits_mod +""" + + +def test_a_shift_stays_in_the_operands_width(tmp_path: Path) -> None: + """``ishft(1, 31)`` is -2147483648 in a default INTEGER; a literal ``1`` + has no NumPy dtype for the runtime to read the width off, and the shift + went unbounded. So did a Python int ``i`` a caller passed.""" + module, text, deferred = translate(tmp_path, BITS) + assert not deferred + assert "_f_ishft(1, I_31, 32)" in text + assert module.bits(1, 1) == (-(2**31), -(2**31), 2**40, -(2**31) + 1, -(2**30) - 1) + + +# --- FNP-D0012: INTEGER ** a negative exponent is an integer ------------------- + +POWERS = """\ +module ipow_mod + implicit none +contains + subroutine powers(j, n, x, v, k, m, p, q, r, w) + integer, intent(in) :: j, n, v(3) + real(8), intent(in) :: x + integer, intent(out) :: k, m, p, q, w(3) + real(8), intent(out) :: r + k = 2**(-1) + m = j**(-1) + p = j**n + q = j**3 + r = x**(-2) + w = v**(-1) + end subroutine powers +end module ipow_mod +""" + + +def test_an_integer_to_a_negative_power_is_integer_division(tmp_path: Path) -> None: + """Fortran's ``2**(-1)`` is zero, and ``j**(-1)`` is zero but for j = +-1; + Python said 0.5. A REAL base and a non-negative literal are unchanged.""" + module, text, deferred = translate(tmp_path, POWERS) + assert not deferred + assert "q = (j ** 3)" in text and "r = (x ** -2)" in text + k, m, p, q, r, w = module.powers(2, -2, 2.0, np.array([1, -1, 2], dtype=np.int32)) + assert (k, m, p, q, r) == (0, 0, 0, 8, 0.25) + assert list(w) == [1, -1, 0] and w.dtype == np.int32 + assert module.powers(-1, -3, 2.0, np.array([1, 1, 1], dtype=np.int32))[2] == -1 + assert module.powers(3, 2, 2.0, np.array([1, 1, 1], dtype=np.int32))[2] == 9 + + +def test_zero_to_a_negative_power_traps_as_the_reference_does() -> None: + with pytest.raises(ZeroDivisionError): + runtime._f_ipow(0, -1) + with pytest.raises(ZeroDivisionError): + runtime._f_ipow(np.array([1, 0]), -1) + + +# --- FNP-D0036 (and FNP-D0035): integer division and scalar-only intrinsics +# over arrays ------------------------------------------------------------ + +OVER_ARRAYS = """\ +module arrays_mod + implicit none +contains + subroutine over(a, b, x, c, t, m, k, n, s) + integer, intent(in) :: a(3), b(3) + real(8), intent(in) :: x(3) + integer, intent(out) :: c(3), m(3), k(3), n(3), s(3) + real(8), intent(out) :: t(3) + c = a / b + t = tan(x) + m = mod(a, b) + k = int(x) + n = nint(x) + s = ishft(a, 1) + end subroutine over +end module arrays_mod +""" + + +def test_integer_division_and_scalar_spellings_take_arrays(tmp_path: Path) -> None: + """``c = a / b`` over integer arrays was ``_f_int_div(a, b)``, which + called ``int`` on an array; ``tan``, ``mod`` and ``ishft`` had only + scalar spellings; and ``nint`` of a REAL array was ``np.int32`` of it, + which truncates -- the one of these that did not raise.""" + module, _, deferred = translate(tmp_path, OVER_ARRAYS) + assert not deferred + a = np.array([7, -7, 7], dtype=np.int32) + b = np.array([2, 2, -2], dtype=np.int32) + x = np.array([0.5, 2.5, -2.5]) + c, t, m, k, n, s = module.over(a, b, x) + assert list(c) == [3, -3, -3] + assert list(m) == [1, -1, 1] + assert list(k) == [0, 2, -2] + assert list(n) == [1, 3, -3] + assert list(s) == [14, -14, 14] + assert list(t) == [math.tan(v) for v in x] + + +def test_integer_division_is_exact_past_two_to_the_53() -> None: + """``int(a / b)`` rounds the quotient through a double.""" + assert runtime._f_int_div(2**53 + 1, 1) == 2**53 + 1 + assert runtime._f_int_div(-(2**62) - 1, 3) == -((2**62 + 1) // 3) + assert type(runtime._f_int_div(np.int32(7), np.int32(2))) is int + + +def test_a_substring_argument_keeps_the_scalar_spelling(tmp_path: Path) -> None: + """``s(i:i)`` ranks as a section, and is a scalar string.""" + source = """\ +module parity_mod + implicit none +contains + subroutine parity(s, i, p) + character(len=4), intent(in) :: s + integer, intent(in) :: i + integer, intent(out) :: p + p = mod(ichar(s(i:i)), 2) + end subroutine parity +end module parity_mod +""" + module, text, deferred = translate(tmp_path, source) + assert not deferred and "_f_ecall" not in text[text.index("def parity") :] + assert module.parity("abcd", 1) == 1 + + +# --- FNP-D0040: DOT_PRODUCT of INTEGER arrays is an INTEGER -------------------- + +DOT = """\ +module dotint_mod + implicit none +contains + subroutine idot(n, m, a, b, c, d, v) + integer, intent(in) :: n, m + integer, intent(in) :: a(n), b(n) + real(8), intent(in) :: c(m) + integer, intent(out) :: d + real(8), intent(out) :: v + d = dot_product(a, b) + v = c(dot_product(a, b)) + end subroutine idot +end module dotint_mod +""" + + +def test_an_integer_dot_product_can_subscript(tmp_path: Path) -> None: + """The accumulator was seeded with ``0.0``, so ``c(dot_product(a, b))`` + subscripted with a float and NumPy raised (the audit's dotint probe).""" + module, _, deferred = translate(tmp_path, DOT) + assert not deferred + a = np.array([1, 2], dtype=np.int32) + b = np.array([3, 4], dtype=np.int32) + c = np.array([10.0 * i for i in range(1, 12)]) + assert module.idot(2, 11, a, b, c) == (11, 110.0) + assert isinstance(runtime._f_vdot(a, b), np.integer) + + +def test_a_real_dot_product_is_unchanged_to_the_bit() -> None: + rng = np.random.default_rng(7) + a, b = rng.standard_normal(97), rng.standard_normal(97) + s = 0.0 + for x, y in zip(a, b, strict=True): + s += x * y + assert runtime._f_vdot(a, b).tobytes() == np.float64(s).tobytes() + + +def test_a_complex_dot_product_conjugates_its_first_operand() -> None: + a = np.array([1j, 2.0]) + b = np.array([1j, 1.0]) + assert runtime._f_vdot(a, b) == 1.0 + 2.0 diff --git a/tests/test_fnp_intrinsics_review.py b/tests/test_fnp_intrinsics_review.py new file mode 100644 index 0000000..7440599 --- /dev/null +++ b/tests/test_fnp_intrinsics_review.py @@ -0,0 +1,267 @@ +"""The intrinsics fixes of the RecastEngine-Pro-Lean audit, held to what +gfortran computes where an independent review ran it against them. + +Each test is a case the first round of fixes (``test_fnp_intrinsics``) left +wrong or open: a construct translated into Python that computes a different +number from the compiled Fortran, or that fails where the Fortran runs. The +expected values are gfortran's (``-O0 -fcheck=all``), not the runtime's. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +import numpy as np + +from recast.transform.numpy import runtime +from tests.test_fnp_intrinsics import translate + +# --- FNP-D0007/8/9/27: an INTEGER kind the frontend could not resolve --------- + +UNRESOLVED = """\ +module unresolved_mod + implicit none + integer, parameter :: ik = kind(1_8) + integer, parameter :: i4 = selected_int_kind(9) +contains + subroutine wide(a, r, h, b, k, d) + integer(ik), intent(in) :: a + integer(8), intent(out) :: r, h + integer, intent(out) :: b, k, d + r = ishft(a, 40) + h = huge(a) + b = bit_size(a) + k = kind(a) + d = digits(a) + end subroutine wide + subroutine default(h, m, b) + integer, intent(out) :: h, m, b + integer :: n + integer(i4) :: n4 + n = 1 + n4 = 1 + h = huge(n) + m = huge(n4) + b = bit_size(n) + end subroutine default + integer function top(x) + integer, intent(in) :: x + top = x + top = huge(top) + end function top +end module unresolved_mod +""" + + +def test_an_unresolved_integer_kind_is_read_from_its_spelling(tmp_path: Path) -> None: + """``integer(kind(1_8))`` reaches the emitter as ``int32``, the dtype the + frontend gives every INTEGER kind it cannot resolve. Taken at its word, + ``ishft(a, 40)`` of 1 was shifted within 32 bits to 0, and HUGE, + BIT_SIZE, KIND and DIGITS answered for a default INTEGER; gfortran says + 1099511627776, 9223372036854775807, 64, 8 and 63.""" + module, text, deferred = translate(tmp_path, UNRESOLVED) + assert not deferred, deferred + assert "_f_ishft(a, I_40, 64)" in text + assert module.wide(np.int64(1)) == (2**40, 2**63 - 1, 64, 8, 63) + assert module.wide(1) == (2**40, 2**63 - 1, 64, 8, 63) + + +def test_a_default_integer_keeps_its_answers(tmp_path: Path) -> None: + """No kind, a kind parameter that names the 4-byte kind, and a function + result: the default INTEGER, as before.""" + module, text, deferred = translate(tmp_path, UNRESOLVED) + assert not deferred, deferred + assert "h = _f_huge(n, 'int32')" in text + assert module.default() == (2**31 - 1, 2**31 - 1, 32) + assert module.top(3) == 2**31 - 1 + + +@pytest.mark.parametrize("inquiry", ["huge", "digits", "kind", "bit_size", "range"]) +def test_a_kind_neither_four_nor_eight_is_refused(tmp_path: Path, inquiry: str) -> None: + """``integer(2) :: k2`` is ``int32`` to the frontend as well, and + ``huge(k2)`` answered 2147483647 where gfortran says 32767 (DIGITS 15, + KIND 2, BIT_SIZE 16, RANGE 4). No dtype here holds a 2-byte INTEGER.""" + source = f"""\ +module short_mod + implicit none +contains + subroutine short(h) + integer, intent(out) :: h + integer(2) :: k2 + k2 = 1 + h = {inquiry}(k2) + end subroutine short +end module short_mod +""" + _, _, deferred = translate(tmp_path, source) + assert len(deferred) == 1 and f"{inquiry}(k2)" in deferred[0] + + +def test_selected_int_kind_is_the_kind_gfortran_selects(tmp_path: Path) -> None: + """``selected_int_kind(4)`` is gfortran's 2-byte kind; read as "4 unless + it needs 8", ``int(x, i2)`` was emitted as a default conversion where + ``int(x, 2)`` is refused. ``selected_int_kind(9)`` stays the default + kind and ``selected_int_kind(18)`` the 8-byte one; ``huge(1_i2)`` asks + about the 2-byte kind too.""" + source = """\ +module sik_mod + implicit none + integer, parameter :: i2 = selected_int_kind(4) + integer, parameter :: i4 = selected_int_kind(9) + integer, parameter :: i8 = selected_int_kind(18) +contains + subroutine named(x, k) + real(8), intent(in) :: x + integer, intent(out) :: k + k = int(x, i2) + end subroutine named + subroutine literal(k) + integer, intent(out) :: k + k = huge(1_i2) + end subroutine literal + subroutine kept(x, k4, k8) + real(8), intent(in) :: x + integer, intent(out) :: k4 + integer(8), intent(out) :: k8 + k4 = int(x, i4) + k8 = int(x, i8) + end subroutine kept +end module sik_mod +""" + module, _, deferred = translate(tmp_path, source) + assert len(deferred) == 2, deferred + assert "kind 2" in deferred[0] and "huge(1_i2)" in deferred[1] + assert module.kept(3.0e10) == (-(2**31), 30000000000) + + +# --- FNP-D0004/5: a default-kind NINT is the low half of a 64-bit lround ------ + +NINT = """\ +module nintwrap_mod + implicit none +contains + subroutine rounded(x, v, n4, n8, w) + real(8), intent(in) :: x, v(3) + integer, intent(out) :: n4, w(3) + integer(8), intent(out) :: n8 + n4 = nint(x) + n8 = nint(x, kind=8) + w = nint(v) + end subroutine rounded +end module nintwrap_mod +""" + +NINT_CASES = [ # x, gfortran's nint(x), gfortran's nint(x, kind=8) + (3.0e10, -64771072, 30000000000), + (-3.0e10, 64771072, -30000000000), + (4503599627370497.0, 1, 4503599627370497), + (float("nan"), 0, -(2**63)), + (1.0e19, 0, -(2**63)), + (-2.5e9, 1794967296, -2500000000), + (2.0**31, -(2**31), 2**31), + (-(2.0**31) - 0.5, 2**31 - 1, -(2**31) - 1), + (2.5, 3, 3), + (-0.49999999999999994, 0, 0), +] + + +@pytest.mark.parametrize(("x", "default", "wide"), NINT_CASES) +def test_nint_wraps_as_gfortran_does(tmp_path: Path, x: float, default: int, wide: int) -> None: + """gfortran computes a default-kind NINT as a 64-bit ``lround`` and + keeps its low 32 bits. The conversion saturated at -2147483648 instead, + for a NaN and for every value past the int32 range: ``nint(3.0d10)`` is + -64771072 and ``nint`` of a NaN 0. The 8-byte kind was right already, + and a REAL array rounds element by element the same way.""" + module, _, deferred = translate(tmp_path, NINT) + assert not deferred + n4, n8, w = module.rounded(x, np.array([x, 0.5, x])) + assert (n4, n8) == (default, wide) + assert list(w) == [default, 1, default] + + +# --- FNP-D0005: FLOOR and CEILING keep their KIND over an array --------------- + +ROUNDING = """\ +module floor8_mod + implicit none +contains + subroutine rounding(x, f, c, f4, g, h) + real(8), intent(in) :: x(2) + integer(8), intent(out) :: f(2), c(2), g, h + integer, intent(out) :: f4(2) + f = floor(x, kind=8) + c = ceiling(x, 8) + f4 = floor(x / 1.0d4) + g = floor(x(1), kind=8) + h = ceiling(x(2), 8) + end subroutine rounding +end module floor8_mod +""" + + +def test_floor_and_ceiling_of_an_array_keep_their_kind(tmp_path: Path) -> None: + """``floor(x, kind=8)`` of ``[3d10, -3d10]`` is ``[30000000000, + -30000000000]``; the array spelling dropped the KIND and converted into + int32, the int32 edge for both. The default kind and the scalar + spelling keep what they had.""" + module, text, deferred = translate(tmp_path, ROUNDING) + assert not deferred + assert "_f_vfloor(x, 8)" in text and "_f_vceil(x, 8)" in text + assert "g = math.floor(x[0])" in text + f, c, f4, g, h = module.rounding(np.array([3.0e10 + 0.5, -3.0e10 - 0.5])) + assert list(f) == [30000000000, -30000000001] + assert list(c) == [30000000001, -30000000000] + assert list(f4) == [3000000, -3000001] + assert (g, h) == (30000000000, -30000000000) + + +# --- FNP-D0036/9: the elementwise mapping over an empty or integer array ------ + +EMPTY = """\ +module empty_mod + implicit none +contains + subroutine mapped(n, a, i, t, m, s) + integer, intent(in) :: n + real(8), intent(in) :: a(n) + integer, intent(in) :: i(n) + real(8), intent(out) :: t(n) + integer, intent(out) :: m(n), s(n) + t = tan(a) + m = mod(i, 3) + s = ishft(i, 1) + end subroutine mapped +end module empty_mod +""" + + +def test_a_mapped_intrinsic_of_a_zero_size_array_is_empty(tmp_path: Path) -> None: + """``t = tan(a)`` with ``n = 0`` is nothing at all in gfortran; the + mapping raised "cannot call vectorize on size 0 inputs unless otypes is + set". Non-empty, the values are as before.""" + module, text, deferred = translate(tmp_path, EMPTY) + assert not deferred + assert "_f_ecall(math.tan, a)" in text + t, m, s = module.mapped(0, np.zeros(0), np.zeros(0, dtype=np.int32)) + assert t.size == m.size == s.size == 0 + t, m, s = module.mapped(2, np.array([0.5, 1.0]), np.array([7, -(2**30)], dtype=np.int32)) + assert list(t) == [np.tan(0.5), np.tan(1.0)] and list(m) == [1, -1] + assert list(s) == [14, -(2**31)] + + +def test_a_mapped_bit_intrinsic_stays_in_its_operands_width() -> None: + """``np.vectorize`` hands the helpers Python ints, so a width the + emitter did not pass could not be read off a dtype, and the result came + back int64 for int32 operands.""" + i = np.array([1, 2**30], dtype=np.int32) + shifted = runtime._f_ecall(runtime._f_ishft, i, 1) + assert shifted.dtype == np.int32 and list(shifted) == [2, -(2**31)] + given = runtime._f_ecall(runtime._f_ior, i, 1, 32) + assert given.dtype == np.int32 and list(given) == [1, 2**30 + 1] + wide = runtime._f_ecall(runtime._f_ishft, i.astype(np.int64), 33) + assert wide.dtype == np.int64 and list(wide) == [2**33, -(2**63)] diff --git a/tests/test_fnp_statements.py b/tests/test_fnp_statements.py new file mode 100644 index 0000000..ad97c91 --- /dev/null +++ b/tests/test_fnp_statements.py @@ -0,0 +1,722 @@ +"""Statement-level defects the formal audit of this backend refuted. + +The RecastEngine-Pro-Lean audit states, rule by rule, what a translation must +compute for the Fortran it accepts, and records a defect (``FNP-Dxxxx``) +wherever the emitted Python computes something else. The ones here are +statements -- FORALL, ASSOCIATE, a CHARACTER assignment, a SAVEd local, the +intents a dummy is given -- and every test translates a small module, runs +the emitted Python, and asserts the number gfortran prints for the same +source, so reverting the fix fails with the defect's own symptom. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +pytest.importorskip("fparser", reason="needs recast-engine[fortran]") +np = pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +from recast.fortran.frontend import FortranFrontend # noqa: E402 +from recast.transform.numpy.translate import NumpyTranslation # noqa: E402 + + +def translate(tmp_path: Path, source: str, module: str) -> tuple[ModuleType, str, Any]: + """Translate ``module`` out of ``source`` and import what was emitted. + + Returns the imported module, its text and the candidate. Imported from + its own directory under a name no other test uses, and dropped from + ``sys.modules`` again, so two tests translating a module of one name do + not see each other's. + """ + root = tmp_path / "src" + root.mkdir() + (root / f"{module}.f90").write_text(source) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(root) if u.uid == f"fortran:{module}") + facts = frontend.analyze(unit, root) + candidate = NumpyTranslation().apply(unit, facts, {"root": root}) + out = tmp_path / "emitted" + out.mkdir() + for path, content in candidate.files.items(): + (out / path.name).write_bytes(content) + text = (out / f"{module}_numpy.py").read_text() + sys.path.insert(0, str(out)) + try: + for stale in [m for m in sys.modules if m.startswith(module)]: + del sys.modules[stale] + imported = importlib.import_module(f"{module}_numpy") + finally: + sys.path.remove(str(out)) + for loaded in [m for m in sys.modules if m.startswith(module)]: + del sys.modules[loaded] + return imported, text, candidate + + +def body_of(text: str, name: str) -> str: + """One emitted function, from its ``def`` to the next one.""" + start = text.index(f"\ndef {name}(") + end = text.find("\ndef ", start + 1) + return text[start : end if end >= 0 else len(text)] + + +# -- FORALL (FNP-D0002, FNP-D0003; rule FNP-R0121) ----------------------------- + +FORALL = """\ +module fa_mod + implicit none +contains + subroutine shift(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer :: i + forall (i = 2:n) a(i) = a(i-1) + end subroutine shift + + subroutine masked(a, b, n) + integer, intent(in) :: n + real(8), intent(in) :: a(n) + real(8), intent(inout) :: b(n) + integer :: i + forall (i = 1:n, a(i) > 0.0d0) b(i) = a(i) + end subroutine masked + + subroutine twice(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer :: i + forall (i = 1:n) a(i) = 2.0d0 * a(i) + end subroutine twice + + subroutine flip(a, b, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n), b(n) + integer :: i + forall (i = 1:n) + a(i) = dble(i) + b(i) = a(n + 1 - i) + end forall + end subroutine flip + + subroutine guarded(a, n) + integer, intent(in) :: n + real(8), intent(inout) :: a(n) + integer :: i + forall (i = 2:n, a(i-1) > 0.0d0) a(i) = -a(i-1) + end subroutine guarded + + subroutine grid(c, n, m) + integer, intent(in) :: n, m + real(8), intent(inout) :: c(n, m) + integer :: i, j + forall (i = 1:n, j = 2:m, c(i, j) >= 0.0d0) c(i, j) = c(i, j-1) + 1.0d0 + end subroutine grid + + subroutine nested(c, n, m) + integer, intent(in) :: n, m + real(8), intent(inout) :: c(n, m) + integer :: i, j + forall (i = 2:n) + forall (j = 1:m) c(i, j) = c(i-1, j) + end forall + end subroutine nested +end module fa_mod +""" + + +@pytest.fixture(scope="module") +def forall(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("forall"), FORALL, "fa_mod") + + +def test_forall_evaluates_every_value_before_storing_any(forall: Any) -> None: + """``forall (i = 2:n) a(i) = a(i-1)`` shifts the array by one; the + loops it was emitted as read each ``a(i-1)`` after the previous trip had + overwritten it and copied ``a(1)`` everywhere (FNP-D0002).""" + module, _, _ = forall + shifted = module.shift(np.array([1.0, 2.0, 3.0, 4.0]), 4) + assert list(shifted) == [1.0, 1.0, 2.0, 3.0] + + +def test_forall_honours_its_mask(forall: Any) -> None: + """The scalar mask was dropped, so every element was stored (FNP-D0003).""" + module, _, _ = forall + stored = module.masked(np.array([1.0, -1.0, 2.0, -2.0]), np.full(4, 9.0), 4) + assert list(stored) == [1.0, 9.0, 2.0, 9.0] + + +def test_a_forall_reading_only_what_it_overwrites_stays_a_loop(forall: Any) -> None: + """``a(i) = 2*a(i)`` reads the element the same trip stores and nothing + else, so the loops mean what the FORALL means and are still what is + emitted: no gathered values, and the numbers of a plain loop.""" + module, text, _ = forall + assert "_fa" not in body_of(text, "twice") + assert list(module.twice(np.array([1.0, 2.0, 3.0]), 3)) == [2.0, 4.0, 6.0] + assert "if (a[_fi_i - 1] > 0.0):" in body_of(text, "masked") + + +def test_each_assignment_of_a_construct_completes_before_the_next(forall: Any) -> None: + """The second assignment reads the first's array at another element: it + sees every value the first stored, not only the trips before its own.""" + module, _, _ = forall + a, b = module.flip(np.zeros(4), np.zeros(4), 4) + assert list(a) == [1.0, 2.0, 3.0, 4.0] + assert list(b) == [4.0, 3.0, 2.0, 1.0] + + +def test_the_mask_is_evaluated_once_before_any_store(forall: Any) -> None: + """``a(i-1) > 0`` is asked of the array as it was: the store to ``a(2)`` + does not switch ``a(3)``'s trip off. gfortran: 1 -1 -2 4.""" + module, _, _ = forall + assert list(module.guarded(np.array([1.0, 2.0, -3.0, 4.0]), 4)) == [1.0, -1.0, -2.0, 4.0] + + +def test_a_two_index_forall_with_a_mask(forall: Any) -> None: + module, _, _ = forall + c = np.array([[0.0, 5.0, -1.0], [1.0, 1.0, 1.0]], order="F") + got = module.grid(c, 2, 3) + # Every value from the array before the FORALL: c(1,3) < 0 is masked out. + assert got.tolist() == [[0.0, 1.0, -1.0], [1.0, 2.0, 2.0]] + + +def test_a_dependent_forall_with_a_body_that_is_not_assignments_is_refused(forall: Any) -> None: + _, text, candidate = forall + assert any(entry.startswith("nested/") and "FORALL" in entry for entry in candidate.deferred) + assert "raise NotImplementedError" in body_of(text, "nested") + + +def rwset_verdict(candidate: Any, workspace: Path) -> Any: + """``static.rwset`` on a candidate: the read/write sets the source says + each block has, against the ones its translation has.""" + from recast.executors.local import LocalExecutor + from recast.model import Unit + from recast.verify.rwset import factory + + return factory().check( + Unit(uid=candidate.unit, kind="module"), candidate, workspace, LocalExecutor(), {} + ) + + +# -- ASSOCIATE (FNP-D0015; rule FNP-R0124) ------------------------------------- + +ASSOCIATE = """\ +module as_mod + implicit none + type box_t + real(8) :: dx + real(8) :: cells(3) + end type box_t +contains + subroutine bump(x, y) + real(8), intent(inout) :: x + real(8), intent(out) :: y + associate (t => x) + t = t + 1.0d0 + end associate + y = x + end subroutine bump + + subroutine follow(x, y) + real(8), intent(inout) :: x + real(8), intent(out) :: y + associate (t => x) + x = 5.0d0 + y = t + end associate + end subroutine follow + + subroutine element(a) + real(8), intent(inout) :: a(3) + associate (t => a(2)) + t = 7.0d0 + end associate + end subroutine element + + subroutine component(g) + type(box_t), intent(inout) :: g + associate (t => g%dx, c => g%cells) + t = 3.0d0 + c(1) = t + end associate + end subroutine component + + subroutine reads(x, y) + real(8), intent(in) :: x + real(8), intent(out) :: y + associate (t => x) + y = 2.0d0 * t + end associate + end subroutine reads + + subroutine moving(a, i) + real(8), intent(inout) :: a(3) + integer, intent(inout) :: i + associate (t => a(i)) + i = i + 1 + t = 0.0d0 + end associate + end subroutine moving + + subroutine valued(x, y) + real(8), intent(in) :: x + real(8), intent(out) :: y + associate (t => x + 1.0d0) + y = t + end associate + end subroutine valued +end module as_mod +""" + + +@pytest.fixture(scope="module") +def associated(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("associate"), ASSOCIATE, "as_mod") + + +def test_a_store_through_an_associate_name_reaches_its_variable(associated: Any) -> None: + """``associate (t => x); t = t + 1`` stores to ``x``. Bound as the local + ``t = x``, the store rebound ``t`` and ``x`` stayed 1.0 (FNP-D0015).""" + module, text, _ = associated + assert module.bump(1.0) == (2.0, 2.0) + assert "t = x" not in body_of(text, "bump") + + +def test_a_store_to_the_variable_is_seen_through_the_name(associated: Any) -> None: + module, _, _ = associated + assert module.follow(1.0) == (5.0, 5.0) + + +def test_an_element_and_a_component_are_written_through(associated: Any) -> None: + module, _, _ = associated + assert list(module.element(np.array([1.0, 2.0, 3.0]))) == [1.0, 7.0, 3.0] + + class Box: + dx = 0.0 + cells = np.zeros(3) + + box = module.component(Box()) + assert box.dx == 3.0 + assert list(box.cells) == [3.0, 0.0, 0.0] + + +def test_a_name_only_read_is_still_bound_once(associated: Any) -> None: + module, text, _ = associated + assert module.reads(1.5) == 3.0 + assert "t = x" in body_of(text, "reads") + assert module.valued(1.0) == 2.0 + + +def test_an_element_whose_subscript_the_body_changes_is_refused(associated: Any) -> None: + """``a(i)`` is the element of ``i`` as it was at the ASSOCIATE; a + respelled ``a[i - 1]`` would store to the next one.""" + _, _, candidate = associated + assert any(entry.startswith("moving/") for entry in candidate.deferred) + + +def test_the_read_write_gate_agrees_with_the_respelled_body( + associated: Any, tmp_path: Path +) -> None: + """The source's sets now say the name is its variable too: a store to + ``t`` writes ``x``. Every associate block above agrees with its + translation.""" + _, _, candidate = associated + verdict = rwset_verdict(candidate, tmp_path) + failures = [f["block"] for f in verdict.metrics["failures"]] + assert not [f for f in failures if not f.startswith("moving/")], verdict.metrics["failures"] + + +# -- CHARACTER assignment (FNP-D0021; rule FNP-R0111) -------------------------- + +CHARACTERS = """\ +module ch_mod + implicit none + integer, parameter :: nl = 6 +contains + subroutine lens(n1, n2, same) + integer, intent(out) :: n1, n2 + logical, intent(out) :: same + character(len=10) :: s + character(len=3) :: t + s = 'ab' + t = 'abcdef' + n1 = len(s) + n2 = len_trim(t) + same = (s == 'ab') + end subroutine lens + + subroutine old_style(c, n) + character*4, intent(out) :: c + integer, intent(out) :: n + character u + c = 'q' + u = 'yz' + n = len(c) + len(u) + end subroutine old_style + + subroutine assumed(a) + character(len=*), intent(inout) :: a + a = 'z' + end subroutine assumed + + subroutine elements(w) + character(len=3), intent(out) :: w(2) + w(1) = 'a' + w(2) = 'abcdef' + end subroutine elements + + subroutine named(n, t) + integer, intent(out) :: n + character(len=nl), intent(out) :: t + t = 'x' + n = len(t) + end subroutine named + + subroutine internal(s, n) + character(len=8), intent(out) :: s + integer, intent(out) :: n + write(s, '(i3)') 42 + n = len(s) + end subroutine internal + + subroutine initialized(u, n) + character(len=5), intent(out) :: u + integer, intent(out) :: n + character(len=5) :: s = 'ab' + u = s + n = len(s) + end subroutine initialized + + subroutine unassigned(flag, b) + logical, intent(in) :: flag + character(len=4), intent(out) :: b + if (flag) b = 'x' + end subroutine unassigned + + subroutine automatic(s, n) + character(len=*), intent(in) :: s + integer, intent(out) :: n + character(len=len(s)) :: t + t = s + n = len(t) + end subroutine automatic +end module ch_mod +""" + + +@pytest.fixture(scope="module") +def characters(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("characters"), CHARACTERS, "ch_mod") + + +def test_a_character_assignment_pads_and_truncates_to_the_length(characters: Any) -> None: + """gfortran: ``lens 10 3 T``. The translation kept the bare strings, so + ``len(s)`` was 2 and ``len_trim(t)`` 6 (FNP-D0021).""" + module, _, _ = characters + assert module.lens() == (10, 3, True) + + +def test_the_f77_length_spellings_are_lengths_too(characters: Any) -> None: + """``character*4`` and a bare ``character`` (length 1) were recorded with + no length at all.""" + module, _, _ = characters + assert module.old_style() == ("q ", 5) + + +def test_an_assumed_length_dummy_keeps_the_callers_length(characters: Any) -> None: + module, _, _ = characters + assert module.assumed("abcde") == "z " + + +def test_an_element_of_a_character_array_is_fitted(characters: Any) -> None: + module, _, _ = characters + assert list(module.elements()) == ["a ", "abc"] + + +def test_a_length_named_by_a_constant(characters: Any) -> None: + module, _, _ = characters + assert module.named() == (6, "x ") + + +def test_an_internal_write_fills_the_variable(characters: Any) -> None: + module, _, _ = characters + assert module.internal() == (" 42 ", 8) + + +def test_a_declared_initializer_is_its_literal_at_the_length(characters: Any) -> None: + """``character(len=5) :: s = 'ab'`` was initialized to the empty string: + the initializer was dropped for every CHARACTER local.""" + module, _, _ = characters + assert module.initialized() == ("ab ", 5) + + +def test_an_unassigned_out_character_is_blanks_of_its_length(characters: Any) -> None: + module, _, _ = characters + assert module.unassigned(True) == "x " + assert module.unassigned(False) == " " + + +def test_a_length_computed_by_a_call_is_left_as_it_was(characters: Any) -> None: + """``len=len(s)`` is evaluated where ``t`` is created; the declared text + is not a length this can respell, and a respelling of it emitted + ``len_(s`` -- a file that does not parse -- on fortran-utils.""" + module, text, _ = characters + assert module.automatic("abc") == 3 + assert "ljust" not in body_of(text, "automatic") + + +def test_the_read_write_gate_counts_what_a_fitted_length_reads( + characters: Any, tmp_path: Path +) -> None: + """``(value).ljust(nl)`` reads ``nl`` and ``ljust(len(a))`` reads ``a``; + the source's sets say so too, or every fitted store would disagree.""" + _, _, candidate = characters + verdict = rwset_verdict(candidate, tmp_path) + assert not verdict.metrics["failures"], verdict.metrics["failures"] + + +# -- SAVEd locals (FNP-D0014; rules FNP-R0143, FNP-R0165) ---------------------- + +SAVED = """\ +module sv_mod + implicit none +contains + subroutine count(c) + integer, intent(out) :: c + integer :: calls = 0 + calls = calls + 1 + c = calls + end subroutine count + + subroutine remember(n, prev) + integer, intent(in) :: n + integer, intent(out) :: prev + integer, save :: last = -1 + prev = last + last = n + end subroutine remember + + subroutine tally(x, s) + real(8), intent(in) :: x + real(8), intent(out) :: s + real(8) :: acc + data acc /0.0d0/ + acc = acc + x + s = acc + end subroutine tally + + subroutine every(n, total) + integer, intent(in) :: n + integer, intent(out) :: total + integer :: acc + save + if (n == 0) acc = 0 + acc = acc + n + total = acc + end subroutine every + + subroutine grow(n, m) + integer, intent(in) :: n + integer, intent(out) :: m + real(8), allocatable, save :: buf(:) + if (.not. allocated(buf)) then + allocate(buf(n)) + buf = 0.0d0 + end if + buf(1) = buf(1) + 1.0d0 + m = int(buf(1)) + end subroutine grow + + subroutine scaled(x, y) + real(8), intent(in) :: x + real(8), intent(out) :: y + real(8) :: half = 0.5d0 + y = x * half + end subroutine scaled + + subroutine mixed(k) + integer, intent(out) :: k + integer :: a, b + data a, b /1, 2/ + a = a + b + k = a + end subroutine mixed +end module sv_mod +""" + + +@pytest.fixture(scope="module") +def saved(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("saved"), SAVED, "sv_mod") + + +def test_an_initialized_local_keeps_its_value_between_calls(saved: Any) -> None: + """``integer :: calls = 0`` is SAVEd by its initializer. gfortran counts + 1, 2; re-initialized at every entry the translation answered 1, 1 + (FNP-D0014).""" + module, _, _ = saved + assert [module.count(), module.count(), module.count()] == [1, 2, 3] + + +def test_a_save_attribute_keeps_the_last_value(saved: Any) -> None: + module, _, _ = saved + assert [module.remember(5), module.remember(7)] == [-1, 5] + + +def test_a_data_initialized_local_is_initialized_once(saved: Any) -> None: + module, text, _ = saved + assert [module.tally(1.0), module.tally(2.0)] == [1.0, 3.0] + assert "if _fresh:" in body_of(text, "tally") + + +def test_a_bare_save_statement_saves_every_local(saved: Any) -> None: + module, _, _ = saved + assert [module.every(0), module.every(4), module.every(5)] == [0, 4, 9] + + +def test_a_saved_allocatable_stays_allocated(saved: Any) -> None: + module, _, _ = saved + assert [module.grow(3), module.grow(3)] == [1, 2] + + +def test_a_saved_local_the_body_never_changes_is_still_a_local(saved: Any) -> None: + """Re-initializing a value nothing changes gives the same value on every + call, so the emission is what it always was.""" + module, text, _ = saved + assert module.scaled(3.0) == 1.5 + assert "_saved_" not in body_of(text, "scaled") + + +def test_a_data_statement_naming_kept_and_unchanged_locals_is_refused(saved: Any) -> None: + _, _, candidate = saved + assert any(entry.startswith("mixed/D001") for entry in candidate.deferred) + + +def test_the_read_write_gate_reads_a_kept_local_back_as_itself(saved: Any, tmp_path: Path) -> None: + _, _, candidate = saved + verdict = rwset_verdict(candidate, tmp_path) + failures = [f["block"] for f in verdict.metrics["failures"]] + assert not [f for f in failures if not f.startswith("mixed/")], verdict.metrics["failures"] + + +# -- Intents a dummy is given (FNP-D0031, FNP-D0047; FNP-R0140, FNP-R0141) ------ + +INTENTS = """\ +module in_mod + implicit none + type pair_t + real(8) :: a + real(8) :: b + end type pair_t +contains + subroutine chk(x, ierr) + real(8) :: x + integer :: ierr + if (x < 0d0) ierr = 1 + end subroutine chk + + subroutine setk(k) + integer :: k + k = 3 + end subroutine setk + + subroutine early(x, k) + real(8) :: x + integer :: k + if (x < 0d0) return + k = 5 + end subroutine early + + subroutine setname(s, n) + character(len=8) :: s + integer :: n + s = 'hello' + n = 42 + end subroutine setname + + subroutine drive(s, n) + character(len=8), intent(out) :: s + integer, intent(out) :: n + s = 'xxxxxxxx' + n = -1 + call setname(s, n) + end subroutine drive + + subroutine component(o) + type(pair_t) :: o + o%a = 1d0 + end subroutine component + + subroutine formatted(s, n) + character(len=6) :: s + integer :: n + write (s, '(i3)') n + end subroutine formatted + + subroutine sometimes(flag, a) + logical :: flag + real(8) :: a(3) + if (flag) a = 1d0 + end subroutine sometimes +end module in_mod +""" + + +@pytest.fixture(scope="module") +def intents(tmp_path_factory: pytest.TempPathFactory) -> tuple[ModuleType, str, Any]: + return translate(tmp_path_factory.mktemp("intents"), INTENTS, "in_mod") + + +def test_a_conditional_write_leaves_the_callers_value(intents: Any) -> None: + """``if (x < 0) ierr = 1`` writes ``ierr`` on one path only. Inferred + ``intent(out)``, the other path returned the prologue's 0 where gfortran + leaves the caller's 7 (FNP-D0031).""" + module, _, _ = intents + assert module.chk(1.0, 7) == 7 + assert module.chk(-1.0, 7) == 1 + + +def test_a_write_on_every_path_is_still_out(intents: Any) -> None: + module, text, _ = intents + assert module.setk() == 3 + assert "def setk():" in text + + +def test_a_return_before_the_write_leaves_the_callers_value(intents: Any) -> None: + module, _, _ = intents + assert module.early(-1.0, 9) == 9 + assert module.early(1.0, 9) == 5 + + +def test_a_character_dummy_with_no_intent_hands_its_value_back(intents: Any) -> None: + """gfortran: ``drive -> [hello ] 42``. The CHARACTER dummy had no + intent anything inferred, so it was passed in and never returned, and + the caller kept ``xxxxxxxx`` (FNP-D0047).""" + module, _, _ = intents + assert module.drive() == ("hello ", 42) + + +def test_a_component_write_keeps_the_rest_of_the_structure(intents: Any) -> None: + module, _, _ = intents + + class Pair: + a = 0.0 + b = 2.0 + + pair = module.component(Pair()) + assert (pair.a, pair.b) == (1.0, 2.0) + + +def test_an_internal_write_is_a_write_of_its_unit(intents: Any) -> None: + module, _, _ = intents + assert module.formatted(42) == " 42 " + + +def test_a_conditional_whole_array_write_leaves_the_callers_array(intents: Any) -> None: + module, _, _ = intents + assert list(module.sometimes(False, np.full(3, 5.0))) == [5.0, 5.0, 5.0] + assert list(module.sometimes(True, np.full(3, 5.0))) == [1.0, 1.0, 1.0] diff --git a/tests/test_fortran_analysis.py b/tests/test_fortran_analysis.py index 8151461..19edb36 100644 --- a/tests/test_fortran_analysis.py +++ b/tests/test_fortran_analysis.py @@ -1837,7 +1837,8 @@ def test_a_dummy_handed_to_what_might_write_it_is_not_read_only(tmp_path: Path) 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 + # Assigned through its ASSOCIATE alias: a store to ``d``, handed back. + assert intents["d"] == "INOUT" assert intents["e"] == "IN" # ``e + 1.0`` is a temporary; g cannot write e assert intents["out"] == "OUT" @@ -3057,7 +3058,7 @@ def test_a_character_parameter_is_a_resolvable_use_constant(tmp_path: Path) -> N assert records["path"] == Expr("str", "a/b'c") assert render(records["namep"], real=str, integer=str, name=str) == "'pft'" assert _python(records["path"]) == repr("a/b'c") - assert _python(records["half"]) == "(3 // 2)" + assert _python(records["half"]) == "_f_int_div(3, 2)" def test_a_transformational_intrinsic_is_not_a_read(tmp_path: Path) -> None: diff --git a/tests/test_numpy_modules.py b/tests/test_numpy_modules.py index 6b3eaf7..37ca4c8 100644 --- a/tests/test_numpy_modules.py +++ b/tests/test_numpy_modules.py @@ -96,7 +96,8 @@ def test_save_initializers_render_by_form(source: Path, renderer: Modules) -> No assert "count = 3" + tail % "int32" in body assert "ready = False" + tail % "bool" in body assert "weight = np.float64('2.5')" + tail % "float64" in body - assert "tag = 'abc'" + tail % "str" in body + # A ``character(len=8)`` holds eight characters from the start. + assert "tag = 'abc '" + tail % "str" in body assert "biggest = np.finfo(np.float64).max # HUGE(real(r8))" + tail % "float64" in body assert "eps_v = np.finfo(np.float64).eps # EPSILON" + tail % "float64" in body assert "ratio = np.float64(0.2857142857142857)" + tail % "float64" in body diff --git a/tests/test_numpy_runtime.py b/tests/test_numpy_runtime.py index ae89ac7..fba5825 100644 --- a/tests/test_numpy_runtime.py +++ b/tests/test_numpy_runtime.py @@ -293,23 +293,21 @@ def test_huge_distinguishes_real_from_integer() -> None: def test_epsilon_and_tiny_are_float64_only() -> None: - """A known limitation, pinned so it is a documented answer rather than a - surprise. Fortran's EPSILON and TINY depend on the argument's kind; these - return the double-precision value whatever they are handed. - - Harmless where it is used -- CESM physics is ``r8`` throughout -- and wrong - the day a single-precision kernel asks. Left as it was rather than changed - under cover of moving the file: the emitted runtime should change in a - commit that is about changing it. - """ + """Without the declared kind these return the double-precision value + whatever they are handed, which was the known limitation pinned here. + The emitter now passes the kind (FNP-D0008), and with it the answer is + the kind's.""" assert runtime._f_epsilon(np.float64(1.0)) == np.finfo(np.float64).eps assert runtime._f_epsilon(np.float32(1.0)) == np.finfo(np.float64).eps assert runtime._f_tiny(np.float32(1.0)) == np.finfo(np.float64).tiny + assert runtime._f_epsilon(1.0, "float32") == np.finfo(np.float32).eps + assert runtime._f_tiny(1.0, "float32") == np.finfo(np.float32).tiny def test_lbound_is_one_for_a_translated_array() -> None: - """The arrays are NumPy's, so every lower bound is 1 after the shift the - rules apply. Reporting 0 here would double-count the shift.""" + """Without the declared lower bounds -- an array expression, a section, + an array based at one -- every axis answers 1. The emitter hands any + other axis's bound over (FNP-D0006, ``tests/test_fnp_intrinsics.py``).""" assert list(runtime._f_lbound(np.zeros((3, 4)))) == [1, 1] assert runtime._f_lbound(np.zeros((3, 4)), 1) == 1 @@ -374,7 +372,8 @@ def test_copy_out_writes_the_overlap_and_leaves_the_rest() -> None: assert list(buffer) == [1.0, 2.0, -1.0, -1.0] two_d = np.zeros((3, 3)) runtime._f_copy_out(two_d, np.ones((2, 2))) - assert two_d.sum() == 4.0 and two_d[2, 2] == 0.0 + # The first four cells of the storage, column-major (FNP-D0016). + assert np.ravel(two_d, order="F").tolist() == [1.0] * 4 + [0.0] * 5 same = np.zeros(2) runtime._f_copy_out(same, np.array([5.0, 6.0])) assert list(same) == [5.0, 6.0] @@ -666,40 +665,52 @@ def test_seq_tail_is_the_column_major_storage_from_the_element_on() -> None: """``a(i, 1)`` for ``dx(*)``: Fortran hands the callee the memory from that element to the end of the array in column-major order. A view of a Fortran-contiguous actual, so the callee's writes land in the caller's - array; ``x(2, *)`` folds it onto the leading extent with the last axis - taking the whole columns left.""" + array; ``x(2, *)`` folds it onto the leading extent, and the last axis + takes every column the storage reaches, the partial last one included + (FNP-D0045) -- a copy, then, since no view can end half-way down a + column.""" a = np.asfortranarray(np.arange(1.0, 13.0).reshape(3, 4, order="F")) tail = runtime._f_seq_tail(a, 1) # a(2, 1) onward: 2, 3, 4, ..., 12 assert tail.tolist() == list(range(2, 13)) assert np.shares_memory(tail, a) tail[0] = -1.0 assert a[1, 0] == -1.0 - folded = runtime._f_seq_tail(a, 1, 2) # 11 elements: five whole columns of 2 - assert folded.shape == (2, 5) + whole = runtime._f_seq_tail(a, 2, 2) # 10 elements: five whole columns of 2 + assert whole.shape == (2, 5) + assert whole[:, 0].tolist() == [3.0, 4.0] + assert np.shares_memory(whole, a) + folded = runtime._f_seq_tail(a, 1, 2) # 11 elements: five columns and a half + assert folded.shape == (2, 6) assert folded[:, 0].tolist() == [-1.0, 3.0] - assert np.shares_memory(folded, a) + assert folded[0, 5] == 12.0 + assert not np.shares_memory(folded, a) assert runtime._f_seq_tail(a, 0).tolist() == [-1.0 if v == 2.0 else v for v in range(1, 13)] -def test_seq_tail_with_the_matrix_s_own_leading_extent_is_the_matrix_from_that_row() -> None: +def test_seq_tail_with_the_matrix_s_own_leading_extent_is_the_storage_from_that_row() -> None: """``h12(..., a(i, 1), mda, ...)`` walks row ``i`` with the matrix's own leading extent: ``u(1, j)`` is ``a(i, j)`` for every column, the last - one included though the storage from ``a(i, 1)`` holds only part of it. - That is the slice ``a[i-1:, :]``, a view in either memory order, where - folding onto whole columns lost the last column altogether (SLSQP's - ``hfti``).""" + one included though the storage from ``a(i, 1)`` holds only part of it, + and ``u(r, j)`` past the bottom of column ``j`` is the top of column + ``j + 1`` (FNP-D0044). Folding onto whole columns lost the last column + (SLSQP's ``hfti``); the slice ``a[i-1:, :]`` that replaced it lost the + wrap. The storage, padded to whole columns, has both -- as a copy, whose + changed elements alone go back, so the writes a second view of the same + matrix made in the same call stand.""" for order in ("F", "C"): a = np.array(np.arange(1.0, 13.0).reshape(3, 4, order="F"), order=order) u = runtime._f_seq_tail(a, 1, 3) # a(2, 1) with iue = mda = 3 - assert u.shape == (2, 4) + assert u.shape == (3, 4) assert u[0].tolist() == a[1].tolist() - assert np.shares_memory(u, a) - u[0, 3] = -4.0 - assert a[1, 3] == -4.0 - c = runtime._f_seq_tail(a, 1, 3) - c[0, 0] = 0.0 - runtime._f_seq_tail_out(a, 1, c) # a view already: nothing to redo - assert a[1, 0] == 0.0 and a[1, 3] == -4.0 + assert u[2, 0] == a[0, 1] # u(3, 1): past the bottom of column 1 + c = runtime._f_seq_tail(a, 6, 3) # a(1, 3) onward: two whole columns + assert np.shares_memory(c, a) == (order == "F") + u[0, 3] = -4.0 # the callee writes u(1, 4) = a(2, 4) ... + c[0, 0] = -6.0 # ... and a(1, 3) through the other tail + runtime._f_seq_tail_out(a, 6, c) + runtime._f_seq_tail_out(a, 1, u) # u still holds a(1, 3)'s old value + assert a[1, 3] == -4.0 and a[0, 2] == -6.0 + assert np.ravel(a, order="F").tolist()[:6] == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] def test_seq_tail_out_reaches_a_c_ordered_matrix() -> None: @@ -727,9 +738,13 @@ def test_seq_tail_out_reaches_a_c_ordered_matrix() -> None: (3, 1, -1, 1, [3, 2, 1]), # down to the first element: the stop edge underflows (1, 3, 1, 1, [1, 2, 3]), # a(1:3:1) (2, 9, 3, 1, [2, 5, 8]), # a(2:9:3) - (None, 2, -1, 1, [10, 9, 8, 7, 6, 5, 4, 3, 2]), # a(:2:-1) over a(10) - (None, 4, 1, 0, [0, 1, 2, 3, 4]), # b(:4) over b(0:9) - (7, None, -2, 0, [7, 5, 3, 1]), # b(7::-2) over b(0:9) + (10, 2, -1, 1, [10, 9, 8, 7, 6, 5, 4, 3, 2]), # a(10:2:-1) + # An implied edge is the axis's bound whatever the step's sign, and + # the emitter spells it (FNP-D0001): a(:2:-1) is a(1:2:-1), empty. + (1, 2, -1, 1, []), # a(:2:-1) over a(10) + (0, 4, 1, 0, [0, 1, 2, 3, 4]), # b(:4) over b(0:9) + (7, 9, -2, 0, []), # b(7::-2) over b(0:9): b(7:9:-2) + (7, 0, -2, 0, [7, 5, 3, 1]), # b(7:0:-2) ], ) def test_a_step_of_either_sign_enumerates_what_fortran_does( diff --git a/tests/test_numpy_statements.py b/tests/test_numpy_statements.py index 5d7bb41..8c23a47 100644 --- a/tests/test_numpy_statements.py +++ b/tests/test_numpy_statements.py @@ -731,12 +731,13 @@ def test_a_descending_section_carries_its_declared_lower_bound( sources: dict[str, Path], ) -> None: """The stop edge underflows at the first element, and an array declared - from 0 shifts by 0, not by 1 -- so the runtime is handed the bound and - either edge may be left implied.""" + from 0 shifts by 0, not by 1 -- so the runtime is handed the bound. An + implied edge is the bound it stands for whatever the step's sign: + ``a(:n:-1)`` is ``a(1:n:-1)``, empty unless ``n`` is 1 (FNP-D0001).""" statements, nodes = build(sources["emit_mod"], "sections") rendered = [statements.render(node, 1)[0] for node in nodes[:3]] assert rendered[0] == " a[_f_rstep_lb(n, 1, (-1), 1)] = 0.0" - assert rendered[1] == " a[_f_rstep_lb(None, n, (-1), 1)] = 1.0" + assert rendered[1] == " a[_f_rstep_lb(1, n, (-1), 1)] = 1.0" assert rendered[2] == " b[_f_rstep_lb(I_9, 0, (-1), 0)] = F_2P0" @@ -747,12 +748,13 @@ def test_a_section_stepped_by_a_variable_is_sliced_by_the_runtime( with the grid's direction, which the emitter cannot read. Spelled ascending, ``lo:hi+1:step`` stopped one short under a negative step and was empty at the first element; the runtime reads the sign (#75). A - literal positive step keeps the plain slice.""" + literal positive step keeps the plain slice, its stop edge kept from + counting from the end when ``k2`` goes below zero (FNP-D0037).""" statements, nodes = build(sources["emit_mod"], "stepped") rendered = [statements.render(node, 1)[0] for node in nodes[:3]] assert rendered[0] == " a[_f_rstep_any(k1, k2, st, 1)] = 0.0" - assert rendered[1] == " b[_f_rstep_any(None, k2, st, 0)] = 1.0" - assert rendered[2] == " a[k1 - 1:k2:2] = F_2P0" + assert rendered[1] == " b[_f_rstep_any(0, k2, st, 0)] = 1.0" + assert rendered[2] == " a[k1 - 1:max(0, k2):2] = F_2P0" def test_an_implied_do_in_an_array_constructor_is_a_comprehension( @@ -941,9 +943,11 @@ def test_a_goto_with_no_structuring_pattern_is_refused(sources: dict[str, Path]) def test_allocate_takes_the_declared_dtype(sources: dict[str, Path]) -> None: + """The extent is clamped at zero: ``allocate(buf(n))`` with ``n < 0`` is + an empty ``buf``, not a negative dimension (FNP-D0050).""" statements, nodes = build(sources["emit_mod"], "alloc") - assert statements.render(nodes[0], 1) == [" buf = np.zeros((n,), dtype=np.float64)"] - assert statements.render(nodes[1], 1) == [" idx = np.zeros((n,), dtype=np.int32)"] + assert statements.render(nodes[0], 1) == [" buf = np.zeros((max(0, n),), dtype=np.float64)"] + assert statements.render(nodes[1], 1) == [" idx = np.zeros((max(0, n),), dtype=np.int32)"] def test_an_allocate_is_undefined_memory_too_and_is_poisoned_with_the_rest( @@ -956,12 +960,14 @@ def test_an_allocate_is_undefined_memory_too_and_is_poisoned_with_the_rest( defect here. """ statements, nodes = build(sources["emit_mod"], "alloc", poison=True) - assert statements.render(nodes[0], 1) == [" buf = np.full((n,), np.nan, dtype=np.float64)"] - assert statements.render(nodes[1], 1) == [" idx = np.zeros((n,), dtype=np.int32)"] + assert statements.render(nodes[0], 1) == [ + " buf = np.full((max(0, n),), np.nan, dtype=np.float64)" + ] + assert statements.render(nodes[1], 1) == [" idx = np.zeros((max(0, n),), dtype=np.int32)"] statements, nodes = build(sources["emit_mod"], "alloc", poison=True, poison_integers=True) assert statements.render(nodes[1], 1) == [ - f" idx = np.full((n,), {INT_SENTINEL}, dtype=np.int32)" + f" idx = np.full((max(0, n),), {INT_SENTINEL}, dtype=np.int32)" ] @@ -970,7 +976,7 @@ def test_an_allocated_lower_bound_shifts_later_subscripts(sources: dict[str, Pat shift by 0, not by the 1 its declaration would suggest.""" statements, nodes = build(sources["emit_mod"], "alloc") assert statements.render(nodes[2], 1) == [ - " off = np.zeros(((n) - (0) + 1,), dtype=np.float64)" + " off = np.zeros((max(0, (n) - (0) + 1),), dtype=np.float64)" ] assert statements.render(nodes[3], 1) == [" off[(i) - (0)] = 0.0"] @@ -1321,7 +1327,8 @@ def test_writes_split_on_where_the_records_go(sources: dict[str, Path]) -> None: whole translation (see the ADVANCE= test below).""" statements, nodes = build(sources["emit_mod"], "io") assert statements.render(nodes[1], 1) == [" pass # write(*,...) log — no dataflow"] - assert statements.render(nodes[2], 1) == [" line = _f_list_write(s, a[0])"] + # The record fills ``line``, blank-padded to its declared length. + assert statements.render(nodes[2], 1) == [" line = (_f_list_write(s, a[0])).ljust(32)[:32]"] with pytest.raises(REFUSED): statements.render(nodes[3], 1) # iostat= is control flow, not logging diff --git a/tests/test_numpy_subprograms.py b/tests/test_numpy_subprograms.py index 67111c7..f7a4c58 100644 --- a/tests/test_numpy_subprograms.py +++ b/tests/test_numpy_subprograms.py @@ -280,7 +280,7 @@ def test_locals_are_determinized(source: Path) -> None: undefined. An automatic array allocates off its declared extent, an allocatable waits as None, a derived type gets its factory.""" lines, _ = build(source).render(node_of(source, "work"), "work") - assert " scr = np.zeros((n,), dtype=np.float64)" in lines + assert " scr = np.zeros((max(0, n),), dtype=np.float64)" in lines assert " dyn = None" in lines assert " box = _make_pack_t()" in lines assert " i = 0" in lines @@ -296,7 +296,7 @@ def test_poisoning_makes_a_read_before_write_visible(source: Path) -> None: compares and ``differential.bitexact`` counts it as ``nan_mismatch``. """ lines, _ = build(source, poison=True).render(node_of(source, "work"), "work") - assert " scr = np.full((n,), np.nan, dtype=np.float64)" in lines + assert " scr = np.full((max(0, n),), np.nan, dtype=np.float64)" in lines # Unchanged: an allocatable is not undefined memory, it is unallocated, # and a scalar is outside what this covers. assert " dyn = None" in lines @@ -311,8 +311,8 @@ def test_the_integer_arm_is_a_second_switch(source: Path) -> None: both, _ = build(source, poison=True, poison_integers=True).render( node_of(source, "work"), "work" ) - assert " tally = np.zeros((n,), dtype=np.int32)" in plain - assert f" tally = np.full((n,), {INT_SENTINEL}, dtype=np.int32)" in both + assert " tally = np.zeros((max(0, n),), dtype=np.int32)" in plain + assert f" tally = np.full((max(0, n),), {INT_SENTINEL}, dtype=np.int32)" in both def test_a_function_result_is_preinitialized(source: Path) -> None: @@ -341,8 +341,11 @@ def test_an_extent_is_a_term_of_a_bound_not_only_a_whole_one(source: Path) -> No deferred as "extent not resolvable" and took its subprogram with it. The inquiry is one token now, comma and all.""" lines, report = build(source).render(node_of(source, "sized_locals"), "sized_locals") - assert " knots = np.zeros(((I_4) - (0) + 1, np.size(x) - 1,), dtype=np.float64)" in lines - assert " band = np.zeros((I_5, 2 * np.size(c, 1),), dtype=np.float64)" in lines + assert ( + " knots = np.zeros(((I_4) - (0) + 1, max(0, np.size(x) - 1),), dtype=np.float64)" + in lines + ) + assert " band = np.zeros((I_5, max(0, 2 * np.size(c, 1)),), dtype=np.float64)" in lines assert [entry for entry in report if entry["status"] == "agent_queue"] == [] diff --git a/tests/test_numpy_translate.py b/tests/test_numpy_translate.py index e1d2006..dc08f60 100644 --- a/tests/test_numpy_translate.py +++ b/tests/test_numpy_translate.py @@ -364,7 +364,7 @@ def test_a_complex_is_spelled_at_its_kind_not_as_float64(tmp_path: Path) -> None candidate = NumpyTranslation().apply(unit, facts, {"root": tmp_path}) module = candidate.files[Path("roots_mod_numpy.py")].decode() assert "roots = np.zeros((nz, 2,), dtype=np.complex128)" in module - assert "sqrt_det = np.zeros((nz,), dtype=np.complex128)" in module + assert "sqrt_det = np.zeros((max(0, nz),), dtype=np.complex128)" in module assert "np.sqrt(np.complex128(determinant))" in module assert "i_unit = np.complex128(complex(0.0, 1.0))" in module assert "-np.complex128(b_coef)" in module @@ -831,7 +831,7 @@ def test_an_integer_parameter_quotient_truncates(tmp_path: Path) -> None: float64 1.5s where Fortran truncates (ledger #32 row 18); an integer initializer with a quotient takes the parse path and ``_f_int_div``. At module level the constants renderer spells the one shape its flat - tokens can, ``int(A / B)``, and refuses a quotient inside a larger + tokens can, ``int(_f_int_div(A, B))``, and refuses a quotient inside a larger expression rather than folding it wrong.""" import importlib import sys @@ -844,7 +844,7 @@ def test_an_integer_parameter_quotient_truncates(tmp_path: Path) -> None: body = candidate.files[Path("quot_mod_numpy.py")].decode() assert "_f_int_div(" in body, body constants = candidate.files[Path("quot_mod_constants.py")].decode() - assert "HBS = int(FBS / 2)" in constants, constants + assert "HBS = int(_f_int_div(FBS, 2))" in constants, constants assert "# SKIPPED MIXED" in constants and "integer division inside" in constants, constants out = tmp_path / "emitted" out.mkdir() diff --git a/tests/test_relayed_defects.py b/tests/test_relayed_defects.py index 6cf3cc1..02f14df 100644 --- a/tests/test_relayed_defects.py +++ b/tests/test_relayed_defects.py @@ -846,12 +846,14 @@ def test_a_formatted_internal_write_keeps_its_format(tmp_path: Path) -> None: src = _write(tmp_path, "fmt", FORMATTED_WRITE) statements, nodes = build(src, "fmt2") body = "\n".join(line for node in nodes for line in statements.render(node, 1)) - assert "tmp = _f_fmt_write('(I2.2)', " in body + # Blank-padded to the variable's length, as every internal record is + # (FNP-D0021). + assert "tmp = (_f_fmt_write('(I2.2)', abs(code))).ljust(2)[:2]" in body assert "_f_list_write" not in body statements, nodes = build(src, "lw") body = "\n".join(line for node in nodes for line in statements.render(node, 1)) - assert "s = _f_list_write(n)" in body + assert "s = (_f_list_write(n)).ljust(20)[:20]" in body statements, nodes = build(src, "dexp") with pytest.raises(NoRule, match="unsupported edit descriptor"):